Compare commits
84
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -1,7 +1,7 @@
|
||||
[flake8]
|
||||
max-line-length = 127
|
||||
max-complexity = 10
|
||||
exclude =
|
||||
exclude =
|
||||
.git,
|
||||
__pycache__,
|
||||
.mypy_cache,
|
||||
@@ -11,8 +11,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 +33,4 @@ per-file-ignores =
|
||||
|
||||
# Statistics
|
||||
count = True
|
||||
statistics = True
|
||||
statistics = True
|
||||
|
||||
+1
-1
@@ -38,4 +38,4 @@
|
||||
*.safetensors binary
|
||||
*.ckpt binary
|
||||
*.pt binary
|
||||
*.pth binary
|
||||
*.pth binary
|
||||
|
||||
@@ -7,4 +7,4 @@ updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
interval: "weekly"
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v6
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
python-version: '3.10'
|
||||
|
||||
- name: Cache pip dependencies
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-quality-${{ hashFiles('**/requirements-dev.txt') }}
|
||||
@@ -141,7 +141,7 @@ jobs:
|
||||
security:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v6
|
||||
@@ -172,7 +172,7 @@ jobs:
|
||||
architecture:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v6
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
if: ${{ github.repository_owner == 'ComfyAssets' }}
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: true
|
||||
- name: Publish Custom Node
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v6
|
||||
|
||||
+40
-448
@@ -11,479 +11,71 @@ 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@v5
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v6
|
||||
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
|
||||
pip install -r requirements-dev.txt aiohttp
|
||||
|
||||
- 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
|
||||
|
||||
# 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 'ComfyAssets' in ComfyAssetsBaseNode.CATEGORY
|
||||
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 'ComfyAssets/' in res_class.CATEGORY
|
||||
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 'ComfyAssets/' in wh_class.CATEGORY
|
||||
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 'ComfyAssets/' in sampler_class.CATEGORY
|
||||
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 'ComfyAssets/' in seed_class.CATEGORY
|
||||
print('✓ Seed History ComfyUI integration passed')
|
||||
|
||||
print('🎉 All tools ComfyUI integration readiness tests passed!')
|
||||
"
|
||||
|
||||
test-package-structure:
|
||||
coverage:
|
||||
name: coverage gate (85%)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v6
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.10"
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
cache-dependency-path: requirements-dev.txt
|
||||
|
||||
- name: Test package structure
|
||||
- name: Install dependencies
|
||||
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)
|
||||
python -m pip install --upgrade pip
|
||||
pip install torch --index-url https://download.pytorch.org/whl/cpu
|
||||
pip install -r requirements-dev.txt aiohttp
|
||||
|
||||
# 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)
|
||||
# fail_under = 85 lives in pyproject.toml [tool.coverage.report]
|
||||
- name: Measure coverage
|
||||
run: python -m pytest tests --cov=kikotools --cov-report=term-missing
|
||||
|
||||
# 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)
|
||||
|
||||
# Model Downloader files
|
||||
test -f kikotools/tools/model_downloader/node.py || (echo "model_downloader node.py missing" && exit 1)
|
||||
test -f kikotools/tools/model_downloader/base.py || (echo "model_downloader base.py missing" && exit 1)
|
||||
test -f kikotools/tools/model_downloader/detector.py || (echo "model_downloader detector.py missing" && exit 1)
|
||||
|
||||
# Text Input files
|
||||
test -f kikotools/tools/text_input/node.py || (echo "text_input node.py missing" && exit 1)
|
||||
|
||||
# Web files
|
||||
test -f web/width_height_swap.js || (echo "width_height_swap.js missing" && exit 1)
|
||||
test -f web/seed_history_ui.js || (echo "seed_history_ui.js missing" && exit 1)
|
||||
|
||||
echo "✓ Package structure tests passed"
|
||||
|
||||
test-documentation:
|
||||
js-check:
|
||||
name: frontend syntax
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Test documentation completeness
|
||||
# No JS unit tests yet; at least make sure every module parses
|
||||
- name: Check web/*.js parse as ES modules
|
||||
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)
|
||||
|
||||
# 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)
|
||||
|
||||
echo "✓ Documentation tests passed"
|
||||
for f in web/*.js; do
|
||||
node --input-type=module --check < "$f" || { echo "Syntax error in $f"; exit 1; }
|
||||
done
|
||||
|
||||
@@ -159,6 +159,7 @@ test_images/
|
||||
test_outputs/
|
||||
experiments/
|
||||
.claude/
|
||||
.serena
|
||||
|
||||
# Gemini model cache
|
||||
.gemini_models_cache.json
|
||||
|
||||
@@ -81,4 +81,4 @@ exclude: |
|
||||
.*\.egg-info/|
|
||||
venv/|
|
||||
env/
|
||||
)
|
||||
)
|
||||
|
||||
@@ -38,6 +38,9 @@ I’m sharing them here with the community, and I hope you find them as useful a
|
||||
| [🔤 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
|
||||
|
||||
@@ -378,6 +381,64 @@ Visual gallery browser for loading local images, videos, and audio files directl
|
||||
- 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.**
|
||||
@@ -421,7 +482,7 @@ This feature is an enhanced fork of the autocomplete functionality from [ComfyUI
|
||||
**Intelligent GPU memory management with threshold-based triggering and detailed reporting.**
|
||||
|
||||
**Key Features:**
|
||||
- **4 Purge Modes**:
|
||||
- **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
|
||||
@@ -699,6 +760,9 @@ Example workflow available: [xyz_helpers_lora_testing.json](examples/workflows/x
|
||||
| **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 |
|
||||
|
||||
@@ -981,7 +1045,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
|
||||
|
||||
@@ -992,14 +1056,15 @@ MIT License - see [LICENSE](LICENSE) file for details.
|
||||
|
||||
## 📈 Stats
|
||||
|
||||
- **Nodes**: 19 (13 core tools + 6 xyz-helpers)
|
||||
- **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**: 100% (470+ comprehensive tests)
|
||||
- **Python Version**: 3.8+
|
||||
- **ComfyUI Compatibility**: Latest
|
||||
- **Dependencies**: Minimal (PyTorch, NumPy, Pillow, google-generativeai for Gemini)
|
||||
|
||||
+5
-2
@@ -105,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)
|
||||
@@ -118,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()
|
||||
|
||||
|
||||
@@ -103,4 +103,4 @@ The node provides several ways to track progress:
|
||||
### 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
|
||||
- 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.
|
||||
|
||||
@@ -206,4 +206,4 @@ Errors are displayed in the prompt output for easy debugging.
|
||||
|
||||
**Import error for google-generativeai**:
|
||||
- Run `pip install google-generativeai` in your ComfyUI environment
|
||||
- Restart ComfyUI after installation
|
||||
- Restart ComfyUI after installation
|
||||
|
||||
@@ -79,4 +79,4 @@ Load Images → Image to Multiple Of (multiple_of: 16, method: rescale) → Batc
|
||||
The node will raise an error if:
|
||||
- The image dimensions are smaller than the specified multiple_of value
|
||||
- Invalid input types are provided
|
||||
- The resulting dimensions would be 0 or negative
|
||||
- The resulting dimensions would be 0 or negative
|
||||
|
||||
@@ -122,4 +122,4 @@ Creates monochrome grain perfect for black and white photography.
|
||||
- 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
|
||||
- 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
|
||||
|
||||
@@ -155,4 +155,4 @@ The node creates a visual widget that runs in the ComfyUI interface and communic
|
||||
- Save user preferences
|
||||
- Handle file selection
|
||||
|
||||
All file operations are performed server-side for security, with proper path validation to prevent directory traversal attacks.
|
||||
All file operations are performed server-side for security, with proper path validation to prevent directory traversal attacks.
|
||||
|
||||
@@ -261,4 +261,4 @@ batch_mode: sequential
|
||||
- **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.
|
||||
|
||||
@@ -11,4 +11,4 @@ Enchanted forest with magical glowing mushrooms, fairy lights, mystical atmosphe
|
||||
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
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,4 +162,4 @@
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,12 @@ KikoTools package initialization and node registry
|
||||
Handles automatic discovery and registration of all ComfyAssets tools
|
||||
"""
|
||||
|
||||
from .tools.batch_list_converter import (
|
||||
ImageBatchToImageListNode,
|
||||
ImageListToImageBatchNode,
|
||||
LatentBatchToLatentListNode,
|
||||
LatentListToLatentBatchNode,
|
||||
)
|
||||
from .tools.batch_prompts import BatchPromptsNode
|
||||
from .tools.display_any import DisplayAnyNode
|
||||
from .tools.display_text import DisplayTextNode
|
||||
@@ -13,14 +19,17 @@ 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_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,
|
||||
@@ -32,6 +41,10 @@ from .tools.xyz_helpers import (
|
||||
|
||||
# ComfyUI node registration mappings
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"ImageBatchToImageList": ImageBatchToImageListNode,
|
||||
"ImageListToImageBatch": ImageListToImageBatchNode,
|
||||
"LatentBatchToLatentList": LatentBatchToLatentListNode,
|
||||
"LatentListToLatentBatch": LatentListToLatentBatchNode,
|
||||
"BatchPrompts": BatchPromptsNode,
|
||||
"ResolutionCalculator": ResolutionCalculatorNode,
|
||||
"WidthHeightSelector": WidthHeightSelectorNode,
|
||||
@@ -56,11 +69,18 @@ NODE_CLASS_MAPPINGS = {
|
||||
"FluxSamplerParams": FluxSamplerParamsNode,
|
||||
"PlotParameters+": PlotParametersNode,
|
||||
"LoRAFolderBatch": LoRAFolderBatchNode,
|
||||
"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",
|
||||
@@ -85,6 +105,9 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"FluxSamplerParams": "Flux Sampler Parameters",
|
||||
"PlotParameters+": "Plot Parameters",
|
||||
"LoRAFolderBatch": "LoRA Folder Batch",
|
||||
"WidthHeightToVec2": "Width Height to VEC2",
|
||||
"KikoWorkflowTimer": "Workflow Timer",
|
||||
"KikoProjectFilePath": "Project File Path",
|
||||
# KikoEmbeddingAutocomplete removed - settings only, not a node
|
||||
}
|
||||
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
"""Tool registry for KikoTools.
|
||||
|
||||
This module provides the central registration system for all KikoTools nodes.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
from typing import Dict, Any
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class ToolRegistry:
|
||||
"""Central registry for all KikoTools."""
|
||||
|
||||
def __init__(self):
|
||||
self.tools: Dict[str, Any] = {}
|
||||
self.node_classes: Dict[str, Any] = {}
|
||||
|
||||
def register_tool(self, tool_name: str, node_class: Any) -> None:
|
||||
"""Register a tool and its node class.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the tool
|
||||
node_class: The ComfyUI node class
|
||||
"""
|
||||
self.tools[tool_name] = node_class
|
||||
|
||||
# Also register by class name for ComfyUI
|
||||
class_name = node_class.__name__
|
||||
self.node_classes[class_name] = node_class
|
||||
|
||||
def discover_tools(self) -> None:
|
||||
"""Automatically discover and load all tools in the tools directory."""
|
||||
tools_dir = Path(__file__).parent.parent / "tools"
|
||||
|
||||
if not tools_dir.exists():
|
||||
return
|
||||
|
||||
for tool_dir in tools_dir.iterdir():
|
||||
if tool_dir.is_dir() and not tool_dir.name.startswith("_"):
|
||||
self._load_tool(tool_dir.name)
|
||||
|
||||
def _load_tool(self, tool_name: str) -> None:
|
||||
"""Load a single tool module.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the tool directory
|
||||
"""
|
||||
try:
|
||||
# Try to import the tool's node module
|
||||
module = importlib.import_module(f"kikotools.tools.{tool_name}.node")
|
||||
|
||||
# Look for node classes (classes with ComfyUI node attributes)
|
||||
for attr_name in dir(module):
|
||||
attr = getattr(module, attr_name)
|
||||
if (
|
||||
isinstance(attr, type)
|
||||
and hasattr(attr, "INPUT_TYPES")
|
||||
and hasattr(attr, "FUNCTION")
|
||||
):
|
||||
self.register_tool(tool_name, attr)
|
||||
|
||||
# If the tool has settings, register them
|
||||
if hasattr(attr, "SETTINGS"):
|
||||
from .settings import settings_registry
|
||||
|
||||
settings_registry.register_tool_settings(
|
||||
tool_name,
|
||||
getattr(
|
||||
attr,
|
||||
"DISPLAY_NAME",
|
||||
tool_name.replace("_", " ").title(),
|
||||
),
|
||||
attr.SETTINGS,
|
||||
)
|
||||
|
||||
except ImportError:
|
||||
# Tool might not have a node.py file yet
|
||||
pass
|
||||
|
||||
def get_node_class_mappings(self) -> Dict[str, Any]:
|
||||
"""Get node class mappings for ComfyUI registration."""
|
||||
return self.node_classes.copy()
|
||||
|
||||
def get_node_display_name_mappings(self) -> Dict[str, str]:
|
||||
"""Get display name mappings for ComfyUI."""
|
||||
mappings = {}
|
||||
for class_name, node_class in self.node_classes.items():
|
||||
if hasattr(node_class, "DISPLAY_NAME"):
|
||||
mappings[class_name] = node_class.DISPLAY_NAME
|
||||
else:
|
||||
# Generate a display name from class name
|
||||
mappings[class_name] = class_name.replace("Kiko", "").replace(
|
||||
"Node", ""
|
||||
)
|
||||
return mappings
|
||||
@@ -1,201 +0,0 @@
|
||||
"""Settings registry for KikoTools.
|
||||
|
||||
This module provides a centralized settings management system for all KikoTools.
|
||||
Tools can register their settings, which are then exposed in ComfyUI's settings UI.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Dict, Any, List, Optional, Union
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class SettingDefinition:
|
||||
"""Definition of a single setting."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
type: str # "boolean", "combo", "number", "string", "custom"
|
||||
default: Any
|
||||
description: Optional[str] = None
|
||||
options: Optional[Union[List[Any], Dict[str, Any]]] = None
|
||||
min_value: Optional[float] = None
|
||||
max_value: Optional[float] = None
|
||||
step: Optional[float] = None
|
||||
on_change: Optional[str] = None # JavaScript callback as string
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolSettings:
|
||||
"""Settings collection for a single tool."""
|
||||
|
||||
tool_name: str
|
||||
display_name: str
|
||||
settings: List[SettingDefinition] = field(default_factory=list)
|
||||
|
||||
|
||||
class SettingsRegistry:
|
||||
"""Central registry for all KikoTools settings."""
|
||||
|
||||
def __init__(self):
|
||||
self.tools: Dict[str, ToolSettings] = {}
|
||||
self.settings_by_id: Dict[str, SettingDefinition] = {}
|
||||
|
||||
def register_tool_settings(
|
||||
self, tool_name: str, display_name: str, settings: Dict[str, Dict[str, Any]]
|
||||
) -> None:
|
||||
"""Register settings for a tool.
|
||||
|
||||
Args:
|
||||
tool_name: Internal tool identifier (e.g., "embedding_autocomplete")
|
||||
display_name: Display name for the tool (e.g., "Embedding Autocomplete")
|
||||
settings: Dictionary of setting configurations
|
||||
{
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"default": True,
|
||||
"description": "Enable embedding autocomplete"
|
||||
},
|
||||
"max_suggestions": {
|
||||
"type": "combo",
|
||||
"default": 20,
|
||||
"options": [10, 20, 50],
|
||||
"description": "Maximum number of suggestions"
|
||||
}
|
||||
}
|
||||
"""
|
||||
tool_settings = ToolSettings(tool_name, display_name)
|
||||
|
||||
for setting_key, config in settings.items():
|
||||
# Generate fully qualified setting ID
|
||||
setting_id = f"kikotools.{tool_name}.{setting_key}"
|
||||
|
||||
# Create display name with branding
|
||||
setting_name = f"🫶 {display_name}: {setting_key.replace('_', ' ').title()}"
|
||||
|
||||
setting_def = SettingDefinition(
|
||||
id=setting_id,
|
||||
name=setting_name,
|
||||
type=config.get("type", "string"),
|
||||
default=config.get("default"),
|
||||
description=config.get("description"),
|
||||
options=config.get("options"),
|
||||
min_value=config.get("min"),
|
||||
max_value=config.get("max"),
|
||||
step=config.get("step"),
|
||||
on_change=config.get("on_change"),
|
||||
)
|
||||
|
||||
tool_settings.settings.append(setting_def)
|
||||
self.settings_by_id[setting_id] = setting_def
|
||||
|
||||
self.tools[tool_name] = tool_settings
|
||||
|
||||
def get_setting(self, setting_id: str) -> Optional[SettingDefinition]:
|
||||
"""Get a setting definition by ID."""
|
||||
return self.settings_by_id.get(setting_id)
|
||||
|
||||
def get_tool_settings(self, tool_name: str) -> Optional[ToolSettings]:
|
||||
"""Get all settings for a tool."""
|
||||
return self.tools.get(tool_name)
|
||||
|
||||
def generate_frontend_registration(self) -> str:
|
||||
"""Generate JavaScript code for frontend settings registration."""
|
||||
js_lines = [
|
||||
"// Auto-generated KikoTools settings registration",
|
||||
"// This file is automatically generated by the settings registry",
|
||||
"",
|
||||
"import { app } from '../../scripts/app.js';",
|
||||
"",
|
||||
"app.registerExtension({",
|
||||
" name: 'kikotools.settings',",
|
||||
" async init() {",
|
||||
" // Register all KikoTools settings",
|
||||
]
|
||||
|
||||
for tool_name, tool_settings in self.tools.items():
|
||||
js_lines.append(f" // {tool_settings.display_name} settings")
|
||||
|
||||
for setting in tool_settings.settings:
|
||||
js_lines.append(" app.ui.settings.addSetting({")
|
||||
js_lines.append(f' id: "{setting.id}",')
|
||||
js_lines.append(f' name: "{setting.name}",')
|
||||
js_lines.append(
|
||||
f" defaultValue: {self._js_value(setting.default)},"
|
||||
)
|
||||
js_lines.append(f' type: "{setting.type}",')
|
||||
|
||||
if setting.description:
|
||||
js_lines.append(f' tooltip: "{setting.description}",')
|
||||
|
||||
if setting.type == "combo" and setting.options:
|
||||
js_lines.append(" options: (value) => {")
|
||||
js_lines.append(
|
||||
f" const options = {json.dumps(setting.options)};"
|
||||
)
|
||||
js_lines.append(" return options.map(opt => ({")
|
||||
js_lines.append(" value: opt,")
|
||||
js_lines.append(" text: String(opt),")
|
||||
js_lines.append(" selected: opt === value")
|
||||
js_lines.append(" }));")
|
||||
js_lines.append(" }},")
|
||||
|
||||
if setting.type == "number":
|
||||
if setting.min_value is not None:
|
||||
js_lines.append(f" min: {setting.min_value},")
|
||||
if setting.max_value is not None:
|
||||
js_lines.append(f" max: {setting.max_value},")
|
||||
if setting.step is not None:
|
||||
js_lines.append(f" step: {setting.step},")
|
||||
|
||||
if setting.on_change:
|
||||
js_lines.append(" onChange(value) {")
|
||||
js_lines.append(f" {setting.on_change}")
|
||||
js_lines.append(" }")
|
||||
|
||||
js_lines.append(" }});")
|
||||
js_lines.append("")
|
||||
|
||||
js_lines.extend([" }", "});", ""])
|
||||
|
||||
return "\n".join(js_lines)
|
||||
|
||||
def _js_value(self, value: Any) -> str:
|
||||
"""Convert Python value to JavaScript literal."""
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
elif isinstance(value, str):
|
||||
return f'"{value}"'
|
||||
elif value is None:
|
||||
return "null"
|
||||
else:
|
||||
return str(value)
|
||||
|
||||
def save_frontend_settings(
|
||||
self, output_path: str = "web/js/kikoSettings.js"
|
||||
) -> None:
|
||||
"""Save the generated frontend settings to a file."""
|
||||
js_content = self.generate_frontend_registration()
|
||||
|
||||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||||
with open(output_path, "w") as f:
|
||||
f.write(js_content)
|
||||
|
||||
def get_all_settings(self) -> Dict[str, Any]:
|
||||
"""Get all registered settings as a dictionary."""
|
||||
result = {}
|
||||
for tool_name, tool_settings in self.tools.items():
|
||||
result[tool_name] = {
|
||||
"display_name": tool_settings.display_name,
|
||||
"settings": {
|
||||
setting.id.split(".")[-1]: {
|
||||
"type": setting.type,
|
||||
"default": setting.default,
|
||||
"description": setting.description,
|
||||
"options": setting.options,
|
||||
}
|
||||
for setting in tool_settings.settings
|
||||
},
|
||||
}
|
||||
return result
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Batch/List conversion tool for ComfyUI."""
|
||||
|
||||
from .node import (
|
||||
ImageBatchToImageListNode,
|
||||
ImageListToImageBatchNode,
|
||||
LatentBatchToLatentListNode,
|
||||
LatentListToLatentBatchNode,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ImageBatchToImageListNode",
|
||||
"ImageListToImageBatchNode",
|
||||
"LatentBatchToLatentListNode",
|
||||
"LatentListToLatentBatchNode",
|
||||
]
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Pure tensor split/join functions for batch-list conversions."""
|
||||
|
||||
import torch
|
||||
from typing import Dict, List
|
||||
|
||||
|
||||
def split_image_batch(images: torch.Tensor) -> List[torch.Tensor]:
|
||||
"""Split [B,H,W,C] image batch into list of [1,H,W,C] tensors."""
|
||||
return [images[i : i + 1] for i in range(images.shape[0])]
|
||||
|
||||
|
||||
def join_image_batch(image_list: List[torch.Tensor]) -> torch.Tensor:
|
||||
"""Join list of image tensors into single [B,H,W,C] batch."""
|
||||
return torch.cat(image_list, dim=0)
|
||||
|
||||
|
||||
def split_latent_batch(
|
||||
latent: Dict[str, torch.Tensor],
|
||||
) -> List[Dict[str, torch.Tensor]]:
|
||||
"""Split latent dict into list of single-item latent dicts.
|
||||
|
||||
Preserves all keys (e.g. noise_mask, batch_index). Tensor values whose
|
||||
first dimension matches the batch size of ``samples`` are sliced along
|
||||
dim-0; all other values are copied as-is to every item.
|
||||
"""
|
||||
samples = latent["samples"]
|
||||
batch_size = samples.shape[0]
|
||||
result: List[Dict[str, torch.Tensor]] = []
|
||||
for i in range(batch_size):
|
||||
item: Dict[str, torch.Tensor] = {}
|
||||
for key, value in latent.items():
|
||||
if isinstance(value, torch.Tensor) and value.shape[0] == batch_size:
|
||||
item[key] = value[i : i + 1]
|
||||
else:
|
||||
item[key] = value
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
|
||||
def join_latent_batch(
|
||||
latent_list: List[Dict[str, torch.Tensor]],
|
||||
) -> Dict[str, torch.Tensor]:
|
||||
"""Join list of latent dicts into single batched latent dict.
|
||||
|
||||
Tensor values that were sliced during split are concatenated along dim-0.
|
||||
Non-tensor values are taken from the first item.
|
||||
"""
|
||||
result: Dict[str, torch.Tensor] = {}
|
||||
first = latent_list[0]
|
||||
for key in first:
|
||||
if isinstance(first[key], torch.Tensor):
|
||||
result[key] = torch.cat([lat[key] for lat in latent_list], dim=0)
|
||||
else:
|
||||
result[key] = first[key]
|
||||
return result
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Batch/List conversion nodes for ComfyUI."""
|
||||
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
import torch
|
||||
|
||||
from ...base.base_node import ComfyAssetsBaseNode
|
||||
from .logic import (
|
||||
split_image_batch,
|
||||
join_image_batch,
|
||||
split_latent_batch,
|
||||
join_latent_batch,
|
||||
)
|
||||
|
||||
|
||||
class ImageBatchToImageListNode(ComfyAssetsBaseNode):
|
||||
"""Split an IMAGE batch [B,H,W,C] into a list of individual images."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE",),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "INT")
|
||||
RETURN_NAMES = ("images", "count")
|
||||
OUTPUT_IS_LIST = (True, False)
|
||||
FUNCTION = "split_batch"
|
||||
CATEGORY = "🫶 ComfyAssets/📦 Latents"
|
||||
|
||||
def split_batch(self, images: torch.Tensor) -> Tuple[List[torch.Tensor], int]:
|
||||
image_list = split_image_batch(images)
|
||||
count = len(image_list)
|
||||
self.log_info(f"Split image batch of {count} into list")
|
||||
return (image_list, count)
|
||||
|
||||
|
||||
class ImageListToImageBatchNode(ComfyAssetsBaseNode):
|
||||
"""Join a list of IMAGE tensors into a single batched IMAGE [B,H,W,C]."""
|
||||
|
||||
INPUT_IS_LIST = True
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE",),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "INT")
|
||||
RETURN_NAMES = ("images", "count")
|
||||
FUNCTION = "join_batch"
|
||||
CATEGORY = "🫶 ComfyAssets/📦 Latents"
|
||||
|
||||
def join_batch(self, images: List[torch.Tensor]) -> Tuple[torch.Tensor, int]:
|
||||
batch = join_image_batch(images)
|
||||
count = batch.shape[0]
|
||||
self.log_info(f"Joined {count} images into batch")
|
||||
return (batch, count)
|
||||
|
||||
|
||||
class LatentBatchToLatentListNode(ComfyAssetsBaseNode):
|
||||
"""Split a LATENT batch into a list of individual latent dicts."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"latent": ("LATENT",),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("LATENT", "INT")
|
||||
RETURN_NAMES = ("latents", "count")
|
||||
OUTPUT_IS_LIST = (True, False)
|
||||
FUNCTION = "split_batch"
|
||||
CATEGORY = "🫶 ComfyAssets/📦 Latents"
|
||||
|
||||
def split_batch(
|
||||
self, latent: Dict[str, torch.Tensor]
|
||||
) -> Tuple[List[Dict[str, torch.Tensor]], int]:
|
||||
latent_list = split_latent_batch(latent)
|
||||
count = len(latent_list)
|
||||
self.log_info(f"Split latent batch of {count} into list")
|
||||
return (latent_list, count)
|
||||
|
||||
|
||||
class LatentListToLatentBatchNode(ComfyAssetsBaseNode):
|
||||
"""Join a list of LATENT dicts into a single batched LATENT."""
|
||||
|
||||
INPUT_IS_LIST = True
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"latents": ("LATENT",),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("LATENT", "INT")
|
||||
RETURN_NAMES = ("latent", "count")
|
||||
FUNCTION = "join_batch"
|
||||
CATEGORY = "🫶 ComfyAssets/📦 Latents"
|
||||
|
||||
def join_batch(
|
||||
self, latents: List[Dict[str, torch.Tensor]]
|
||||
) -> Tuple[Dict[str, torch.Tensor], int]:
|
||||
batch = join_latent_batch(latents)
|
||||
count = batch["samples"].shape[0]
|
||||
self.log_info(f"Joined {count} latents into batch")
|
||||
return (batch, count)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"ImageBatchToImageList": ImageBatchToImageListNode,
|
||||
"ImageListToImageBatch": ImageListToImageBatchNode,
|
||||
"LatentBatchToLatentList": LatentBatchToLatentListNode,
|
||||
"LatentListToLatentBatch": LatentListToLatentBatchNode,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"ImageBatchToImageList": "Image Batch to Image List",
|
||||
"ImageListToImageBatch": "Image List to Image Batch",
|
||||
"LatentBatchToLatentList": "Latent Batch to Latent List",
|
||||
"LatentListToLatentBatch": "Latent List to Latent Batch",
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
"""Simple Batch Prompts node for ComfyUI - debugging version."""
|
||||
|
||||
import os
|
||||
from typing import Tuple
|
||||
from ...base.base_node import ComfyAssetsBaseNode
|
||||
from .logic import (
|
||||
load_prompts_from_file,
|
||||
get_prompt_at_index,
|
||||
split_prompt_into_positive_negative,
|
||||
)
|
||||
|
||||
# Global counter that persists across all executions
|
||||
GLOBAL_COUNTER = {"count": 0}
|
||||
|
||||
|
||||
class SimpleBatchPromptsNode(ComfyAssetsBaseNode):
|
||||
"""
|
||||
Simplified Batch Prompts node for debugging.
|
||||
Uses a global counter to ensure prompts change.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
"""Define the input types for the ComfyUI node."""
|
||||
return {
|
||||
"required": {
|
||||
"prompt_file": ("STRING", {"default": "prompts.txt"}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "STRING", "INT")
|
||||
RETURN_NAMES = ("positive", "negative", "index")
|
||||
FUNCTION = "get_next_prompt"
|
||||
CATEGORY = "🫶 ComfyAssets/📝 Text"
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
"""Force re-execution every time."""
|
||||
GLOBAL_COUNTER["count"] += 1
|
||||
return GLOBAL_COUNTER["count"]
|
||||
|
||||
def get_next_prompt(self, prompt_file: str) -> Tuple[str, str, int]:
|
||||
"""Get the next prompt in sequence."""
|
||||
# Resolve file path
|
||||
if not os.path.isabs(prompt_file):
|
||||
try:
|
||||
import folder_paths
|
||||
|
||||
input_dir = folder_paths.get_input_directory()
|
||||
full_path = os.path.join(input_dir, prompt_file)
|
||||
except ImportError:
|
||||
full_path = os.path.abspath(prompt_file)
|
||||
else:
|
||||
full_path = prompt_file
|
||||
|
||||
# Load prompts
|
||||
prompts = load_prompts_from_file(full_path)
|
||||
if not prompts:
|
||||
return ("No prompts found", "", 0)
|
||||
|
||||
# Get current prompt based on global counter
|
||||
index = GLOBAL_COUNTER["count"] % len(prompts)
|
||||
current_prompt, _ = get_prompt_at_index(prompts, index, wrap=True)
|
||||
|
||||
# Split positive/negative
|
||||
positive, negative = split_prompt_into_positive_negative(current_prompt)
|
||||
|
||||
print(
|
||||
f"[SimpleBatchPrompts] Counter={GLOBAL_COUNTER['count']}, Index={index}, Prompt={positive[:30]}..."
|
||||
)
|
||||
|
||||
return (positive, negative, index)
|
||||
@@ -19,7 +19,7 @@ class StateManager:
|
||||
"""Load state from file."""
|
||||
if self.state_file.exists():
|
||||
try:
|
||||
with open(self.state_file, "r") as f:
|
||||
with open(self.state_file, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, IOError):
|
||||
pass
|
||||
@@ -28,7 +28,7 @@ class StateManager:
|
||||
def save_state(self, state: Dict[str, Any]):
|
||||
"""Save state to file."""
|
||||
try:
|
||||
with open(self.state_file, "w") as f:
|
||||
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}")
|
||||
|
||||
@@ -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"
|
||||
|
||||
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
|
||||
|
||||
@@ -86,4 +86,4 @@
|
||||
"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:
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -100,13 +100,15 @@ def generate_grain_texture(
|
||||
Returns:
|
||||
Grain texture tensor of shape [B, H/scale, W/scale, 3]
|
||||
"""
|
||||
torch.manual_seed(seed)
|
||||
# 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)
|
||||
grain = torch.rand(batch_size, grain_height, grain_width, 3, generator=generator)
|
||||
|
||||
return grain
|
||||
|
||||
@@ -140,7 +142,8 @@ def apply_film_grain(
|
||||
Returns:
|
||||
Image with film grain applied
|
||||
"""
|
||||
if strength == 0.0:
|
||||
# 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
|
||||
|
||||
@@ -75,7 +75,7 @@ class KikoFilmGrainNode(ComfyAssetsBaseNode):
|
||||
{
|
||||
"default": 0,
|
||||
"min": 0,
|
||||
"max": 0xFFFFFFFFFFFFFFFF,
|
||||
"max": 0xFFFFFFFF, # 2**32 - 1
|
||||
"description": "Random seed for grain pattern generation",
|
||||
},
|
||||
),
|
||||
|
||||
@@ -55,6 +55,11 @@ class KikoPurgeVRAM(BaseNode):
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {}
|
||||
@@ -1,7 +1,12 @@
|
||||
{
|
||||
"last_path": "/home/vito/ai-apps/ComfyUI-3.12/output",
|
||||
"last_path": "/home/vito/ai-apps/ComfyUI/output/vids",
|
||||
"saved_paths": [
|
||||
"/home/vito/ai-apps/ComfyUI-3.12/output/2025-05-01",
|
||||
"/home/vito/ai-apps/ComfyUI-3.12/output/"
|
||||
"/home/vito/ai-apps/ComfyUI-3.12/output/",
|
||||
"/home/vito/Downloads/vito",
|
||||
"/home/vito/ai-apps/ComfyUI/output/2025-06-08",
|
||||
"/home/vito/ai-apps/ComfyUI/output",
|
||||
"/home/vito/Downloads",
|
||||
"/home/vito/Downloads/images"
|
||||
]
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import json
|
||||
import torch
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
from typing import Tuple, Dict, Any, List
|
||||
from typing import Tuple, Dict, Any, List, Optional, Iterable
|
||||
|
||||
|
||||
def get_supported_extensions() -> Dict[str, List[str]]:
|
||||
@@ -17,6 +17,108 @@ def get_supported_extensions() -> Dict[str, List[str]]:
|
||||
}
|
||||
|
||||
|
||||
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.
|
||||
@@ -73,6 +175,7 @@ def scan_directory(
|
||||
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.
|
||||
@@ -83,6 +186,7 @@ def scan_directory(
|
||||
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
|
||||
@@ -94,6 +198,10 @@ def scan_directory(
|
||||
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:
|
||||
@@ -139,6 +247,98 @@ def scan_directory(
|
||||
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)
|
||||
|
||||
@@ -1,13 +1,37 @@
|
||||
"""Local Image Loader node for ComfyUI."""
|
||||
|
||||
import os
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
import torch
|
||||
from typing import Dict, Any, Tuple
|
||||
|
||||
from ...base.base_node import ComfyAssetsBaseNode
|
||||
from .logic import load_image_from_path, create_empty_tensor
|
||||
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")
|
||||
@@ -31,7 +55,7 @@ def save_selections(data: Dict[str, Any]) -> None:
|
||||
with open(SELECTIONS_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=4, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
print(f"KikoLocalImageLoader: Error saving selections: {e}")
|
||||
logger.error("Error saving selections: %s", e)
|
||||
|
||||
|
||||
def load_config() -> Dict[str, Any]:
|
||||
@@ -51,7 +75,28 @@ def save_config(data: Dict[str, Any]) -> None:
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=4)
|
||||
except Exception as e:
|
||||
print(f"KikoLocalImageLoader: Error saving config: {e}")
|
||||
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):
|
||||
@@ -68,13 +113,9 @@ class LocalImageLoaderNode(ComfyAssetsBaseNode):
|
||||
RETURN_TYPES = (
|
||||
"IMAGE",
|
||||
"STRING",
|
||||
"STRING",
|
||||
"STRING",
|
||||
)
|
||||
RETURN_NAMES = (
|
||||
"image",
|
||||
"video_path",
|
||||
"audio_path",
|
||||
"info",
|
||||
)
|
||||
FUNCTION = "load_media"
|
||||
@@ -87,7 +128,7 @@ class LocalImageLoaderNode(ComfyAssetsBaseNode):
|
||||
return os.path.getmtime(SELECTIONS_FILE)
|
||||
return float("inf")
|
||||
|
||||
def load_media(self, unique_id: str) -> Tuple[torch.Tensor, str, str, str]:
|
||||
def load_media(self, unique_id: str) -> Tuple[torch.Tensor, str]:
|
||||
"""
|
||||
Load selected media based on node's unique ID.
|
||||
|
||||
@@ -95,11 +136,9 @@ class LocalImageLoaderNode(ComfyAssetsBaseNode):
|
||||
unique_id: Unique identifier for this node instance
|
||||
|
||||
Returns:
|
||||
Tuple of (image tensor, video path, audio path, info string)
|
||||
Tuple of (image tensor, info string)
|
||||
"""
|
||||
image_tensor = create_empty_tensor()
|
||||
video_path = ""
|
||||
audio_path = ""
|
||||
info_string = ""
|
||||
|
||||
selections = load_selections()
|
||||
@@ -108,150 +147,223 @@ class LocalImageLoaderNode(ComfyAssetsBaseNode):
|
||||
# Load image if selected
|
||||
image_selection = node_selections.get("image")
|
||||
if image_selection and image_selection.get("path"):
|
||||
image_path = image_selection["path"]
|
||||
if os.path.exists(image_path):
|
||||
# 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:
|
||||
print(f"KikoLocalImageLoader: Error loading image: {e}")
|
||||
logger.error("Error loading image: %s", e)
|
||||
|
||||
# Get video path if selected
|
||||
video_selection = node_selections.get("video")
|
||||
if video_selection and video_selection.get("path"):
|
||||
if os.path.exists(video_selection["path"]):
|
||||
video_path = video_selection["path"]
|
||||
|
||||
# Get audio path if selected
|
||||
audio_selection = node_selections.get("audio")
|
||||
if audio_selection and audio_selection.get("path"):
|
||||
if os.path.exists(audio_selection["path"]):
|
||||
audio_path = audio_selection["path"]
|
||||
|
||||
return (image_tensor, video_path, audio_path, info_string)
|
||||
return (image_tensor, info_string)
|
||||
|
||||
|
||||
# Setup API routes
|
||||
try:
|
||||
import server
|
||||
from aiohttp import web
|
||||
import urllib.parse
|
||||
import io
|
||||
from PIL import Image
|
||||
from .logic import scan_directory
|
||||
# ---------------------------------------------------------------------------
|
||||
# API route handlers (module level so they can be tested without a server)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
prompt_server = server.PromptServer.instance
|
||||
|
||||
@prompt_server.routes.post("/kiko_local_image_loader/set_node_selection")
|
||||
async def set_node_selection(request):
|
||||
"""API endpoint to set node selection."""
|
||||
try:
|
||||
data = await request.json()
|
||||
node_id = str(data.get("node_id"))
|
||||
path = data.get("path")
|
||||
media_type = data.get("type")
|
||||
def _error(status: int, message: str):
|
||||
return web.json_response({"status": "error", "error": message}, status=status)
|
||||
|
||||
if not all([node_id, path, media_type]):
|
||||
return web.json_response(
|
||||
{"status": "error", "message": "Missing required data."}, status=400
|
||||
)
|
||||
|
||||
selections = load_selections()
|
||||
if node_id not in selections:
|
||||
selections[node_id] = {}
|
||||
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
|
||||
|
||||
selections[node_id][media_type] = {"path": path}
|
||||
save_selections(selections)
|
||||
|
||||
return web.json_response({"status": "ok"})
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)}, status=500)
|
||||
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))
|
||||
|
||||
@prompt_server.routes.get("/kiko_local_image_loader/get_saved_paths")
|
||||
async def get_saved_paths(request):
|
||||
"""API endpoint to get saved directory paths."""
|
||||
config = load_config()
|
||||
return web.json_response({"saved_paths": config.get("saved_paths", [])})
|
||||
|
||||
@prompt_server.routes.post("/kiko_local_image_loader/save_paths")
|
||||
async def save_paths(request):
|
||||
"""API endpoint to save directory paths."""
|
||||
try:
|
||||
data = await request.json()
|
||||
paths = data.get("paths", [])
|
||||
config = load_config()
|
||||
config["saved_paths"] = paths
|
||||
save_config(config)
|
||||
return web.json_response({"status": "ok"})
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)}, status=500)
|
||||
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.")
|
||||
|
||||
@prompt_server.routes.get("/kiko_local_image_loader/images")
|
||||
async def get_local_images(request):
|
||||
"""API endpoint to get images from a directory."""
|
||||
directory = request.query.get("directory", "")
|
||||
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.")
|
||||
|
||||
if not directory or not os.path.isdir(directory):
|
||||
return web.json_response({"error": "Directory not found."}, status=404)
|
||||
# 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.")
|
||||
|
||||
# Save last path
|
||||
config = load_config()
|
||||
config["last_path"] = directory
|
||||
save_config(config)
|
||||
selections = load_selections()
|
||||
selections.setdefault(str(node_id), {})[media_type] = {"path": path}
|
||||
save_selections(selections)
|
||||
return web.json_response({"status": "ok"})
|
||||
|
||||
show_videos = request.query.get("show_videos", "false").lower() == "true"
|
||||
show_audio = request.query.get("show_audio", "false").lower() == "true"
|
||||
|
||||
page = int(request.query.get("page", 1))
|
||||
per_page = int(request.query.get("per_page", 50))
|
||||
sort_by = request.query.get("sort_by", "name")
|
||||
sort_order = request.query.get("sort_order", "asc")
|
||||
async def get_saved_paths(request):
|
||||
"""Return saved directory paths."""
|
||||
return web.json_response({"saved_paths": load_config().get("saved_paths", [])})
|
||||
|
||||
try:
|
||||
items = scan_directory(
|
||||
directory, show_videos, show_audio, sort_by, sort_order
|
||||
)
|
||||
|
||||
# Get parent directory
|
||||
parent_directory = os.path.dirname(directory)
|
||||
if parent_directory == directory:
|
||||
parent_directory = None
|
||||
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"})
|
||||
|
||||
# Paginate results
|
||||
start = (page - 1) * per_page
|
||||
end = start + per_page
|
||||
paginated_items = items[start:end]
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"items": paginated_items,
|
||||
"total_pages": (len(items) + per_page - 1) // per_page,
|
||||
"current_page": page,
|
||||
"current_directory": directory,
|
||||
"parent_directory": parent_directory,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
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.")
|
||||
|
||||
@prompt_server.routes.get("/kiko_local_image_loader/get_last_path")
|
||||
async def get_last_path(request):
|
||||
"""API endpoint to get last used directory path."""
|
||||
return web.json_response({"last_path": load_config().get("last_path", "")})
|
||||
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.")
|
||||
|
||||
@prompt_server.routes.get("/kiko_local_image_loader/thumbnail")
|
||||
async def get_thumbnail(request):
|
||||
"""API endpoint to get image thumbnail."""
|
||||
filepath = request.query.get("filepath")
|
||||
if not filepath or ".." in filepath:
|
||||
return web.Response(status=400)
|
||||
# Normalize path (trailing slashes etc.) but keep the user-visible spelling
|
||||
directory = os.path.normpath(directory)
|
||||
|
||||
filepath = urllib.parse.unquote(filepath)
|
||||
if not os.path.exists(filepath):
|
||||
return web.Response(status=404)
|
||||
config = load_config()
|
||||
config["last_path"] = directory
|
||||
save_config(config)
|
||||
|
||||
try:
|
||||
img = Image.open(filepath)
|
||||
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
|
||||
)
|
||||
@@ -259,33 +371,39 @@ try:
|
||||
img.thumbnail([320, 320], Image.LANCZOS)
|
||||
|
||||
buffer = io.BytesIO()
|
||||
format, content_type = (
|
||||
fmt, content_type = (
|
||||
("PNG", "image/png") if has_alpha else ("JPEG", "image/jpeg")
|
||||
)
|
||||
img.save(buffer, format=format, quality=90 if format == "JPEG" else None)
|
||||
buffer.seek(0)
|
||||
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)
|
||||
|
||||
return web.Response(body=buffer.read(), content_type=content_type)
|
||||
except Exception as e:
|
||||
print(f"KikoLocalImageLoader: Error generating thumbnail: {e}")
|
||||
return web.Response(status=500)
|
||||
|
||||
@prompt_server.routes.get("/kiko_local_image_loader/view")
|
||||
async def view_image(request):
|
||||
"""API endpoint to view full image."""
|
||||
filepath = request.query.get("filepath")
|
||||
if not filepath or ".." in filepath:
|
||||
return web.Response(status=400)
|
||||
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)
|
||||
|
||||
filepath = urllib.parse.unquote(filepath)
|
||||
if not os.path.exists(filepath):
|
||||
return web.Response(status=404)
|
||||
|
||||
try:
|
||||
return web.FileResponse(filepath)
|
||||
except Exception:
|
||||
return web.Response(status=500)
|
||||
# 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
|
||||
|
||||
@@ -8,5 +8,80 @@
|
||||
"image": {
|
||||
"path": "/home/vito/ai-apps/ComfyUI-3.12/output/CharacterName_00016_.png"
|
||||
}
|
||||
},
|
||||
"18": {
|
||||
"image": {
|
||||
"path": "/home/vito/ai-apps/ComfyUI/output/vids/KikoSave_00005.png"
|
||||
}
|
||||
},
|
||||
"445": {
|
||||
"image": {
|
||||
"path": "/home/vito/ai-apps/ComfyUI/output/ComfyUI_00002_.png"
|
||||
}
|
||||
},
|
||||
"23": {
|
||||
"image": {
|
||||
"path": "/home/vito/ai-apps/ComfyUI/output/Z_Image_Char/Image_00098_.png"
|
||||
}
|
||||
},
|
||||
"69": {
|
||||
"image": {
|
||||
"path": "/home/vito/Downloads/KikoSave_00086.png"
|
||||
}
|
||||
},
|
||||
"52": {
|
||||
"image": {
|
||||
"path": "/home/vito/ai-apps/ComfyUI/output/kiko/XXX/images/kiko_00038_.png"
|
||||
}
|
||||
},
|
||||
"38": {
|
||||
"image": {
|
||||
"path": "/home/vito/Downloads/vito/IMG_20160422_163419.jpg"
|
||||
}
|
||||
},
|
||||
"170": {
|
||||
"image": {
|
||||
"path": "/home/vito/ai-apps/ComfyUI/output/Z_Image_Char/Image_00326_.png"
|
||||
}
|
||||
},
|
||||
"214": {
|
||||
"image": {
|
||||
"path": "/home/vito/ai-apps/ComfyUI/output/Z_Image_Char/Image_00329_.png"
|
||||
}
|
||||
},
|
||||
"299": {
|
||||
"image": {
|
||||
"path": "/home/vito/Downloads/images/KikoSave_00016.png"
|
||||
}
|
||||
},
|
||||
"527": {
|
||||
"image": {
|
||||
"path": "/home/vito/Downloads/ComfyUI_temp_sktzg_00012_.png"
|
||||
}
|
||||
},
|
||||
"522": {
|
||||
"image": {
|
||||
"path": "/home/vito/Downloads/KikoSave_00086.png"
|
||||
}
|
||||
},
|
||||
"517": {
|
||||
"image": {
|
||||
"path": "/home/vito/ai-apps/ComfyUI/output/vids/KikoSave_00005.png"
|
||||
}
|
||||
},
|
||||
"144": {
|
||||
"image": {
|
||||
"path": "/home/vito/ai-apps/ComfyUI/output/2025-04-24/ComfyUI_00002_.png"
|
||||
}
|
||||
},
|
||||
"569": {
|
||||
"image": {
|
||||
"path": "/home/vito/ai-apps/ComfyUI/output/vids/KikoSave_00008.png"
|
||||
}
|
||||
},
|
||||
"136": {
|
||||
"image": {
|
||||
"path": "/home/vito/ai-apps/ComfyUI/output/vids/KikoSave_00005.png"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,21 +2,179 @@
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from pathlib import Path
|
||||
from typing import Optional, Callable
|
||||
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):
|
||||
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:
|
||||
@@ -38,6 +196,15 @@ class BaseDownloader(ABC):
|
||||
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
|
||||
|
||||
@@ -74,16 +241,152 @@ class BaseDownloader(ABC):
|
||||
Returns:
|
||||
Extracted filename or None
|
||||
"""
|
||||
try:
|
||||
if "filename=" in content_disposition:
|
||||
filename = content_disposition.split("filename=")[1]
|
||||
# Remove quotes and whitespace
|
||||
filename = filename.strip().strip('"').strip("'")
|
||||
return filename
|
||||
except Exception:
|
||||
pass
|
||||
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
|
||||
|
||||
|
||||
@@ -1,36 +1,37 @@
|
||||
"""CivitAI downloader implementation"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
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
|
||||
from urllib.parse import urlparse, parse_qs, unquote, urljoin
|
||||
|
||||
from .base import BaseDownloader
|
||||
from .base import (
|
||||
USER_AGENT,
|
||||
BaseDownloader,
|
||||
safe_server_filename,
|
||||
validate_filename,
|
||||
validate_url_scheme,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CHUNK_SIZE = 1638400
|
||||
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||
API_BASE = "https://civitai.com/api/v1"
|
||||
MAX_RETRIES = 3
|
||||
RETRY_DELAY = 5
|
||||
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 __init__(self, token: Optional[str] = None):
|
||||
"""Initialize CivitAI downloader
|
||||
|
||||
Args:
|
||||
token: Optional CivitAI API token
|
||||
"""
|
||||
super().__init__(token)
|
||||
|
||||
def _make_request(
|
||||
self, url: str, headers: Optional[Dict[str, str]] = None
|
||||
) -> urllib.request.Request:
|
||||
@@ -105,7 +106,7 @@ class CivitAIDownloader(BaseDownloader):
|
||||
request = self._make_request(url)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(request) as response:
|
||||
with self.open_url(request) as response:
|
||||
return json.loads(response.read().decode())
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 404:
|
||||
@@ -133,11 +134,20 @@ class CivitAIDownloader(BaseDownloader):
|
||||
Raises:
|
||||
Exception: If download fails
|
||||
"""
|
||||
# Validate output path
|
||||
self.validate_output_path(output_path)
|
||||
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 "civitai.com" in url and "/api/download/models/" not in url:
|
||||
if "/api/download/models/" not in url:
|
||||
ids = self._parse_civitai_url(url)
|
||||
|
||||
# If we have a version ID, use it directly
|
||||
@@ -162,150 +172,67 @@ class CivitAIDownloader(BaseDownloader):
|
||||
headers = {"User-Agent": USER_AGENT}
|
||||
if self.token:
|
||||
headers["Authorization"] = f"Bearer {self.token}"
|
||||
|
||||
# Disable automatic redirect handling
|
||||
class NoRedirection(urllib.request.HTTPErrorProcessor):
|
||||
def http_response(self, request, response):
|
||||
return response
|
||||
|
||||
https_response = http_response
|
||||
|
||||
request = urllib.request.Request(url, headers=headers)
|
||||
opener = urllib.request.build_opener(NoRedirection)
|
||||
|
||||
try:
|
||||
response = opener.open(request)
|
||||
response = self.open_url(request, follow_redirects=False)
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 401:
|
||||
raise Exception(
|
||||
"Authentication required. Please provide a valid API token."
|
||||
)
|
||||
elif e.code == 403:
|
||||
raise Exception(
|
||||
"Access forbidden. The model might be restricted or require special permissions."
|
||||
)
|
||||
elif e.code == 404:
|
||||
raise Exception(
|
||||
"Model not found. The URL might be incorrect or the model was removed."
|
||||
)
|
||||
elif e.code == 429:
|
||||
raise Exception(
|
||||
"Rate limited. Please wait a moment before trying again."
|
||||
)
|
||||
else:
|
||||
raise Exception(f"HTTP error {e.code}: {e.reason}")
|
||||
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}")
|
||||
|
||||
# Handle redirects
|
||||
if response.status in [301, 302, 303, 307, 308]:
|
||||
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)
|
||||
|
||||
# Handle relative redirects
|
||||
if redirect_url.startswith("/"):
|
||||
base_url = urlparse(url)
|
||||
redirect_url = f"{base_url.scheme}://{base_url.netloc}{redirect_url}"
|
||||
|
||||
# Extract filename from redirect URL if not provided
|
||||
if not filename:
|
||||
parsed_url = urlparse(redirect_url)
|
||||
query_params = parse_qs(parsed_url.query)
|
||||
content_disposition = query_params.get(
|
||||
"response-content-disposition", [None]
|
||||
)[0]
|
||||
filename = self._filename_from_redirect(redirect_url)
|
||||
|
||||
if content_disposition and "filename=" in content_disposition:
|
||||
filename = unquote(
|
||||
content_disposition.split("filename=")[1].strip('"')
|
||||
# The signed redirect target gets no credentials
|
||||
try:
|
||||
response = self.open_url(
|
||||
urllib.request.Request(
|
||||
redirect_url, headers={"User-Agent": USER_AGENT}
|
||||
)
|
||||
else:
|
||||
# Fallback: extract filename from URL path
|
||||
path = parsed_url.path
|
||||
if path and "/" in path:
|
||||
filename = path.split("/")[-1]
|
||||
else:
|
||||
filename = "downloaded_file.safetensors"
|
||||
|
||||
response = urllib.request.urlopen(redirect_url)
|
||||
elif response.status == 404:
|
||||
raise Exception("File not found")
|
||||
)
|
||||
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:
|
||||
raise Exception(f"Download failed with status {response.status}")
|
||||
|
||||
# Use provided filename or extracted filename
|
||||
if not filename:
|
||||
filename = self.extract_filename(url, default="model.safetensors")
|
||||
|
||||
output_file = os.path.join(output_path, filename)
|
||||
|
||||
# Check if should download
|
||||
if not self.should_download(output_file, force):
|
||||
print(f"File already exists: {output_file}")
|
||||
return output_file
|
||||
|
||||
total_size = response.getheader("Content-Length")
|
||||
if total_size is not None:
|
||||
total_size = int(total_size)
|
||||
|
||||
print(f"Downloading: {filename}")
|
||||
print(f"Destination: {output_file}")
|
||||
if total_size:
|
||||
print(f"Size: {self.format_size(total_size)}")
|
||||
|
||||
# Download with progress
|
||||
with open(output_file, "wb") as f:
|
||||
downloaded = 0
|
||||
start_time = time.time()
|
||||
|
||||
while True:
|
||||
chunk_start_time = time.time()
|
||||
buffer = response.read(CHUNK_SIZE)
|
||||
chunk_end_time = time.time()
|
||||
|
||||
if not buffer:
|
||||
break
|
||||
|
||||
downloaded += len(buffer)
|
||||
f.write(buffer)
|
||||
chunk_time = chunk_end_time - chunk_start_time
|
||||
|
||||
# Calculate speed
|
||||
speed = self.calculate_speed(len(buffer), chunk_time)
|
||||
|
||||
# Report progress
|
||||
if total_size is not None:
|
||||
progress = downloaded / total_size
|
||||
sys.stdout.write(
|
||||
f'\r[{"=" * int(progress * 50):<50}] {progress * 100:.2f}% - {speed:.2f} MB/s'
|
||||
)
|
||||
sys.stdout.flush()
|
||||
self.report_progress(downloaded, total_size, f"{speed:.2f} MB/s")
|
||||
else:
|
||||
sys.stdout.write(
|
||||
f"\rDownloaded: {self.format_size(downloaded)} - {speed:.2f} MB/s"
|
||||
)
|
||||
sys.stdout.flush()
|
||||
self.report_progress(downloaded, 0, f"{speed:.2f} MB/s")
|
||||
|
||||
end_time = time.time()
|
||||
time_taken = end_time - start_time
|
||||
hours, remainder = divmod(time_taken, 3600)
|
||||
minutes, seconds = divmod(remainder, 60)
|
||||
|
||||
if hours > 0:
|
||||
time_str = f"{int(hours)}h {int(minutes)}m {int(seconds)}s"
|
||||
elif minutes > 0:
|
||||
time_str = f"{int(minutes)}m {int(seconds)}s"
|
||||
else:
|
||||
time_str = f"{int(seconds)}s"
|
||||
|
||||
sys.stdout.write("\n")
|
||||
print(f"✓ Download completed in {time_str}")
|
||||
print(f"✓ File saved as: {output_file}")
|
||||
|
||||
# Verify file size
|
||||
actual_size = os.path.getsize(output_file)
|
||||
if total_size and actual_size != total_size:
|
||||
raise Exception(
|
||||
f"Download incomplete. Expected {total_size} bytes, got {actual_size} bytes"
|
||||
code = response.status
|
||||
response.close()
|
||||
self.raise_for_http_status(
|
||||
code, "Download failed", {**HTTP_ERROR_HINTS, 404: "File not found"}
|
||||
)
|
||||
|
||||
return output_file
|
||||
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")
|
||||
|
||||
@@ -1,29 +1,36 @@
|
||||
"""Custom URL downloader - best effort for direct download links"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Optional
|
||||
|
||||
from .base import BaseDownloader
|
||||
from .base import (
|
||||
USER_AGENT,
|
||||
BaseDownloader,
|
||||
safe_server_filename,
|
||||
validate_filename,
|
||||
validate_url_scheme,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CHUNK_SIZE = 1638400
|
||||
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||
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"""
|
||||
"""Best-effort downloader for custom/direct URLs
|
||||
|
||||
def __init__(self, token: Optional[str] = None):
|
||||
"""Initialize custom downloader
|
||||
|
||||
Args:
|
||||
token: Optional authentication token (will be sent as Bearer token)
|
||||
"""
|
||||
super().__init__(token)
|
||||
The optional token is sent as a Bearer token and is dropped automatically
|
||||
if the server redirects to a different host.
|
||||
"""
|
||||
|
||||
def download(
|
||||
self,
|
||||
@@ -35,147 +42,57 @@ class CustomDownloader(BaseDownloader):
|
||||
"""Download file from custom URL
|
||||
|
||||
Args:
|
||||
url: Direct download URL
|
||||
output_path: Directory to save file
|
||||
filename: Optional filename override
|
||||
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 output path
|
||||
self.validate_output_path(output_path)
|
||||
validate_url_scheme(url)
|
||||
if filename:
|
||||
validate_filename(filename)
|
||||
output_dir = self.resolve_output_dir(output_path)
|
||||
|
||||
# Determine filename
|
||||
if not filename:
|
||||
filename = self.extract_filename(
|
||||
url, default="downloaded_model.safetensors"
|
||||
)
|
||||
# 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
|
||||
|
||||
output_file = os.path.join(output_path, filename)
|
||||
|
||||
# Check if should download
|
||||
if not self.should_download(output_file, force):
|
||||
print(f"File already exists: {output_file}")
|
||||
return output_file
|
||||
|
||||
# Prepare headers
|
||||
headers = {"User-Agent": USER_AGENT}
|
||||
|
||||
# Add authentication if token provided
|
||||
if self.token:
|
||||
headers["Authorization"] = f"Bearer {self.token}"
|
||||
|
||||
# Create request
|
||||
request = urllib.request.Request(url, headers=headers)
|
||||
|
||||
try:
|
||||
# First request to check if file exists and get metadata
|
||||
response = urllib.request.urlopen(request)
|
||||
|
||||
# Try to extract filename from Content-Disposition header if not provided
|
||||
if not filename:
|
||||
content_disposition = response.getheader("Content-Disposition")
|
||||
if content_disposition:
|
||||
extracted_filename = self.extract_filename_from_header(
|
||||
content_disposition
|
||||
)
|
||||
if extracted_filename:
|
||||
filename = extracted_filename
|
||||
output_file = os.path.join(output_path, filename)
|
||||
|
||||
response = self.open_url(request)
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 401:
|
||||
raise Exception(
|
||||
"Authentication required. Please provide a valid token if needed."
|
||||
)
|
||||
elif e.code == 403:
|
||||
raise Exception(
|
||||
"Access forbidden. The URL might require authentication or special permissions."
|
||||
)
|
||||
elif e.code == 404:
|
||||
raise Exception("File not found. Please check the URL.")
|
||||
elif e.code == 429:
|
||||
raise Exception(
|
||||
"Rate limited. Please wait a moment before trying again."
|
||||
)
|
||||
else:
|
||||
raise Exception(f"HTTP error {e.code}: {e.reason}")
|
||||
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}")
|
||||
|
||||
# Get file size
|
||||
total_size = response.getheader("Content-Length")
|
||||
if total_size is not None:
|
||||
total_size = int(total_size)
|
||||
|
||||
print(f"Downloading: {filename}")
|
||||
print(f"Destination: {output_file}")
|
||||
if total_size:
|
||||
print(f"Size: {self.format_size(total_size)}")
|
||||
else:
|
||||
print("Size: Unknown")
|
||||
|
||||
# Download with progress
|
||||
with open(output_file, "wb") as f:
|
||||
downloaded = 0
|
||||
start_time = time.time()
|
||||
|
||||
while True:
|
||||
chunk_start_time = time.time()
|
||||
buffer = response.read(CHUNK_SIZE)
|
||||
chunk_end_time = time.time()
|
||||
|
||||
if not buffer:
|
||||
break
|
||||
|
||||
downloaded += len(buffer)
|
||||
f.write(buffer)
|
||||
chunk_time = chunk_end_time - chunk_start_time
|
||||
|
||||
# Calculate speed
|
||||
speed = self.calculate_speed(len(buffer), chunk_time)
|
||||
|
||||
# Report progress
|
||||
if total_size is not None:
|
||||
progress = downloaded / total_size
|
||||
sys.stdout.write(
|
||||
f'\r[{"=" * int(progress * 50):<50}] {progress * 100:.2f}% - {speed:.2f} MB/s'
|
||||
)
|
||||
sys.stdout.flush()
|
||||
self.report_progress(downloaded, total_size, f"{speed:.2f} MB/s")
|
||||
else:
|
||||
sys.stdout.write(
|
||||
f"\rDownloaded: {self.format_size(downloaded)} - {speed:.2f} MB/s"
|
||||
)
|
||||
sys.stdout.flush()
|
||||
self.report_progress(downloaded, 0, f"{speed:.2f} MB/s")
|
||||
|
||||
end_time = time.time()
|
||||
time_taken = end_time - start_time
|
||||
hours, remainder = divmod(time_taken, 3600)
|
||||
minutes, seconds = divmod(remainder, 60)
|
||||
|
||||
if hours > 0:
|
||||
time_str = f"{int(hours)}h {int(minutes)}m {int(seconds)}s"
|
||||
elif minutes > 0:
|
||||
time_str = f"{int(minutes)}m {int(seconds)}s"
|
||||
else:
|
||||
time_str = f"{int(seconds)}s"
|
||||
|
||||
sys.stdout.write("\n")
|
||||
print(f"✓ Download completed in {time_str}")
|
||||
print(f"✓ File saved as: {output_file}")
|
||||
|
||||
# Verify file size if known
|
||||
actual_size = os.path.getsize(output_file)
|
||||
if total_size and actual_size != total_size:
|
||||
print(
|
||||
f"⚠ Warning: Downloaded size ({actual_size} bytes) doesn't match expected size ({total_size} bytes)"
|
||||
if not filename:
|
||||
header_name = self.extract_filename_from_header(
|
||||
response.getheader("Content-Disposition") or ""
|
||||
)
|
||||
# Don't raise error for custom URLs as size mismatch might be acceptable
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
@@ -67,7 +67,8 @@ class URLDetector:
|
||||
Returns:
|
||||
True if CivitAI URL
|
||||
"""
|
||||
if "civitai.com" not in parsed.netloc:
|
||||
# Validate exact domain match to prevent subdomain attacks
|
||||
if parsed.netloc not in ("civitai.com", "www.civitai.com"):
|
||||
return False
|
||||
|
||||
# Check for API download endpoint
|
||||
@@ -90,8 +91,15 @@ class URLDetector:
|
||||
Returns:
|
||||
True if HuggingFace URL
|
||||
"""
|
||||
# Check main domain and CDN
|
||||
if "huggingface.co" in parsed.netloc:
|
||||
# Validate exact domain match to prevent subdomain attacks
|
||||
# Support both main domain and CDN domains
|
||||
allowed_domains = (
|
||||
"huggingface.co",
|
||||
"www.huggingface.co",
|
||||
"cdn.huggingface.co",
|
||||
"cdn-lfs.huggingface.co",
|
||||
)
|
||||
if parsed.netloc in allowed_domains:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@@ -1,31 +1,32 @@
|
||||
"""HuggingFace downloader implementation"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse, quote
|
||||
|
||||
from .base import BaseDownloader
|
||||
from .base import (
|
||||
USER_AGENT,
|
||||
BaseDownloader,
|
||||
safe_server_filename,
|
||||
validate_filename,
|
||||
validate_url_scheme,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CHUNK_SIZE = 1638400
|
||||
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||
HTTP_ERROR_HINTS = {
|
||||
401: "Authentication required. Please provide a valid HuggingFace token.",
|
||||
403: "Access forbidden. The model might be gated or require special permissions.",
|
||||
404: "File not found. The URL might be incorrect or the file was removed.",
|
||||
}
|
||||
|
||||
|
||||
class HuggingFaceDownloader(BaseDownloader):
|
||||
"""Downloader for HuggingFace models"""
|
||||
|
||||
def __init__(self, token: Optional[str] = None):
|
||||
"""Initialize HuggingFace downloader
|
||||
|
||||
Args:
|
||||
token: Optional HuggingFace API token
|
||||
"""
|
||||
super().__init__(token)
|
||||
|
||||
def _parse_huggingface_url(self, url: str) -> dict:
|
||||
"""Parse HuggingFace URL to extract repo and file information
|
||||
|
||||
@@ -103,21 +104,23 @@ class HuggingFaceDownloader(BaseDownloader):
|
||||
"""Download file from HuggingFace
|
||||
|
||||
Args:
|
||||
url: HuggingFace URL to download
|
||||
output_path: Directory to save file
|
||||
filename: Optional filename override
|
||||
url: HuggingFace URL to download (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 output path
|
||||
self.validate_output_path(output_path)
|
||||
validate_url_scheme(url)
|
||||
if filename:
|
||||
validate_filename(filename)
|
||||
output_dir = self.resolve_output_dir(output_path)
|
||||
|
||||
# Parse URL to get file information
|
||||
url_info = self._parse_huggingface_url(url)
|
||||
|
||||
# Convert blob URL to resolve URL if needed
|
||||
@@ -125,124 +128,34 @@ class HuggingFaceDownloader(BaseDownloader):
|
||||
download_url = self._construct_download_url(
|
||||
url_info["repo_id"], url_info["filename"], url_info["revision"]
|
||||
)
|
||||
print(f"[HuggingFace] Converted URL to: {download_url}")
|
||||
logger.info("Converted URL to: %s", download_url)
|
||||
else:
|
||||
# Use original URL if parsing failed
|
||||
download_url = url
|
||||
|
||||
# Determine filename
|
||||
if not filename:
|
||||
if url_info["filename"]:
|
||||
# Use just the basename from the URL
|
||||
filename = os.path.basename(url_info["filename"])
|
||||
name = os.path.basename(url_info["filename"])
|
||||
else:
|
||||
filename = self.extract_filename(url, default="model.safetensors")
|
||||
name = self.extract_filename(url, default="model.safetensors")
|
||||
filename = safe_server_filename(name, "model.safetensors")
|
||||
|
||||
output_file = os.path.join(output_path, filename)
|
||||
|
||||
# Check if should download
|
||||
output_file = os.path.join(output_dir, filename)
|
||||
if not self.should_download(output_file, force):
|
||||
print(f"File already exists: {output_file}")
|
||||
logger.info("File already exists: %s", output_file)
|
||||
return output_file
|
||||
|
||||
# Prepare headers
|
||||
headers = {"User-Agent": USER_AGENT}
|
||||
if self.token:
|
||||
headers["Authorization"] = f"Bearer {self.token}"
|
||||
|
||||
# Create request with converted download URL
|
||||
request = urllib.request.Request(download_url, headers=headers)
|
||||
|
||||
try:
|
||||
response = urllib.request.urlopen(request)
|
||||
response = self.open_url(request)
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 401:
|
||||
raise Exception(
|
||||
"Authentication required. Please provide a valid HuggingFace token."
|
||||
)
|
||||
elif e.code == 403:
|
||||
raise Exception(
|
||||
"Access forbidden. The model might be gated or require special permissions."
|
||||
)
|
||||
elif e.code == 404:
|
||||
raise Exception(
|
||||
"File not found. The URL might be incorrect or the file was removed."
|
||||
)
|
||||
elif e.code == 429:
|
||||
raise Exception(
|
||||
"Rate limited. Please wait a moment before trying again."
|
||||
)
|
||||
else:
|
||||
raise Exception(f"HTTP error {e.code}: {e.reason}")
|
||||
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}")
|
||||
|
||||
# Get file size
|
||||
total_size = response.getheader("Content-Length")
|
||||
if total_size is not None:
|
||||
total_size = int(total_size)
|
||||
|
||||
print(f"Downloading: {filename}")
|
||||
print(f"Destination: {output_file}")
|
||||
if total_size:
|
||||
print(f"Size: {self.format_size(total_size)}")
|
||||
|
||||
# Download with progress
|
||||
with open(output_file, "wb") as f:
|
||||
downloaded = 0
|
||||
start_time = time.time()
|
||||
|
||||
while True:
|
||||
chunk_start_time = time.time()
|
||||
buffer = response.read(CHUNK_SIZE)
|
||||
chunk_end_time = time.time()
|
||||
|
||||
if not buffer:
|
||||
break
|
||||
|
||||
downloaded += len(buffer)
|
||||
f.write(buffer)
|
||||
chunk_time = chunk_end_time - chunk_start_time
|
||||
|
||||
# Calculate speed
|
||||
speed = self.calculate_speed(len(buffer), chunk_time)
|
||||
|
||||
# Report progress
|
||||
if total_size is not None:
|
||||
progress = downloaded / total_size
|
||||
sys.stdout.write(
|
||||
f'\r[{"=" * int(progress * 50):<50}] {progress * 100:.2f}% - {speed:.2f} MB/s'
|
||||
)
|
||||
sys.stdout.flush()
|
||||
self.report_progress(downloaded, total_size, f"{speed:.2f} MB/s")
|
||||
else:
|
||||
sys.stdout.write(
|
||||
f"\rDownloaded: {self.format_size(downloaded)} - {speed:.2f} MB/s"
|
||||
)
|
||||
sys.stdout.flush()
|
||||
self.report_progress(downloaded, 0, f"{speed:.2f} MB/s")
|
||||
|
||||
end_time = time.time()
|
||||
time_taken = end_time - start_time
|
||||
hours, remainder = divmod(time_taken, 3600)
|
||||
minutes, seconds = divmod(remainder, 60)
|
||||
|
||||
if hours > 0:
|
||||
time_str = f"{int(hours)}h {int(minutes)}m {int(seconds)}s"
|
||||
elif minutes > 0:
|
||||
time_str = f"{int(minutes)}m {int(seconds)}s"
|
||||
else:
|
||||
time_str = f"{int(seconds)}s"
|
||||
|
||||
sys.stdout.write("\n")
|
||||
print(f"✓ Download completed in {time_str}")
|
||||
print(f"✓ File saved as: {output_file}")
|
||||
|
||||
# Verify file size
|
||||
actual_size = os.path.getsize(output_file)
|
||||
if total_size and actual_size != total_size:
|
||||
raise Exception(
|
||||
f"Download incomplete. Expected {total_size} bytes, got {actual_size} bytes"
|
||||
)
|
||||
|
||||
return output_file
|
||||
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)
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
"""ComfyUI Model Downloader Node"""
|
||||
|
||||
import logging
|
||||
|
||||
from ...base import ComfyAssetsBaseNode
|
||||
from .detector import URLDetector
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ModelDownloaderNode(ComfyAssetsBaseNode):
|
||||
"""ComfyUI node for downloading models from CivitAI, HuggingFace, and custom URLs"""
|
||||
@@ -101,15 +105,13 @@ class ModelDownloaderNode(ComfyAssetsBaseNode):
|
||||
detector = URLDetector()
|
||||
downloader_type = detector.detect(url)
|
||||
|
||||
print(
|
||||
f"\n[Model Downloader] Detected downloader type: {downloader_type.value}"
|
||||
)
|
||||
print(f"[Model Downloader] URL: {url}")
|
||||
print(f"[Model Downloader] Save path: {save_path}")
|
||||
logger.info("Detected downloader type: %s", downloader_type.value)
|
||||
logger.info("URL: %s", url)
|
||||
logger.info("Save path: %s", save_path)
|
||||
if filename:
|
||||
print(f"[Model Downloader] Filename: {filename}")
|
||||
logger.info("Filename: %s", filename)
|
||||
if force_download:
|
||||
print("[Model Downloader] Force download: enabled")
|
||||
logger.info("Force download: enabled")
|
||||
|
||||
# Get downloader instance
|
||||
downloader = detector.get_downloader(url, api_token=api_token)
|
||||
@@ -120,18 +122,18 @@ class ModelDownloaderNode(ComfyAssetsBaseNode):
|
||||
)
|
||||
|
||||
message = f"Successfully downloaded to {file_path}"
|
||||
print(f"[Model Downloader] {message}")
|
||||
logger.info(message)
|
||||
|
||||
return {"ui": {"text": [message]}}
|
||||
|
||||
except ValueError as e:
|
||||
error_msg = f"Invalid URL: {str(e)}"
|
||||
print(f"[Model Downloader] Error: {error_msg}")
|
||||
logger.error(error_msg)
|
||||
return {"ui": {"text": [error_msg]}}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Download failed: {str(e)}"
|
||||
print(f"[Model Downloader] Error: {error_msg}")
|
||||
logger.error(error_msg)
|
||||
return {"ui": {"text": [error_msg]}}
|
||||
|
||||
@classmethod
|
||||
@@ -144,11 +146,11 @@ class ModelDownloaderNode(ComfyAssetsBaseNode):
|
||||
import time
|
||||
import hashlib
|
||||
|
||||
# Create a unique hash based on inputs and current time
|
||||
input_str = (
|
||||
f"{url}|{save_path}|{filename}|{api_token}|{force_download}|{time.time()}"
|
||||
)
|
||||
return hashlib.md5(input_str.encode()).hexdigest()
|
||||
# Create a unique hash based on non-sensitive inputs and current time
|
||||
# Note: api_token is excluded to avoid sensitive data in hash
|
||||
# The token doesn't affect cache invalidation - URL changes are sufficient
|
||||
input_str = f"{url}|{save_path}|{filename}|{force_download}|{time.time()}"
|
||||
return hashlib.sha256(input_str.encode()).hexdigest()
|
||||
|
||||
|
||||
# Node display name
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Project File Path tool for ComfyUI-KikoTools."""
|
||||
|
||||
from .node import ProjectFilePathNode
|
||||
|
||||
__all__ = ["ProjectFilePathNode"]
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Path building logic for the Project File Path node."""
|
||||
|
||||
import os
|
||||
import re
|
||||
from datetime import date
|
||||
from typing import Optional
|
||||
|
||||
# Characters that are invalid in Windows paths (and awkward elsewhere)
|
||||
_UNSAFE_CHARS = re.compile(r'[<>:"|?*\x00-\x1f]')
|
||||
_SEPARATORS = re.compile(r"[\\/]+")
|
||||
|
||||
# Only these are treated as a file extension and stripped from the filename,
|
||||
# so names like "render_v1.5" keep their dot.
|
||||
FILE_EXTENSIONS = frozenset(
|
||||
{
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".webp",
|
||||
".gif",
|
||||
".bmp",
|
||||
".tif",
|
||||
".tiff",
|
||||
".mp4",
|
||||
".webm",
|
||||
".mov",
|
||||
".mkv",
|
||||
".avi",
|
||||
".wav",
|
||||
".mp3",
|
||||
".flac",
|
||||
".ogg",
|
||||
".txt",
|
||||
".json",
|
||||
".latent",
|
||||
}
|
||||
)
|
||||
|
||||
SEPARATOR_OPTIONS = ("auto", "forward_slash", "backslash")
|
||||
|
||||
|
||||
def _clean_part(part: str) -> str:
|
||||
"""Clean one path segment: drop unsafe chars, spaces to underscores."""
|
||||
part = _UNSAFE_CHARS.sub("", part).strip(" .")
|
||||
return part.replace(" ", "_")
|
||||
|
||||
|
||||
def sanitize_component(text: str) -> str:
|
||||
"""Clean a folder component, keeping nested folders as 'a/b'.
|
||||
|
||||
Splits on both / and \\, drops empty, '.' and '..' segments, so the
|
||||
result is always relative and can never climb out of its parent.
|
||||
"""
|
||||
parts = (_clean_part(p) for p in _SEPARATORS.split(text or ""))
|
||||
return "/".join(p for p in parts if p and p not in (".", ".."))
|
||||
|
||||
|
||||
def sanitize_filename(text: str) -> str:
|
||||
"""Clean a filename: no folders, and a known file extension removed."""
|
||||
name = _UNSAFE_CHARS.sub("", _SEPARATORS.sub("", text or ""))
|
||||
stem, extension = os.path.splitext(name)
|
||||
if extension.lower() in FILE_EXTENSIONS:
|
||||
name = stem
|
||||
elif name.strip().lower() in FILE_EXTENSIONS: # just ".png": splitext sees no ext
|
||||
name = ""
|
||||
return _clean_part(name)
|
||||
|
||||
|
||||
def _resolve_separator(separator: str) -> str:
|
||||
if separator == "auto":
|
||||
return os.sep
|
||||
if separator == "forward_slash":
|
||||
return "/"
|
||||
if separator == "backslash":
|
||||
return "\\"
|
||||
raise ValueError(
|
||||
f"Unknown separator '{separator}'; use one of {', '.join(SEPARATOR_OPTIONS)}"
|
||||
)
|
||||
|
||||
|
||||
def build_project_path(
|
||||
root: str,
|
||||
project_name: str,
|
||||
subfolder: str,
|
||||
filename: str,
|
||||
date_folder: bool = False,
|
||||
separator: str = "auto",
|
||||
today: Optional[date] = None,
|
||||
) -> str:
|
||||
"""Build 'root/project/[YYYY-MM-DD/]subfolder/filename'.
|
||||
|
||||
Raises:
|
||||
ValueError: If the filename is empty after cleaning, or the
|
||||
separator is unknown.
|
||||
"""
|
||||
sep = _resolve_separator(separator)
|
||||
clean_filename = sanitize_filename(filename)
|
||||
if not clean_filename:
|
||||
raise ValueError(f"filename {filename!r} is empty after removing unsafe parts")
|
||||
|
||||
folders = [sanitize_component(root), sanitize_component(project_name)]
|
||||
if date_folder:
|
||||
folders.append((today or date.today()).isoformat())
|
||||
folders.append(sanitize_component(subfolder))
|
||||
|
||||
path = "/".join([f for f in folders if f] + [clean_filename])
|
||||
return path.replace("/", sep)
|
||||
@@ -0,0 +1,90 @@
|
||||
"""ComfyUI node implementation for Project File Path."""
|
||||
|
||||
from datetime import date
|
||||
from typing import Any, Dict, Tuple
|
||||
|
||||
from ...base import ComfyAssetsBaseNode
|
||||
from .logic import SEPARATOR_OPTIONS, build_project_path
|
||||
|
||||
|
||||
def _today() -> date:
|
||||
return date.today()
|
||||
|
||||
|
||||
class ProjectFilePathNode(ComfyAssetsBaseNode):
|
||||
"""
|
||||
Builds a save prefix like 'MyProject/2026-10-13/images/image'.
|
||||
|
||||
Wire the output into a save node's filename_prefix. Every component is
|
||||
sanitized so the result stays inside ComfyUI's output folder.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls) -> Dict[str, Any]:
|
||||
return {
|
||||
"required": {
|
||||
"root": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"tooltip": "Optional top folder inside the output folder",
|
||||
},
|
||||
),
|
||||
"project_name": ("STRING", {"default": "MyProject"}),
|
||||
"subfolder": (
|
||||
"STRING",
|
||||
{"default": "images", "tooltip": "Use a/b for nested folders"},
|
||||
),
|
||||
"filename": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "image",
|
||||
"tooltip": "File extensions like .png are removed",
|
||||
},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"date_folder": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": False,
|
||||
"tooltip": "Insert a YYYY-MM-DD folder after the project",
|
||||
},
|
||||
),
|
||||
"separator": (list(SEPARATOR_OPTIONS), {"default": "auto"}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("path",)
|
||||
FUNCTION = "generate_path"
|
||||
CATEGORY = "🫶 ComfyAssets/🛠️ Utils"
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, date_folder: bool = False, **kwargs) -> str:
|
||||
# Output depends only on the inputs, except for the date folder:
|
||||
# re-run when the day changes so open workflows get the new date.
|
||||
return _today().isoformat() if date_folder else ""
|
||||
|
||||
def generate_path(
|
||||
self,
|
||||
root: str,
|
||||
project_name: str,
|
||||
subfolder: str,
|
||||
filename: str,
|
||||
date_folder: bool = False,
|
||||
separator: str = "auto",
|
||||
) -> Tuple[str]:
|
||||
try:
|
||||
path = build_project_path(
|
||||
root,
|
||||
project_name,
|
||||
subfolder,
|
||||
filename,
|
||||
date_folder=date_folder,
|
||||
separator=separator,
|
||||
today=_today(),
|
||||
)
|
||||
except ValueError as e:
|
||||
self.handle_error(str(e), e)
|
||||
return (path,)
|
||||
@@ -6,6 +6,8 @@ Pure functions for dimension extraction and scaling calculations
|
||||
import torch
|
||||
from typing import Tuple, Optional, Dict
|
||||
|
||||
MIN_DIMENSION = 8
|
||||
|
||||
|
||||
def extract_dimensions(
|
||||
image: Optional[torch.Tensor] = None,
|
||||
@@ -100,8 +102,9 @@ def calculate_scaled_dimensions(
|
||||
new_width = int(width * scale_factor)
|
||||
new_height = int(height * scale_factor)
|
||||
|
||||
# Ensure divisible by 8
|
||||
return ensure_divisible_by_8(new_width, new_height)
|
||||
# Ensure divisible by 8, never collapsing to zero for tiny inputs
|
||||
new_width, new_height = ensure_divisible_by_8(new_width, new_height)
|
||||
return max(new_width, MIN_DIMENSION), max(new_height, MIN_DIMENSION)
|
||||
|
||||
|
||||
def validate_scale_factor(
|
||||
|
||||
@@ -59,7 +59,7 @@ class SamplerComboCompactNode(ComfyAssetsBaseNode):
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("SAMPLER", SCHEDULERS, "INT", "FLOAT")
|
||||
RETURN_TYPES = (SAMPLERS, SCHEDULERS, "INT", "FLOAT")
|
||||
RETURN_NAMES = ("sampler", "scheduler", "steps", "cfg")
|
||||
FUNCTION = "get_combo"
|
||||
CATEGORY = "🫶 ComfyAssets/🌀 Samplers"
|
||||
@@ -82,27 +82,13 @@ class SamplerComboCompactNode(ComfyAssetsBaseNode):
|
||||
try:
|
||||
# Use the same validation logic but with compact interface
|
||||
result = get_sampler_combo(sampler, sched, steps, cfg)
|
||||
# Create the sampler object
|
||||
try:
|
||||
import comfy.samplers
|
||||
|
||||
sampler_obj = comfy.samplers.sampler_object(result[0])
|
||||
except ImportError:
|
||||
# Return sampler name for testing
|
||||
sampler_obj = result[0]
|
||||
return (sampler_obj, result[1], result[2], result[3])
|
||||
# Return the sampler name as string, not object
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
# Graceful fallback
|
||||
self.handle_error(f"Error in compact combo: {str(e)}")
|
||||
try:
|
||||
import comfy.samplers
|
||||
|
||||
sampler_obj = comfy.samplers.sampler_object("euler")
|
||||
except ImportError:
|
||||
# Return sampler name for testing
|
||||
sampler_obj = "euler"
|
||||
return (sampler_obj, "normal", 20, 7.0)
|
||||
return ("euler", "normal", 20, 7.0)
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""String representation of the compact node."""
|
||||
|
||||
@@ -64,7 +64,7 @@ class SamplerComboNode(ComfyAssetsBaseNode):
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("SAMPLER", SCHEDULERS, "INT", "FLOAT")
|
||||
RETURN_TYPES = (SAMPLERS, SCHEDULERS, "INT", "FLOAT")
|
||||
RETURN_NAMES = ("sampler_name", "scheduler", "steps", "cfg")
|
||||
FUNCTION = "get_sampler_combo"
|
||||
CATEGORY = "🫶 ComfyAssets/🌀 Samplers"
|
||||
@@ -97,33 +97,18 @@ class SamplerComboNode(ComfyAssetsBaseNode):
|
||||
f"steps={steps}, cfg={cfg}. "
|
||||
f"Using safe defaults: euler, normal, 20 steps, CFG 7.0"
|
||||
)
|
||||
try:
|
||||
import comfy.samplers
|
||||
|
||||
sampler = comfy.samplers.sampler_object("euler")
|
||||
except ImportError:
|
||||
# Return mock object for testing
|
||||
sampler = "euler"
|
||||
return (sampler, "normal", 20, 7.0)
|
||||
return ("euler", "normal", 20, 7.0)
|
||||
|
||||
# Process and return the combo
|
||||
result = get_sampler_combo(sampler_name, scheduler, steps, cfg)
|
||||
|
||||
# Create the sampler object
|
||||
try:
|
||||
import comfy.samplers
|
||||
|
||||
sampler = comfy.samplers.sampler_object(result[0])
|
||||
except ImportError:
|
||||
# Return sampler name for testing
|
||||
sampler = result[0]
|
||||
|
||||
self.log_info(
|
||||
f"Configured sampler combo: {result[0]}, {result[1]}, "
|
||||
f"{result[2]} steps, CFG {result[3]}"
|
||||
)
|
||||
|
||||
return (sampler, result[1], result[2], result[3])
|
||||
# Return the sampler name as string, not object
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
# Handle any unexpected errors gracefully
|
||||
@@ -134,14 +119,7 @@ class SamplerComboNode(ComfyAssetsBaseNode):
|
||||
f"{self.__class__.__name__}: Error processing sampler combo: {str(e)}. "
|
||||
f"Using safe defaults: euler, normal, 20 steps, CFG 7.0"
|
||||
)
|
||||
try:
|
||||
import comfy.samplers
|
||||
|
||||
sampler = comfy.samplers.sampler_object("euler")
|
||||
except ImportError:
|
||||
# Return mock object for testing
|
||||
sampler = "euler"
|
||||
return (sampler, "normal", 20, 7.0)
|
||||
return ("euler", "normal", 20, 7.0)
|
||||
|
||||
def validate_inputs(
|
||||
self, sampler_name: str, scheduler: str, steps: int, cfg: float
|
||||
|
||||
@@ -10,14 +10,14 @@ def generate_random_seed() -> int:
|
||||
Generate a cryptographically strong random seed value.
|
||||
|
||||
Returns:
|
||||
Random integer in the valid ComfyUI seed range
|
||||
Random integer in the valid ComfyUI seed range (0 to 2**32 - 1)
|
||||
"""
|
||||
return random.randint(0, 0xFFFFFFFFFFFFFFFF)
|
||||
return random.randint(0, 0xFFFFFFFF) # 2**32 - 1
|
||||
|
||||
|
||||
def validate_seed_value(seed: Any) -> bool:
|
||||
"""
|
||||
Validate that a seed value is within acceptable range.
|
||||
Validate that a seed value is within acceptable range (0 to 2**32 - 1).
|
||||
|
||||
Args:
|
||||
seed: Seed value to validate
|
||||
@@ -30,7 +30,7 @@ def validate_seed_value(seed: Any) -> bool:
|
||||
|
||||
try:
|
||||
seed_int = int(seed)
|
||||
return 0 <= seed_int <= 0xFFFFFFFFFFFFFFFF
|
||||
return 0 <= seed_int <= 0xFFFFFFFF # 2**32 - 1
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
@@ -54,11 +54,11 @@ def sanitize_seed_value(seed: Any) -> int:
|
||||
try:
|
||||
seed_int = int(seed)
|
||||
|
||||
# Clamp to valid range
|
||||
# Clamp to valid range (0 to 2**32 - 1)
|
||||
if seed_int < 0:
|
||||
seed_int = 0
|
||||
elif seed_int > 0xFFFFFFFFFFFFFFFF:
|
||||
seed_int = 0xFFFFFFFFFFFFFFFF
|
||||
elif seed_int > 0xFFFFFFFF:
|
||||
seed_int = 0xFFFFFFFF
|
||||
|
||||
return seed_int
|
||||
|
||||
|
||||
@@ -27,12 +27,13 @@ class SeedHistoryNode(ComfyAssetsBaseNode):
|
||||
{
|
||||
"default": 12345,
|
||||
"min": 0,
|
||||
"max": 0xFFFFFFFFFFFFFFFF,
|
||||
"max": 0xFFFFFFFF, # 2**32 - 1
|
||||
"control_after_generate": True,
|
||||
"tooltip": "Seed value for generation processes. "
|
||||
"History UI tracks all changes automatically.",
|
||||
"Use 'control after generate' to set behavior after each run.",
|
||||
},
|
||||
),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("INT",)
|
||||
@@ -40,12 +41,13 @@ class SeedHistoryNode(ComfyAssetsBaseNode):
|
||||
FUNCTION = "output_seed"
|
||||
CATEGORY = "🫶 ComfyAssets/🌱 Seeds"
|
||||
|
||||
def output_seed(self, seed: int) -> Tuple[int]:
|
||||
def output_seed(self, seed: int, **kwargs) -> Tuple[int]:
|
||||
"""
|
||||
Output the seed value for use in other nodes.
|
||||
|
||||
Args:
|
||||
seed: Input seed value
|
||||
**kwargs: Accepts legacy parameters (e.g. mode) for backward compatibility
|
||||
|
||||
Returns:
|
||||
Tuple containing the seed value
|
||||
@@ -53,7 +55,6 @@ class SeedHistoryNode(ComfyAssetsBaseNode):
|
||||
try:
|
||||
# Validate and sanitize the seed
|
||||
if not validate_seed_value(seed):
|
||||
# Log the validation error but don't raise
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -64,11 +65,9 @@ class SeedHistoryNode(ComfyAssetsBaseNode):
|
||||
return (12345,)
|
||||
|
||||
clean_seed = sanitize_seed_value(seed)
|
||||
|
||||
return (clean_seed,)
|
||||
|
||||
except Exception as e:
|
||||
# Handle any unexpected errors gracefully
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -142,7 +141,7 @@ class SeedHistoryNode(ComfyAssetsBaseNode):
|
||||
Returns:
|
||||
Range information string
|
||||
"""
|
||||
max_seed = 0xFFFFFFFFFFFFFFFF
|
||||
max_seed = 0xFFFFFFFF # 2**32 - 1
|
||||
return f"Valid range: 0 to {max_seed:,} ({hex(max_seed)})"
|
||||
|
||||
@classmethod
|
||||
@@ -166,7 +165,7 @@ class SeedHistoryNode(ComfyAssetsBaseNode):
|
||||
Returns:
|
||||
True if seed is in valid range
|
||||
"""
|
||||
return 0 <= seed <= 0xFFFFFFFFFFFFFFFF
|
||||
return 0 <= seed <= 0xFFFFFFFF # 2**32 - 1
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""String representation of the node."""
|
||||
@@ -178,6 +177,6 @@ class SeedHistoryNode(ComfyAssetsBaseNode):
|
||||
f"SeedHistoryNode("
|
||||
f"category='{self.CATEGORY}', "
|
||||
f"function='{self.FUNCTION}', "
|
||||
f"max_seed={hex(0xFFFFFFFFFFFFFFFF)}"
|
||||
f"max_seed={hex(0xFFFFFFFF)}" # 2**32 - 1
|
||||
f")"
|
||||
)
|
||||
|
||||
@@ -78,7 +78,47 @@ PRESET_METADATA: Dict[str, PresetMetadata] = {
|
||||
"Portrait",
|
||||
"SDXL portrait 5:12 - very tall portrait",
|
||||
),
|
||||
"704×1408": PresetMetadata(
|
||||
704,
|
||||
1408,
|
||||
"1:2",
|
||||
0.5,
|
||||
0.99,
|
||||
"SDXL",
|
||||
"Portrait",
|
||||
"SDXL portrait 1:2 - extreme tall portrait",
|
||||
),
|
||||
"960×1024": PresetMetadata(
|
||||
960,
|
||||
1024,
|
||||
"15:16",
|
||||
0.938,
|
||||
0.98,
|
||||
"SDXL",
|
||||
"Portrait",
|
||||
"SDXL near-square portrait - subtle portrait",
|
||||
),
|
||||
"720×1280": PresetMetadata(
|
||||
720,
|
||||
1280,
|
||||
"9:16",
|
||||
0.5625,
|
||||
0.92,
|
||||
"SDXL",
|
||||
"Portrait",
|
||||
"SDXL portrait 9:16 - vertical video/mobile",
|
||||
),
|
||||
# SDXL Presets - Landscape
|
||||
"1024×960": PresetMetadata(
|
||||
1024,
|
||||
960,
|
||||
"16:15",
|
||||
1.067,
|
||||
0.98,
|
||||
"SDXL",
|
||||
"Landscape",
|
||||
"SDXL near-square landscape - subtle landscape",
|
||||
),
|
||||
"1152×896": PresetMetadata(
|
||||
1152,
|
||||
896,
|
||||
@@ -119,6 +159,26 @@ PRESET_METADATA: Dict[str, PresetMetadata] = {
|
||||
"Landscape",
|
||||
"SDXL landscape 12:5 - very wide landscape",
|
||||
),
|
||||
"1728×576": PresetMetadata(
|
||||
1728,
|
||||
576,
|
||||
"3:1",
|
||||
3.0,
|
||||
1.0,
|
||||
"SDXL",
|
||||
"Landscape",
|
||||
"SDXL landscape 3:1 - extreme wide panoramic",
|
||||
),
|
||||
"1280×720": PresetMetadata(
|
||||
1280,
|
||||
720,
|
||||
"16:9",
|
||||
1.778,
|
||||
0.92,
|
||||
"SDXL",
|
||||
"Landscape",
|
||||
"SDXL landscape 16:9 - HD widescreen video",
|
||||
),
|
||||
# FLUX Presets - High Quality
|
||||
"1920×1080": PresetMetadata(
|
||||
1920,
|
||||
@@ -558,21 +618,27 @@ def get_model_recommendation(preset_name: str) -> str:
|
||||
return "Custom dimensions"
|
||||
|
||||
|
||||
def _console_safe(text: str) -> str:
|
||||
"""ASCII for console output: Windows legacy code pages can't print "×"."""
|
||||
return text.replace("×", "x").encode("ascii", "replace").decode()
|
||||
|
||||
|
||||
def validate_preset_dimensions() -> bool:
|
||||
"""Validate that all presets meet ComfyUI requirements."""
|
||||
for preset_name, metadata in PRESET_METADATA.items():
|
||||
for name, metadata in PRESET_METADATA.items():
|
||||
preset_name = _console_safe(name)
|
||||
width, height = metadata.width, metadata.height
|
||||
|
||||
# Check divisible by 8
|
||||
if width % 8 != 0 or height % 8 != 0:
|
||||
print(
|
||||
f"ERROR: {preset_name} dimensions not divisible by 8: {width}×{height}"
|
||||
f"ERROR: {preset_name} dimensions not divisible by 8: {width}x{height}"
|
||||
)
|
||||
return False
|
||||
|
||||
# Check reasonable bounds
|
||||
if not (64 <= width <= 8192) or not (64 <= height <= 8192):
|
||||
print(f"ERROR: {preset_name} dimensions out of bounds: {width}×{height}")
|
||||
print(f"ERROR: {preset_name} dimensions out of bounds: {width}x{height}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
"""
|
||||
Width Height to VEC2 converter node
|
||||
Converts width and height inputs to VEC2 tuple for jovi_glsl and similar nodes
|
||||
"""
|
||||
|
||||
from .node import WidthHeightToVec2Node
|
||||
|
||||
__all__ = ["WidthHeightToVec2Node"]
|
||||
@@ -0,0 +1,128 @@
|
||||
"""
|
||||
Width Height to VEC2 Node
|
||||
|
||||
Converts width and height inputs to a VEC2 tuple for use with
|
||||
nodes like jovi_glsl that expect vector inputs.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Tuple, Union
|
||||
|
||||
from ...base import ComfyAssetsBaseNode
|
||||
|
||||
|
||||
class WidthHeightToVec2Node(ComfyAssetsBaseNode):
|
||||
"""
|
||||
Convert width and height values to VEC2 format.
|
||||
|
||||
Accepts INT, FLOAT, STRING, or ANY types and outputs a VEC2 tuple
|
||||
suitable for nodes expecting vector inputs.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls) -> Dict[str, Any]:
|
||||
"""Define ComfyUI input interface."""
|
||||
return {
|
||||
"required": {
|
||||
"width": (
|
||||
"INT",
|
||||
{
|
||||
"default": 1024,
|
||||
"min": 1,
|
||||
"max": 8192,
|
||||
"step": 1,
|
||||
"tooltip": "Width value (x component of VEC2)",
|
||||
},
|
||||
),
|
||||
"height": (
|
||||
"INT",
|
||||
{
|
||||
"default": 1024,
|
||||
"min": 1,
|
||||
"max": 8192,
|
||||
"step": 1,
|
||||
"tooltip": "Height value (y component of VEC2)",
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("VEC2",)
|
||||
RETURN_NAMES = ("vec2",)
|
||||
FUNCTION = "convert_to_vec2"
|
||||
CATEGORY = "🫶 ComfyAssets/🖼️ Resolution"
|
||||
|
||||
def convert_to_vec2(
|
||||
self,
|
||||
width: Union[int, float, str, Any],
|
||||
height: Union[int, float, str, Any],
|
||||
) -> Tuple[Tuple[int, int]]:
|
||||
"""
|
||||
Convert width and height to VEC2 tuple.
|
||||
|
||||
Args:
|
||||
width: Width value (will be converted to int)
|
||||
height: Height value (will be converted to int)
|
||||
|
||||
Returns:
|
||||
Tuple containing the VEC2 tuple (width, height)
|
||||
"""
|
||||
try:
|
||||
# Convert to integers, handling various input types
|
||||
w = self._to_int(width, "width")
|
||||
h = self._to_int(height, "height")
|
||||
|
||||
# Clamp values to valid range
|
||||
w = max(1, min(8192, w))
|
||||
h = max(1, min(8192, h))
|
||||
|
||||
self.log_info(f"Converted to VEC2: ({w}, {h})")
|
||||
|
||||
# Return as tuple wrapped in tuple (ComfyUI return format)
|
||||
return ((w, h),)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Failed to convert to VEC2: {str(e)}"
|
||||
self.handle_error(error_msg, e)
|
||||
|
||||
def _to_int(self, value: Any, name: str) -> int:
|
||||
"""
|
||||
Convert a value to integer.
|
||||
|
||||
Args:
|
||||
value: Value to convert (int, float, str, or any)
|
||||
name: Parameter name for error messages
|
||||
|
||||
Returns:
|
||||
Integer value
|
||||
|
||||
Raises:
|
||||
ValueError: If conversion fails
|
||||
"""
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
elif isinstance(value, float):
|
||||
return int(value)
|
||||
elif isinstance(value, str):
|
||||
try:
|
||||
# Try parsing as float first (handles "1024.0")
|
||||
return int(float(value.strip()))
|
||||
except ValueError:
|
||||
raise ValueError(f"Cannot convert {name} string '{value}' to integer")
|
||||
else:
|
||||
# Try generic conversion for ANY type
|
||||
try:
|
||||
return int(value)
|
||||
except (ValueError, TypeError):
|
||||
raise ValueError(
|
||||
f"Cannot convert {name} of type {type(value).__name__} to integer"
|
||||
)
|
||||
|
||||
|
||||
# Node registration
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"WidthHeightToVec2": WidthHeightToVec2Node,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"WidthHeightToVec2": "Width Height to VEC2",
|
||||
}
|
||||
@@ -1,77 +1,124 @@
|
||||
"""Logic module for Flux Sampler Params node."""
|
||||
|
||||
from typing import List, Dict, Any, Tuple, Optional
|
||||
import math
|
||||
import random
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_string_to_list(value: str) -> List[float]:
|
||||
RANDOM_SEED_TOKEN = "?"
|
||||
MAX_RANDOM_SEED = 999999
|
||||
|
||||
|
||||
def parse_string_to_list(value: str, field: str = "value") -> List[float]:
|
||||
"""
|
||||
Parse a string containing comma-separated values to a list of floats.
|
||||
|
||||
Args:
|
||||
value: String with comma-separated values
|
||||
field: Name of the input, used in error messages
|
||||
|
||||
Returns:
|
||||
List of float values
|
||||
List of float values (empty when the string is blank)
|
||||
|
||||
Raises:
|
||||
ValueError: If any token is not a finite number
|
||||
"""
|
||||
if not value or not value.strip():
|
||||
return []
|
||||
|
||||
try:
|
||||
values = []
|
||||
for item in value.split(","):
|
||||
item = item.strip()
|
||||
if item:
|
||||
try:
|
||||
values.append(float(item))
|
||||
except ValueError:
|
||||
logger.warning(f"Could not parse '{item}' as float")
|
||||
return values
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing string to list: {e}")
|
||||
return []
|
||||
values = []
|
||||
for item in value.split(","):
|
||||
item = item.strip()
|
||||
if not item:
|
||||
continue
|
||||
try:
|
||||
number = float(item)
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid {field} value '{item}': expected a number")
|
||||
if not math.isfinite(number):
|
||||
raise ValueError(f"Invalid {field} value '{item}': must be finite")
|
||||
values.append(number)
|
||||
return values
|
||||
|
||||
|
||||
def parse_steps_list(value: str, field: str = "steps") -> List[int]:
|
||||
"""
|
||||
Parse comma-separated step counts into positive integers.
|
||||
|
||||
Raises:
|
||||
ValueError: If a token is not a positive whole number
|
||||
"""
|
||||
steps = []
|
||||
for number in parse_string_to_list(value, field):
|
||||
if number != int(number) or number < 1:
|
||||
raise ValueError(
|
||||
f"Invalid {field} value '{number:g}': expected a positive integer"
|
||||
)
|
||||
steps.append(int(number))
|
||||
return steps
|
||||
|
||||
|
||||
def parse_seed_string(seed_string: str) -> List[int]:
|
||||
"""
|
||||
Parse seed string which can contain numbers, '?', or ranges.
|
||||
Parse a seed string of comma or newline separated tokens.
|
||||
|
||||
Args:
|
||||
seed_string: String with seeds (e.g., "123,?,456")
|
||||
Each token is either a non-negative integer or the exact token '?',
|
||||
which draws a random seed. A blank string yields one random seed.
|
||||
Ranges are not supported.
|
||||
|
||||
Returns:
|
||||
List of integer seeds
|
||||
Raises:
|
||||
ValueError: If a token is neither an integer nor '?'
|
||||
"""
|
||||
seeds = []
|
||||
for item in seed_string.replace("\n", ",").split(","):
|
||||
item = item.strip()
|
||||
if not item:
|
||||
continue
|
||||
if item == RANDOM_SEED_TOKEN:
|
||||
seeds.append(random.randint(0, MAX_RANDOM_SEED))
|
||||
continue
|
||||
try:
|
||||
seed = int(item)
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"Invalid seed '{item}': use whole numbers or '?' for a random seed"
|
||||
)
|
||||
if seed < 0:
|
||||
raise ValueError(f"Invalid seed '{item}': seeds cannot be negative")
|
||||
seeds.append(seed)
|
||||
|
||||
try:
|
||||
for item in seed_string.replace("\n", ",").split(","):
|
||||
item = item.strip()
|
||||
if not item:
|
||||
continue
|
||||
|
||||
if "?" in item:
|
||||
seeds.append(random.randint(0, 999999))
|
||||
else:
|
||||
try:
|
||||
seeds.append(int(item))
|
||||
except ValueError:
|
||||
logger.warning(f"Could not parse seed '{item}'")
|
||||
seeds.append(random.randint(0, 999999))
|
||||
|
||||
if not seeds:
|
||||
seeds = [random.randint(0, 999999)]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing seeds: {e}")
|
||||
seeds = [random.randint(0, 999999)]
|
||||
|
||||
if not seeds:
|
||||
seeds = [random.randint(0, MAX_RANDOM_SEED)]
|
||||
return seeds
|
||||
|
||||
|
||||
def seed_string_is_random(seed_string: str) -> bool:
|
||||
"""Return True if the seed string produces a different result each run."""
|
||||
tokens = [t.strip() for t in seed_string.replace("\n", ",").split(",")]
|
||||
tokens = [t for t in tokens if t]
|
||||
return not tokens or RANDOM_SEED_TOKEN in tokens
|
||||
|
||||
|
||||
def is_schnell_model(model: Any) -> bool:
|
||||
"""
|
||||
Detect Flux Schnell from the unet config.
|
||||
|
||||
Schnell is not guidance distilled (guidance_embed is False) while dev is
|
||||
(guidance_embed is True). Anything unrecognised is treated as dev.
|
||||
"""
|
||||
config = getattr(
|
||||
getattr(getattr(model, "model", None), "model_config", None),
|
||||
"unet_config",
|
||||
None,
|
||||
)
|
||||
if not isinstance(config, dict):
|
||||
return False
|
||||
return config.get("guidance_embed") is False
|
||||
|
||||
|
||||
def parse_sampler_string(
|
||||
sampler_string: str, available_samplers: List[str]
|
||||
) -> List[str]:
|
||||
@@ -240,14 +287,13 @@ def validate_flux_params(
|
||||
|
||||
Returns:
|
||||
True if all parameters are valid
|
||||
|
||||
Raises:
|
||||
ValueError: Naming the offending field and token
|
||||
"""
|
||||
try:
|
||||
parse_string_to_list(steps)
|
||||
parse_string_to_list(guidance)
|
||||
parse_string_to_list(max_shift)
|
||||
parse_string_to_list(base_shift)
|
||||
parse_string_to_list(denoise)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Invalid parameters: {e}")
|
||||
return False
|
||||
parse_steps_list(steps, "steps")
|
||||
parse_string_to_list(guidance, "guidance")
|
||||
parse_string_to_list(max_shift, "max_shift")
|
||||
parse_string_to_list(base_shift, "base_shift")
|
||||
parse_string_to_list(denoise, "denoise")
|
||||
return True
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
"""Flux Sampler Params node for ComfyUI."""
|
||||
|
||||
from typing import Tuple, Any, Dict, List, Optional
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
from ....base.base_node import ComfyAssetsBaseNode
|
||||
from .logic import (
|
||||
parse_string_to_list,
|
||||
parse_steps_list,
|
||||
parse_seed_string,
|
||||
seed_string_is_random,
|
||||
is_schnell_model,
|
||||
parse_sampler_string,
|
||||
parse_scheduler_string,
|
||||
get_default_flux_params,
|
||||
@@ -122,6 +127,21 @@ class FluxSamplerParamsNode(ComfyAssetsBaseNode):
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
"""
|
||||
Re-run every time when seeds are random, otherwise use a stable hash.
|
||||
|
||||
NaN never compares equal to itself, so ComfyUI re-executes the node.
|
||||
"""
|
||||
if seed_string_is_random(str(kwargs.get("seed", ""))):
|
||||
return float("nan")
|
||||
widgets = {
|
||||
key: value for key, value in kwargs.items() if isinstance(value, str)
|
||||
}
|
||||
payload = json.dumps(widgets, sort_keys=True)
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||
|
||||
RETURN_TYPES = ("LATENT", "SAMPLER_PARAMS")
|
||||
RETURN_NAMES = ("latent", "params")
|
||||
FUNCTION = "process_batch"
|
||||
@@ -150,7 +170,6 @@ class FluxSamplerParamsNode(ComfyAssetsBaseNode):
|
||||
"""
|
||||
try:
|
||||
import comfy.samplers
|
||||
import comfy.model_base
|
||||
import comfy.model_management
|
||||
import comfy.utils
|
||||
import torch
|
||||
@@ -199,12 +218,15 @@ class FluxSamplerParamsNode(ComfyAssetsBaseNode):
|
||||
return samples_out
|
||||
|
||||
try:
|
||||
if not validate_flux_params(
|
||||
steps, guidance, max_shift, base_shift, denoise
|
||||
):
|
||||
self.handle_error("Invalid parameter format")
|
||||
validate_flux_params(steps, guidance, max_shift, base_shift, denoise)
|
||||
|
||||
is_schnell = model.model.model_type == comfy.model_base.ModelType.FLOW
|
||||
if loras is not None and not loras.get("loras"):
|
||||
raise ValueError(
|
||||
"LoRA input contains no LoRAs (is the LoRA folder empty "
|
||||
"or fully filtered?)"
|
||||
)
|
||||
|
||||
is_schnell = is_schnell_model(model)
|
||||
defaults = get_default_flux_params(is_schnell)
|
||||
|
||||
seeds = parse_seed_string(seed)
|
||||
@@ -214,13 +236,13 @@ class FluxSamplerParamsNode(ComfyAssetsBaseNode):
|
||||
)
|
||||
|
||||
steps = steps if steps else str(defaults["steps"])
|
||||
steps_list = [int(s) for s in parse_string_to_list(steps)]
|
||||
steps_list = parse_steps_list(steps)
|
||||
|
||||
guidance = guidance if guidance else str(defaults["guidance"])
|
||||
guidance_list = parse_string_to_list(guidance)
|
||||
guidance_list = parse_string_to_list(guidance, "guidance")
|
||||
|
||||
denoise = denoise if denoise else "1.0"
|
||||
denoise_list = parse_string_to_list(denoise)
|
||||
denoise_list = parse_string_to_list(denoise, "denoise")
|
||||
|
||||
if not is_schnell:
|
||||
max_shift = max_shift if max_shift else str(defaults["max_shift"])
|
||||
@@ -229,8 +251,8 @@ class FluxSamplerParamsNode(ComfyAssetsBaseNode):
|
||||
max_shift = "0"
|
||||
base_shift = base_shift if base_shift else str(defaults["base_shift"])
|
||||
|
||||
max_shift_list = parse_string_to_list(max_shift)
|
||||
base_shift_list = parse_string_to_list(base_shift)
|
||||
max_shift_list = parse_string_to_list(max_shift, "max_shift")
|
||||
base_shift_list = parse_string_to_list(base_shift, "base_shift")
|
||||
|
||||
cond_text, cond_encoded = process_conditioning_input(conditioning)
|
||||
|
||||
@@ -259,6 +281,12 @@ class FluxSamplerParamsNode(ComfyAssetsBaseNode):
|
||||
lora_strength_count,
|
||||
)
|
||||
|
||||
if total_samples < 1:
|
||||
raise ValueError(
|
||||
"No parameter combinations to run: check that seed, sampler, "
|
||||
"scheduler, steps, guidance and denoise each yield a value"
|
||||
)
|
||||
|
||||
self.log_info(f"Processing {total_samples} parameter combinations")
|
||||
|
||||
basicscheduler = BasicScheduler()
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
"""Logic module for LoRA Folder Batch node."""
|
||||
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
from typing import List, Dict, Any
|
||||
from typing import List, Dict, Any, Optional, Tuple
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_STRENGTH_VALUES = 1000
|
||||
DEFAULT_RANGE_STEP = 0.1
|
||||
_RANGE_EPSILON = 1e-4
|
||||
|
||||
|
||||
def get_lora_folders() -> List[str]:
|
||||
"""
|
||||
@@ -35,108 +40,102 @@ def get_lora_folders() -> List[str]:
|
||||
return [".", "flux", "sdxl", "sd15"]
|
||||
|
||||
|
||||
def scan_folder_for_loras(folder_path: str) -> List[str]: # noqa: C901
|
||||
"""
|
||||
Scan a folder for LoRA files (.safetensors).
|
||||
|
||||
Args:
|
||||
folder_path: Path to folder to scan (absolute or relative to models/loras)
|
||||
|
||||
Returns:
|
||||
List of LoRA filenames relative to models/loras directory
|
||||
"""
|
||||
def _get_lora_roots() -> List[str]:
|
||||
"""Return every configured loras root directory (empty if unavailable)."""
|
||||
try:
|
||||
import folder_paths
|
||||
|
||||
# Get all LoRA paths from ComfyUI (includes extra_model_paths)
|
||||
lora_paths = folder_paths.folder_names_and_paths.get("loras", [[]])[0]
|
||||
|
||||
# Determine the full path and base lora path
|
||||
full_path = None
|
||||
base_lora_path = None
|
||||
|
||||
# Check if this is an absolute path
|
||||
if os.path.isabs(folder_path):
|
||||
full_path = folder_path
|
||||
|
||||
# Check if this path is inside any of the known lora directories
|
||||
for lora_base in lora_paths:
|
||||
# Normalize paths for comparison
|
||||
norm_full = os.path.normpath(full_path)
|
||||
norm_base = os.path.normpath(lora_base)
|
||||
|
||||
# Check if full_path starts with this lora_base
|
||||
if norm_full.startswith(norm_base):
|
||||
base_lora_path = lora_base
|
||||
break
|
||||
|
||||
# Also check if the path is a subdirectory under lora/loras
|
||||
if "lora" in norm_full.lower():
|
||||
# Find the lora or loras directory in the path
|
||||
path_parts = norm_full.replace("\\", "/").split("/")
|
||||
for i, part in enumerate(path_parts):
|
||||
if part.lower() in ["lora", "loras"]:
|
||||
# Check if this matches our lora_base
|
||||
potential_base = "/".join(path_parts[: i + 1])
|
||||
if os.path.normpath(potential_base) == norm_base:
|
||||
base_lora_path = lora_base
|
||||
break
|
||||
if base_lora_path:
|
||||
break
|
||||
else:
|
||||
# Relative path provided
|
||||
base_lora_path = lora_paths[0] if lora_paths else ""
|
||||
full_path = os.path.join(base_lora_path, folder_path)
|
||||
|
||||
if not os.path.exists(full_path):
|
||||
logger.warning(f"Folder does not exist: {full_path}")
|
||||
return []
|
||||
|
||||
# Scan for .safetensors files recursively
|
||||
lora_files = []
|
||||
for root, _, files in os.walk(full_path):
|
||||
for file in files:
|
||||
if file.endswith(".safetensors"):
|
||||
# Get the full path to the file
|
||||
file_full_path = os.path.join(root, file)
|
||||
|
||||
# Calculate the correct relative path for ComfyUI
|
||||
if base_lora_path:
|
||||
# Path is inside a known lora directory
|
||||
try:
|
||||
rel_path = os.path.relpath(file_full_path, base_lora_path)
|
||||
lora_files.append(rel_path.replace("\\", "/"))
|
||||
except ValueError:
|
||||
# Different drives on Windows, use path relative to scan folder
|
||||
rel_path = os.path.relpath(file_full_path, full_path)
|
||||
if rel_path == ".":
|
||||
lora_files.append(file)
|
||||
else:
|
||||
lora_files.append(rel_path.replace("\\", "/"))
|
||||
else:
|
||||
# Path is outside known lora directories
|
||||
# Return path relative to the scanned folder
|
||||
rel_path = os.path.relpath(file_full_path, full_path)
|
||||
if rel_path == ".":
|
||||
lora_files.append(file)
|
||||
else:
|
||||
lora_files.append(rel_path.replace("\\", "/"))
|
||||
|
||||
# Sort naturally (handles epoch numbers properly)
|
||||
lora_files = natural_sort(lora_files)
|
||||
|
||||
logger.info(f"Found {len(lora_files)} LoRA files in {folder_path}")
|
||||
if lora_files and logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug(f"Base lora path: {base_lora_path}")
|
||||
logger.debug(f"Full scan path: {full_path}")
|
||||
logger.debug(f"First few LoRA paths returned: {lora_files[:3]}")
|
||||
return lora_files
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error scanning folder {folder_path}: {e}")
|
||||
entry = folder_paths.folder_names_and_paths.get("loras")
|
||||
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) -> List[Tuple[str, Optional[str]]]:
|
||||
"""
|
||||
Resolve a folder setting to (directory, base_root) pairs to scan.
|
||||
|
||||
base_root is the loras root the directory lives in, or None when the
|
||||
directory is an absolute path outside every known root.
|
||||
"""
|
||||
roots = _get_lora_roots()
|
||||
|
||||
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 loras directory: {folder_path}")
|
||||
continue
|
||||
targets.append((candidate, root))
|
||||
return targets
|
||||
|
||||
|
||||
def _scan_entries(folder_path: str) -> List[Tuple[str, str]]:
|
||||
"""Return (relative_name, absolute_path) for every .safetensors found."""
|
||||
entries: Dict[str, str] = {}
|
||||
for directory, base in _resolve_scan_targets(folder_path):
|
||||
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 scan_folder_for_loras(folder_path: str) -> List[str]:
|
||||
"""
|
||||
Scan a folder for LoRA files (.safetensors).
|
||||
|
||||
Relative folders are searched in every configured loras root. Absolute
|
||||
folders are matched to a root by real-path containment (so a sibling like
|
||||
``loras_backup`` is not mistaken for ``loras``).
|
||||
|
||||
Args:
|
||||
folder_path: Folder to scan (absolute or relative to the loras roots)
|
||||
|
||||
Returns:
|
||||
Naturally sorted LoRA names relative to their loras root (or to the
|
||||
scanned folder when it lies outside every root)
|
||||
"""
|
||||
names = [name for name, _ in _scan_entries(folder_path)]
|
||||
if not names:
|
||||
logger.warning(f"No LoRA files found in {folder_path}")
|
||||
else:
|
||||
logger.info(f"Found {len(names)} LoRA files in {folder_path}")
|
||||
return names
|
||||
|
||||
|
||||
def get_lora_file_mtimes(folder_path: str) -> List[Tuple[str, float]]:
|
||||
"""Return sorted (name, mtime) pairs for LoRAs in a folder."""
|
||||
result = []
|
||||
for name, full in _scan_entries(folder_path):
|
||||
try:
|
||||
result.append((name, os.path.getmtime(full)))
|
||||
except OSError:
|
||||
result.append((name, -1.0))
|
||||
return result
|
||||
|
||||
|
||||
def sort_lora_files(lora_files: List[str], sort_order: str) -> List[str]:
|
||||
"""
|
||||
Sort LoRA files based on the specified order.
|
||||
@@ -232,66 +231,82 @@ def filter_loras_by_pattern(
|
||||
return filtered
|
||||
|
||||
|
||||
def parse_strength_string(strength_str: str) -> List[float]: # noqa: C901
|
||||
def _parse_finite(token: str, what: str, original: str) -> float:
|
||||
try:
|
||||
value = float(token.strip())
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid strength {what} '{token.strip()}' in '{original}'")
|
||||
if not math.isfinite(value):
|
||||
raise ValueError(f"Invalid strength {what} '{token.strip()}' in '{original}'")
|
||||
return value
|
||||
|
||||
|
||||
def _parse_strength_range(text: str) -> List[float]:
|
||||
"""Parse 'start...end' or 'start...end+step'."""
|
||||
parts = text.split("...")
|
||||
if len(parts) != 2:
|
||||
raise ValueError(f"Invalid strength range '{text}': use start...end[+step]")
|
||||
start = _parse_finite(parts[0], "start", text)
|
||||
|
||||
end_part = parts[1]
|
||||
if "+" in end_part:
|
||||
pieces = end_part.split("+")
|
||||
if len(pieces) != 2:
|
||||
raise ValueError(f"Invalid strength range '{text}': use start...end[+step]")
|
||||
end = _parse_finite(pieces[0], "end", text)
|
||||
step = _parse_finite(pieces[1], "step", text)
|
||||
else:
|
||||
end = _parse_finite(end_part, "end", text)
|
||||
step = DEFAULT_RANGE_STEP
|
||||
|
||||
if step <= 0:
|
||||
raise ValueError(
|
||||
f"Invalid strength range '{text}': step must be greater than 0"
|
||||
)
|
||||
if end < start:
|
||||
raise ValueError(
|
||||
f"Invalid strength range '{text}': reversed range "
|
||||
f"(start {start:g} is greater than end {end:g})"
|
||||
)
|
||||
|
||||
count = int(math.floor((end - start) / step + _RANGE_EPSILON)) + 1
|
||||
if count > MAX_STRENGTH_VALUES:
|
||||
raise ValueError(
|
||||
f"Strength range '{text}' would generate {count} values; "
|
||||
f"the maximum is {MAX_STRENGTH_VALUES}"
|
||||
)
|
||||
return [round(start + i * step, 4) for i in range(count)]
|
||||
|
||||
|
||||
def parse_strength_string(strength_str: str) -> List[float]:
|
||||
"""
|
||||
Parse strength string into list of values.
|
||||
|
||||
Supports:
|
||||
- Blank: defaults to [1.0]
|
||||
- Single value: "1.0"
|
||||
- Multiple values: "0.5, 0.75, 1.0"
|
||||
- Range: "0.5...1.0" (with optional step)
|
||||
- Range: "0.5...1.0" (default step 0.1) or "0.5...1.0+0.25"
|
||||
|
||||
Args:
|
||||
strength_str: String representation of strengths
|
||||
|
||||
Returns:
|
||||
List of strength values
|
||||
Raises:
|
||||
ValueError: On malformed input, non-positive step, reversed range, or
|
||||
more than MAX_STRENGTH_VALUES generated values.
|
||||
"""
|
||||
strength_str = strength_str.strip()
|
||||
|
||||
if not strength_str:
|
||||
return [1.0]
|
||||
|
||||
# Check for range notation
|
||||
if "..." in strength_str:
|
||||
parts = strength_str.split("...")
|
||||
if len(parts) == 2:
|
||||
try:
|
||||
start = float(parts[0].strip())
|
||||
end_part = parts[1].strip()
|
||||
return _parse_strength_range(strength_str)
|
||||
|
||||
# Check for step
|
||||
if "+" in end_part:
|
||||
end_str, step_str = end_part.split("+")
|
||||
end = float(end_str.strip())
|
||||
step = float(step_str.strip())
|
||||
else:
|
||||
end = float(end_part)
|
||||
step = 0.1 # Default step
|
||||
|
||||
# Generate range
|
||||
values = []
|
||||
current = start
|
||||
while current <= end + 0.0001: # Small epsilon for float comparison
|
||||
values.append(round(current, 4))
|
||||
current += step
|
||||
|
||||
return values
|
||||
except ValueError as e:
|
||||
logger.error(f"Invalid range format: {e}")
|
||||
return [1.0]
|
||||
|
||||
# Parse comma-separated values
|
||||
try:
|
||||
values = []
|
||||
for item in strength_str.split(","):
|
||||
item = item.strip()
|
||||
if item:
|
||||
values.append(float(item))
|
||||
return values if values else [1.0]
|
||||
except ValueError as e:
|
||||
logger.error(f"Invalid strength values: {e}")
|
||||
return [1.0]
|
||||
values = [
|
||||
_parse_finite(item, "value", strength_str)
|
||||
for item in strength_str.split(",")
|
||||
if item.strip()
|
||||
]
|
||||
if not values:
|
||||
raise ValueError(f"No strength values found in '{strength_str}'")
|
||||
return values
|
||||
|
||||
|
||||
def create_lora_params(
|
||||
@@ -312,6 +327,9 @@ def create_lora_params(
|
||||
logger.warning("No LoRA files provided")
|
||||
return {"loras": [], "strengths": []}
|
||||
|
||||
if not strengths:
|
||||
raise ValueError("At least one strength value is required")
|
||||
|
||||
if batch_mode == "combinatorial":
|
||||
# Each LoRA gets tested with each strength
|
||||
# This creates len(loras) * len(strengths) combinations
|
||||
@@ -412,33 +430,15 @@ def validate_folder_path(folder_path: str) -> bool:
|
||||
"""
|
||||
Validate that the folder path exists and is accessible.
|
||||
|
||||
Args:
|
||||
folder_path: Folder path to validate (absolute or relative)
|
||||
|
||||
Returns:
|
||||
True if valid
|
||||
Relative paths are accepted if they exist inside any loras root.
|
||||
"""
|
||||
try:
|
||||
# Handle absolute paths
|
||||
if os.path.isabs(folder_path):
|
||||
return os.path.exists(folder_path) and os.path.isdir(folder_path)
|
||||
|
||||
# Handle relative paths
|
||||
import folder_paths
|
||||
|
||||
lora_paths = folder_paths.folder_names_and_paths.get("loras", [[]])[0]
|
||||
|
||||
if not lora_paths:
|
||||
return False
|
||||
|
||||
lora_base_path = lora_paths[0]
|
||||
|
||||
if folder_path == ".":
|
||||
full_path = lora_base_path
|
||||
else:
|
||||
full_path = os.path.join(lora_base_path, folder_path)
|
||||
|
||||
return os.path.exists(full_path) and os.path.isdir(full_path)
|
||||
return os.path.isdir(folder_path)
|
||||
|
||||
return any(
|
||||
os.path.isdir(directory)
|
||||
for directory, _ in _resolve_scan_targets(folder_path)
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"""LoRA Folder Batch node for ComfyUI."""
|
||||
|
||||
from typing import Tuple, Any, Dict
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from ....base.base_node import ComfyAssetsBaseNode
|
||||
from .logic import (
|
||||
scan_folder_for_loras,
|
||||
get_lora_file_mtimes,
|
||||
filter_loras_by_pattern,
|
||||
parse_strength_string,
|
||||
create_lora_params,
|
||||
@@ -282,10 +285,28 @@ class LoRAFolderBatchNode(ComfyAssetsBaseNode):
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
"""
|
||||
Force re-execution when folder contents might have changed.
|
||||
Re-execute only when the folder listing or the inputs change.
|
||||
|
||||
This ensures we always scan for the latest LoRAs.
|
||||
Returns a hash of the matching files with their mtimes plus the
|
||||
relevant inputs, so downstream nodes stay cached otherwise.
|
||||
"""
|
||||
import time
|
||||
folder_path = str(kwargs.get("folder_path", "."))
|
||||
files = get_lora_file_mtimes(folder_path)
|
||||
if kwargs.get("include_pattern") or kwargs.get("exclude_pattern"):
|
||||
keep = set(
|
||||
filter_loras_by_pattern(
|
||||
[name for name, _ in files],
|
||||
kwargs.get("include_pattern", "") or "",
|
||||
kwargs.get("exclude_pattern", "") or "",
|
||||
)
|
||||
)
|
||||
files = [(name, mtime) for name, mtime in files if name in keep]
|
||||
|
||||
return str(time.time())
|
||||
payload = json.dumps(
|
||||
{
|
||||
"files": files,
|
||||
"inputs": {k: repr(v) for k, v in sorted(kwargs.items())},
|
||||
},
|
||||
sort_keys=True,
|
||||
)
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||
|
||||
@@ -173,7 +173,7 @@ class PlotParametersNode(ComfyAssetsBaseNode):
|
||||
# Get font
|
||||
font_path = self._get_font_path()
|
||||
width = images.shape[2]
|
||||
font_size = min(48, int(32 * (width / 1024)))
|
||||
font_size = max(1, min(48, int(32 * (width / 1024))))
|
||||
|
||||
try:
|
||||
font = ImageFont.truetype(font_path, font_size)
|
||||
|
||||
@@ -7,83 +7,58 @@ import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# A separator is a whole line of 3+ identical '-', '*', '=' or '~' characters.
|
||||
_SEPARATOR_RE = re.compile(r"(?m)^[ \t]*([-*=~])\1{2,}[ \t]*\r?$")
|
||||
|
||||
|
||||
def split_prompts(text: str) -> List[str]:
|
||||
"""
|
||||
Split text into multiple prompts using separator patterns.
|
||||
Split text into multiple prompts using separator lines.
|
||||
|
||||
Recognizes various separator patterns:
|
||||
- Three or more dashes: ---
|
||||
- Three or more asterisks: ***
|
||||
- Three or more equals: ===
|
||||
- Three or more tildes: ~~~
|
||||
A separator is a line consisting only of three or more identical
|
||||
characters from ``-``, ``*``, ``=`` or ``~`` (surrounding spaces allowed).
|
||||
Separator-like text in the middle of a line does not split.
|
||||
|
||||
Args:
|
||||
text: Multi-line text with separators
|
||||
|
||||
Returns:
|
||||
List of individual prompt strings
|
||||
List of non-empty, stripped prompt strings
|
||||
"""
|
||||
try:
|
||||
normalized = re.sub(r"[-*=~]{3,}\n", "---\n", text)
|
||||
|
||||
parts = normalized.split("---\n")
|
||||
|
||||
prompts = []
|
||||
for part in parts:
|
||||
cleaned = part.strip()
|
||||
if cleaned:
|
||||
prompts.append(cleaned)
|
||||
|
||||
if not prompts and text.strip():
|
||||
prompts = [text.strip()]
|
||||
|
||||
logger.info(f"Split text into {len(prompts)} prompts")
|
||||
return prompts
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error splitting prompts: {e}")
|
||||
if text.strip():
|
||||
return [text.strip()]
|
||||
return []
|
||||
prompts = [part.strip() for part in _SEPARATOR_RE.split(text)[0::2]]
|
||||
prompts = [p for p in prompts if p]
|
||||
logger.info(f"Split text into {len(prompts)} prompts")
|
||||
return prompts
|
||||
|
||||
|
||||
def encode_prompts(prompts: List[str], clip_encoder) -> List[Any]:
|
||||
"""
|
||||
Encode a list of prompts using CLIP encoder.
|
||||
|
||||
Args:
|
||||
prompts: List of text prompts
|
||||
clip_encoder: CLIP encoder instance
|
||||
Returns one conditioning per prompt, in order.
|
||||
|
||||
Returns:
|
||||
List of encoded conditioning tensors
|
||||
Raises:
|
||||
ValueError: If any prompt fails to encode (so indices never misalign)
|
||||
"""
|
||||
encoded = []
|
||||
|
||||
try:
|
||||
from nodes import CLIPTextEncode
|
||||
|
||||
encoder = CLIPTextEncode()
|
||||
|
||||
for i, prompt in enumerate(prompts):
|
||||
try:
|
||||
conditioning = encoder.encode(clip_encoder, prompt)[0]
|
||||
encoded.append(conditioning)
|
||||
logger.debug(f"Encoded prompt {i + 1}/{len(prompts)}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to encode prompt {i + 1}: {e}")
|
||||
encoded.append(None)
|
||||
|
||||
encoded = [e for e in encoded if e is not None]
|
||||
|
||||
logger.info(f"Successfully encoded {len(encoded)}/{len(prompts)} prompts")
|
||||
|
||||
except ImportError:
|
||||
logger.error("CLIPTextEncode not available, returning mock encodings")
|
||||
encoded = [{"mock": prompt} for prompt in prompts]
|
||||
except Exception as e:
|
||||
logger.error(f"Error encoding prompts: {e}")
|
||||
return [{"mock": prompt} for prompt in prompts]
|
||||
|
||||
encoder = CLIPTextEncode()
|
||||
encoded = []
|
||||
for i, prompt in enumerate(prompts):
|
||||
try:
|
||||
conditioning = encoder.encode(clip_encoder, prompt)[0]
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to encode prompt {i + 1}: {e}") from e
|
||||
if conditioning is None:
|
||||
raise ValueError(f"Failed to encode prompt {i + 1}: encoder returned None")
|
||||
encoded.append(conditioning)
|
||||
logger.debug(f"Encoded prompt {i + 1}/{len(prompts)}")
|
||||
|
||||
logger.info(f"Encoded {len(encoded)} prompts")
|
||||
return encoded
|
||||
|
||||
|
||||
|
||||
+3
-11
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
|
||||
[project]
|
||||
name = "kikotools"
|
||||
description = "Simple tools for ComfyUI"
|
||||
version = "1.0.23"
|
||||
version = "1.0.28"
|
||||
license = {text = "MIT"}
|
||||
dependencies = []
|
||||
|
||||
@@ -15,6 +15,7 @@ dev = [
|
||||
"pytest>=7.0.0",
|
||||
"pytest-cov>=4.0.0",
|
||||
"pytest-mock>=3.10.0",
|
||||
"pytest-asyncio>=0.21.0",
|
||||
# Code quality
|
||||
"black>=23.0.0",
|
||||
"flake8>=6.0.0",
|
||||
@@ -69,21 +70,12 @@ no_strict_optional = true
|
||||
files = ["kikotools"]
|
||||
exclude = ["tests"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
minversion = "7.0"
|
||||
testpaths = ["tests"]
|
||||
addopts = "-ra -q --strict-markers"
|
||||
markers = [
|
||||
"unit: Unit tests",
|
||||
"integration: Integration tests",
|
||||
"slow: Slow tests"
|
||||
]
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["kikotools"]
|
||||
omit = ["*/tests/*", "*/__init__.py"]
|
||||
|
||||
[tool.coverage.report]
|
||||
fail_under = 85
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"def __repr__",
|
||||
|
||||
+7
-3
@@ -1,5 +1,9 @@
|
||||
[tool:pytest]
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
python_paths = .
|
||||
pythonpath = .
|
||||
norecursedirs = venv .git __pycache__
|
||||
addopts = --ignore=__init__.py --ignore=venv
|
||||
addopts = -ra --strict-markers --ignore=__init__.py --ignore=venv
|
||||
markers =
|
||||
unit: Unit tests
|
||||
integration: Integration tests
|
||||
slow: Slow tests
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
pytest>=7.0.0
|
||||
pytest-cov>=4.0.0
|
||||
pytest-mock>=3.10.0
|
||||
pytest-asyncio>=0.21.0
|
||||
|
||||
# Code quality
|
||||
black>=23.0.0
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
# Runtime dependencies for ComfyUI-KikoTools
|
||||
|
||||
# Gemini API integration (optional - only needed for Gemini Prompt node)
|
||||
google-generativeai>=0.3.0
|
||||
google-generativeai
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Loading the node must not depend on the console being able to print emoji.
|
||||
|
||||
A Windows console using a legacy code page (cp1252, or cp437 in a plain
|
||||
cmd window) raises UnicodeEncodeError on characters like the emoji in our
|
||||
display names. At import time that aborts loading the whole node pack.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def strict_console(encoding):
|
||||
return io.TextIOWrapper(io.BytesIO(), encoding=encoding, errors="strict")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("encoding", ["cp1252", "cp437"])
|
||||
def test_package_loads_with_a_legacy_windows_console(encoding):
|
||||
name = f"kikotools_pkg_{encoding}"
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
name, os.path.join(ROOT, "__init__.py"), submodule_search_locations=[ROOT]
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[name] = module
|
||||
stream = strict_console(encoding)
|
||||
try:
|
||||
with contextlib.redirect_stdout(stream):
|
||||
spec.loader.exec_module(module)
|
||||
finally:
|
||||
for key in [k for k in sys.modules if k == name or k.startswith(name + ".")]:
|
||||
sys.modules.pop(key)
|
||||
|
||||
stream.flush()
|
||||
banner = stream.buffer.getvalue().decode(encoding)
|
||||
assert "Loaded:" in banner
|
||||
assert "Model Downloader" in banner # display name kept, emoji dropped
|
||||
|
||||
|
||||
def test_preset_validation_messages_are_ascii(monkeypatch):
|
||||
from kikotools.tools.width_height_selector import presets
|
||||
|
||||
bad = presets.PRESET_METADATA[next(iter(presets.PRESET_METADATA))]
|
||||
bad = bad._replace(width=1001) # not divisible by 8
|
||||
monkeypatch.setattr(presets, "PRESET_METADATA", {"bad 1001×1024": bad})
|
||||
stream = strict_console("cp437")
|
||||
with contextlib.redirect_stdout(stream):
|
||||
assert presets.validate_preset_dimensions() is False
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Extra coverage for Local Image Loader logic (scan/search)."""
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from kikotools.tools.local_image_loader import logic
|
||||
from kikotools.tools.local_image_loader.logic import scan_directory, search_files
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def files(tmp_path):
|
||||
(tmp_path / "cat.png").write_bytes(b"12345")
|
||||
(tmp_path / "dog.jpg").write_bytes(b"1")
|
||||
(tmp_path / "cat.mp4").write_bytes(b"1")
|
||||
(tmp_path / "cat.mp3").write_bytes(b"1")
|
||||
(tmp_path / "cat.txt").write_bytes(b"1")
|
||||
(tmp_path / ".hidden.png").write_bytes(b"1")
|
||||
(tmp_path / "sub").mkdir()
|
||||
(tmp_path / "sub" / "cat2.png").write_bytes(b"1")
|
||||
(tmp_path / "catdir").mkdir()
|
||||
(tmp_path / "other").mkdir()
|
||||
(tmp_path / "other" / "deep_cat.webp").write_bytes(b"1")
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_load_image_bad_metadata_json_falls_back_to_raw(tmp_path):
|
||||
from PIL import Image, PngImagePlugin
|
||||
|
||||
info = PngImagePlugin.PngInfo()
|
||||
info.add_text("prompt", "not json")
|
||||
info.add_text("workflow", "{bad")
|
||||
path = tmp_path / "m.png"
|
||||
Image.new("RGB", (2, 2)).save(path, pnginfo=info)
|
||||
_, meta = logic.load_image_from_path(str(path))
|
||||
assert meta["prompt"] == "not json"
|
||||
assert meta["workflow"] == "{bad"
|
||||
|
||||
|
||||
class TestScanDirectory:
|
||||
def test_hide_dot_files_toggle(self, files):
|
||||
names = {i["name"] for i in scan_directory(str(files))}
|
||||
assert ".hidden.png" not in names
|
||||
names = {i["name"] for i in scan_directory(str(files), hide_dot_folders=False)}
|
||||
assert ".hidden.png" in names
|
||||
|
||||
def test_sort_by_size_and_date(self, files):
|
||||
by_size = [
|
||||
i["name"]
|
||||
for i in scan_directory(str(files), sort_by="size", sort_order="desc")
|
||||
if i["type"] == "image"
|
||||
]
|
||||
assert by_size[0] == "cat.png"
|
||||
by_date = scan_directory(str(files), sort_by="date")
|
||||
assert by_date[0]["type"] == "dir"
|
||||
|
||||
def test_vanishing_file_skipped(self, files):
|
||||
real = os.stat
|
||||
|
||||
def flaky(p, *a, **k):
|
||||
if str(p).endswith(".png"):
|
||||
raise FileNotFoundError
|
||||
return real(p, *a, **k)
|
||||
|
||||
with patch.object(os, "stat", side_effect=flaky):
|
||||
names = {i["name"] for i in scan_directory(str(files))}
|
||||
assert "cat.png" not in names and "dog.jpg" in names
|
||||
|
||||
|
||||
class TestSearchFiles:
|
||||
def test_finds_nested_and_dirs(self, files):
|
||||
names = [r["name"] for r in search_files(str(files), "cat")]
|
||||
assert {"cat.png", "cat2.png", "catdir", "deep_cat.webp"} <= set(names)
|
||||
assert "cat.txt" not in names and "cat.mp4" not in names
|
||||
|
||||
def test_media_flags(self, files):
|
||||
names = [
|
||||
r["name"]
|
||||
for r in search_files(str(files), "cat", show_videos=True, show_audio=True)
|
||||
]
|
||||
assert "cat.mp4" in names and "cat.mp3" in names
|
||||
|
||||
def test_blank_query_empty(self, files):
|
||||
assert search_files(str(files), " ") == []
|
||||
assert search_files(str(files), "") == []
|
||||
|
||||
def test_not_a_directory(self, tmp_path):
|
||||
with pytest.raises(NotADirectoryError):
|
||||
search_files(str(tmp_path / "nope"), "x")
|
||||
|
||||
def test_max_results(self, files):
|
||||
assert len(search_files(str(files), "cat", max_results=2)) == 2
|
||||
|
||||
def test_unreadable_directory_ignored(self, files):
|
||||
real = os.listdir
|
||||
|
||||
def flaky(p):
|
||||
if str(p).endswith("sub"):
|
||||
raise PermissionError
|
||||
return real(p)
|
||||
|
||||
with patch.object(os, "listdir", side_effect=flaky):
|
||||
names = [r["name"] for r in search_files(str(files), "cat")]
|
||||
assert "cat2.png" not in names
|
||||
|
||||
def test_stat_failure_skipped(self, files):
|
||||
real = os.stat
|
||||
|
||||
def flaky(p, *a, **k):
|
||||
if str(p).endswith("cat.png"):
|
||||
raise PermissionError
|
||||
return real(p, *a, **k)
|
||||
|
||||
with patch.object(os, "stat", side_effect=flaky):
|
||||
names = [r["name"] for r in search_files(str(files), "cat")]
|
||||
assert "cat.png" not in names and "cat2.png" in names
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Path-security tests for the Local Image Loader (logic helpers)."""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from kikotools.tools.local_image_loader.logic import (
|
||||
is_allowed_image_path,
|
||||
is_too_broad_root,
|
||||
is_within_roots,
|
||||
list_subdirectories,
|
||||
resolve_path,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tree(tmp_path):
|
||||
root = tmp_path / "root"
|
||||
root.mkdir()
|
||||
(root / "a.png").write_bytes(b"x")
|
||||
(root / "notes.txt").write_text("x")
|
||||
(root / "sub").mkdir()
|
||||
(root / "sub" / "b.JPG").write_bytes(b"x")
|
||||
sibling = tmp_path / "root_evil" # shares a string prefix with root
|
||||
sibling.mkdir()
|
||||
(sibling / "c.png").write_bytes(b"x")
|
||||
return tmp_path
|
||||
|
||||
|
||||
class TestResolvePath:
|
||||
@pytest.mark.parametrize("bad", [None, "", 5, "a\0b", b"bytes"])
|
||||
def test_invalid_input_returns_none(self, bad):
|
||||
assert resolve_path(bad) is None
|
||||
|
||||
def test_resolves_dotdot_and_symlinks(self, tree):
|
||||
link = tree / "link"
|
||||
link.symlink_to(tree / "root")
|
||||
got = resolve_path(str(link / "sub" / ".." / "a.png"))
|
||||
assert got == os.path.realpath(tree / "root" / "a.png")
|
||||
|
||||
|
||||
class TestIsWithinRoots:
|
||||
def test_inside_root(self, tree):
|
||||
assert is_within_roots(str(tree / "root" / "sub"), [str(tree / "root")])
|
||||
|
||||
def test_root_itself_allowed(self, tree):
|
||||
assert is_within_roots(str(tree / "root"), [str(tree / "root")])
|
||||
|
||||
def test_prefix_sibling_rejected(self, tree):
|
||||
assert not is_within_roots(
|
||||
str(tree / "root_evil" / "c.png"), [str(tree / "root")]
|
||||
)
|
||||
|
||||
def test_dotdot_escape_rejected(self, tree):
|
||||
escape = str(tree / "root" / ".." / "root_evil" / "c.png")
|
||||
assert not is_within_roots(escape, [str(tree / "root")])
|
||||
|
||||
def test_symlink_escape_rejected(self, tree):
|
||||
(tree / "root" / "out").symlink_to(tree / "root_evil")
|
||||
assert not is_within_roots(
|
||||
str(tree / "root" / "out" / "c.png"), [str(tree / "root")]
|
||||
)
|
||||
|
||||
def test_empty_or_invalid_roots_ignored(self, tree):
|
||||
assert not is_within_roots(str(tree), [])
|
||||
assert not is_within_roots(str(tree), [None, "", 3, "a\0b"])
|
||||
|
||||
def test_invalid_path(self):
|
||||
assert not is_within_roots(None, ["/"])
|
||||
|
||||
|
||||
class TestIsAllowedImagePath:
|
||||
def test_image_in_root(self, tree):
|
||||
roots = [str(tree / "root")]
|
||||
assert is_allowed_image_path(str(tree / "root" / "a.png"), roots)
|
||||
assert is_allowed_image_path(str(tree / "root" / "sub" / "b.JPG"), roots)
|
||||
|
||||
def test_non_image_in_root_rejected(self, tree):
|
||||
assert not is_allowed_image_path(
|
||||
str(tree / "root" / "notes.txt"), [str(tree / "root")]
|
||||
)
|
||||
|
||||
def test_image_outside_root_rejected(self, tree):
|
||||
assert not is_allowed_image_path(
|
||||
str(tree / "root_evil" / "c.png"), [str(tree / "root")]
|
||||
)
|
||||
|
||||
def test_root_set_to_filesystem_root_still_requires_image_ext(self):
|
||||
assert not is_allowed_image_path("/etc/passwd", ["/"])
|
||||
|
||||
def test_symlink_with_image_name_to_secret_rejected(self, tree):
|
||||
secret = tree / "root_evil" / "id_rsa"
|
||||
secret.write_text("secret")
|
||||
(tree / "root" / "fake.png").symlink_to(secret)
|
||||
assert not is_allowed_image_path(
|
||||
str(tree / "root" / "fake.png"), [str(tree / "root"), str(tree)]
|
||||
)
|
||||
|
||||
def test_invalid_path(self):
|
||||
assert not is_allowed_image_path(None, ["/"])
|
||||
|
||||
|
||||
class TestListSubdirectories:
|
||||
def test_trailing_sep_lists_children_dirs_only(self, tree):
|
||||
got = list_subdirectories(str(tree / "root") + os.sep)
|
||||
assert got == [os.path.join(str(tree / "root") + os.sep, "sub")]
|
||||
|
||||
def test_prefix_match_in_parent(self, tree):
|
||||
got = list_subdirectories(str(tree / "ro"))
|
||||
assert got == [str(tree / "root"), str(tree / "root_evil")]
|
||||
|
||||
def test_missing_dir_returns_empty(self, tree):
|
||||
assert list_subdirectories(str(tree / "nope") + os.sep) == []
|
||||
assert list_subdirectories(str(tree / "nope" / "x")) == []
|
||||
|
||||
def test_limit(self, tree):
|
||||
for i in range(5):
|
||||
(tree / f"d{i}").mkdir()
|
||||
assert len(list_subdirectories(str(tree) + os.sep, limit=3)) == 3
|
||||
|
||||
def test_relative_without_parent_returns_empty(self):
|
||||
assert list_subdirectories("zzz_not_here") == []
|
||||
|
||||
def test_top_level_prefix(self):
|
||||
got = list_subdirectories("/e")
|
||||
assert all(os.path.isdir(p) for p in got)
|
||||
assert all(p.startswith("/e") for p in got)
|
||||
|
||||
|
||||
class TestTooBroadRoot:
|
||||
"""Saved roots that would expose everything are refused."""
|
||||
|
||||
@pytest.fixture
|
||||
def home(self, tmp_path):
|
||||
home = tmp_path / "home" / "user"
|
||||
home.mkdir(parents=True)
|
||||
return str(home)
|
||||
|
||||
def test_filesystem_root(self, home):
|
||||
assert is_too_broad_root(os.path.abspath(os.sep), home=home)
|
||||
|
||||
def test_home_itself(self, home):
|
||||
assert is_too_broad_root(home, home=home)
|
||||
assert is_too_broad_root(home + os.sep, home=home)
|
||||
|
||||
def test_ancestor_of_home(self, home):
|
||||
assert is_too_broad_root(os.path.dirname(home), home=home)
|
||||
|
||||
def test_folder_inside_home_ok(self, home):
|
||||
assert not is_too_broad_root(os.path.join(home, "Pictures"), home=home)
|
||||
|
||||
def test_unrelated_folder_ok(self, home, tmp_path):
|
||||
assert not is_too_broad_root(str(tmp_path / "srv" / "media"), home=home)
|
||||
|
||||
def test_symlink_to_home_refused(self, home, tmp_path):
|
||||
link = tmp_path / "link"
|
||||
link.symlink_to(home)
|
||||
assert is_too_broad_root(str(link), home=home)
|
||||
|
||||
@pytest.mark.parametrize("bad", [None, "", 5, "a\0b"])
|
||||
def test_unusable_values_refused(self, home, bad):
|
||||
assert is_too_broad_root(bad, home=home)
|
||||
@@ -0,0 +1,423 @@
|
||||
"""Route handler tests for the Local Image Loader."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from aiohttp.test_utils import make_mocked_request
|
||||
from PIL import Image
|
||||
|
||||
from kikotools.tools.local_image_loader import node as node_mod
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
def req(query=None, body=None):
|
||||
async def _json():
|
||||
if isinstance(body, Exception):
|
||||
raise body
|
||||
return body
|
||||
|
||||
return SimpleNamespace(query=query or {}, json=_json)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env(tmp_path, monkeypatch):
|
||||
root = tmp_path / "root"
|
||||
root.mkdir()
|
||||
Image.new("RGB", (400, 300), "red").save(root / "a.png")
|
||||
Image.new("RGBA", (40, 30), (1, 2, 3, 4)).save(root / "alpha.png")
|
||||
(root / "notes.txt").write_text("secret")
|
||||
(root / "sub").mkdir()
|
||||
for i in range(3):
|
||||
Image.new("RGB", (4, 4)).save(root / "sub" / f"i{i}.png")
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
Image.new("RGB", (4, 4)).save(outside / "o.png")
|
||||
cfg = tmp_path / "config.json"
|
||||
sel = tmp_path / "selections.json"
|
||||
monkeypatch.setattr(node_mod, "CONFIG_FILE", str(cfg))
|
||||
monkeypatch.setattr(node_mod, "SELECTIONS_FILE", str(sel))
|
||||
monkeypatch.setattr(node_mod, "get_allowed_roots", lambda: [str(root)])
|
||||
return SimpleNamespace(root=root, outside=outside, cfg=cfg, sel=sel, tmp=tmp_path)
|
||||
|
||||
|
||||
def body(resp):
|
||||
return json.loads(resp.text)
|
||||
|
||||
|
||||
class TestAllowedRoots:
|
||||
def test_combines_saved_and_comfy_dirs(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(node_mod, "CONFIG_FILE", str(tmp_path / "c.json"))
|
||||
node_mod.save_config({"saved_paths": ["/saved", 7, ""]})
|
||||
fp = MagicMock()
|
||||
fp.get_input_directory.return_value = "/in"
|
||||
fp.get_output_directory.return_value = "/out"
|
||||
with patch.dict("sys.modules", {"folder_paths": fp}):
|
||||
roots = node_mod.get_allowed_roots()
|
||||
assert roots == ["/saved", "/in", "/out"]
|
||||
|
||||
def test_folder_paths_unavailable(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(node_mod, "CONFIG_FILE", str(tmp_path / "c.json"))
|
||||
with patch.dict("sys.modules", {"folder_paths": None}):
|
||||
assert node_mod.get_allowed_roots() == []
|
||||
|
||||
def test_folder_paths_non_string_ignored(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(node_mod, "CONFIG_FILE", str(tmp_path / "c.json"))
|
||||
fp = MagicMock()
|
||||
fp.get_input_directory.side_effect = RuntimeError("x")
|
||||
fp.get_output_directory.return_value = MagicMock()
|
||||
with patch.dict("sys.modules", {"folder_paths": fp}):
|
||||
assert node_mod.get_allowed_roots() == []
|
||||
|
||||
def test_too_broad_saved_paths_ignored(self, tmp_path, monkeypatch):
|
||||
# A "/" saved before the guard existed must not keep working.
|
||||
monkeypatch.setattr(node_mod, "CONFIG_FILE", str(tmp_path / "c.json"))
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
node_mod.save_config(
|
||||
{"saved_paths": [os.path.abspath(os.sep), str(tmp_path), "/saved"]}
|
||||
)
|
||||
with patch.dict("sys.modules", {"folder_paths": None}):
|
||||
assert node_mod.get_allowed_roots() == ["/saved"]
|
||||
|
||||
def test_non_list_saved_paths(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(node_mod, "CONFIG_FILE", str(tmp_path / "c.json"))
|
||||
node_mod.save_config({"saved_paths": "oops"})
|
||||
with patch.dict("sys.modules", {"folder_paths": None}):
|
||||
assert node_mod.get_allowed_roots() == []
|
||||
|
||||
|
||||
class TestView:
|
||||
def test_serves_allowed_image(self, env):
|
||||
r = run(node_mod.view_image(req({"filepath": str(env.root / "a.png")})))
|
||||
assert r.status == 200
|
||||
|
||||
def test_works_with_make_mocked_request(self, env):
|
||||
q = str(env.root / "a.png").replace("/", "%2F")
|
||||
r = run(node_mod.view_image(make_mocked_request("GET", f"/view?filepath={q}")))
|
||||
assert r.status == 200
|
||||
|
||||
def test_missing_param_400(self, env):
|
||||
assert run(node_mod.view_image(req())).status == 400
|
||||
|
||||
def test_null_byte_400(self, env):
|
||||
assert run(node_mod.view_image(req({"filepath": "a\0.png"}))).status == 400
|
||||
|
||||
def test_non_image_in_root_403(self, env):
|
||||
r = run(node_mod.view_image(req({"filepath": str(env.root / "notes.txt")})))
|
||||
assert r.status == 403
|
||||
|
||||
def test_outside_root_403(self, env):
|
||||
r = run(node_mod.view_image(req({"filepath": str(env.outside / "o.png")})))
|
||||
assert r.status == 403
|
||||
|
||||
def test_ssh_key_style_403(self, env):
|
||||
assert run(node_mod.view_image(req({"filepath": "/etc/passwd"}))).status == 403
|
||||
|
||||
def test_dotdot_traversal_403(self, env):
|
||||
p = str(env.root / ".." / "outside" / "o.png")
|
||||
assert run(node_mod.view_image(req({"filepath": p}))).status == 403
|
||||
|
||||
def test_missing_file_404(self, env):
|
||||
r = run(node_mod.view_image(req({"filepath": str(env.root / "no.png")})))
|
||||
assert r.status == 404
|
||||
|
||||
def test_dotdot_inside_root_is_fine(self, env):
|
||||
p = str(env.root / "sub" / ".." / "a.png")
|
||||
assert run(node_mod.view_image(req({"filepath": p}))).status == 200
|
||||
|
||||
|
||||
class TestViewMedia:
|
||||
def test_video_and_audio_served(self, env):
|
||||
for name in ("clip.mp4", "song.mp3"):
|
||||
(env.root / name).write_bytes(b"x")
|
||||
r = run(node_mod.view_image(req({"filepath": str(env.root / name)})))
|
||||
assert r.status == 200
|
||||
|
||||
def test_video_outside_root_403(self, env):
|
||||
(env.outside / "clip.mp4").write_bytes(b"x")
|
||||
r = run(node_mod.view_image(req({"filepath": str(env.outside / "clip.mp4")})))
|
||||
assert r.status == 403
|
||||
|
||||
def test_key_files_rejected(self, env):
|
||||
for name in ("id_rsa", "id_rsa.pem", "notes.txt"):
|
||||
(env.root / name).write_text("secret")
|
||||
r = run(node_mod.view_image(req({"filepath": str(env.root / name)})))
|
||||
assert r.status == 403
|
||||
|
||||
def test_thumbnail_stays_image_only(self, env):
|
||||
(env.root / "clip.mp4").write_bytes(b"x")
|
||||
r = run(node_mod.get_thumbnail(req({"filepath": str(env.root / "clip.mp4")})))
|
||||
assert r.status == 403
|
||||
|
||||
def test_video_selection_allowed_but_not_loaded(self, env):
|
||||
(env.root / "clip.mp4").write_bytes(b"x")
|
||||
p = str(env.root / "clip.mp4")
|
||||
r = run(
|
||||
node_mod.set_node_selection(
|
||||
req(body={"node_id": 1, "path": p, "type": "video"})
|
||||
)
|
||||
)
|
||||
assert r.status == 200
|
||||
|
||||
|
||||
class TestThumbnail:
|
||||
def test_jpeg_for_opaque(self, env):
|
||||
r = run(node_mod.get_thumbnail(req({"filepath": str(env.root / "a.png")})))
|
||||
assert r.status == 200 and r.content_type == "image/jpeg"
|
||||
|
||||
def test_png_for_alpha(self, env):
|
||||
r = run(node_mod.get_thumbnail(req({"filepath": str(env.root / "alpha.png")})))
|
||||
assert r.status == 200 and r.content_type == "image/png"
|
||||
|
||||
def test_errors(self, env):
|
||||
t = node_mod.get_thumbnail
|
||||
assert run(t(req())).status == 400
|
||||
assert run(t(req({"filepath": "a\0"}))).status == 400
|
||||
assert run(t(req({"filepath": str(env.root / "notes.txt")}))).status == 403
|
||||
assert run(t(req({"filepath": str(env.outside / "o.png")}))).status == 403
|
||||
assert run(t(req({"filepath": str(env.root / "no.png")}))).status == 404
|
||||
|
||||
def test_corrupt_image_500(self, env):
|
||||
(env.root / "bad.png").write_bytes(b"not an image")
|
||||
r = run(node_mod.get_thumbnail(req({"filepath": str(env.root / "bad.png")})))
|
||||
assert r.status == 500
|
||||
|
||||
|
||||
class TestImages:
|
||||
def test_lists_and_paginates(self, env):
|
||||
r = run(
|
||||
node_mod.get_local_images(
|
||||
req({"directory": str(env.root / "sub"), "per_page": "2"})
|
||||
)
|
||||
)
|
||||
data = body(r)
|
||||
assert r.status == 200
|
||||
assert len(data["items"]) == 2
|
||||
assert data["total_pages"] == 2
|
||||
assert data["parent_directory"] == str(env.root)
|
||||
assert json.loads(env.cfg.read_text())["last_path"] == str(env.root / "sub")
|
||||
|
||||
def test_root_has_no_parent(self, env):
|
||||
r = run(node_mod.get_local_images(req({"directory": str(env.root)})))
|
||||
assert body(r)["parent_directory"] is None
|
||||
|
||||
def test_outside_root_403_and_last_path_not_saved(self, env):
|
||||
r = run(node_mod.get_local_images(req({"directory": str(env.outside)})))
|
||||
assert r.status == 403
|
||||
assert not env.cfg.exists()
|
||||
|
||||
def test_root_slash_403(self, env):
|
||||
assert run(node_mod.get_local_images(req({"directory": "/"}))).status == 403
|
||||
|
||||
def test_missing_directory_param_400(self, env):
|
||||
assert run(node_mod.get_local_images(req())).status == 400
|
||||
assert run(node_mod.get_local_images(req({"directory": "x\0"}))).status == 400
|
||||
|
||||
def test_nonexistent_404(self, env):
|
||||
r = run(node_mod.get_local_images(req({"directory": str(env.root / "zz")})))
|
||||
assert r.status == 404
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"extra",
|
||||
[{"page": "abc"}, {"per_page": "x"}, {"page": "0"}, {"page": "-3"}],
|
||||
)
|
||||
def test_bad_paging_400(self, env, extra):
|
||||
q = {"directory": str(env.root), **extra}
|
||||
assert run(node_mod.get_local_images(req(q))).status == 400
|
||||
|
||||
def test_per_page_clamped(self, env):
|
||||
for raw, expected in [("0", 1), ("-5", 1), ("100000", 200)]:
|
||||
r = run(
|
||||
node_mod.get_local_images(
|
||||
req({"directory": str(env.root / "sub"), "per_page": raw})
|
||||
)
|
||||
)
|
||||
assert r.status == 200
|
||||
if expected == 1:
|
||||
assert len(body(r)["items"]) == 1
|
||||
else:
|
||||
assert len(body(r)["items"]) == 3
|
||||
|
||||
def test_scan_error_500(self, env):
|
||||
with patch.object(node_mod, "scan_directory", side_effect=OSError("boom")):
|
||||
r = run(node_mod.get_local_images(req({"directory": str(env.root)})))
|
||||
assert r.status == 500
|
||||
assert "boom" not in r.text
|
||||
|
||||
def test_flags(self, env):
|
||||
q = {
|
||||
"directory": str(env.root),
|
||||
"show_videos": "true",
|
||||
"show_audio": "true",
|
||||
"hide_dot_folders": "false",
|
||||
"sort_by": "date",
|
||||
"sort_order": "desc",
|
||||
}
|
||||
assert run(node_mod.get_local_images(req(q))).status == 200
|
||||
|
||||
|
||||
class TestListDirectories:
|
||||
def test_empty_path_returns_root(self, env):
|
||||
r = run(node_mod.list_directories(req()))
|
||||
assert body(r)["directories"] == [os.path.abspath(os.sep)] or os.name == "nt"
|
||||
|
||||
def test_directories_only_no_files(self, env):
|
||||
r = run(node_mod.list_directories(req({"path": str(env.root) + os.sep})))
|
||||
dirs = body(r)["directories"]
|
||||
assert dirs == [os.path.join(str(env.root) + os.sep, "sub")]
|
||||
|
||||
def test_can_browse_outside_roots_names_only(self, env):
|
||||
r = run(node_mod.list_directories(req({"path": str(env.tmp) + os.sep})))
|
||||
assert r.status == 200
|
||||
assert all(os.path.isdir(d) for d in body(r)["directories"])
|
||||
|
||||
def test_nul_byte_returns_empty(self, env):
|
||||
r = run(node_mod.list_directories(req({"path": "a\0b"})))
|
||||
assert body(r)["directories"] == []
|
||||
|
||||
def test_permission_error(self, env):
|
||||
with patch.object(node_mod, "list_subdirectories", side_effect=PermissionError):
|
||||
r = run(node_mod.list_directories(req({"path": "/x/"})))
|
||||
assert body(r) == {"directories": [], "error": "Permission denied"}
|
||||
|
||||
def test_unexpected_error(self, env):
|
||||
with patch.object(node_mod, "list_subdirectories", side_effect=OSError):
|
||||
r = run(node_mod.list_directories(req({"path": "/x/"})))
|
||||
assert body(r)["directories"] == []
|
||||
|
||||
|
||||
class TestSelectionAndConfig:
|
||||
def test_set_selection_ok(self, env):
|
||||
p = str(env.root / "a.png")
|
||||
r = run(
|
||||
node_mod.set_node_selection(
|
||||
req(body={"node_id": 5, "path": p, "type": "image"})
|
||||
)
|
||||
)
|
||||
assert r.status == 200
|
||||
assert json.loads(env.sel.read_text())["5"]["image"] == {"path": p}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
{},
|
||||
{"node_id": 1, "path": "", "type": "image"},
|
||||
{"node_id": 1, "path": ["x"], "type": "image"},
|
||||
{"node_id": 1, "path": "a\0.png", "type": "image"},
|
||||
[1, 2],
|
||||
],
|
||||
)
|
||||
def test_set_selection_bad_input_400(self, env, payload):
|
||||
assert run(node_mod.set_node_selection(req(body=payload))).status == 400
|
||||
|
||||
def test_set_selection_bad_json_400(self, env):
|
||||
r = run(node_mod.set_node_selection(req(body=ValueError("bad"))))
|
||||
assert r.status == 400
|
||||
|
||||
def test_set_selection_disallowed_403(self, env):
|
||||
for p in (
|
||||
"/etc/passwd",
|
||||
str(env.root / "notes.txt"),
|
||||
str(env.outside / "o.png"),
|
||||
):
|
||||
r = run(
|
||||
node_mod.set_node_selection(
|
||||
req(body={"node_id": 1, "path": p, "type": "image"})
|
||||
)
|
||||
)
|
||||
assert r.status == 403
|
||||
assert not env.sel.exists()
|
||||
|
||||
def test_saved_paths_roundtrip(self, env):
|
||||
r = run(node_mod.save_paths(req(body={"paths": ["/a", "/b"]})))
|
||||
assert r.status == 200
|
||||
assert body(run(node_mod.get_saved_paths(req()))) == {
|
||||
"saved_paths": ["/a", "/b"]
|
||||
}
|
||||
|
||||
@pytest.mark.parametrize("payload", [{"paths": "x"}, {"paths": [1]}, [1]])
|
||||
def test_save_paths_bad_input_400(self, env, payload):
|
||||
assert run(node_mod.save_paths(req(body=payload))).status == 400
|
||||
|
||||
@pytest.mark.parametrize("broad", ["root", "home", "home_parent"])
|
||||
def test_save_paths_refuses_too_broad_roots(
|
||||
self, env, monkeypatch, tmp_path, broad
|
||||
):
|
||||
home = tmp_path / "h" / "user"
|
||||
home.mkdir(parents=True)
|
||||
monkeypatch.setenv("HOME", str(home))
|
||||
monkeypatch.setenv("USERPROFILE", str(home))
|
||||
path = {
|
||||
"root": os.path.abspath(os.sep),
|
||||
"home": str(home),
|
||||
"home_parent": str(home.parent),
|
||||
}[broad]
|
||||
node_mod.save_config({"saved_paths": ["/keep"]})
|
||||
r = run(node_mod.save_paths(req(body={"paths": ["/ok", path]})))
|
||||
assert r.status == 400
|
||||
assert path in body(r)["error"]
|
||||
# Nothing was saved
|
||||
assert node_mod.load_config()["saved_paths"] == ["/keep"]
|
||||
|
||||
def test_save_paths_bad_json_400(self, env):
|
||||
assert run(node_mod.save_paths(req(body=ValueError()))).status == 400
|
||||
|
||||
def test_last_path(self, env):
|
||||
assert body(run(node_mod.get_last_path(req())))["last_path"] == ""
|
||||
node_mod.save_config({"last_path": "/q"})
|
||||
assert body(run(node_mod.get_last_path(req())))["last_path"] == "/q"
|
||||
|
||||
|
||||
class TestLoadMediaGuard:
|
||||
def test_tampered_selection_refused(self, env):
|
||||
env.sel.write_text(
|
||||
json.dumps({"7": {"image": {"path": str(env.outside / "o.png")}}})
|
||||
)
|
||||
image, info = node_mod.LocalImageLoaderNode().load_media("7")
|
||||
assert image.shape == (1, 1, 1, 4) and info == ""
|
||||
|
||||
def test_non_image_selection_refused(self, env):
|
||||
env.sel.write_text(
|
||||
json.dumps({"7": {"image": {"path": str(env.root / "notes.txt")}}})
|
||||
)
|
||||
_, info = node_mod.LocalImageLoaderNode().load_media("7")
|
||||
assert info == ""
|
||||
|
||||
def test_allowed_selection_loads(self, env):
|
||||
env.sel.write_text(
|
||||
json.dumps({"7": {"image": {"path": str(env.root / "a.png")}}})
|
||||
)
|
||||
image, info = node_mod.LocalImageLoaderNode().load_media("7")
|
||||
assert image.shape == (1, 300, 400, 3)
|
||||
assert json.loads(info)["width"] == 400
|
||||
|
||||
def test_load_error_is_swallowed(self, env):
|
||||
(env.root / "bad.png").write_bytes(b"junk")
|
||||
env.sel.write_text(
|
||||
json.dumps({"7": {"image": {"path": str(env.root / "bad.png")}}})
|
||||
)
|
||||
image, info = node_mod.LocalImageLoaderNode().load_media("7")
|
||||
assert info == ""
|
||||
|
||||
|
||||
class TestPersistence:
|
||||
def test_selections_corrupt_file(self, env):
|
||||
env.sel.write_text("{nope")
|
||||
assert node_mod.load_selections() == {}
|
||||
|
||||
def test_config_corrupt_file(self, env):
|
||||
env.cfg.write_text("{nope")
|
||||
assert node_mod.load_config() == {}
|
||||
|
||||
def test_save_errors_logged_not_raised(self, env, monkeypatch):
|
||||
monkeypatch.setattr(node_mod, "SELECTIONS_FILE", "/nonexistent_dir/x.json")
|
||||
monkeypatch.setattr(node_mod, "CONFIG_FILE", "/nonexistent_dir/y.json")
|
||||
node_mod.save_selections({"a": 1})
|
||||
node_mod.save_config({"a": 1})
|
||||
@@ -10,6 +10,8 @@ from kikotools.tools.model_downloader.base import BaseDownloader
|
||||
class TestDownloader(BaseDownloader):
|
||||
"""Concrete downloader for testing"""
|
||||
|
||||
__test__ = False
|
||||
|
||||
def download(self, url, output_path, filename=None, force=False):
|
||||
"""Test implementation"""
|
||||
return f"{output_path}/{filename or 'test.file'}"
|
||||
|
||||
@@ -0,0 +1,814 @@
|
||||
"""Download flow, security and failure-path tests for all downloaders.
|
||||
|
||||
All network access is mocked; nothing here touches the real network.
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from kikotools.tools.model_downloader import base
|
||||
from kikotools.tools.model_downloader.base import (
|
||||
BaseDownloader,
|
||||
safe_server_filename,
|
||||
validate_filename,
|
||||
validate_url_scheme,
|
||||
)
|
||||
from kikotools.tools.model_downloader.civitai import CivitAIDownloader
|
||||
from kikotools.tools.model_downloader.custom import CustomDownloader
|
||||
from kikotools.tools.model_downloader.huggingface import HuggingFaceDownloader
|
||||
|
||||
HF_URL = "https://huggingface.co/user/repo/resolve/main/model.safetensors"
|
||||
CIVITAI_URL = "https://civitai.com/api/download/models/123"
|
||||
SIGNED_URL = "https://cdn.example.com/blob/model.safetensors"
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, data=b"", status=200, headers=None, fail_after=None):
|
||||
self._io = io.BytesIO(data)
|
||||
self.status = status
|
||||
self.headers = headers or {}
|
||||
self.closed = False
|
||||
self._fail_after = fail_after
|
||||
|
||||
def getheader(self, name, default=None):
|
||||
return self.headers.get(name, default)
|
||||
|
||||
def read(self, n=-1):
|
||||
if self._fail_after is not None and self._io.tell() >= self._fail_after:
|
||||
raise ConnectionResetError("boom")
|
||||
if self._fail_after is not None:
|
||||
n = self._fail_after
|
||||
return self._io.read(n)
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
self.close()
|
||||
|
||||
|
||||
def http_error(code, reason="err"):
|
||||
return urllib.error.HTTPError("http://x", code, reason, {}, None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def root(tmp_path):
|
||||
models = tmp_path / "models"
|
||||
(models / "checkpoints").mkdir(parents=True)
|
||||
return models
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def out(root):
|
||||
return str(root / "checkpoints")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def opener():
|
||||
"""Patch build_opener; yields (build_opener_mock, opener_mock)."""
|
||||
with patch.object(urllib.request, "build_opener") as build:
|
||||
op = MagicMock()
|
||||
build.return_value = op
|
||||
yield build, op
|
||||
|
||||
|
||||
def make(cls, root, token=None):
|
||||
return cls(token=token, models_root=str(root))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- helpers
|
||||
|
||||
|
||||
class TestValidators:
|
||||
@pytest.mark.parametrize(
|
||||
"name", ["", ".", "..", "../x", "a/b", "/etc/passwd", "a\\b", None, "x\x00y"]
|
||||
)
|
||||
def test_validate_filename_rejects(self, name):
|
||||
with pytest.raises(ValueError):
|
||||
validate_filename(name)
|
||||
|
||||
def test_validate_filename_accepts_plain(self):
|
||||
assert validate_filename("model.safetensors") == "model.safetensors"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw,expected",
|
||||
[
|
||||
("../../evil.bin", "evil.bin"),
|
||||
("/etc/passwd", "passwd"),
|
||||
("..\\win\\x.bin", "x.bin"),
|
||||
("ok.bin", "ok.bin"),
|
||||
("..", "dflt"),
|
||||
("dir/", "dflt"),
|
||||
("", "dflt"),
|
||||
(None, "dflt"),
|
||||
],
|
||||
)
|
||||
def test_safe_server_filename(self, raw, expected):
|
||||
assert safe_server_filename(raw, "dflt") == expected
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url", ["file:///etc/passwd", "ftp://h/x", "gopher://h", "", "//h/x"]
|
||||
)
|
||||
def test_scheme_rejected(self, url):
|
||||
with pytest.raises(ValueError):
|
||||
validate_url_scheme(url)
|
||||
|
||||
@pytest.mark.parametrize("url", ["http://h/x", "HTTPS://h/x"])
|
||||
def test_scheme_allowed(self, url):
|
||||
validate_url_scheme(url)
|
||||
|
||||
|
||||
class TestHeaderParsing:
|
||||
@pytest.mark.parametrize(
|
||||
"header,expected",
|
||||
[
|
||||
('attachment; filename="a b.bin"', "a b.bin"),
|
||||
("attachment; filename=a.bin; size=3", "a.bin"),
|
||||
("attachment; filename='q.bin'", "q.bin"),
|
||||
("attachment; filename*=UTF-8''na%C3%AFve.bin", "naïve.bin"),
|
||||
("attachment", None),
|
||||
("", None),
|
||||
('attachment; filename=""', None),
|
||||
],
|
||||
)
|
||||
def test_extract(self, header, expected):
|
||||
d = CustomDownloader()
|
||||
assert d.extract_filename_from_header(header) == expected
|
||||
|
||||
|
||||
class TestResolveOutputDir:
|
||||
def test_inside_ok_and_created(self, root):
|
||||
d = make(CustomDownloader, root)
|
||||
new = root / "loras" / "sub"
|
||||
assert d.resolve_output_dir(str(new)) == os.path.realpath(new)
|
||||
assert new.is_dir()
|
||||
|
||||
def test_root_itself_ok(self, root):
|
||||
assert make(CustomDownloader, root).resolve_output_dir(str(root))
|
||||
|
||||
@pytest.mark.parametrize("rel", ["..", "../other", "checkpoints/../../x"])
|
||||
def test_traversal_rejected(self, root, rel):
|
||||
d = make(CustomDownloader, root)
|
||||
with pytest.raises(ValueError):
|
||||
d.resolve_output_dir(str(root / rel))
|
||||
assert not (root.parent / "other").exists()
|
||||
|
||||
def test_sibling_prefix_not_accepted(self, root, tmp_path):
|
||||
sibling = tmp_path / "models_evil"
|
||||
with pytest.raises(ValueError):
|
||||
make(CustomDownloader, root).resolve_output_dir(str(sibling))
|
||||
|
||||
def test_symlink_escape_rejected(self, root, tmp_path):
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
(root / "link").symlink_to(outside)
|
||||
with pytest.raises(ValueError):
|
||||
make(CustomDownloader, root).resolve_output_dir(str(root / "link"))
|
||||
|
||||
def test_absolute_outside_rejected(self, root):
|
||||
with pytest.raises(ValueError):
|
||||
make(CustomDownloader, root).resolve_output_dir("/etc")
|
||||
|
||||
def test_default_roots_include_models_dir_and_registered_paths(
|
||||
self, root, tmp_path
|
||||
):
|
||||
extra = tmp_path / "mnt" / "loras"
|
||||
extra.mkdir(parents=True)
|
||||
fp = MagicMock()
|
||||
fp.models_dir = str(root)
|
||||
fp.folder_names_and_paths = {
|
||||
"loras": ([str(extra), "/other/x"], {".safetensors"}),
|
||||
"broken": 5,
|
||||
}
|
||||
with patch.dict("sys.modules", {"folder_paths": fp}):
|
||||
d = CustomDownloader()
|
||||
assert d.resolve_output_dir(str(root / "x"))
|
||||
assert d.resolve_output_dir(str(extra / "sub"))
|
||||
with pytest.raises(ValueError):
|
||||
d.resolve_output_dir("/etc")
|
||||
|
||||
def test_default_roots_exclude_custom_nodes(self, root, tmp_path):
|
||||
# ComfyUI registers custom_nodes in folder_names_and_paths; writing
|
||||
# there means code execution on the next restart.
|
||||
custom_nodes = tmp_path / "custom_nodes"
|
||||
custom_nodes.mkdir()
|
||||
fp = MagicMock()
|
||||
fp.models_dir = str(root)
|
||||
fp.folder_names_and_paths = {"custom_nodes": ([str(custom_nodes)], set())}
|
||||
with patch.dict("sys.modules", {"folder_paths": fp}):
|
||||
with pytest.raises(ValueError):
|
||||
CustomDownloader().resolve_output_dir(str(custom_nodes / "evil"))
|
||||
|
||||
def test_injected_roots_accept_list(self, root, tmp_path):
|
||||
other = tmp_path / "other"
|
||||
d = CustomDownloader(models_root=[str(root), str(other)])
|
||||
assert d.resolve_output_dir(str(other / "a"))
|
||||
|
||||
def test_relative_path_resolved_against_base_path(self, root, tmp_path):
|
||||
fp = MagicMock()
|
||||
fp.models_dir = str(root)
|
||||
fp.base_path = str(tmp_path)
|
||||
with patch.dict("sys.modules", {"folder_paths": fp}):
|
||||
result = CustomDownloader().resolve_output_dir("models/checkpoints")
|
||||
assert result == os.path.realpath(root / "checkpoints")
|
||||
|
||||
def test_relative_path_falls_back_to_cwd(self, root, monkeypatch):
|
||||
monkeypatch.chdir(root.parent)
|
||||
fp = MagicMock() # base_path not a str
|
||||
with patch.dict("sys.modules", {"folder_paths": fp}):
|
||||
result = CustomDownloader(models_root=str(root)).resolve_output_dir(
|
||||
"models/loras"
|
||||
)
|
||||
assert result == os.path.realpath(root / "loras")
|
||||
|
||||
def test_unavailable_root_fails_closed(self, tmp_path):
|
||||
fp = MagicMock() # models_dir is a MagicMock, not a str
|
||||
with patch.dict("sys.modules", {"folder_paths": fp}):
|
||||
with pytest.raises(ValueError):
|
||||
CustomDownloader().resolve_output_dir(str(tmp_path))
|
||||
|
||||
def test_folder_paths_import_error(self, tmp_path):
|
||||
with patch.dict("sys.modules", {"folder_paths": None}):
|
||||
with pytest.raises(ValueError):
|
||||
CustomDownloader().resolve_output_dir(str(tmp_path))
|
||||
|
||||
|
||||
class TestOpenUrl:
|
||||
def test_timeout_and_scheme(self, opener):
|
||||
build, op = opener
|
||||
d = CustomDownloader()
|
||||
d.open_url(urllib.request.Request("https://h/x"))
|
||||
assert op.open.call_args.kwargs["timeout"] == base.DOWNLOAD_TIMEOUT
|
||||
with pytest.raises(ValueError):
|
||||
d.open_url(urllib.request.Request("file:///etc/passwd"))
|
||||
|
||||
def test_no_redirect_mode_uses_processor(self, opener):
|
||||
build, _ = opener
|
||||
CustomDownloader().open_url(
|
||||
urllib.request.Request("https://h/x"), follow_redirects=False
|
||||
)
|
||||
assert build.call_args.args == (base._NoRedirectProcessor,)
|
||||
|
||||
def test_no_redirect_processor_passes_through(self):
|
||||
resp = object()
|
||||
assert base._NoRedirectProcessor().http_response(None, resp) is resp
|
||||
|
||||
|
||||
class TestRedirectHandler:
|
||||
def redirect(self, src, dst, headers):
|
||||
req = urllib.request.Request(src, headers=headers)
|
||||
return base._SafeRedirectHandler().redirect_request(
|
||||
req, io.BytesIO(), 302, "Found", {}, dst
|
||||
)
|
||||
|
||||
def test_cross_host_drops_authorization(self):
|
||||
new = self.redirect(
|
||||
"https://a.com/x", "https://b.com/y", {"Authorization": "Bearer s"}
|
||||
)
|
||||
assert not new.has_header("Authorization")
|
||||
|
||||
def test_same_host_keeps_authorization(self):
|
||||
new = self.redirect(
|
||||
"https://a.com/x", "https://a.com/y", {"Authorization": "Bearer s"}
|
||||
)
|
||||
assert new.get_header("Authorization") == "Bearer s"
|
||||
|
||||
def test_disallowed_scheme_blocked(self):
|
||||
with pytest.raises(urllib.error.HTTPError):
|
||||
self.redirect("https://a.com/x", "file:///etc/passwd", {})
|
||||
|
||||
|
||||
class TestSaveResponse:
|
||||
@pytest.mark.parametrize(
|
||||
"name", ["evil.py", "__init__.py", "run.sh", "x.pyc", "noext", "a.SH"]
|
||||
)
|
||||
def test_refuses_non_model_file_types(self, tmp_path, name):
|
||||
target = tmp_path / name
|
||||
with pytest.raises(ValueError, match="file type"):
|
||||
CustomDownloader().save_response(FakeResponse(b"x"), str(target), 1)
|
||||
assert not target.exists()
|
||||
assert not (tmp_path / (name + ".part")).exists()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name", ["m.safetensors", "m.GGUF", "m.ckpt", "m.pth", "cfg.json"]
|
||||
)
|
||||
def test_accepts_model_file_types(self, tmp_path, name):
|
||||
target = str(tmp_path / name)
|
||||
assert CustomDownloader().save_response(FakeResponse(b"x"), target, 1)
|
||||
|
||||
def test_success_atomic(self, tmp_path):
|
||||
target = str(tmp_path / "f.bin")
|
||||
resp = FakeResponse(b"abcdef")
|
||||
cb = MagicMock()
|
||||
d = CustomDownloader()
|
||||
d.set_progress_callback(cb)
|
||||
assert d.save_response(resp, target, 6) == target
|
||||
assert open(target, "rb").read() == b"abcdef"
|
||||
assert not os.path.exists(target + ".part")
|
||||
assert resp.closed
|
||||
cb.assert_called()
|
||||
|
||||
def test_unknown_size_progress(self, tmp_path):
|
||||
cb = MagicMock()
|
||||
d = CustomDownloader()
|
||||
d.set_progress_callback(cb)
|
||||
d.save_response(FakeResponse(b"abc"), str(tmp_path / "f.bin"), None)
|
||||
assert cb.call_args.args[:2] == (3, 0)
|
||||
|
||||
def test_strict_size_mismatch_leaves_nothing(self, tmp_path):
|
||||
target = str(tmp_path / "f.bin")
|
||||
with pytest.raises(Exception, match="incomplete"):
|
||||
CustomDownloader().save_response(FakeResponse(b"abc"), target, 10)
|
||||
assert os.listdir(tmp_path) == []
|
||||
|
||||
def test_lenient_size_mismatch_saves(self, tmp_path):
|
||||
target = str(tmp_path / "f.bin")
|
||||
CustomDownloader().save_response(
|
||||
FakeResponse(b"abc"), target, 10, strict_size=False
|
||||
)
|
||||
assert os.path.exists(target)
|
||||
|
||||
def test_network_error_midstream_cleans_part(self, tmp_path):
|
||||
target = str(tmp_path / "f.bin")
|
||||
resp = FakeResponse(b"x" * 100, fail_after=10)
|
||||
with pytest.raises(ConnectionResetError):
|
||||
CustomDownloader().save_response(resp, target, 100)
|
||||
assert os.listdir(tmp_path) == []
|
||||
|
||||
def test_keyboard_interrupt_cleans_part(self, tmp_path):
|
||||
d = CustomDownloader()
|
||||
with patch.object(d, "check_interrupt", side_effect=KeyboardInterrupt):
|
||||
with pytest.raises(KeyboardInterrupt):
|
||||
d.save_response(FakeResponse(b"abc"), str(tmp_path / "f.bin"), 3)
|
||||
assert os.listdir(tmp_path) == []
|
||||
|
||||
def test_existing_final_file_untouched_on_failure(self, tmp_path):
|
||||
target = tmp_path / "f.bin"
|
||||
target.write_bytes(b"old")
|
||||
with pytest.raises(Exception):
|
||||
CustomDownloader().save_response(FakeResponse(b"abc"), str(target), 10)
|
||||
assert target.read_bytes() == b"old"
|
||||
|
||||
@pytest.mark.parametrize("value,expected", [("12", 12), (None, None), ("x", None)])
|
||||
def test_parse_content_length(self, value, expected):
|
||||
resp = FakeResponse(headers={} if value is None else {"Content-Length": value})
|
||||
assert BaseDownloader.parse_content_length(resp) == expected
|
||||
|
||||
def test_http_error_messages(self):
|
||||
d = CustomDownloader()
|
||||
with pytest.raises(Exception, match="Rate limited"):
|
||||
d.raise_for_http_status(429, "r", {})
|
||||
with pytest.raises(Exception, match="HTTP error 500: boom"):
|
||||
d.raise_for_http_status(500, "boom", {})
|
||||
with pytest.raises(Exception, match="hint"):
|
||||
d.raise_for_http_status(404, "r", {404: "hint"})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- custom
|
||||
|
||||
|
||||
class TestCustom:
|
||||
def test_download_with_header_filename_and_token(self, root, out, opener):
|
||||
_, op = opener
|
||||
op.open.return_value = FakeResponse(
|
||||
b"data",
|
||||
headers={
|
||||
"Content-Disposition": 'attachment; filename="named.safetensors"',
|
||||
"Content-Length": "4",
|
||||
},
|
||||
)
|
||||
path = make(CustomDownloader, root, "tok").download("https://h/dl?id=1", out)
|
||||
assert path == os.path.join(os.path.realpath(out), "named.safetensors")
|
||||
assert open(path, "rb").read() == b"data"
|
||||
req = op.open.call_args.args[0]
|
||||
assert req.get_header("Authorization") == "Bearer tok"
|
||||
assert op.open.call_args.kwargs["timeout"] == base.DOWNLOAD_TIMEOUT
|
||||
|
||||
def test_malicious_header_filename_is_basenamed(self, root, out, opener):
|
||||
_, op = opener
|
||||
op.open.return_value = FakeResponse(
|
||||
b"d",
|
||||
headers={"Content-Disposition": 'attachment; filename="../../evil.bin"'},
|
||||
)
|
||||
path = make(CustomDownloader, root).download("https://h/dl", out)
|
||||
assert os.path.basename(path) == "evil.bin"
|
||||
assert os.path.dirname(path) == os.path.realpath(out)
|
||||
assert not (root.parent / "evil.bin").exists()
|
||||
|
||||
def test_default_name_only_when_no_header_and_no_url_name(self, root, out, opener):
|
||||
_, op = opener
|
||||
op.open.return_value = FakeResponse(b"d")
|
||||
path = make(CustomDownloader, root).download("https://h/", out)
|
||||
assert os.path.basename(path) == "downloaded_model.safetensors"
|
||||
|
||||
def test_url_name_used_when_no_header(self, root, out, opener):
|
||||
_, op = opener
|
||||
op.open.return_value = FakeResponse(b"d")
|
||||
path = make(CustomDownloader, root).download("https://h/a/m.ckpt", out)
|
||||
assert os.path.basename(path) == "m.ckpt"
|
||||
|
||||
def test_existing_file_skipped_with_header_name(self, root, out, opener):
|
||||
_, op = opener
|
||||
existing = os.path.join(out, "named.bin")
|
||||
open(existing, "wb").write(b"old")
|
||||
resp = FakeResponse(
|
||||
b"new", headers={"Content-Disposition": "attachment; filename=named.bin"}
|
||||
)
|
||||
op.open.return_value = resp
|
||||
path = make(CustomDownloader, root).download("https://h/dl", out)
|
||||
assert open(path, "rb").read() == b"old"
|
||||
assert resp.closed
|
||||
|
||||
def test_explicit_filename_existing_skips_request(self, root, out, opener):
|
||||
build, op = opener
|
||||
open(os.path.join(out, "a.bin"), "wb").write(b"old")
|
||||
make(CustomDownloader, root).download("https://h/x", out, filename="a.bin")
|
||||
op.open.assert_not_called()
|
||||
|
||||
def test_force_overwrites(self, root, out, opener):
|
||||
_, op = opener
|
||||
open(os.path.join(out, "a.bin"), "wb").write(b"old")
|
||||
op.open.return_value = FakeResponse(b"new")
|
||||
path = make(CustomDownloader, root).download(
|
||||
"https://h/x", out, filename="a.bin", force=True
|
||||
)
|
||||
assert open(path, "rb").read() == b"new"
|
||||
|
||||
@pytest.mark.parametrize("bad", ["../x", "/abs", "..", "a/b"])
|
||||
def test_bad_filename_rejected(self, root, out, bad):
|
||||
with pytest.raises(ValueError):
|
||||
make(CustomDownloader, root).download("https://h/x", out, filename=bad)
|
||||
|
||||
def test_out_of_root_rejected_before_network(self, root, opener):
|
||||
_, op = opener
|
||||
with pytest.raises(ValueError):
|
||||
make(CustomDownloader, root).download("https://h/x", str(root.parent))
|
||||
op.open.assert_not_called()
|
||||
|
||||
@pytest.mark.parametrize("url", ["file:///etc/passwd", "ftp://h/f"])
|
||||
def test_bad_scheme_rejected(self, root, out, url):
|
||||
with pytest.raises(ValueError):
|
||||
make(CustomDownloader, root).download(url, out)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"code,text",
|
||||
[
|
||||
(401, "Authentication"),
|
||||
(403, "forbidden"),
|
||||
(404, "not found"),
|
||||
(429, "Rate limited"),
|
||||
(500, "HTTP error 500"),
|
||||
],
|
||||
)
|
||||
def test_http_errors(self, root, out, opener, code, text):
|
||||
_, op = opener
|
||||
op.open.side_effect = http_error(code)
|
||||
with pytest.raises(Exception, match=text):
|
||||
make(CustomDownloader, root).download("https://h/x", out, filename="a.bin")
|
||||
|
||||
def test_url_error(self, root, out, opener):
|
||||
_, op = opener
|
||||
op.open.side_effect = urllib.error.URLError("dns")
|
||||
with pytest.raises(Exception, match="Network error: dns"):
|
||||
make(CustomDownloader, root).download("https://h/x", out, filename="a.bin")
|
||||
|
||||
def test_size_mismatch_warns_but_saves(self, root, out, opener):
|
||||
_, op = opener
|
||||
op.open.return_value = FakeResponse(b"ab", headers={"Content-Length": "9"})
|
||||
path = make(CustomDownloader, root).download(
|
||||
"https://h/x", out, filename="a.bin"
|
||||
)
|
||||
assert os.path.exists(path)
|
||||
|
||||
def test_midstream_failure_leaves_no_file(self, root, out, opener):
|
||||
_, op = opener
|
||||
op.open.return_value = FakeResponse(b"x" * 50, fail_after=5)
|
||||
with pytest.raises(ConnectionResetError):
|
||||
make(CustomDownloader, root).download("https://h/x", out, filename="a.bin")
|
||||
assert os.listdir(out) == []
|
||||
|
||||
def test_token_not_logged(self, root, out, opener, caplog):
|
||||
_, op = opener
|
||||
op.open.return_value = FakeResponse(b"d")
|
||||
with caplog.at_level("DEBUG"):
|
||||
make(CustomDownloader, root, "SECRET123").download(
|
||||
"https://h/x", out, filename="a.bin"
|
||||
)
|
||||
assert "SECRET123" not in caplog.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- hugging face
|
||||
|
||||
|
||||
class TestHuggingFace:
|
||||
def test_download_blob_url_converted(self, root, out, opener):
|
||||
_, op = opener
|
||||
op.open.return_value = FakeResponse(b"d", headers={"Content-Length": "1"})
|
||||
url = "https://huggingface.co/user/repo/blob/main/sub/m.safetensors"
|
||||
path = make(HuggingFaceDownloader, root, "t").download(url, out)
|
||||
assert os.path.basename(path) == "m.safetensors"
|
||||
req = op.open.call_args.args[0]
|
||||
assert req.full_url == (
|
||||
"https://huggingface.co/user/repo/resolve/main/sub/m.safetensors"
|
||||
)
|
||||
assert req.get_header("Authorization") == "Bearer t"
|
||||
|
||||
def test_non_hf_structure_uses_original_url(self, root, out, opener):
|
||||
_, op = opener
|
||||
op.open.return_value = FakeResponse(b"d")
|
||||
path = make(HuggingFaceDownloader, root).download(
|
||||
"https://huggingface.co/some/file.bin", out
|
||||
)
|
||||
assert op.open.call_args.args[0].full_url.endswith("/some/file.bin")
|
||||
assert os.path.basename(path) == "file.bin"
|
||||
|
||||
def test_fallback_name(self, root, out, opener):
|
||||
_, op = opener
|
||||
op.open.return_value = FakeResponse(b"d")
|
||||
path = make(HuggingFaceDownloader, root).download(
|
||||
"https://huggingface.co/", out
|
||||
)
|
||||
assert os.path.basename(path) == "model.safetensors"
|
||||
|
||||
def test_existing_skipped(self, root, out, opener):
|
||||
_, op = opener
|
||||
open(os.path.join(out, "model.safetensors"), "wb").write(b"old")
|
||||
make(HuggingFaceDownloader, root).download(HF_URL, out)
|
||||
op.open.assert_not_called()
|
||||
|
||||
def test_size_mismatch_removes_file(self, root, out, opener):
|
||||
_, op = opener
|
||||
op.open.return_value = FakeResponse(b"ab", headers={"Content-Length": "9"})
|
||||
with pytest.raises(Exception, match="incomplete"):
|
||||
make(HuggingFaceDownloader, root).download(HF_URL, out)
|
||||
assert os.listdir(out) == []
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"code,text",
|
||||
[(401, "Authentication"), (403, "gated"), (404, "not found"), (500, "HTTP")],
|
||||
)
|
||||
def test_http_errors(self, root, out, opener, code, text):
|
||||
_, op = opener
|
||||
op.open.side_effect = http_error(code)
|
||||
with pytest.raises(Exception, match=text):
|
||||
make(HuggingFaceDownloader, root).download(HF_URL, out)
|
||||
|
||||
def test_url_error(self, root, out, opener):
|
||||
_, op = opener
|
||||
op.open.side_effect = urllib.error.URLError("down")
|
||||
with pytest.raises(Exception, match="Network error"):
|
||||
make(HuggingFaceDownloader, root).download(HF_URL, out)
|
||||
|
||||
def test_traversal_filename_rejected(self, root, out):
|
||||
with pytest.raises(ValueError):
|
||||
make(HuggingFaceDownloader, root).download(HF_URL, out, filename="../x")
|
||||
|
||||
def test_scheme_rejected(self, root, out):
|
||||
with pytest.raises(ValueError):
|
||||
make(HuggingFaceDownloader, root).download("file:///etc/passwd", out)
|
||||
|
||||
def test_path_traversal_in_url_path_is_basenamed(self, root, out, opener):
|
||||
_, op = opener
|
||||
op.open.return_value = FakeResponse(b"d")
|
||||
url = "https://huggingface.co/u/r/resolve/main/..%2F..%2Fevil.bin"
|
||||
path = make(HuggingFaceDownloader, root).download(url, out)
|
||||
assert os.path.dirname(path) == os.path.realpath(out)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- civitai
|
||||
|
||||
|
||||
def redirect_resp(location, status=302):
|
||||
return FakeResponse(
|
||||
status=status, headers={"Location": location} if location else {}
|
||||
)
|
||||
|
||||
|
||||
class TestCivitAI:
|
||||
def test_redirect_flow_filename_from_disposition(self, root, out, opener):
|
||||
_, op = opener
|
||||
loc = (
|
||||
SIGNED_URL
|
||||
+ "?response-content-disposition=attachment%3B%20filename%3D%22real.safetensors%22"
|
||||
)
|
||||
op.open.side_effect = [
|
||||
redirect_resp(loc),
|
||||
FakeResponse(b"data", headers={"Content-Length": "4"}),
|
||||
]
|
||||
path = make(CivitAIDownloader, root, "tok").download(CIVITAI_URL, out)
|
||||
assert os.path.basename(path) == "real.safetensors"
|
||||
first, second = [c.args[0] for c in op.open.call_args_list]
|
||||
assert first.get_header("Authorization") == "Bearer tok"
|
||||
assert not second.has_header("Authorization")
|
||||
assert all(
|
||||
c.kwargs["timeout"] == base.DOWNLOAD_TIMEOUT for c in op.open.call_args_list
|
||||
)
|
||||
|
||||
def test_malicious_redirect_disposition_sanitized(self, root, out, opener):
|
||||
_, op = opener
|
||||
loc = (
|
||||
SIGNED_URL
|
||||
+ "?response-content-disposition=filename%3D%22..%2F..%2Fevil.bin%22"
|
||||
)
|
||||
op.open.side_effect = [redirect_resp(loc), FakeResponse(b"d")]
|
||||
path = make(CivitAIDownloader, root).download(CIVITAI_URL, out)
|
||||
assert os.path.dirname(path) == os.path.realpath(out)
|
||||
assert os.path.basename(path) == "evil.bin"
|
||||
|
||||
def test_filename_from_redirect_path(self, root, out, opener):
|
||||
_, op = opener
|
||||
op.open.side_effect = [redirect_resp(SIGNED_URL), FakeResponse(b"d")]
|
||||
path = make(CivitAIDownloader, root).download(CIVITAI_URL, out)
|
||||
assert os.path.basename(path) == "model.safetensors"
|
||||
|
||||
def test_redirect_path_without_name_uses_default(self, root, out, opener):
|
||||
_, op = opener
|
||||
op.open.side_effect = [
|
||||
redirect_resp("https://cdn.example.com/"),
|
||||
FakeResponse(b"d"),
|
||||
]
|
||||
path = make(CivitAIDownloader, root).download(CIVITAI_URL, out)
|
||||
assert os.path.basename(path) == "downloaded_file.safetensors"
|
||||
|
||||
def test_relative_redirect(self, root, out, opener):
|
||||
_, op = opener
|
||||
op.open.side_effect = [redirect_resp("/files/x.bin"), FakeResponse(b"d")]
|
||||
make(CivitAIDownloader, root).download(CIVITAI_URL, out)
|
||||
assert op.open.call_args_list[1].args[0].full_url == (
|
||||
"https://civitai.com/files/x.bin"
|
||||
)
|
||||
|
||||
def test_missing_location_header(self, root, out, opener):
|
||||
_, op = opener
|
||||
op.open.return_value = redirect_resp(None)
|
||||
with pytest.raises(Exception, match="Location"):
|
||||
make(CivitAIDownloader, root).download(CIVITAI_URL, out)
|
||||
|
||||
@pytest.mark.parametrize("loc", ["file:///etc/passwd", "ftp://h/x"])
|
||||
def test_redirect_bad_scheme(self, root, out, opener, loc):
|
||||
_, op = opener
|
||||
op.open.return_value = redirect_resp(loc)
|
||||
with pytest.raises(ValueError):
|
||||
make(CivitAIDownloader, root).download(CIVITAI_URL, out)
|
||||
assert op.open.call_count == 1
|
||||
|
||||
def test_direct_200_no_redirect(self, root, out, opener):
|
||||
_, op = opener
|
||||
op.open.return_value = FakeResponse(b"d", status=200)
|
||||
path = make(CivitAIDownloader, root).download(
|
||||
CIVITAI_URL, out, filename="m.bin"
|
||||
)
|
||||
assert os.path.basename(path) == "m.bin"
|
||||
|
||||
def test_200_without_filename_uses_url_default(self, root, out, opener):
|
||||
_, op = opener
|
||||
op.open.return_value = FakeResponse(b"d", status=200)
|
||||
path = make(CivitAIDownloader, root).download(CIVITAI_URL, out)
|
||||
assert os.path.basename(path) == "model.safetensors"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status,text",
|
||||
[
|
||||
(401, "Authentication"),
|
||||
(403, "forbidden"),
|
||||
(404, "File not found"),
|
||||
(500, "HTTP error 500"),
|
||||
],
|
||||
)
|
||||
def test_non_200_statuses(self, root, out, opener, status, text):
|
||||
_, op = opener
|
||||
op.open.return_value = FakeResponse(status=status)
|
||||
with pytest.raises(Exception, match=text):
|
||||
make(CivitAIDownloader, root).download(CIVITAI_URL, out)
|
||||
|
||||
def test_http_error_raised(self, root, out, opener):
|
||||
_, op = opener
|
||||
op.open.side_effect = http_error(401)
|
||||
with pytest.raises(Exception, match="Authentication"):
|
||||
make(CivitAIDownloader, root).download(CIVITAI_URL, out)
|
||||
|
||||
def test_url_error_first_and_second_request(self, root, out, opener):
|
||||
_, op = opener
|
||||
op.open.side_effect = urllib.error.URLError("x")
|
||||
with pytest.raises(Exception, match="Network error"):
|
||||
make(CivitAIDownloader, root).download(CIVITAI_URL, out)
|
||||
op.open.side_effect = [redirect_resp(SIGNED_URL), urllib.error.URLError("y")]
|
||||
with pytest.raises(Exception, match="Network error: y"):
|
||||
make(CivitAIDownloader, root).download(CIVITAI_URL, out)
|
||||
|
||||
def test_second_request_http_error(self, root, out, opener):
|
||||
_, op = opener
|
||||
op.open.side_effect = [redirect_resp(SIGNED_URL), http_error(403)]
|
||||
with pytest.raises(Exception, match="forbidden"):
|
||||
make(CivitAIDownloader, root).download(CIVITAI_URL, out)
|
||||
|
||||
def test_existing_file_skipped(self, root, out, opener):
|
||||
_, op = opener
|
||||
open(os.path.join(out, "model.safetensors"), "wb").write(b"old")
|
||||
op.open.side_effect = [redirect_resp(SIGNED_URL), FakeResponse(b"new")]
|
||||
path = make(CivitAIDownloader, root).download(CIVITAI_URL, out)
|
||||
assert open(path, "rb").read() == b"old"
|
||||
|
||||
def test_size_mismatch_removes_file(self, root, out, opener):
|
||||
_, op = opener
|
||||
op.open.return_value = FakeResponse(
|
||||
b"ab", status=200, headers={"Content-Length": "9"}
|
||||
)
|
||||
with pytest.raises(Exception, match="incomplete"):
|
||||
make(CivitAIDownloader, root).download(
|
||||
CIVITAI_URL, out, filename="m.safetensors"
|
||||
)
|
||||
assert os.listdir(out) == []
|
||||
|
||||
def test_interrupt_propagates_and_cleans(self, root, out, opener):
|
||||
_, op = opener
|
||||
op.open.return_value = FakeResponse(b"abc", status=200)
|
||||
|
||||
class Interrupted(Exception):
|
||||
pass
|
||||
|
||||
d = make(CivitAIDownloader, root)
|
||||
with patch.object(d, "check_interrupt", side_effect=Interrupted):
|
||||
with pytest.raises(Interrupted):
|
||||
d.download(CIVITAI_URL, out, filename="m.safetensors")
|
||||
assert os.listdir(out) == []
|
||||
|
||||
@pytest.mark.parametrize("url", ["file:///etc/passwd", "ftp://civitai.com/x"])
|
||||
def test_bad_scheme(self, root, out, url):
|
||||
with pytest.raises(ValueError):
|
||||
make(CivitAIDownloader, root).download(url, out)
|
||||
|
||||
def test_foreign_domain_rejected(self, root, out):
|
||||
with pytest.raises(ValueError, match="civitai.com"):
|
||||
make(CivitAIDownloader, root).download("https://evil.com/x", out)
|
||||
|
||||
def test_bad_filename_rejected(self, root, out):
|
||||
with pytest.raises(ValueError):
|
||||
make(CivitAIDownloader, root).download(CIVITAI_URL, out, filename="../x")
|
||||
|
||||
def test_save_path_outside_rejected(self, root):
|
||||
with pytest.raises(ValueError):
|
||||
make(CivitAIDownloader, root).download(CIVITAI_URL, "/etc")
|
||||
|
||||
def test_web_url_with_version_id(self, root, out, opener):
|
||||
_, op = opener
|
||||
op.open.return_value = FakeResponse(b"d", status=200)
|
||||
make(CivitAIDownloader, root).download(
|
||||
"https://civitai.com/models/5?modelVersionId=77",
|
||||
out,
|
||||
filename="m.safetensors",
|
||||
)
|
||||
assert op.open.call_args.args[0].full_url.endswith("/api/download/models/77")
|
||||
|
||||
def test_web_url_model_id_resolves_latest(self, root, out, opener):
|
||||
_, op = opener
|
||||
details = FakeResponse(json.dumps({"modelVersions": [{"id": 9}]}).encode())
|
||||
op.open.side_effect = [details, FakeResponse(b"d", status=200)]
|
||||
make(CivitAIDownloader, root).download(
|
||||
"https://civitai.com/models/5/name", out, filename="m.safetensors"
|
||||
)
|
||||
assert op.open.call_args.args[0].full_url.endswith("/api/download/models/9")
|
||||
|
||||
def test_web_url_model_without_versions(self, root, out, opener):
|
||||
_, op = opener
|
||||
op.open.return_value = FakeResponse(b'{"modelVersions": []}')
|
||||
with pytest.raises(Exception, match="No versions"):
|
||||
make(CivitAIDownloader, root).download("https://civitai.com/models/5", out)
|
||||
|
||||
def test_web_url_unparseable(self, root, out):
|
||||
with pytest.raises(Exception, match="Could not parse"):
|
||||
make(CivitAIDownloader, root).download("https://civitai.com/about", out)
|
||||
|
||||
def test_model_details_errors(self, opener):
|
||||
_, op = opener
|
||||
d = CivitAIDownloader(token="t")
|
||||
op.open.side_effect = http_error(404)
|
||||
with pytest.raises(Exception, match="not found"):
|
||||
d.get_model_details(1)
|
||||
op.open.side_effect = http_error(500)
|
||||
with pytest.raises(Exception, match="API request failed"):
|
||||
d.get_model_details(1)
|
||||
|
||||
def test_make_request_adds_auth(self):
|
||||
req = CivitAIDownloader(token="t")._make_request("https://civitai.com/x")
|
||||
assert req.get_header("Authorization") == "Bearer t"
|
||||
assert (
|
||||
CivitAIDownloader()
|
||||
._make_request("https://civitai.com/x")
|
||||
.has_header("Authorization")
|
||||
is False
|
||||
)
|
||||
|
||||
def test_parse_urls(self):
|
||||
d = CivitAIDownloader()
|
||||
assert d._parse_civitai_url(CIVITAI_URL)["version_id"] == 123
|
||||
assert d._parse_civitai_url("https://civitai.com/models/5")["model_id"] == 5
|
||||
@@ -0,0 +1,262 @@
|
||||
"""Tests for Batch/List conversion nodes and logic."""
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from kikotools.tools.batch_list_converter.logic import (
|
||||
split_image_batch,
|
||||
join_image_batch,
|
||||
split_latent_batch,
|
||||
join_latent_batch,
|
||||
)
|
||||
from kikotools.tools.batch_list_converter.node import (
|
||||
ImageBatchToImageListNode,
|
||||
ImageListToImageBatchNode,
|
||||
LatentBatchToLatentListNode,
|
||||
LatentListToLatentBatchNode,
|
||||
)
|
||||
|
||||
|
||||
class TestBatchListConverterLogic:
|
||||
"""Test pure split/join functions."""
|
||||
|
||||
# -- Image split --
|
||||
|
||||
def test_split_image_batch_single(self):
|
||||
"""Single image batch returns list of one."""
|
||||
images = torch.rand(1, 64, 64, 3)
|
||||
result = split_image_batch(images)
|
||||
assert len(result) == 1
|
||||
assert result[0].shape == (1, 64, 64, 3)
|
||||
assert torch.equal(result[0], images)
|
||||
|
||||
def test_split_image_batch_multiple(self):
|
||||
"""Multi-image batch splits correctly."""
|
||||
images = torch.rand(4, 64, 64, 3)
|
||||
result = split_image_batch(images)
|
||||
assert len(result) == 4
|
||||
for i, img in enumerate(result):
|
||||
assert img.shape == (1, 64, 64, 3)
|
||||
assert torch.equal(img, images[i : i + 1])
|
||||
|
||||
def test_split_image_preserves_batch_dim(self):
|
||||
"""Each split image keeps 4D shape [1,H,W,C]."""
|
||||
images = torch.rand(3, 128, 256, 3)
|
||||
result = split_image_batch(images)
|
||||
for img in result:
|
||||
assert img.ndim == 4
|
||||
assert img.shape[0] == 1
|
||||
|
||||
# -- Image join --
|
||||
|
||||
def test_join_image_batch_single(self):
|
||||
"""Join single image produces batch of 1."""
|
||||
image_list = [torch.rand(1, 64, 64, 3)]
|
||||
result = join_image_batch(image_list)
|
||||
assert result.shape == (1, 64, 64, 3)
|
||||
|
||||
def test_join_image_batch_multiple(self):
|
||||
"""Join multiple images into batch."""
|
||||
image_list = [torch.rand(1, 64, 64, 3) for _ in range(5)]
|
||||
result = join_image_batch(image_list)
|
||||
assert result.shape == (5, 64, 64, 3)
|
||||
|
||||
def test_image_roundtrip(self):
|
||||
"""split -> join produces identical tensor."""
|
||||
original = torch.rand(4, 64, 64, 3)
|
||||
reconstructed = join_image_batch(split_image_batch(original))
|
||||
assert torch.equal(original, reconstructed)
|
||||
|
||||
# -- Latent split --
|
||||
|
||||
def test_split_latent_batch_single(self):
|
||||
"""Single latent returns list of one dict."""
|
||||
latent = {"samples": torch.rand(1, 4, 32, 32)}
|
||||
result = split_latent_batch(latent)
|
||||
assert len(result) == 1
|
||||
assert "samples" in result[0]
|
||||
assert result[0]["samples"].shape == (1, 4, 32, 32)
|
||||
|
||||
def test_split_latent_batch_multiple(self):
|
||||
"""Multi-item latent splits correctly."""
|
||||
latent = {"samples": torch.rand(3, 4, 32, 32)}
|
||||
result = split_latent_batch(latent)
|
||||
assert len(result) == 3
|
||||
for i, lat in enumerate(result):
|
||||
assert lat["samples"].shape == (1, 4, 32, 32)
|
||||
assert torch.equal(lat["samples"], latent["samples"][i : i + 1])
|
||||
|
||||
# -- Latent join --
|
||||
|
||||
def test_join_latent_batch_single(self):
|
||||
"""Join single latent dict."""
|
||||
latent_list = [{"samples": torch.rand(1, 4, 32, 32)}]
|
||||
result = join_latent_batch(latent_list)
|
||||
assert "samples" in result
|
||||
assert result["samples"].shape == (1, 4, 32, 32)
|
||||
|
||||
def test_join_latent_batch_multiple(self):
|
||||
"""Join multiple latent dicts into batch."""
|
||||
latent_list = [{"samples": torch.rand(1, 4, 32, 32)} for _ in range(4)]
|
||||
result = join_latent_batch(latent_list)
|
||||
assert result["samples"].shape == (4, 4, 32, 32)
|
||||
|
||||
def test_latent_roundtrip(self):
|
||||
"""split -> join produces identical tensor."""
|
||||
original = {"samples": torch.rand(5, 4, 64, 64)}
|
||||
reconstructed = join_latent_batch(split_latent_batch(original))
|
||||
assert torch.equal(original["samples"], reconstructed["samples"])
|
||||
|
||||
def test_split_latent_preserves_noise_mask(self):
|
||||
"""noise_mask is sliced alongside samples."""
|
||||
latent = {
|
||||
"samples": torch.rand(3, 4, 32, 32),
|
||||
"noise_mask": torch.rand(3, 1, 32, 32),
|
||||
}
|
||||
result = split_latent_batch(latent)
|
||||
assert len(result) == 3
|
||||
for i, item in enumerate(result):
|
||||
assert "noise_mask" in item
|
||||
assert item["noise_mask"].shape == (1, 1, 32, 32)
|
||||
assert torch.equal(item["noise_mask"], latent["noise_mask"][i : i + 1])
|
||||
|
||||
def test_join_latent_preserves_noise_mask(self):
|
||||
"""noise_mask is concatenated alongside samples."""
|
||||
latent_list = [
|
||||
{
|
||||
"samples": torch.rand(1, 4, 32, 32),
|
||||
"noise_mask": torch.rand(1, 1, 32, 32),
|
||||
}
|
||||
for _ in range(3)
|
||||
]
|
||||
result = join_latent_batch(latent_list)
|
||||
assert "noise_mask" in result
|
||||
assert result["noise_mask"].shape == (3, 1, 32, 32)
|
||||
|
||||
def test_latent_roundtrip_with_extra_keys(self):
|
||||
"""Round-trip preserves all tensor keys."""
|
||||
original = {
|
||||
"samples": torch.rand(4, 4, 64, 64),
|
||||
"noise_mask": torch.rand(4, 1, 64, 64),
|
||||
}
|
||||
reconstructed = join_latent_batch(split_latent_batch(original))
|
||||
assert torch.equal(original["samples"], reconstructed["samples"])
|
||||
assert torch.equal(original["noise_mask"], reconstructed["noise_mask"])
|
||||
|
||||
def test_split_latent_copies_non_tensor_values(self):
|
||||
"""Non-tensor metadata is copied to each item."""
|
||||
latent = {
|
||||
"samples": torch.rand(2, 4, 32, 32),
|
||||
"some_flag": "preserve_me",
|
||||
}
|
||||
result = split_latent_batch(latent)
|
||||
for item in result:
|
||||
assert item["some_flag"] == "preserve_me"
|
||||
|
||||
|
||||
class TestBatchListConverterNodes:
|
||||
"""Test ComfyUI node classes."""
|
||||
|
||||
# -- ImageBatchToImageList --
|
||||
|
||||
def test_image_b2l_attributes(self):
|
||||
assert ImageBatchToImageListNode.RETURN_TYPES == ("IMAGE", "INT")
|
||||
assert ImageBatchToImageListNode.RETURN_NAMES == ("images", "count")
|
||||
assert ImageBatchToImageListNode.OUTPUT_IS_LIST == (True, False)
|
||||
assert ImageBatchToImageListNode.FUNCTION == "split_batch"
|
||||
assert ImageBatchToImageListNode.CATEGORY == "🫶 ComfyAssets/📦 Latents"
|
||||
|
||||
def test_image_b2l_input_types(self):
|
||||
inputs = ImageBatchToImageListNode.INPUT_TYPES()
|
||||
assert "required" in inputs
|
||||
assert "images" in inputs["required"]
|
||||
assert inputs["required"]["images"] == ("IMAGE",)
|
||||
|
||||
def test_image_b2l_execute(self):
|
||||
node = ImageBatchToImageListNode()
|
||||
images = torch.rand(3, 64, 64, 3)
|
||||
result = node.split_batch(images)
|
||||
image_list, count = result
|
||||
assert isinstance(image_list, list)
|
||||
assert len(image_list) == 3
|
||||
assert count == 3
|
||||
|
||||
# -- ImageListToImageBatch --
|
||||
|
||||
def test_image_l2b_attributes(self):
|
||||
assert ImageListToImageBatchNode.INPUT_IS_LIST is True
|
||||
assert ImageListToImageBatchNode.RETURN_TYPES == ("IMAGE", "INT")
|
||||
assert ImageListToImageBatchNode.RETURN_NAMES == ("images", "count")
|
||||
assert ImageListToImageBatchNode.FUNCTION == "join_batch"
|
||||
|
||||
def test_image_l2b_execute(self):
|
||||
node = ImageListToImageBatchNode()
|
||||
image_list = [torch.rand(1, 64, 64, 3) for _ in range(4)]
|
||||
batch, count = node.join_batch(image_list)
|
||||
assert batch.shape == (4, 64, 64, 3)
|
||||
assert count == 4
|
||||
|
||||
# -- LatentBatchToLatentList --
|
||||
|
||||
def test_latent_b2l_attributes(self):
|
||||
assert LatentBatchToLatentListNode.RETURN_TYPES == ("LATENT", "INT")
|
||||
assert LatentBatchToLatentListNode.RETURN_NAMES == ("latents", "count")
|
||||
assert LatentBatchToLatentListNode.OUTPUT_IS_LIST == (True, False)
|
||||
assert LatentBatchToLatentListNode.FUNCTION == "split_batch"
|
||||
|
||||
def test_latent_b2l_execute(self):
|
||||
node = LatentBatchToLatentListNode()
|
||||
latent = {"samples": torch.rand(2, 4, 32, 32)}
|
||||
latent_list, count = node.split_batch(latent)
|
||||
assert isinstance(latent_list, list)
|
||||
assert len(latent_list) == 2
|
||||
assert count == 2
|
||||
|
||||
# -- LatentListToLatentBatch --
|
||||
|
||||
def test_latent_l2b_attributes(self):
|
||||
assert LatentListToLatentBatchNode.INPUT_IS_LIST is True
|
||||
assert LatentListToLatentBatchNode.RETURN_TYPES == ("LATENT", "INT")
|
||||
assert LatentListToLatentBatchNode.RETURN_NAMES == ("latent", "count")
|
||||
assert LatentListToLatentBatchNode.FUNCTION == "join_batch"
|
||||
|
||||
def test_latent_l2b_execute(self):
|
||||
node = LatentListToLatentBatchNode()
|
||||
latent_list = [{"samples": torch.rand(1, 4, 32, 32)} for _ in range(3)]
|
||||
batch, count = node.join_batch(latent_list)
|
||||
assert "samples" in batch
|
||||
assert batch["samples"].shape == (3, 4, 32, 32)
|
||||
assert count == 3
|
||||
|
||||
# -- Inheritance --
|
||||
|
||||
def test_all_nodes_inherit_base(self):
|
||||
from kikotools.base.base_node import ComfyAssetsBaseNode
|
||||
|
||||
for cls in (
|
||||
ImageBatchToImageListNode,
|
||||
ImageListToImageBatchNode,
|
||||
LatentBatchToLatentListNode,
|
||||
LatentListToLatentBatchNode,
|
||||
):
|
||||
assert issubclass(cls, ComfyAssetsBaseNode)
|
||||
|
||||
# -- Registration mappings --
|
||||
|
||||
def test_node_class_mappings(self):
|
||||
from kikotools.tools.batch_list_converter.node import NODE_CLASS_MAPPINGS
|
||||
|
||||
assert len(NODE_CLASS_MAPPINGS) == 4
|
||||
assert "ImageBatchToImageList" in NODE_CLASS_MAPPINGS
|
||||
assert "ImageListToImageBatch" in NODE_CLASS_MAPPINGS
|
||||
assert "LatentBatchToLatentList" in NODE_CLASS_MAPPINGS
|
||||
assert "LatentListToLatentBatch" in NODE_CLASS_MAPPINGS
|
||||
|
||||
def test_node_display_name_mappings(self):
|
||||
from kikotools.tools.batch_list_converter.node import NODE_DISPLAY_NAME_MAPPINGS
|
||||
|
||||
assert len(NODE_DISPLAY_NAME_MAPPINGS) == 4
|
||||
assert (
|
||||
NODE_DISPLAY_NAME_MAPPINGS["ImageBatchToImageList"]
|
||||
== "Image Batch to Image List"
|
||||
)
|
||||
@@ -0,0 +1,267 @@
|
||||
"""Additional behavioural tests for batch_prompts (logic, state manager, node)."""
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from kikotools.tools.batch_prompts import logic
|
||||
from kikotools.tools.batch_prompts import node as node_mod
|
||||
from kikotools.tools.batch_prompts.node import BatchPromptsNode
|
||||
from kikotools.tools.batch_prompts.state_manager import StateManager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def prompt_file(tmp_path):
|
||||
f = tmp_path / "prompts.txt"
|
||||
f.write_text("alpha\n---\nbeta Negative: ugly\n---\n\n---\ngamma\nline two\n")
|
||||
return str(f)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def state(tmp_path, monkeypatch):
|
||||
"""Isolated StateManager injected in place of the global one."""
|
||||
monkeypatch.setattr("tempfile.gettempdir", lambda: str(tmp_path))
|
||||
sm = StateManager()
|
||||
monkeypatch.setattr(node_mod, "STATE_MANAGER", sm)
|
||||
monkeypatch.setattr(BatchPromptsNode, "_prompt_cache", {})
|
||||
return sm
|
||||
|
||||
|
||||
class TestStateManager:
|
||||
def test_creates_state_dir_under_tempdir(self, state, tmp_path):
|
||||
assert state.state_dir.parent == tmp_path
|
||||
assert state.state_dir.is_dir()
|
||||
|
||||
def test_empty_state_when_no_file(self, state):
|
||||
assert state.get_state() == {}
|
||||
assert state.get_execution_count("x") == 0
|
||||
|
||||
def test_increment_returns_previous_count(self, state):
|
||||
assert state.increment_execution_count("f") == 0
|
||||
assert state.increment_execution_count("f") == 1
|
||||
assert state.get_execution_count("f") == 2
|
||||
|
||||
def test_counts_are_per_file(self, state):
|
||||
state.increment_execution_count("a")
|
||||
assert state.get_execution_count("b") == 0
|
||||
|
||||
def test_reset(self, state):
|
||||
state.increment_execution_count("a")
|
||||
state.reset_execution_count("a")
|
||||
assert state.get_execution_count("a") == 0
|
||||
|
||||
def test_corrupt_state_file_is_treated_as_empty(self, state):
|
||||
state.state_file.write_text("{oops")
|
||||
assert state.get_state() == {}
|
||||
assert state.increment_execution_count("a") == 0
|
||||
|
||||
def test_state_persists_across_instances(self, state):
|
||||
state.increment_execution_count("a")
|
||||
assert StateManager().get_execution_count("a") == 1
|
||||
|
||||
def test_save_failure_is_swallowed(self, state, capsys):
|
||||
state.state_file = state.state_dir # a directory: open() fails
|
||||
state.save_state({"a": 1})
|
||||
assert "Failed to save state" in capsys.readouterr().out
|
||||
|
||||
|
||||
class TestLogic:
|
||||
def test_load_prompts_splits_and_skips_empty(self, prompt_file):
|
||||
assert logic.load_prompts_from_file(prompt_file) == [
|
||||
"alpha",
|
||||
"beta Negative: ugly",
|
||||
"gamma\nline two",
|
||||
]
|
||||
|
||||
def test_load_missing_file_returns_empty(self, tmp_path):
|
||||
assert logic.load_prompts_from_file(str(tmp_path / "nope.txt")) == []
|
||||
|
||||
def test_get_prompt_at_index(self):
|
||||
p = ["a", "b", "c"]
|
||||
assert logic.get_prompt_at_index(p, 4) == ("b", 1)
|
||||
assert logic.get_prompt_at_index(p, 4, wrap=False) == ("c", 2)
|
||||
assert logic.get_prompt_at_index([], 3) == ("", 0)
|
||||
|
||||
def test_get_next_prompt(self):
|
||||
p = ["a", "b", "c"]
|
||||
assert logic.get_next_prompt(p, 2) == ("a", 0)
|
||||
assert logic.get_next_prompt(p, 2, wrap=False) == ("c", 2)
|
||||
assert logic.get_next_prompt(p, 0) == ("b", 1)
|
||||
assert logic.get_next_prompt([], 0) == ("", 0)
|
||||
|
||||
def test_preview(self):
|
||||
assert logic.get_prompt_preview("short", 10) == "short"
|
||||
assert logic.get_prompt_preview("x" * 20, 10) == "x" * 10 + "..."
|
||||
|
||||
def test_parse_file_list(self):
|
||||
assert logic.parse_prompt_file_list(" a.txt, ,b.txt ,") == ["a.txt", "b.txt"]
|
||||
assert logic.parse_prompt_file_list("") == []
|
||||
|
||||
def test_merge_multiple_files(self, tmp_path):
|
||||
a = tmp_path / "a.txt"
|
||||
b = tmp_path / "b.txt"
|
||||
a.write_text("1---2")
|
||||
b.write_text("3")
|
||||
assert logic.merge_prompts_from_multiple_files([str(a), str(b)]) == [
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
]
|
||||
|
||||
def test_batch_info(self):
|
||||
info = logic.get_batch_info(["a", "b", "c", "d"], 1)
|
||||
assert info["progress"] == "2/4"
|
||||
assert info["percentage"] == 25
|
||||
assert info["remaining"] == 2
|
||||
assert info["is_complete"] is False
|
||||
assert logic.get_batch_info(["a"], 0)["is_complete"] is True
|
||||
|
||||
def test_batch_info_empty(self):
|
||||
info = logic.get_batch_info([], 0)
|
||||
assert info["progress"] == "0/0" and info["is_complete"] is True
|
||||
assert info["percentage"] == 0 and info["remaining"] == 0
|
||||
|
||||
def test_validate_prompt_file(self, tmp_path, prompt_file):
|
||||
assert logic.validate_prompt_file(prompt_file) == (True, "")
|
||||
assert logic.validate_prompt_file("") == (False, "No file path provided")
|
||||
ok, msg = logic.validate_prompt_file(str(tmp_path / "missing"))
|
||||
assert not ok and "not found" in msg
|
||||
ok, msg = logic.validate_prompt_file(str(tmp_path))
|
||||
assert not ok and "not a file" in msg
|
||||
|
||||
def test_validate_unreadable_file(self, tmp_path):
|
||||
f = tmp_path / "bin.txt"
|
||||
f.write_bytes(b"\xff\xfe\xfa")
|
||||
ok, msg = logic.validate_prompt_file(str(f))
|
||||
assert not ok and "Cannot read file" in msg
|
||||
|
||||
def test_format_for_display(self):
|
||||
assert logic.format_prompt_for_display("hi", 0, 3) == (
|
||||
"[Prompt 1/3]\n------------\nhi"
|
||||
)
|
||||
|
||||
def test_split_positive_negative(self):
|
||||
assert logic.split_prompt_into_positive_negative("cat NEGATIVE: dog") == (
|
||||
"cat",
|
||||
"dog",
|
||||
)
|
||||
assert logic.split_prompt_into_positive_negative("cat") == ("cat", "")
|
||||
assert logic.split_prompt_into_positive_negative("a || b", "||") == ("a", "b")
|
||||
|
||||
def test_batch_queue(self):
|
||||
assert logic.create_batch_queue([]) == []
|
||||
assert logic.create_batch_queue(list("abcde"), 2) == [[0, 1], [2, 3], [4]]
|
||||
|
||||
def test_batch_queue_randomized_is_permutation(self):
|
||||
flat = [i for b in logic.create_batch_queue(list("abcdef"), 4, True) for i in b]
|
||||
assert sorted(flat) == list(range(6))
|
||||
|
||||
|
||||
class TestNode:
|
||||
def run(self, node, prompt_file, **kw):
|
||||
args = dict(
|
||||
prompt_file=prompt_file,
|
||||
index=0,
|
||||
auto_increment=False,
|
||||
wrap_around=True,
|
||||
split_negative=True,
|
||||
show_preview=True,
|
||||
)
|
||||
args.update(kw)
|
||||
return node.process_batch_prompts(**args)
|
||||
|
||||
def test_input_types_shape(self):
|
||||
spec = BatchPromptsNode.INPUT_TYPES()
|
||||
assert set(spec["required"]) == {
|
||||
"prompt_file",
|
||||
"index",
|
||||
"auto_increment",
|
||||
"wrap_around",
|
||||
"split_negative",
|
||||
}
|
||||
assert len(BatchPromptsNode.RETURN_TYPES) == len(BatchPromptsNode.RETURN_NAMES)
|
||||
|
||||
def test_manual_index_with_negative_split(self, state, prompt_file):
|
||||
out = self.run(BatchPromptsNode(), prompt_file, index=1)
|
||||
pos, neg, full, nxt, idx, total, info = out
|
||||
assert (pos, neg) == ("beta", "ugly")
|
||||
assert full == "beta Negative: ugly"
|
||||
assert nxt == "gamma\nline two"
|
||||
assert (idx, total) == (1, 3)
|
||||
assert info == "Prompt 2 of 3 (33.3% complete)"
|
||||
|
||||
def test_no_split(self, state, prompt_file):
|
||||
out = self.run(BatchPromptsNode(), prompt_file, index=1, split_negative=False)
|
||||
assert out[0] == "beta Negative: ugly" and out[1] == ""
|
||||
|
||||
def test_wrap_vs_clamp(self, state, prompt_file):
|
||||
node = BatchPromptsNode()
|
||||
assert self.run(node, prompt_file, index=4)[4] == 1
|
||||
assert self.run(node, prompt_file, index=4, wrap_around=False)[4] == 2
|
||||
|
||||
def test_auto_increment_walks_through_prompts(self, state, prompt_file):
|
||||
node = BatchPromptsNode()
|
||||
seen = [
|
||||
self.run(node, prompt_file, auto_increment=True, index=99)[4]
|
||||
for _ in range(4)
|
||||
]
|
||||
assert seen == [0, 1, 2, 0]
|
||||
|
||||
def test_reload_file_resets_counter_and_rereads(self, state, prompt_file):
|
||||
node = BatchPromptsNode()
|
||||
self.run(node, prompt_file, auto_increment=True)
|
||||
self.run(node, prompt_file, auto_increment=True)
|
||||
with open(prompt_file, "w") as f:
|
||||
f.write("only one")
|
||||
# cached content is stale until reload_file is requested
|
||||
assert self.run(node, prompt_file, index=0)[5] == 3
|
||||
out = self.run(node, prompt_file, index=0, reload_file=True)
|
||||
assert out[5] == 1 and out[2] == "only one"
|
||||
assert state.get_execution_count(prompt_file) == 0
|
||||
|
||||
def test_relative_path_resolved_against_input_dir(
|
||||
self, state, tmp_path, monkeypatch
|
||||
):
|
||||
(tmp_path / "rel.txt").write_text("x---y")
|
||||
import sys
|
||||
|
||||
monkeypatch.setattr(
|
||||
sys.modules["folder_paths"],
|
||||
"get_input_directory",
|
||||
lambda: str(tmp_path),
|
||||
raising=False,
|
||||
)
|
||||
out = self.run(BatchPromptsNode(), "rel.txt", index=1)
|
||||
assert out[2] == "y"
|
||||
|
||||
def test_relative_path_falls_back_to_cwd(self, state, tmp_path, monkeypatch):
|
||||
import sys
|
||||
|
||||
def boom():
|
||||
raise RuntimeError("no input dir")
|
||||
|
||||
monkeypatch.setattr(
|
||||
sys.modules["folder_paths"], "get_input_directory", boom, raising=False
|
||||
)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "cwd.txt").write_text("only")
|
||||
assert self.run(BatchPromptsNode(), "cwd.txt")[2] == "only"
|
||||
|
||||
def test_missing_file_raises(self, state, tmp_path):
|
||||
with pytest.raises(ValueError, match="Invalid prompt file"):
|
||||
self.run(BatchPromptsNode(), str(tmp_path / "missing.txt"))
|
||||
|
||||
def test_empty_file_raises(self, state, tmp_path):
|
||||
f = tmp_path / "empty.txt"
|
||||
f.write_text("---\n---")
|
||||
with pytest.raises(ValueError, match="No prompts found"):
|
||||
self.run(BatchPromptsNode(), str(f))
|
||||
|
||||
def test_is_changed_always_differs(self):
|
||||
import time
|
||||
|
||||
a = BatchPromptsNode.IS_CHANGED()
|
||||
time.sleep(0.002)
|
||||
b = BatchPromptsNode.IS_CHANGED()
|
||||
assert a != b and re.fullmatch(r"[\d.]+", a)
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Behavioural tests for KikoEmbeddingAutocomplete node and suggestion API."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from kikotools.tools.embedding_autocomplete import node as node_mod
|
||||
from kikotools.tools.embedding_autocomplete.node import (
|
||||
KikoEmbeddingAutocomplete,
|
||||
KikoEmbeddingAutocompleteAPI,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_paths(monkeypatch):
|
||||
fp = MagicMock()
|
||||
files = {
|
||||
"embeddings": ["easynegative.pt", "sub/BadHands.safetensors", "other.pt"],
|
||||
"loras": ["detail.safetensors", "easy_style.safetensors"],
|
||||
}
|
||||
fp.get_filename_list.side_effect = lambda kind: files[kind]
|
||||
monkeypatch.setattr(node_mod, "folder_paths", fp)
|
||||
return fp
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def no_paths(monkeypatch):
|
||||
monkeypatch.setattr(node_mod, "folder_paths", None)
|
||||
|
||||
|
||||
class TestNodeBasics:
|
||||
def test_input_types_hidden_unique_id(self):
|
||||
spec = KikoEmbeddingAutocomplete.INPUT_TYPES()
|
||||
assert spec["required"] == {}
|
||||
assert spec["hidden"] == {"unique_id": "UNIQUE_ID"}
|
||||
|
||||
def test_validate_inputs_accepts_anything(self):
|
||||
assert KikoEmbeddingAutocomplete.VALIDATE_INPUTS(foo=1) is True
|
||||
|
||||
def test_update_settings_returns_empty_tuple(self):
|
||||
assert KikoEmbeddingAutocomplete().update_settings(unique_id="7") == ()
|
||||
|
||||
def test_settings_defaults_are_consistent(self):
|
||||
for key, spec in KikoEmbeddingAutocomplete.SETTINGS.items():
|
||||
assert "default" in spec, key
|
||||
if spec["type"] == "combo":
|
||||
assert spec["default"] in spec["options"]
|
||||
|
||||
|
||||
class TestListing:
|
||||
def test_get_embeddings_strips_extension(self, fake_paths):
|
||||
result = KikoEmbeddingAutocomplete().get_embeddings()
|
||||
assert [e["name"] for e in result] == [
|
||||
"easynegative",
|
||||
"sub/BadHands",
|
||||
"other",
|
||||
]
|
||||
assert result[0] == {
|
||||
"name": "easynegative",
|
||||
"file": "easynegative.pt",
|
||||
"type": "embedding",
|
||||
"display": "embedding:easynegative",
|
||||
"value": "embedding:easynegative",
|
||||
}
|
||||
|
||||
def test_get_loras_format(self, fake_paths):
|
||||
result = KikoEmbeddingAutocomplete().get_loras()
|
||||
assert result[0]["value"] == "<lora:detail:1.0>"
|
||||
assert all(r["type"] == "lora" for r in result)
|
||||
|
||||
def test_without_comfyui_lists_are_empty(self, no_paths):
|
||||
n = KikoEmbeddingAutocomplete()
|
||||
assert n.get_embeddings() == []
|
||||
assert n.get_loras() == []
|
||||
|
||||
def test_listing_errors_are_swallowed(self, fake_paths):
|
||||
fake_paths.get_filename_list.side_effect = RuntimeError("disk")
|
||||
n = KikoEmbeddingAutocomplete()
|
||||
assert n.get_embeddings() == []
|
||||
assert n.get_loras() == []
|
||||
|
||||
def test_refresh_cache_populates_both(self, fake_paths):
|
||||
n = KikoEmbeddingAutocomplete()
|
||||
assert n.embeddings_cache is None
|
||||
n.refresh_cache()
|
||||
assert len(n.embeddings_cache) == 3
|
||||
assert len(n.loras_cache) == 2
|
||||
|
||||
|
||||
class TestIsChanged:
|
||||
def test_refresh_flag_forces_nan(self):
|
||||
value = KikoEmbeddingAutocomplete.IS_CHANGED(refresh=True)
|
||||
assert value != value
|
||||
|
||||
def test_without_comfyui_is_zero(self, no_paths):
|
||||
assert KikoEmbeddingAutocomplete.IS_CHANGED() == 0
|
||||
|
||||
def test_uses_folder_mtimes(self, fake_paths, tmp_path):
|
||||
a = tmp_path / "emb"
|
||||
b = tmp_path / "lor"
|
||||
a.mkdir()
|
||||
b.mkdir()
|
||||
fake_paths.get_folder_paths.side_effect = lambda k: [
|
||||
str(a if k == "embeddings" else b)
|
||||
]
|
||||
import os
|
||||
|
||||
assert KikoEmbeddingAutocomplete.IS_CHANGED() == pytest.approx(
|
||||
os.path.getmtime(a) + os.path.getmtime(b)
|
||||
)
|
||||
|
||||
def test_missing_folders_fall_back_to_zero(self, fake_paths):
|
||||
fake_paths.get_folder_paths.side_effect = lambda k: ["/definitely/not/here"]
|
||||
assert KikoEmbeddingAutocomplete.IS_CHANGED() == 0
|
||||
|
||||
|
||||
class TestSuggestions:
|
||||
api = KikoEmbeddingAutocompleteAPI
|
||||
|
||||
def test_prefix_match_case_insensitive(self, fake_paths):
|
||||
result = self.api.get_suggestions("EASY")
|
||||
# equal priority -> ordered by name
|
||||
assert [(r["type"], r["name"]) for r in result] == [
|
||||
("lora", "easy_style"),
|
||||
("embedding", "easynegative"),
|
||||
]
|
||||
|
||||
def test_substring_match_ranked_below_prefix(self, fake_paths):
|
||||
result = self.api.get_suggestions("hands")
|
||||
assert [r["name"] for r in result] == ["sub/BadHands"]
|
||||
assert result[0]["priority"] == -1
|
||||
|
||||
def test_exact_match_first(self, fake_paths):
|
||||
result = self.api.get_suggestions("detail")
|
||||
assert result[0]["priority"] == 1
|
||||
assert result[0]["value"] == "<lora:detail:1.0>"
|
||||
|
||||
def test_case_sensitive_mode(self, fake_paths):
|
||||
assert self.api.get_suggestions("EASY", case_sensitive=True) == []
|
||||
names = [
|
||||
r["name"] for r in self.api.get_suggestions("easy", case_sensitive=True)
|
||||
]
|
||||
assert names == ["easy_style", "easynegative"]
|
||||
|
||||
def test_exclude_types(self, fake_paths):
|
||||
only_emb = self.api.get_suggestions("e", include_loras=False)
|
||||
assert {r["type"] for r in only_emb} == {"embedding"}
|
||||
only_lora = self.api.get_suggestions("e", include_embeddings=False)
|
||||
assert {r["type"] for r in only_lora} == {"lora"}
|
||||
|
||||
def test_max_results_limits_output(self, fake_paths):
|
||||
assert len(self.api.get_suggestions("e", max_results=1)) == 1
|
||||
|
||||
def test_empty_prefix_matches_everything(self, fake_paths):
|
||||
assert len(self.api.get_suggestions("")) == 5
|
||||
|
||||
def test_no_match(self, fake_paths):
|
||||
assert self.api.get_suggestions("zzz") == []
|
||||
|
||||
def test_without_comfyui(self, no_paths):
|
||||
assert self.api.get_suggestions("a") == []
|
||||
|
||||
def test_errors_swallowed(self, fake_paths):
|
||||
fake_paths.get_filename_list.side_effect = RuntimeError("x")
|
||||
assert self.api.get_suggestions("a") == []
|
||||
@@ -131,8 +131,13 @@ class TestEmptyLatentBatchNode:
|
||||
|
||||
def test_node_attributes(self):
|
||||
"""Test node class attributes."""
|
||||
assert EmptyLatentBatchNode.RETURN_TYPES == ("LATENT", "INT", "INT")
|
||||
assert EmptyLatentBatchNode.RETURN_NAMES == ("latent", "width", "height")
|
||||
assert EmptyLatentBatchNode.RETURN_TYPES == ("LATENT", "INT", "INT", "INT")
|
||||
assert EmptyLatentBatchNode.RETURN_NAMES == (
|
||||
"latent",
|
||||
"width",
|
||||
"height",
|
||||
"batch_size",
|
||||
)
|
||||
assert EmptyLatentBatchNode.FUNCTION == "create_empty_latent"
|
||||
assert EmptyLatentBatchNode.CATEGORY == "🫶 ComfyAssets/📦 Latents"
|
||||
|
||||
@@ -141,13 +146,14 @@ class TestEmptyLatentBatchNode:
|
||||
result = self.node.create_empty_latent("custom", 512, 512, 1)
|
||||
|
||||
assert isinstance(result, tuple)
|
||||
assert len(result) == 3 # Now returns (latent, width, height)
|
||||
assert len(result) == 4 # Returns (latent, width, height, batch_size)
|
||||
|
||||
latent_dict, width, height = result
|
||||
latent_dict, width, height, batch_size = result
|
||||
assert isinstance(latent_dict, dict)
|
||||
assert "samples" in latent_dict
|
||||
assert width == 512
|
||||
assert height == 512
|
||||
assert batch_size == 1
|
||||
|
||||
samples = latent_dict["samples"]
|
||||
assert isinstance(samples, torch.Tensor)
|
||||
@@ -155,12 +161,13 @@ class TestEmptyLatentBatchNode:
|
||||
|
||||
def test_create_empty_latent_with_batch(self):
|
||||
"""Test empty latent creation with batch size."""
|
||||
batch_size = 3
|
||||
result = self.node.create_empty_latent("custom", 1024, 768, batch_size)
|
||||
input_batch_size = 3
|
||||
result = self.node.create_empty_latent("custom", 1024, 768, input_batch_size)
|
||||
|
||||
latent_dict, width, height = result
|
||||
latent_dict, width, height, batch_size = result
|
||||
assert width == 1024
|
||||
assert height == 768
|
||||
assert batch_size == 3
|
||||
samples = latent_dict["samples"]
|
||||
assert samples.shape == (3, 4, 96, 128) # batch=3, 768/8=96, 1024/8=128
|
||||
|
||||
@@ -169,10 +176,11 @@ class TestEmptyLatentBatchNode:
|
||||
# Input dimensions not divisible by 8
|
||||
result = self.node.create_empty_latent("custom", 513, 515, 1)
|
||||
|
||||
latent_dict, width, height = result
|
||||
latent_dict, width, height, batch_size = result
|
||||
# Dimensions should be rounded UP to nearest multiple of 8
|
||||
assert width == 520 # 513 -> 520
|
||||
assert height == 520 # 515 -> 520
|
||||
assert batch_size == 1
|
||||
samples = latent_dict["samples"]
|
||||
# Should be adjusted to 520x520 -> 65x65 latent
|
||||
assert samples.shape == (1, 4, 65, 65)
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Additional behavioural tests for EmptyLatentBatchNode."""
|
||||
|
||||
import pytest
|
||||
|
||||
from kikotools.tools.empty_latent_batch.node import EmptyLatentBatchNode
|
||||
from kikotools.tools.width_height_selector.presets import PRESET_METADATA
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def node():
|
||||
return EmptyLatentBatchNode()
|
||||
|
||||
|
||||
class TestPresetNameExtraction:
|
||||
def test_custom(self, node):
|
||||
assert node._extract_preset_name("custom") == "custom"
|
||||
|
||||
def test_plain_preset(self, node):
|
||||
assert node._extract_preset_name("1024×1024") == "1024×1024"
|
||||
|
||||
def test_formatted_option_from_input_types_roundtrips(self, node):
|
||||
options = EmptyLatentBatchNode.INPUT_TYPES()["required"]["preset"][0]
|
||||
assert options[0] == "custom"
|
||||
for option in options[1:]:
|
||||
name = node._extract_preset_name(option)
|
||||
assert name in PRESET_METADATA
|
||||
|
||||
def test_unknown_formatted_falls_back_to_custom(self, node):
|
||||
assert node._extract_preset_name("nope - 1:1 - X") == "custom"
|
||||
assert node._extract_preset_name("garbage") == "custom"
|
||||
|
||||
|
||||
class TestCreateEmptyLatent:
|
||||
def test_formatted_preset_overrides_width_height(self, node):
|
||||
option = next(
|
||||
o
|
||||
for o in EmptyLatentBatchNode.INPUT_TYPES()["required"]["preset"][0]
|
||||
if o.startswith("832×1216")
|
||||
)
|
||||
latent, w, h, b = node.create_empty_latent(option, 64, 64, 2)
|
||||
assert (w, h, b) == (832, 1216, 2)
|
||||
assert latent["samples"].shape == (2, 4, 152, 104)
|
||||
|
||||
def test_custom_dimensions_rounded_and_reported(self, node):
|
||||
latent, w, h, _ = node.create_empty_latent("custom", 1030, 1030, 1)
|
||||
assert (w, h) == (1032, 1032)
|
||||
assert latent["samples"].shape == (1, 4, 129, 129)
|
||||
|
||||
def test_latent_is_zeros(self, node):
|
||||
latent, *_ = node.create_empty_latent("custom", 64, 64, 1)
|
||||
assert float(latent["samples"].abs().sum()) == 0.0
|
||||
|
||||
def test_out_of_range_dimensions_are_clamped(self, node):
|
||||
_, w, h, _ = node.create_empty_latent("custom", 1, 99999, 1)
|
||||
assert (w, h) == (64, 8192)
|
||||
|
||||
@pytest.mark.parametrize("batch", [0, -3])
|
||||
def test_non_positive_batch_raises(self, node, batch):
|
||||
with pytest.raises(ValueError, match="Batch size must be positive"):
|
||||
node.create_empty_latent("custom", 512, 512, batch)
|
||||
|
||||
def test_large_batch_is_allowed(self, node):
|
||||
latent, _, _, b = node.create_empty_latent("custom", 64, 64, 65)
|
||||
assert b == 65 and latent["samples"].shape[0] == 65
|
||||
|
||||
|
||||
class TestValidateInputs:
|
||||
def test_valid(self, node):
|
||||
assert node.validate_inputs("custom", 512, 512, 4) is True
|
||||
|
||||
def test_valid_formatted_preset(self, node):
|
||||
assert node.validate_inputs("1024×1024 - 1:1 (1.0MP) - SDXL", 0, 0, 1) is True
|
||||
|
||||
@pytest.mark.parametrize("batch", [0, 65])
|
||||
def test_batch_bounds(self, node, batch):
|
||||
assert node.validate_inputs("custom", 512, 512, batch) is False
|
||||
|
||||
def test_bad_preset_parses_as_custom_so_uses_dimensions(self, node):
|
||||
assert node.validate_inputs("garbage", 512, 512, 1) is True
|
||||
|
||||
def test_custom_with_zero_dimensions_is_clamped_valid(self, node):
|
||||
# sanitisation clamps 0 up to the 64 minimum, so this is accepted
|
||||
assert node.validate_inputs("custom", 0, 0, 1) is True
|
||||
|
||||
|
||||
class TestInfoHelpers:
|
||||
def test_latent_info(self, node):
|
||||
info = node.get_latent_info(64, 64, 1)
|
||||
assert "1 × 4 × 8 × 8" in info and "64×64" in info
|
||||
|
||||
def test_latent_info_sanitises(self, node):
|
||||
assert "1032×1032" in node.get_latent_info(1030, 1030, 1)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"w,h,b,suffix",
|
||||
[
|
||||
(64, 64, 1, "KB"), # 1*4*8*8*4 = 1024 bytes -> 1.0 KB
|
||||
(1024, 1024, 1, "KB"), # 262144 bytes = 256 KB
|
||||
(1024, 1024, 8, "MB"),
|
||||
(8192, 8192, 64, "GB"),
|
||||
],
|
||||
)
|
||||
def test_memory_estimate_units(self, node, w, h, b, suffix):
|
||||
assert node.get_memory_estimate(w, h, b).endswith(suffix)
|
||||
|
||||
def test_memory_estimate_value(self, node):
|
||||
assert node.get_memory_estimate(1024, 1024, 1) == "256.0 KB"
|
||||
assert node.get_memory_estimate(1024, 1024, 8) == "2.0 MB"
|
||||
|
||||
def test_memory_estimate_bytes_branch(self, node, monkeypatch):
|
||||
from kikotools.tools.empty_latent_batch import node as mod
|
||||
|
||||
monkeypatch.setattr(mod, "sanitize_dimensions", lambda w, h: (8, 8))
|
||||
assert node.get_memory_estimate(8, 8, 1) == "16 bytes"
|
||||
|
||||
|
||||
class TestDunder:
|
||||
def test_str_and_repr(self, node):
|
||||
assert str(node) == "EmptyLatentBatchNode"
|
||||
assert "EmptyLatentBatchNode(" in repr(node)
|
||||
@@ -0,0 +1,362 @@
|
||||
"""Additional behavioural tests for gemini_prompt (models, logic, node)."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from kikotools.tools.gemini_prompt import logic, models
|
||||
from kikotools.tools.gemini_prompt.node import GeminiPromptNode
|
||||
from kikotools.tools.gemini_prompt.prompts import DEFAULT_GEMINI_MODELS
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cache_file(tmp_path, monkeypatch):
|
||||
path = tmp_path / "cache.json"
|
||||
monkeypatch.setattr(models, "CACHE_FILE", str(path))
|
||||
return path
|
||||
|
||||
|
||||
def fake_genai(model_specs):
|
||||
genai = MagicMock()
|
||||
genai.list_models.return_value = [
|
||||
SimpleNamespace(
|
||||
name=f"models/{name}",
|
||||
display_name=display,
|
||||
supported_generation_methods=methods,
|
||||
)
|
||||
for name, display, methods in model_specs
|
||||
]
|
||||
return genai
|
||||
|
||||
|
||||
class TestSortModels:
|
||||
def test_newer_versions_come_first(self):
|
||||
result = models._sort_models(
|
||||
["gemini-1.5-pro", "gemini-2.5-pro", "gemini-2.0-pro"]
|
||||
)
|
||||
assert result == ["gemini-2.5-pro", "gemini-2.0-pro", "gemini-1.5-pro"]
|
||||
|
||||
def test_stable_before_experimental_and_preview(self):
|
||||
result = models._sort_models(
|
||||
["gemini-2.5-pro-preview", "gemini-2.5-pro-exp", "gemini-2.5-pro"]
|
||||
)
|
||||
assert result[0] == "gemini-2.5-pro"
|
||||
|
||||
def test_flash_variants_penalised(self):
|
||||
result = models._sort_models(
|
||||
[
|
||||
"gemini-2.5-flash-lite",
|
||||
"gemini-2.5-flash-thinking",
|
||||
"gemini-2.5-flash-tts",
|
||||
"gemini-2.5-flash",
|
||||
]
|
||||
)
|
||||
assert result[0] == "gemini-2.5-flash"
|
||||
assert result[-1] != "gemini-2.5-flash"
|
||||
|
||||
def test_empty_list(self):
|
||||
assert models._sort_models([]) == []
|
||||
|
||||
|
||||
class TestCache:
|
||||
def test_load_returns_none_when_missing(self, cache_file):
|
||||
assert models._load_cache() is None
|
||||
|
||||
def test_save_then_load_roundtrip(self, cache_file):
|
||||
models._save_cache(["a"], {"a": "A"})
|
||||
data = models._load_cache()
|
||||
assert data["models"] == ["a"]
|
||||
assert data["descriptions"] == {"a": "A"}
|
||||
|
||||
def test_expired_cache_is_ignored(self, cache_file):
|
||||
cache_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"models": ["old"],
|
||||
"descriptions": {},
|
||||
"timestamp": time.time() - models.CACHE_DURATION - 10,
|
||||
}
|
||||
)
|
||||
)
|
||||
assert models._load_cache() is None
|
||||
|
||||
def test_corrupt_cache_is_ignored(self, cache_file):
|
||||
cache_file.write_text("{not json")
|
||||
assert models._load_cache() is None
|
||||
|
||||
def test_save_failure_is_swallowed(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(models, "CACHE_FILE", str(tmp_path / "nodir" / "c.json"))
|
||||
models._save_cache(["a"], {}) # must not raise
|
||||
|
||||
def test_clear_cache_removes_file(self, cache_file):
|
||||
models._save_cache(["a"], {})
|
||||
assert cache_file.exists()
|
||||
models.clear_cache()
|
||||
assert not cache_file.exists()
|
||||
|
||||
def test_clear_cache_when_missing_is_noop(self, cache_file):
|
||||
models.clear_cache()
|
||||
|
||||
def test_clear_cache_remove_error_is_swallowed(self, cache_file):
|
||||
cache_file.write_text("{}")
|
||||
with patch.object(models.os, "remove", side_effect=OSError("nope")):
|
||||
models.clear_cache()
|
||||
|
||||
|
||||
class TestFetchModelsFromApi:
|
||||
def test_missing_sdk_returns_empty(self):
|
||||
with patch.dict(sys.modules, {"google.generativeai": None, "google": None}):
|
||||
assert models._fetch_models_from_api("k") == ([], {})
|
||||
|
||||
def test_no_api_key_returns_empty(self):
|
||||
genai = fake_genai([])
|
||||
google = MagicMock(generativeai=genai)
|
||||
with (
|
||||
patch.dict(sys.modules, {"google": google, "google.generativeai": genai}),
|
||||
patch.object(logic, "get_api_key", return_value=None),
|
||||
):
|
||||
assert models._fetch_models_from_api(None, silent=True) == ([], {})
|
||||
|
||||
def test_api_key_taken_from_logic_when_not_given(self):
|
||||
genai = fake_genai([("gemini-2.5-pro", "Pro", ["generateContent"])])
|
||||
google = MagicMock(generativeai=genai)
|
||||
with (
|
||||
patch.dict(sys.modules, {"google": google, "google.generativeai": genai}),
|
||||
patch.object(logic, "get_api_key", return_value="envkey"),
|
||||
):
|
||||
names, _ = models._fetch_models_from_api(None)
|
||||
genai.configure.assert_called_once_with(api_key="envkey")
|
||||
assert names == ["gemini-2.5-pro"]
|
||||
|
||||
def test_filters_to_generate_content_and_strips_prefix(self):
|
||||
genai = fake_genai(
|
||||
[
|
||||
("gemini-1.5-pro", "Pro 1.5", ["generateContent"]),
|
||||
("embedding-001", "Embed", ["embedContent"]),
|
||||
("gemini-2.5-flash", "Flash", ["generateContent", "countTokens"]),
|
||||
]
|
||||
)
|
||||
google = MagicMock(generativeai=genai)
|
||||
with patch.dict(sys.modules, {"google": google, "google.generativeai": genai}):
|
||||
names, desc = models._fetch_models_from_api("k")
|
||||
assert names == ["gemini-2.5-flash", "gemini-1.5-pro"]
|
||||
assert desc == {"gemini-1.5-pro": "Pro 1.5", "gemini-2.5-flash": "Flash"}
|
||||
|
||||
def test_api_error_returns_empty(self):
|
||||
genai = MagicMock()
|
||||
genai.list_models.side_effect = RuntimeError("boom")
|
||||
google = MagicMock(generativeai=genai)
|
||||
with patch.dict(sys.modules, {"google": google, "google.generativeai": genai}):
|
||||
assert models._fetch_models_from_api("k") == ([], {})
|
||||
|
||||
|
||||
class TestGetAvailableModels:
|
||||
def test_uses_fresh_cache_without_calling_api(self, cache_file):
|
||||
models._save_cache(["cached"], {"cached": "C"})
|
||||
with patch.object(models, "_fetch_models_from_api") as fetch:
|
||||
result = models.get_available_models("k")
|
||||
fetch.assert_not_called()
|
||||
assert result == (["cached"], {"cached": "C"})
|
||||
|
||||
def test_fetches_and_caches(self, cache_file):
|
||||
with patch.object(
|
||||
models, "_fetch_models_from_api", return_value=(["m"], {"m": "M"})
|
||||
):
|
||||
assert models.get_available_models("k") == (["m"], {"m": "M"})
|
||||
assert models._load_cache()["models"] == ["m"]
|
||||
|
||||
def test_falls_back_to_defaults_when_empty(self, cache_file):
|
||||
with patch.object(models, "_fetch_models_from_api", return_value=([], {})):
|
||||
assert models.get_available_models("k") == (DEFAULT_GEMINI_MODELS, {})
|
||||
assert not cache_file.exists()
|
||||
|
||||
def test_falls_back_to_defaults_on_exception(self, cache_file):
|
||||
with patch.object(models, "_fetch_models_from_api", side_effect=Exception("x")):
|
||||
assert models.get_available_models("k")[0] == DEFAULT_GEMINI_MODELS
|
||||
|
||||
|
||||
class TestLogicExtra:
|
||||
def test_get_api_key_config_file_read(self, monkeypatch, tmp_path):
|
||||
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
|
||||
cfg = tmp_path / "gemini_config.json"
|
||||
cfg.write_text(json.dumps({"api_key": "from-file"}))
|
||||
with patch.object(logic.os.path, "join", return_value=str(cfg)):
|
||||
assert logic.get_api_key() == "from-file"
|
||||
|
||||
def test_get_api_key_bad_config_returns_none(self, monkeypatch, tmp_path):
|
||||
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
|
||||
cfg = tmp_path / "gemini_config.json"
|
||||
cfg.write_text("garbage")
|
||||
with patch.object(logic.os.path, "join", return_value=str(cfg)):
|
||||
assert logic.get_api_key() is None
|
||||
|
||||
def test_tensor_conversion_failure_reported(self):
|
||||
out, err = logic.analyze_image_with_gemini(
|
||||
np.zeros((4, 4, 5)), "flux", api_key="k"
|
||||
)
|
||||
assert out == ""
|
||||
assert "Failed to convert image" in err
|
||||
|
||||
def test_missing_sdk_reported(self):
|
||||
img = np.zeros((1, 4, 4, 3), dtype=np.float32)
|
||||
with patch.dict(sys.modules, {"google.generativeai": None, "google": None}):
|
||||
out, err = logic.analyze_image_with_gemini(img, "flux", api_key="k")
|
||||
assert out == "" and "not installed" in err
|
||||
|
||||
def _run(self, response=None, error=None, **kwargs):
|
||||
genai = MagicMock()
|
||||
model = genai.GenerativeModel.return_value
|
||||
if error:
|
||||
model.generate_content.side_effect = error
|
||||
else:
|
||||
model.generate_content.return_value = response
|
||||
google = MagicMock(generativeai=genai)
|
||||
img = np.zeros((1, 4, 4, 3), dtype=np.float32)
|
||||
with patch.dict(sys.modules, {"google": google, "google.generativeai": genai}):
|
||||
result = logic.analyze_image_with_gemini(img, "flux", api_key="k", **kwargs)
|
||||
return result, genai
|
||||
|
||||
def test_success_strips_text_and_uses_model(self):
|
||||
(out, err), genai = self._run(
|
||||
SimpleNamespace(text=" hello \n"), model_name="gemini-x"
|
||||
)
|
||||
assert (out, err) == ("hello", None)
|
||||
genai.configure.assert_called_once_with(api_key="k")
|
||||
genai.GenerativeModel.assert_called_once_with("gemini-x")
|
||||
|
||||
def test_custom_prompt_is_sent_as_system_prompt(self):
|
||||
(_, _), genai = self._run(SimpleNamespace(text="x"), custom_prompt="BE TERSE")
|
||||
sent = genai.GenerativeModel.return_value.generate_content.call_args[0][0]
|
||||
assert sent[0] == "BE TERSE"
|
||||
|
||||
def test_unknown_prompt_type_falls_back_to_flux_template(self):
|
||||
genai = MagicMock()
|
||||
genai.GenerativeModel.return_value.generate_content.return_value = (
|
||||
SimpleNamespace(text="x")
|
||||
)
|
||||
google = MagicMock(generativeai=genai)
|
||||
img = np.zeros((1, 4, 4, 3), dtype=np.float32)
|
||||
with patch.dict(sys.modules, {"google": google, "google.generativeai": genai}):
|
||||
logic.analyze_image_with_gemini(img, "nonsense", api_key="k")
|
||||
sent = genai.GenerativeModel.return_value.generate_content.call_args[0][0]
|
||||
assert sent[0] == logic.PROMPT_TEMPLATES["flux"]
|
||||
|
||||
def test_empty_response(self):
|
||||
(out, err), _ = self._run(SimpleNamespace(text=""))
|
||||
assert out == "" and "No response" in err
|
||||
|
||||
def test_api_exception(self):
|
||||
(out, err), _ = self._run(error=RuntimeError("quota"))
|
||||
assert out == "" and "Gemini API error: quota" in err
|
||||
|
||||
|
||||
class TestNodeExtra:
|
||||
def _image(self):
|
||||
return torch.zeros(1, 4, 4, 3)
|
||||
|
||||
def test_input_types_defaults_to_first_model(self):
|
||||
with patch(
|
||||
"kikotools.tools.gemini_prompt.node.get_available_models",
|
||||
return_value=(["m1", "m2"], {}),
|
||||
):
|
||||
spec = GeminiPromptNode.INPUT_TYPES()
|
||||
assert spec["required"]["model"] == (["m1", "m2"], {"default": "m1"})
|
||||
|
||||
def test_input_types_with_no_models_uses_defaults(self):
|
||||
with patch(
|
||||
"kikotools.tools.gemini_prompt.node.get_available_models",
|
||||
return_value=([], {}),
|
||||
):
|
||||
spec = GeminiPromptNode.INPUT_TYPES()
|
||||
assert spec["required"]["model"][0] == DEFAULT_GEMINI_MODELS
|
||||
|
||||
def test_torch_tensor_converted_to_numpy_and_blank_args_become_none(self):
|
||||
node = GeminiPromptNode()
|
||||
with patch(
|
||||
"kikotools.tools.gemini_prompt.node.analyze_image_with_gemini",
|
||||
return_value=("p", None),
|
||||
) as analyze:
|
||||
result = node.generate_prompt(self._image(), "flux", "m")
|
||||
assert result == ("p", "")
|
||||
args, kwargs = analyze.call_args
|
||||
assert isinstance(args[0], np.ndarray)
|
||||
assert kwargs["api_key"] is None
|
||||
assert kwargs["custom_prompt"] is None
|
||||
assert kwargs["model_name"] == "m"
|
||||
|
||||
def test_numpy_image_passes_through(self):
|
||||
node = GeminiPromptNode()
|
||||
arr = np.zeros((1, 4, 4, 3), dtype=np.float32)
|
||||
with patch(
|
||||
"kikotools.tools.gemini_prompt.node.analyze_image_with_gemini",
|
||||
return_value=("p", None),
|
||||
) as analyze:
|
||||
node.generate_prompt(arr, "flux", "m")
|
||||
assert analyze.call_args[0][0] is arr
|
||||
|
||||
def test_refresh_with_api_key_clears_cache(self):
|
||||
node = GeminiPromptNode()
|
||||
with (
|
||||
patch("kikotools.tools.gemini_prompt.models.clear_cache") as clear,
|
||||
patch(
|
||||
"kikotools.tools.gemini_prompt.models.get_available_models",
|
||||
return_value=(["x"], {}),
|
||||
),
|
||||
patch(
|
||||
"kikotools.tools.gemini_prompt.node.analyze_image_with_gemini",
|
||||
return_value=("p", None),
|
||||
),
|
||||
):
|
||||
node.generate_prompt(
|
||||
self._image(), "flux", "m", api_key="k", refresh_models=True
|
||||
)
|
||||
clear.assert_called_once()
|
||||
|
||||
def test_refresh_failure_does_not_break_generation(self):
|
||||
node = GeminiPromptNode()
|
||||
with (
|
||||
patch(
|
||||
"kikotools.tools.gemini_prompt.models.clear_cache",
|
||||
side_effect=RuntimeError("x"),
|
||||
),
|
||||
patch(
|
||||
"kikotools.tools.gemini_prompt.models.get_available_models",
|
||||
side_effect=RuntimeError("y"),
|
||||
),
|
||||
patch(
|
||||
"kikotools.tools.gemini_prompt.node.analyze_image_with_gemini",
|
||||
return_value=("p", None),
|
||||
),
|
||||
):
|
||||
result = node.generate_prompt(
|
||||
self._image(), "flux", "m", api_key="k", refresh_models=True
|
||||
)
|
||||
assert result == ("p", "")
|
||||
|
||||
def test_sdxl_without_markers_returns_whole_text_as_positive(self):
|
||||
node = GeminiPromptNode()
|
||||
with patch(
|
||||
"kikotools.tools.gemini_prompt.node.analyze_image_with_gemini",
|
||||
return_value=("just a prompt", None),
|
||||
):
|
||||
assert node.generate_prompt(self._image(), "sdxl", "m") == (
|
||||
"just a prompt",
|
||||
"",
|
||||
)
|
||||
|
||||
def test_sdxl_splits_positive_and_negative(self):
|
||||
node = GeminiPromptNode()
|
||||
with patch(
|
||||
"kikotools.tools.gemini_prompt.node.analyze_image_with_gemini",
|
||||
return_value=("Positive: cat\nNegative: blurry", None),
|
||||
):
|
||||
assert node.generate_prompt(self._image(), "sdxl", "m") == (
|
||||
"cat",
|
||||
"blurry",
|
||||
)
|
||||
@@ -247,3 +247,30 @@ class TestEdgeCases:
|
||||
)
|
||||
assert result.min() >= 0.0
|
||||
assert result.max() <= 1.0
|
||||
|
||||
|
||||
class TestFilmGrainRegressions:
|
||||
def test_does_not_touch_global_rng(self):
|
||||
image = torch.rand(1, 16, 16, 3)
|
||||
torch.manual_seed(1234)
|
||||
expected = torch.rand(3)
|
||||
torch.manual_seed(1234)
|
||||
apply_film_grain(image, strength=0.5, seed=99)
|
||||
assert torch.equal(torch.rand(3), expected)
|
||||
|
||||
def test_same_seed_is_deterministic(self):
|
||||
image = torch.rand(1, 16, 16, 3)
|
||||
a = apply_film_grain(image, strength=0.5, seed=7)
|
||||
b = apply_film_grain(image, strength=0.5, seed=7)
|
||||
c = apply_film_grain(image, strength=0.5, seed=8)
|
||||
assert torch.equal(a, b)
|
||||
assert not torch.equal(a, c)
|
||||
|
||||
def test_zero_strength_zero_toe_returns_input(self):
|
||||
image = torch.rand(1, 8, 8, 3)
|
||||
assert torch.equal(apply_film_grain(image, strength=0.0, toe=0.0), image)
|
||||
|
||||
def test_zero_strength_still_applies_toe(self):
|
||||
image = torch.zeros(1, 8, 8, 3)
|
||||
result = apply_film_grain(image, strength=0.0, toe=0.25, seed=1)
|
||||
assert torch.allclose(result, torch.full_like(result, 0.25), atol=1e-6)
|
||||
|
||||
@@ -299,3 +299,11 @@ class TestKikoPurgeVRAMNode:
|
||||
assert KikoPurgeVRAM.OUTPUT_NODE is True
|
||||
assert len(KikoPurgeVRAM.RETURN_TYPES) == 2
|
||||
assert KikoPurgeVRAM.RETURN_NAMES == ("passthrough", "memory_report")
|
||||
|
||||
|
||||
class TestPurgeIsChanged:
|
||||
def test_is_changed_is_nan_so_cache_never_hits(self):
|
||||
from kikotools.tools.kiko_purge_vram.node import KikoPurgeVRAM
|
||||
|
||||
value = KikoPurgeVRAM.IS_CHANGED(anything=1, mode="soft", report_memory=True)
|
||||
assert value != value # NaN is never equal to itself
|
||||
|
||||
@@ -18,6 +18,7 @@ from kikotools.tools.kiko_save_image.logic import (
|
||||
save_image_with_format,
|
||||
get_save_image_path,
|
||||
create_png_metadata,
|
||||
get_next_counter,
|
||||
)
|
||||
|
||||
|
||||
@@ -48,26 +49,105 @@ class TestKikoSaveImageLogic:
|
||||
assert pil_image.size == (32, 32)
|
||||
assert pil_image.mode == "RGBA"
|
||||
|
||||
def test_get_save_image_path(self):
|
||||
"""Test save path generation"""
|
||||
def test_get_next_counter_creates_file(self):
|
||||
"""Test counter file creation"""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
# Test basic path generation
|
||||
# First call should create file with counter = 1
|
||||
counter = get_next_counter(temp_dir, "test_prefix")
|
||||
assert counter == 1
|
||||
|
||||
# Verify counter file was created
|
||||
counter_file = os.path.join(temp_dir, ".test_prefix_counter.txt")
|
||||
assert os.path.exists(counter_file)
|
||||
|
||||
# Verify content
|
||||
with open(counter_file, "r") as f:
|
||||
assert f.read().strip() == "1"
|
||||
|
||||
def test_get_next_counter_increments(self):
|
||||
"""Test counter increments correctly"""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
# Multiple calls should increment
|
||||
counter1 = get_next_counter(temp_dir, "test")
|
||||
counter2 = get_next_counter(temp_dir, "test")
|
||||
counter3 = get_next_counter(temp_dir, "test")
|
||||
|
||||
assert counter1 == 1
|
||||
assert counter2 == 2
|
||||
assert counter3 == 3
|
||||
|
||||
def test_get_next_counter_different_prefixes(self):
|
||||
"""Test counters are independent per prefix"""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
# Different prefixes should have separate counters
|
||||
counter_a1 = get_next_counter(temp_dir, "prefix_a")
|
||||
counter_b1 = get_next_counter(temp_dir, "prefix_b")
|
||||
counter_a2 = get_next_counter(temp_dir, "prefix_a")
|
||||
|
||||
assert counter_a1 == 1
|
||||
assert counter_b1 == 1 # Independent counter
|
||||
assert counter_a2 == 2
|
||||
|
||||
def test_get_next_counter_corrupted_file(self):
|
||||
"""Test counter handles corrupted counter files"""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
# Create corrupted counter file
|
||||
counter_file = os.path.join(temp_dir, ".test_counter.txt")
|
||||
with open(counter_file, "w") as f:
|
||||
f.write("not_a_number")
|
||||
|
||||
# Should handle gracefully and start from 1
|
||||
counter = get_next_counter(temp_dir, "test")
|
||||
assert counter == 1
|
||||
|
||||
def test_get_next_counter_empty_file(self):
|
||||
"""Test counter handles empty counter files"""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
# Create empty counter file
|
||||
counter_file = os.path.join(temp_dir, ".test_counter.txt")
|
||||
with open(counter_file, "w") as f:
|
||||
f.write("")
|
||||
|
||||
# Should handle gracefully and start from 1
|
||||
counter = get_next_counter(temp_dir, "test")
|
||||
assert counter == 1
|
||||
|
||||
def test_get_next_counter_sanitizes_prefix(self):
|
||||
"""Test counter sanitizes special characters in prefix"""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
# Prefix with special characters
|
||||
get_next_counter(temp_dir, "test/prefix:with*special")
|
||||
|
||||
# Counter file should be created with sanitized name
|
||||
# Should only contain alphanumeric, dot, dash, underscore
|
||||
counter_files = [
|
||||
f for f in os.listdir(temp_dir) if f.endswith("_counter.txt")
|
||||
]
|
||||
assert len(counter_files) == 1
|
||||
assert "/" not in counter_files[0]
|
||||
assert ":" not in counter_files[0]
|
||||
assert "*" not in counter_files[0]
|
||||
|
||||
def test_get_save_image_path(self):
|
||||
"""Test save path generation with counter"""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
# Test basic path generation with counter
|
||||
full_path, filename, subfolder = get_save_image_path(
|
||||
"test_prefix", 0, ".png", temp_dir
|
||||
"test_prefix", 1, ".png", temp_dir
|
||||
)
|
||||
|
||||
assert full_path.startswith(temp_dir)
|
||||
assert filename.startswith("test_prefix_")
|
||||
assert filename.endswith("_00000.png")
|
||||
assert filename.endswith("00001.png")
|
||||
|
||||
# Test with empty subfolder (standard behavior)
|
||||
# Test with different counter values
|
||||
full_path, filename, subfolder = get_save_image_path(
|
||||
"test", 1, ".jpg", temp_dir, ""
|
||||
"test", 42, ".jpg", temp_dir, ""
|
||||
)
|
||||
|
||||
assert full_path.startswith(temp_dir)
|
||||
assert filename.startswith("test_")
|
||||
assert filename.endswith("_00001.jpg")
|
||||
assert filename.endswith("00042.jpg")
|
||||
|
||||
def test_create_png_metadata(self):
|
||||
"""Test PNG metadata creation"""
|
||||
@@ -124,8 +204,8 @@ class TestKikoSaveImageLogic:
|
||||
assert os.path.exists(filepath)
|
||||
|
||||
# Verify image can be loaded
|
||||
saved_img = Image.open(filepath)
|
||||
assert saved_img.size == (32, 32)
|
||||
with Image.open(filepath) as saved_img:
|
||||
assert saved_img.size == (32, 32)
|
||||
|
||||
@patch("kikotools.tools.kiko_save_image.logic.folder_paths")
|
||||
def test_process_image_batch_jpeg(self, mock_folder_paths):
|
||||
@@ -156,9 +236,9 @@ class TestKikoSaveImageLogic:
|
||||
filepath = os.path.join(temp_dir, results[0]["filename"])
|
||||
assert os.path.exists(filepath)
|
||||
|
||||
saved_img = Image.open(filepath)
|
||||
assert saved_img.size == (64, 64)
|
||||
assert saved_img.mode == "RGB" # JPEG converts to RGB
|
||||
with Image.open(filepath) as saved_img:
|
||||
assert saved_img.size == (64, 64)
|
||||
assert saved_img.mode == "RGB" # JPEG converts to RGB
|
||||
|
||||
@patch("kikotools.tools.kiko_save_image.logic.folder_paths")
|
||||
def test_process_image_batch_webp(self, mock_folder_paths):
|
||||
@@ -258,8 +338,8 @@ class TestKikoSaveImageLogic:
|
||||
assert os.path.exists(temp_path)
|
||||
|
||||
# Verify saved image
|
||||
saved_img = Image.open(temp_path)
|
||||
assert saved_img.size == (32, 32)
|
||||
with Image.open(temp_path) as saved_img:
|
||||
assert saved_img.size == (32, 32)
|
||||
|
||||
finally:
|
||||
if os.path.exists(temp_path):
|
||||
@@ -281,8 +361,8 @@ class TestKikoSaveImageLogic:
|
||||
assert result["quality"] == 95
|
||||
|
||||
# Verify saved image is RGB
|
||||
saved_img = Image.open(temp_path)
|
||||
assert saved_img.mode == "RGB"
|
||||
with Image.open(temp_path) as saved_img:
|
||||
assert saved_img.mode == "RGB"
|
||||
|
||||
finally:
|
||||
if os.path.exists(temp_path):
|
||||
@@ -508,9 +588,9 @@ class TestIntegration:
|
||||
assert os.path.exists(filepath)
|
||||
|
||||
# Verify image properties
|
||||
img = Image.open(filepath)
|
||||
assert img.size == (64, 64)
|
||||
assert img.format == "PNG"
|
||||
with Image.open(filepath) as img:
|
||||
assert img.size == (64, 64)
|
||||
assert img.format == "PNG"
|
||||
|
||||
@patch("kikotools.tools.kiko_save_image.logic.folder_paths")
|
||||
def test_full_pipeline_all_formats(self, mock_folder_paths, sample_image_tensor):
|
||||
@@ -548,5 +628,101 @@ class TestIntegration:
|
||||
filepath = os.path.join(temp_dir, image_info["filename"])
|
||||
assert os.path.exists(filepath)
|
||||
|
||||
img = Image.open(filepath)
|
||||
assert img.size == (64, 64)
|
||||
with Image.open(filepath) as img:
|
||||
assert img.size == (64, 64)
|
||||
|
||||
@patch("kikotools.tools.kiko_save_image.logic.folder_paths")
|
||||
def test_multiple_calls_no_overwrites(self, mock_folder_paths):
|
||||
"""Test that multiple node calls don't overwrite files (bug fix verification)"""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
mock_folder_paths.get_output_directory.return_value = temp_dir
|
||||
|
||||
node = KikoSaveImageNode()
|
||||
|
||||
# Simulate the bug scenario: 6 separate calls with single images
|
||||
# This would have caused overwrites before the counter fix
|
||||
all_filenames = []
|
||||
|
||||
for i in range(6):
|
||||
# Each call processes a single image (like in the bug report)
|
||||
single_image = torch.rand(1, 32, 32, 3)
|
||||
|
||||
result = node.save_images(
|
||||
images=single_image,
|
||||
filename_prefix="KikoSave",
|
||||
format="PNG",
|
||||
)
|
||||
|
||||
# Collect filenames
|
||||
for image_info in result["ui"]["images"]:
|
||||
all_filenames.append(image_info["filename"])
|
||||
|
||||
# Verify all 6 images were saved with unique filenames
|
||||
assert len(all_filenames) == 6
|
||||
assert len(set(all_filenames)) == 6 # All filenames are unique
|
||||
|
||||
# Verify all files actually exist
|
||||
for filename in all_filenames:
|
||||
filepath = os.path.join(temp_dir, filename)
|
||||
assert os.path.exists(filepath), f"File {filename} should exist"
|
||||
|
||||
# Verify filenames follow counter pattern
|
||||
# Should be: KikoSave_00001.png, KikoSave_00002.png, ..., KikoSave_00006.png
|
||||
sorted_filenames = sorted(all_filenames)
|
||||
for i, filename in enumerate(sorted_filenames, start=1):
|
||||
expected_counter = f"{i:05d}"
|
||||
assert (
|
||||
expected_counter in filename
|
||||
), f"Expected counter {expected_counter} in {filename}"
|
||||
|
||||
|
||||
class TestSavePathTraversal:
|
||||
"""filename_prefix directory part must stay inside the output directory"""
|
||||
|
||||
def test_parent_traversal_rejected(self, tmp_path):
|
||||
out = tmp_path / "output"
|
||||
out.mkdir()
|
||||
with pytest.raises(ValueError, match="outside"):
|
||||
get_save_image_path("../../evil/x", 1, ".png", str(out))
|
||||
assert not (tmp_path / "evil").exists()
|
||||
|
||||
def test_absolute_prefix_rejected(self, tmp_path):
|
||||
out = tmp_path / "output"
|
||||
out.mkdir()
|
||||
target = tmp_path / "abs_target"
|
||||
with pytest.raises(ValueError, match="absolute"):
|
||||
get_save_image_path(f"{target}/x", 1, ".png", str(out))
|
||||
assert not target.exists()
|
||||
|
||||
def test_symlink_escape_rejected(self, tmp_path):
|
||||
out = tmp_path / "output"
|
||||
out.mkdir()
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
os.symlink(outside, out / "link")
|
||||
with pytest.raises(ValueError, match="outside"):
|
||||
get_save_image_path("link/x", 1, ".png", str(out))
|
||||
|
||||
def test_subfolder_traversal_rejected(self, tmp_path):
|
||||
out = tmp_path / "output"
|
||||
out.mkdir()
|
||||
with pytest.raises(ValueError, match="outside"):
|
||||
get_save_image_path("x", 1, ".png", str(out), "../escape")
|
||||
|
||||
def test_nested_prefix_allowed(self, tmp_path):
|
||||
out = tmp_path / "output"
|
||||
out.mkdir()
|
||||
full_path, name, rel = get_save_image_path("a/b/img", 3, ".png", str(out))
|
||||
assert os.path.isdir(out / "a" / "b")
|
||||
# realpath both sides: Windows tmp dirs use 8.3 names (RUNNER~1)
|
||||
expected = os.path.join(str(out), "a", "b", "img_00003.png")
|
||||
assert os.path.realpath(full_path) == os.path.realpath(expected)
|
||||
# Separator follows the user's prefix ("a/b" on Windows too);
|
||||
# ComfyUI's /view normalizes it, so compare normalized paths
|
||||
assert os.path.normpath(rel) == os.path.join("a", "b")
|
||||
|
||||
def test_inner_dotdot_that_stays_inside_allowed(self, tmp_path):
|
||||
out = tmp_path / "output"
|
||||
out.mkdir()
|
||||
full_path, _, _ = get_save_image_path("a/../b/img", 1, ".png", str(out))
|
||||
assert os.path.realpath(full_path).startswith(os.path.realpath(out))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user