Compare commits
35
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 | ||
|
|
8457db0041 | ||
|
|
8a84927adf | ||
|
|
97ea5b9a7d | ||
|
|
f5f956ab4e |
@@ -11,7 +11,8 @@ exclude =
|
||||
build,
|
||||
dist,
|
||||
*.egg-info,
|
||||
.tox
|
||||
.tox,
|
||||
referance
|
||||
ignore =
|
||||
# W503: line break before binary operator (conflicts with Black)
|
||||
W503,
|
||||
|
||||
@@ -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
-447
@@ -11,478 +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 - Updated to match SAMPLERS list change
|
||||
assert node.RETURN_TYPES[0] == SAMPLERS # Now returns SAMPLERS list
|
||||
assert isinstance(node.RETURN_TYPES[1], list) # SCHEDULERS is a list
|
||||
assert node.RETURN_TYPES[2] == 'INT'
|
||||
assert node.RETURN_TYPES[3] == 'FLOAT'
|
||||
assert node.RETURN_NAMES == ('sampler_name', 'scheduler', 'steps', 'cfg')
|
||||
assert node.CATEGORY == '🫶 ComfyAssets/🌀 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
|
||||
|
||||
@@ -40,6 +40,7 @@ I’m sharing them here with the community, and I hope you find them as useful a
|
||||
| [📂 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
|
||||
|
||||
@@ -426,6 +427,18 @@ Real-time execution timer that displays workflow duration with millisecond preci
|
||||
- **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.**
|
||||
|
||||
+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()
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -16,6 +22,7 @@ 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
|
||||
@@ -34,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,
|
||||
@@ -60,11 +71,16 @@ NODE_CLASS_MAPPINGS = {
|
||||
"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",
|
||||
@@ -91,6 +107,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -43,7 +43,7 @@ def get_next_counter(output_dir: str, prefix: str) -> int:
|
||||
counter = 0
|
||||
if os.path.exists(counter_file):
|
||||
try:
|
||||
with open(counter_file, "r") as f:
|
||||
with open(counter_file, "r", encoding="utf-8") as f:
|
||||
content = f.read().strip()
|
||||
counter = int(content) if content else 0
|
||||
except (ValueError, IOError):
|
||||
@@ -55,7 +55,7 @@ def get_next_counter(output_dir: str, prefix: str) -> int:
|
||||
|
||||
# Save updated counter
|
||||
try:
|
||||
with open(counter_file, "w") as f:
|
||||
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
|
||||
@@ -65,6 +65,23 @@ def get_next_counter(output_dir: str, prefix: str) -> int:
|
||||
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,
|
||||
counter: int,
|
||||
@@ -87,6 +104,11 @@ def get_save_image_path(
|
||||
"""
|
||||
# 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)
|
||||
|
||||
@@ -114,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)
|
||||
|
||||
@@ -265,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
|
||||
|
||||
@@ -281,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()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"last_path": "/home/vito/ai-apps/ComfyUI/output/vids/images",
|
||||
"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/",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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):
|
||||
@@ -102,229 +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)
|
||||
|
||||
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.")
|
||||
|
||||
# Normalize path to remove trailing slashes and resolve relative paths
|
||||
directory = os.path.normpath(directory)
|
||||
selections = load_selections()
|
||||
selections.setdefault(str(node_id), {})[media_type] = {"path": path}
|
||||
save_selections(selections)
|
||||
return web.json_response({"status": "ok"})
|
||||
|
||||
# Save last path
|
||||
config = load_config()
|
||||
config["last_path"] = directory
|
||||
save_config(config)
|
||||
|
||||
show_videos = request.query.get("show_videos", "false").lower() == "true"
|
||||
show_audio = request.query.get("show_audio", "false").lower() == "true"
|
||||
hide_dot_folders = (
|
||||
request.query.get("hide_dot_folders", "true").lower() == "true"
|
||||
async def get_saved_paths(request):
|
||||
"""Return saved directory paths."""
|
||||
return web.json_response({"saved_paths": load_config().get("saved_paths", [])})
|
||||
|
||||
|
||||
async def save_paths(request):
|
||||
"""Persist the list of saved directory paths."""
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
return _error(400, "Invalid JSON.")
|
||||
paths = data.get("paths", []) if isinstance(data, dict) else None
|
||||
if not isinstance(paths, list) or not all(isinstance(p, str) for p in paths):
|
||||
return _error(400, "paths must be a list of strings.")
|
||||
too_broad = [p for p in paths if is_too_broad_root(p)]
|
||||
if too_broad:
|
||||
return _error(
|
||||
400,
|
||||
f"Folder too broad to share: {', '.join(too_broad)}. "
|
||||
"Pick a folder inside your home directory or another media folder.",
|
||||
)
|
||||
config = load_config()
|
||||
config["saved_paths"] = paths
|
||||
save_config(config)
|
||||
return web.json_response({"status": "ok"})
|
||||
|
||||
|
||||
async def get_local_images(request):
|
||||
"""List images and sub-directories of a directory inside an allowed root."""
|
||||
query = request.query
|
||||
directory = query.get("directory", "")
|
||||
if not directory or "\0" in directory:
|
||||
return _error(400, "Directory is required.")
|
||||
try:
|
||||
page, per_page = _parse_paging(query)
|
||||
except ValueError:
|
||||
return _error(400, "Invalid page or per_page.")
|
||||
|
||||
if not is_within_roots(directory, get_allowed_roots()):
|
||||
return _error(403, "Directory not allowed.")
|
||||
real = resolve_path(directory)
|
||||
if not os.path.isdir(real):
|
||||
return _error(404, "Directory not found.")
|
||||
|
||||
# Normalize path (trailing slashes etc.) but keep the user-visible spelling
|
||||
directory = os.path.normpath(directory)
|
||||
|
||||
config = load_config()
|
||||
config["last_path"] = directory
|
||||
save_config(config)
|
||||
|
||||
show_videos = query.get("show_videos", "false").lower() == "true"
|
||||
show_audio = query.get("show_audio", "false").lower() == "true"
|
||||
hide_dot_folders = query.get("hide_dot_folders", "true").lower() == "true"
|
||||
|
||||
try:
|
||||
items = scan_directory(
|
||||
directory,
|
||||
show_videos,
|
||||
show_audio,
|
||||
query.get("sort_by", "name"),
|
||||
query.get("sort_order", "asc"),
|
||||
hide_dot_folders,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Error scanning directory %s", directory)
|
||||
return _error(500, "Error reading directory.")
|
||||
|
||||
# Do not offer navigation above the allowed roots
|
||||
parent_directory = os.path.dirname(directory)
|
||||
if parent_directory == directory or not is_within_roots(
|
||||
parent_directory, get_allowed_roots()
|
||||
):
|
||||
parent_directory = None
|
||||
|
||||
start = (page - 1) * per_page
|
||||
return web.json_response(
|
||||
{
|
||||
"items": items[start : start + per_page],
|
||||
"total_pages": (len(items) + per_page - 1) // per_page,
|
||||
"current_page": page,
|
||||
"current_directory": directory,
|
||||
"parent_directory": parent_directory,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def get_last_path(request):
|
||||
"""Return the last used directory path."""
|
||||
return web.json_response({"last_path": load_config().get("last_path", "")})
|
||||
|
||||
|
||||
async def list_directories(request):
|
||||
"""List directory names for the folder-picker autocomplete.
|
||||
|
||||
Trade-off: this is how users browse to a location that is not yet a saved
|
||||
root, so it is intentionally NOT restricted to the allowed roots. It only
|
||||
ever returns directory paths (never files), capped at
|
||||
MAX_DIRECTORY_SUGGESTIONS; file contents stay protected by the root and
|
||||
extension checks on every other route.
|
||||
"""
|
||||
path = request.query.get("path", "")
|
||||
if "\0" in path:
|
||||
return web.json_response({"directories": []})
|
||||
|
||||
try:
|
||||
if not path:
|
||||
if os.name == "nt":
|
||||
import string
|
||||
|
||||
drives = [
|
||||
f"{d}:\\"
|
||||
for d in string.ascii_uppercase
|
||||
if os.path.exists(f"{d}:\\")
|
||||
]
|
||||
return web.json_response({"directories": drives})
|
||||
return web.json_response({"directories": [os.sep]})
|
||||
|
||||
dirs = list_subdirectories(os.path.expanduser(path), MAX_DIRECTORY_SUGGESTIONS)
|
||||
return web.json_response({"directories": dirs})
|
||||
except PermissionError:
|
||||
return web.json_response({"directories": [], "error": "Permission denied"})
|
||||
except Exception:
|
||||
logger.exception("Error listing directories for %s", path)
|
||||
return web.json_response(
|
||||
{"directories": [], "error": "Error listing directories"}
|
||||
)
|
||||
|
||||
page = int(request.query.get("page", 1))
|
||||
per_page = int(request.query.get("per_page", 50))
|
||||
sort_by = request.query.get("sort_by", "name")
|
||||
sort_order = request.query.get("sort_order", "asc")
|
||||
|
||||
try:
|
||||
items = scan_directory(
|
||||
directory,
|
||||
show_videos,
|
||||
show_audio,
|
||||
sort_by,
|
||||
sort_order,
|
||||
hide_dot_folders,
|
||||
)
|
||||
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
|
||||
|
||||
# Get parent directory
|
||||
parent_directory = os.path.dirname(directory)
|
||||
if parent_directory == directory:
|
||||
parent_directory = None
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
# Paginate results
|
||||
start = (page - 1) * per_page
|
||||
end = start + per_page
|
||||
paginated_items = items[start:end]
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"items": paginated_items,
|
||||
"total_pages": (len(items) + per_page - 1) // per_page,
|
||||
"current_page": page,
|
||||
"current_directory": directory,
|
||||
"parent_directory": parent_directory,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
@prompt_server.routes.get("/kiko_local_image_loader/get_last_path")
|
||||
async def get_last_path(request):
|
||||
"""API endpoint to get last used directory path."""
|
||||
return web.json_response({"last_path": load_config().get("last_path", "")})
|
||||
|
||||
@prompt_server.routes.get("/kiko_local_image_loader/list_directories")
|
||||
async def list_directories(request):
|
||||
"""API endpoint to list directories for autocomplete."""
|
||||
path = request.query.get("path", "")
|
||||
|
||||
try:
|
||||
# Handle empty path - show root or common starting points
|
||||
if not path:
|
||||
# Return filesystem root
|
||||
if os.name == "nt": # Windows
|
||||
import string
|
||||
|
||||
drives = [
|
||||
f"{d}:\\"
|
||||
for d in string.ascii_uppercase
|
||||
if os.path.exists(f"{d}:\\")
|
||||
]
|
||||
return web.json_response({"directories": drives})
|
||||
else: # Unix/Linux/Mac
|
||||
return web.json_response({"directories": ["/"]})
|
||||
|
||||
# Normalize the path
|
||||
path = os.path.expanduser(path) # Handle ~ for home directory
|
||||
|
||||
# If path ends with separator, list contents of that directory
|
||||
if path.endswith(os.sep) or (os.name == "nt" and path.endswith("/")):
|
||||
if os.path.isdir(path):
|
||||
try:
|
||||
entries = os.listdir(path)
|
||||
dirs = []
|
||||
for entry in entries:
|
||||
full_path = os.path.join(path, entry)
|
||||
if os.path.isdir(full_path):
|
||||
dirs.append(full_path)
|
||||
dirs.sort(key=lambda x: x.lower())
|
||||
return web.json_response(
|
||||
{"directories": dirs[:50]}
|
||||
) # Limit results
|
||||
except PermissionError:
|
||||
return web.json_response(
|
||||
{"directories": [], "error": "Permission denied"}
|
||||
)
|
||||
else:
|
||||
return web.json_response({"directories": []})
|
||||
|
||||
# Otherwise, find matching directories in parent
|
||||
parent_dir = os.path.dirname(path)
|
||||
basename = os.path.basename(path).lower()
|
||||
|
||||
if not parent_dir:
|
||||
# Handle root level on Unix
|
||||
if path.startswith("/"):
|
||||
parent_dir = "/"
|
||||
basename = path[1:].lower()
|
||||
else:
|
||||
return web.json_response({"directories": []})
|
||||
|
||||
if os.path.isdir(parent_dir):
|
||||
try:
|
||||
entries = os.listdir(parent_dir)
|
||||
dirs = []
|
||||
for entry in entries:
|
||||
full_path = os.path.join(parent_dir, entry)
|
||||
if os.path.isdir(full_path) and entry.lower().startswith(
|
||||
basename
|
||||
):
|
||||
dirs.append(full_path)
|
||||
dirs.sort(key=lambda x: x.lower())
|
||||
return web.json_response(
|
||||
{"directories": dirs[:50]}
|
||||
) # Limit results
|
||||
except PermissionError:
|
||||
return web.json_response(
|
||||
{"directories": [], "error": "Permission denied"}
|
||||
)
|
||||
|
||||
return web.json_response({"directories": []})
|
||||
except Exception as e:
|
||||
return web.json_response({"directories": [], "error": str(e)})
|
||||
|
||||
@prompt_server.routes.get("/kiko_local_image_loader/thumbnail")
|
||||
async def get_thumbnail(request):
|
||||
"""API endpoint to get image thumbnail."""
|
||||
filepath = request.query.get("filepath")
|
||||
if not filepath or ".." in filepath:
|
||||
return web.Response(status=400)
|
||||
|
||||
filepath = urllib.parse.unquote(filepath)
|
||||
if not os.path.exists(filepath):
|
||||
return web.Response(status=404)
|
||||
|
||||
try:
|
||||
img = Image.open(filepath)
|
||||
with Image.open(real) as img:
|
||||
has_alpha = img.mode == "RGBA" or (
|
||||
img.mode == "P" and "transparency" in img.info
|
||||
)
|
||||
@@ -332,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
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
},
|
||||
"18": {
|
||||
"image": {
|
||||
"path": "/home/vito/ai-apps/ComfyUI/output/ComfyUI_00002_.png"
|
||||
"path": "/home/vito/ai-apps/ComfyUI/output/vids/KikoSave_00005.png"
|
||||
}
|
||||
},
|
||||
"445": {
|
||||
@@ -66,12 +66,22 @@
|
||||
},
|
||||
"517": {
|
||||
"image": {
|
||||
"path": "/home/vito/ai-apps/ComfyUI/output/vids/images/kittybear_00004.png"
|
||||
"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,9 +2,44 @@
|
||||
|
||||
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
|
||||
@@ -14,16 +49,132 @@ 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:
|
||||
@@ -90,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,48 +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,
|
||||
)
|
||||
|
||||
try:
|
||||
import comfy.model_management
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
COMFY_AVAILABLE = True
|
||||
InterruptProcessingException = comfy.model_management.InterruptProcessingException
|
||||
except ImportError:
|
||||
COMFY_AVAILABLE = False
|
||||
# Fallback exception type that will never be raised
|
||||
InterruptProcessingException = type(
|
||||
"InterruptProcessingException", (Exception,), {}
|
||||
)
|
||||
|
||||
|
||||
CHUNK_SIZE = 1638400
|
||||
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||
API_BASE = "https://civitai.com/api/v1"
|
||||
MAX_RETRIES = 3
|
||||
RETRY_DELAY = 5
|
||||
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:
|
||||
@@ -117,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:
|
||||
@@ -145,8 +134,10 @@ 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)
|
||||
@@ -181,161 +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")
|
||||
elif response.status != 200:
|
||||
raise Exception(f"Download failed with status {response.status}")
|
||||
|
||||
# Use provided filename or extracted filename
|
||||
if not filename:
|
||||
filename = self.extract_filename(url, default="model.safetensors")
|
||||
|
||||
output_file = os.path.join(output_path, filename)
|
||||
|
||||
# Check if should download
|
||||
if not self.should_download(output_file, force):
|
||||
print(f"File already exists: {output_file}")
|
||||
return output_file
|
||||
|
||||
total_size = response.getheader("Content-Length")
|
||||
if total_size is not None:
|
||||
total_size = int(total_size)
|
||||
|
||||
print(f"Downloading: {filename}")
|
||||
print(f"Destination: {output_file}")
|
||||
if total_size:
|
||||
print(f"Size: {self.format_size(total_size)}")
|
||||
|
||||
# Download with progress
|
||||
try:
|
||||
with open(output_file, "wb") as f:
|
||||
downloaded = 0
|
||||
start_time = time.time()
|
||||
|
||||
while True:
|
||||
chunk_start_time = time.time()
|
||||
buffer = response.read(CHUNK_SIZE)
|
||||
chunk_end_time = time.time()
|
||||
|
||||
if not buffer:
|
||||
break
|
||||
|
||||
downloaded += len(buffer)
|
||||
f.write(buffer)
|
||||
chunk_time = chunk_end_time - chunk_start_time
|
||||
|
||||
# Check for user cancellation
|
||||
self.check_interrupt()
|
||||
|
||||
# Calculate speed
|
||||
speed = self.calculate_speed(len(buffer), chunk_time)
|
||||
|
||||
# Report progress
|
||||
if total_size is not None:
|
||||
progress = downloaded / total_size
|
||||
sys.stdout.write(
|
||||
f'\r[{"=" * int(progress * 50):<50}] {progress * 100:.2f}% - {speed:.2f} MB/s'
|
||||
)
|
||||
sys.stdout.flush()
|
||||
self.report_progress(
|
||||
downloaded, total_size, f"{speed:.2f} MB/s"
|
||||
)
|
||||
else:
|
||||
sys.stdout.write(
|
||||
f"\rDownloaded: {self.format_size(downloaded)} - {speed:.2f} MB/s"
|
||||
)
|
||||
sys.stdout.flush()
|
||||
self.report_progress(downloaded, 0, f"{speed:.2f} MB/s")
|
||||
|
||||
end_time = time.time()
|
||||
time_taken = end_time - start_time
|
||||
hours, remainder = divmod(time_taken, 3600)
|
||||
minutes, seconds = divmod(remainder, 60)
|
||||
|
||||
if hours > 0:
|
||||
time_str = f"{int(hours)}h {int(minutes)}m {int(seconds)}s"
|
||||
elif minutes > 0:
|
||||
time_str = f"{int(minutes)}m {int(seconds)}s"
|
||||
else:
|
||||
time_str = f"{int(seconds)}s"
|
||||
|
||||
sys.stdout.write("\n")
|
||||
print(f"✓ Download completed in {time_str}")
|
||||
print(f"✓ File saved as: {output_file}")
|
||||
|
||||
# Verify file size
|
||||
actual_size = os.path.getsize(output_file)
|
||||
if total_size and actual_size != total_size:
|
||||
raise Exception(
|
||||
f"Download incomplete. Expected {total_size} bytes, got {actual_size} bytes"
|
||||
)
|
||||
except urllib.error.HTTPError as e:
|
||||
self.raise_for_http_status(e.code, e.reason, HTTP_ERROR_HINTS)
|
||||
except urllib.error.URLError as e:
|
||||
raise Exception(f"Network error: {e.reason}")
|
||||
elif response.status != 200:
|
||||
code = response.status
|
||||
response.close()
|
||||
self.raise_for_http_status(
|
||||
code, "Download failed", {**HTTP_ERROR_HINTS, 404: "File not found"}
|
||||
)
|
||||
|
||||
if not filename:
|
||||
filename = safe_server_filename(
|
||||
self.extract_filename(url, default="model.safetensors"),
|
||||
"model.safetensors",
|
||||
)
|
||||
|
||||
output_file = os.path.join(output_dir, filename)
|
||||
if not self.should_download(output_file, force):
|
||||
response.close()
|
||||
logger.info("File already exists: %s", output_file)
|
||||
return output_file
|
||||
except InterruptProcessingException:
|
||||
# Clean up partial download on interrupt
|
||||
if os.path.exists(output_file):
|
||||
os.remove(output_file)
|
||||
raise InterruptProcessingException("Download interrupted")
|
||||
|
||||
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,41 +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,
|
||||
)
|
||||
|
||||
try:
|
||||
import comfy.model_management
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
COMFY_AVAILABLE = True
|
||||
InterruptProcessingException = comfy.model_management.InterruptProcessingException
|
||||
except ImportError:
|
||||
COMFY_AVAILABLE = False
|
||||
# Fallback exception type that will never be raised
|
||||
InterruptProcessingException = type(
|
||||
"InterruptProcessingException", (Exception,), {}
|
||||
)
|
||||
DEFAULT_FILENAME = "downloaded_model.safetensors"
|
||||
|
||||
|
||||
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 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,
|
||||
@@ -47,158 +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)
|
||||
if not filename:
|
||||
header_name = self.extract_filename_from_header(
|
||||
response.getheader("Content-Disposition") or ""
|
||||
)
|
||||
filename = safe_server_filename(
|
||||
header_name, self.extract_filename(url, default=DEFAULT_FILENAME)
|
||||
)
|
||||
output_file = os.path.join(output_dir, filename)
|
||||
if not self.should_download(output_file, force):
|
||||
response.close()
|
||||
logger.info("File already exists: %s", output_file)
|
||||
return output_file
|
||||
|
||||
print(f"Downloading: {filename}")
|
||||
print(f"Destination: {output_file}")
|
||||
if total_size:
|
||||
print(f"Size: {self.format_size(total_size)}")
|
||||
else:
|
||||
print("Size: Unknown")
|
||||
total_size = self.parse_content_length(response)
|
||||
logger.info("Downloading %s to %s", filename, output_file)
|
||||
|
||||
# Download with progress
|
||||
try:
|
||||
with open(output_file, "wb") as f:
|
||||
downloaded = 0
|
||||
start_time = time.time()
|
||||
|
||||
while True:
|
||||
chunk_start_time = time.time()
|
||||
buffer = response.read(CHUNK_SIZE)
|
||||
chunk_end_time = time.time()
|
||||
|
||||
if not buffer:
|
||||
break
|
||||
|
||||
downloaded += len(buffer)
|
||||
f.write(buffer)
|
||||
chunk_time = chunk_end_time - chunk_start_time
|
||||
|
||||
# Check for user cancellation
|
||||
self.check_interrupt()
|
||||
|
||||
# Calculate speed
|
||||
speed = self.calculate_speed(len(buffer), chunk_time)
|
||||
|
||||
# Report progress
|
||||
if total_size is not None:
|
||||
progress = downloaded / total_size
|
||||
sys.stdout.write(
|
||||
f'\r[{"=" * int(progress * 50):<50}] {progress * 100:.2f}% - {speed:.2f} MB/s'
|
||||
)
|
||||
sys.stdout.flush()
|
||||
self.report_progress(
|
||||
downloaded, total_size, f"{speed:.2f} MB/s"
|
||||
)
|
||||
else:
|
||||
sys.stdout.write(
|
||||
f"\rDownloaded: {self.format_size(downloaded)} - {speed:.2f} MB/s"
|
||||
)
|
||||
sys.stdout.flush()
|
||||
self.report_progress(downloaded, 0, f"{speed:.2f} MB/s")
|
||||
|
||||
end_time = time.time()
|
||||
time_taken = end_time - start_time
|
||||
hours, remainder = divmod(time_taken, 3600)
|
||||
minutes, seconds = divmod(remainder, 60)
|
||||
|
||||
if hours > 0:
|
||||
time_str = f"{int(hours)}h {int(minutes)}m {int(seconds)}s"
|
||||
elif minutes > 0:
|
||||
time_str = f"{int(minutes)}m {int(seconds)}s"
|
||||
else:
|
||||
time_str = f"{int(seconds)}s"
|
||||
|
||||
sys.stdout.write("\n")
|
||||
print(f"✓ Download completed in {time_str}")
|
||||
print(f"✓ File saved as: {output_file}")
|
||||
|
||||
# Verify file size if known
|
||||
actual_size = os.path.getsize(output_file)
|
||||
if total_size and actual_size != total_size:
|
||||
print(
|
||||
f"⚠ Warning: Downloaded size ({actual_size} bytes) doesn't match expected size ({total_size} bytes)"
|
||||
)
|
||||
# Don't raise error for custom URLs as size mismatch might be acceptable
|
||||
|
||||
return output_file
|
||||
except InterruptProcessingException:
|
||||
# Clean up partial download on interrupt
|
||||
if os.path.exists(output_file):
|
||||
os.remove(output_file)
|
||||
raise
|
||||
# Size mismatch is only a warning for arbitrary URLs
|
||||
return self.save_response(response, output_file, total_size, strict_size=False)
|
||||
|
||||
@@ -1,43 +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,
|
||||
)
|
||||
|
||||
try:
|
||||
import comfy.model_management
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
COMFY_AVAILABLE = True
|
||||
InterruptProcessingException = comfy.model_management.InterruptProcessingException
|
||||
except ImportError:
|
||||
COMFY_AVAILABLE = False
|
||||
# Fallback exception type that will never be raised
|
||||
InterruptProcessingException = type(
|
||||
"InterruptProcessingException", (Exception,), {}
|
||||
)
|
||||
|
||||
|
||||
CHUNK_SIZE = 1638400
|
||||
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||
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
|
||||
|
||||
@@ -115,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
|
||||
@@ -137,135 +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
|
||||
try:
|
||||
with open(output_file, "wb") as f:
|
||||
downloaded = 0
|
||||
start_time = time.time()
|
||||
|
||||
while True:
|
||||
chunk_start_time = time.time()
|
||||
buffer = response.read(CHUNK_SIZE)
|
||||
chunk_end_time = time.time()
|
||||
|
||||
if not buffer:
|
||||
break
|
||||
|
||||
downloaded += len(buffer)
|
||||
f.write(buffer)
|
||||
chunk_time = chunk_end_time - chunk_start_time
|
||||
|
||||
# Check for user cancellation
|
||||
self.check_interrupt()
|
||||
|
||||
# Calculate speed
|
||||
speed = self.calculate_speed(len(buffer), chunk_time)
|
||||
|
||||
# Report progress
|
||||
if total_size is not None:
|
||||
progress = downloaded / total_size
|
||||
sys.stdout.write(
|
||||
f'\r[{"=" * int(progress * 50):<50}] {progress * 100:.2f}% - {speed:.2f} MB/s'
|
||||
)
|
||||
sys.stdout.flush()
|
||||
self.report_progress(
|
||||
downloaded, total_size, f"{speed:.2f} MB/s"
|
||||
)
|
||||
else:
|
||||
sys.stdout.write(
|
||||
f"\rDownloaded: {self.format_size(downloaded)} - {speed:.2f} MB/s"
|
||||
)
|
||||
sys.stdout.flush()
|
||||
self.report_progress(downloaded, 0, f"{speed:.2f} MB/s")
|
||||
|
||||
end_time = time.time()
|
||||
time_taken = end_time - start_time
|
||||
hours, remainder = divmod(time_taken, 3600)
|
||||
minutes, seconds = divmod(remainder, 60)
|
||||
|
||||
if hours > 0:
|
||||
time_str = f"{int(hours)}h {int(minutes)}m {int(seconds)}s"
|
||||
elif minutes > 0:
|
||||
time_str = f"{int(minutes)}m {int(seconds)}s"
|
||||
else:
|
||||
time_str = f"{int(seconds)}s"
|
||||
|
||||
sys.stdout.write("\n")
|
||||
print(f"✓ Download completed in {time_str}")
|
||||
print(f"✓ File saved as: {output_file}")
|
||||
|
||||
# Verify file size
|
||||
actual_size = os.path.getsize(output_file)
|
||||
if total_size and actual_size != total_size:
|
||||
raise Exception(
|
||||
f"Download incomplete. Expected {total_size} bytes, got {actual_size} bytes"
|
||||
)
|
||||
|
||||
return output_file
|
||||
except InterruptProcessingException:
|
||||
# Clean up partial download on interrupt
|
||||
if os.path.exists(output_file):
|
||||
os.remove(output_file)
|
||||
raise
|
||||
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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,26 +27,10 @@ class SeedHistoryNode(ComfyAssetsBaseNode):
|
||||
{
|
||||
"default": 12345,
|
||||
"min": 0,
|
||||
"max": 0xFFFFFFFFFFFFFFFF,
|
||||
"max": 0xFFFFFFFF, # 2**32 - 1
|
||||
"control_after_generate": True,
|
||||
"tooltip": "Seed value for generation processes. "
|
||||
"Auto-increments/decrements after each run based on mode.",
|
||||
},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"mode": (
|
||||
[
|
||||
"",
|
||||
"fixed",
|
||||
"increment",
|
||||
"decrement",
|
||||
"randomize",
|
||||
], # Added empty string for legacy workflows
|
||||
{
|
||||
"default": "fixed",
|
||||
"tooltip": "Seed behavior after generation: "
|
||||
"fixed (no change), increment (+1), decrement (-1), or randomize (new random)",
|
||||
"Use 'control after generate' to set behavior after each run.",
|
||||
},
|
||||
),
|
||||
},
|
||||
@@ -57,51 +41,20 @@ class SeedHistoryNode(ComfyAssetsBaseNode):
|
||||
FUNCTION = "output_seed"
|
||||
CATEGORY = "🫶 ComfyAssets/🌱 Seeds"
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(cls, seed, mode="fixed"):
|
||||
"""Validate inputs and handle legacy workflows."""
|
||||
# Handle empty or missing mode from old workflows (legacy support)
|
||||
if mode is None or mode == "" or mode == "undefined":
|
||||
return True # Will use default "fixed" in output_seed
|
||||
|
||||
# Validate mode is in allowed list
|
||||
valid_modes = ["fixed", "increment", "decrement", "randomize"]
|
||||
if mode not in valid_modes:
|
||||
return f"Invalid mode: {mode}. Must be one of {valid_modes}"
|
||||
|
||||
return True
|
||||
|
||||
def output_seed(self, seed: int, mode: str = "fixed") -> 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
|
||||
mode: Seed mode (fixed, increment, decrement, randomize) - not used in output,
|
||||
but controls the widget behavior via control_after_generate
|
||||
**kwargs: Accepts legacy parameters (e.g. mode) for backward compatibility
|
||||
|
||||
Returns:
|
||||
Tuple containing the seed value
|
||||
"""
|
||||
try:
|
||||
# Handle empty mode from old workflows
|
||||
if not mode or mode == "":
|
||||
mode = "fixed"
|
||||
|
||||
# Validate mode is in allowed list
|
||||
valid_modes = ["fixed", "increment", "decrement", "randomize"]
|
||||
if mode not in valid_modes:
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(
|
||||
f"{self.__class__.__name__}: Invalid mode '{mode}'. Using 'fixed'."
|
||||
)
|
||||
mode = "fixed"
|
||||
|
||||
# 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__)
|
||||
@@ -112,15 +65,9 @@ class SeedHistoryNode(ComfyAssetsBaseNode):
|
||||
return (12345,)
|
||||
|
||||
clean_seed = sanitize_seed_value(seed)
|
||||
|
||||
# Note: The mode parameter controls the widget's control_after_generate behavior
|
||||
# The actual increment/decrement/randomize happens automatically in the UI
|
||||
# based on the control_after_generate setting and the mode dropdown value
|
||||
|
||||
return (clean_seed,)
|
||||
|
||||
except Exception as e:
|
||||
# Handle any unexpected errors gracefully
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -194,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
|
||||
@@ -218,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."""
|
||||
@@ -230,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")"
|
||||
)
|
||||
|
||||
@@ -618,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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
+2
-10
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -204,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):
|
||||
@@ -236,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):
|
||||
@@ -338,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):
|
||||
@@ -361,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):
|
||||
@@ -588,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):
|
||||
@@ -628,8 +628,8 @@ 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):
|
||||
@@ -674,3 +674,55 @@ class TestIntegration:
|
||||
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))
|
||||
|
||||
@@ -46,6 +46,7 @@ class TestLocalImageLoaderLogic:
|
||||
def test_load_image_from_path_rgb(self):
|
||||
"""Test loading an RGB image from file."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
||||
tmp.close() # Windows can't reopen/delete an open file
|
||||
# Create a test image
|
||||
img = Image.new("RGB", (100, 100), color="red")
|
||||
img.save(tmp.name)
|
||||
@@ -71,6 +72,7 @@ class TestLocalImageLoaderLogic:
|
||||
def test_load_image_from_path_rgba(self):
|
||||
"""Test loading an RGBA image from file."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
||||
tmp.close() # Windows can't reopen/delete an open file
|
||||
# Create a test image with alpha
|
||||
img = Image.new("RGBA", (50, 50), color=(255, 0, 0, 128))
|
||||
img.save(tmp.name)
|
||||
@@ -93,6 +95,7 @@ class TestLocalImageLoaderLogic:
|
||||
def test_load_image_from_path_with_metadata(self):
|
||||
"""Test loading an image with embedded metadata."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
||||
tmp.close() # Windows can't reopen/delete an open file
|
||||
# Create image with metadata
|
||||
img = Image.new("RGB", (100, 100), color="blue")
|
||||
|
||||
@@ -198,18 +201,8 @@ class TestLocalImageLoaderNode:
|
||||
|
||||
def test_node_properties(self):
|
||||
"""Test node properties."""
|
||||
assert LocalImageLoaderNode.RETURN_TYPES == (
|
||||
"IMAGE",
|
||||
"STRING",
|
||||
"STRING",
|
||||
"STRING",
|
||||
)
|
||||
assert LocalImageLoaderNode.RETURN_NAMES == (
|
||||
"image",
|
||||
"video_path",
|
||||
"audio_path",
|
||||
"info",
|
||||
)
|
||||
assert LocalImageLoaderNode.RETURN_TYPES == ("IMAGE", "STRING")
|
||||
assert LocalImageLoaderNode.RETURN_NAMES == ("image", "info")
|
||||
assert LocalImageLoaderNode.FUNCTION == "load_media"
|
||||
assert LocalImageLoaderNode.CATEGORY == "🫶 ComfyAssets/💾 Images"
|
||||
|
||||
@@ -219,14 +212,12 @@ class TestLocalImageLoaderNode:
|
||||
mock_load_selections.return_value = {}
|
||||
|
||||
node = LocalImageLoaderNode()
|
||||
image, video_path, audio_path, info = node.load_media("test_id")
|
||||
image, info = node.load_media("test_id")
|
||||
|
||||
# Check empty returns
|
||||
assert isinstance(image, torch.Tensor)
|
||||
assert image.shape == (1, 1, 1, 4)
|
||||
assert torch.all(image == 0)
|
||||
assert video_path == ""
|
||||
assert audio_path == ""
|
||||
assert info == ""
|
||||
|
||||
@patch("kikotools.tools.local_image_loader.node.load_selections")
|
||||
@@ -244,20 +235,24 @@ class TestLocalImageLoaderNode:
|
||||
test_metadata = {"width": 100, "height": 100, "filename": "image.jpg"}
|
||||
mock_load_image.return_value = (test_tensor, test_metadata)
|
||||
|
||||
# Mock os.path.exists
|
||||
with patch("os.path.exists", return_value=True):
|
||||
# Mock os.path.exists and the allowed roots
|
||||
with (
|
||||
patch("os.path.exists", return_value=True),
|
||||
patch(
|
||||
"kikotools.tools.local_image_loader.node.get_allowed_roots",
|
||||
return_value=["/path/to"],
|
||||
),
|
||||
):
|
||||
node = LocalImageLoaderNode()
|
||||
image, video_path, audio_path, info = node.load_media("test_id")
|
||||
image, info = node.load_media("test_id")
|
||||
|
||||
# Check returns
|
||||
assert torch.equal(image, test_tensor)
|
||||
assert video_path == ""
|
||||
assert audio_path == ""
|
||||
assert json.loads(info) == test_metadata
|
||||
|
||||
@patch("kikotools.tools.local_image_loader.node.load_selections")
|
||||
def test_load_media_with_video_audio_selection(self, mock_load_selections):
|
||||
"""Test loading media with video and audio selection."""
|
||||
def test_load_media_ignores_video_audio_selection(self, mock_load_selections):
|
||||
"""Video/audio selections are not outputs of this node any more."""
|
||||
mock_load_selections.return_value = {
|
||||
"test_id": {
|
||||
"video": {"path": "/path/to/video.mp4"},
|
||||
@@ -265,15 +260,9 @@ class TestLocalImageLoaderNode:
|
||||
}
|
||||
}
|
||||
|
||||
with patch("os.path.exists", return_value=True):
|
||||
node = LocalImageLoaderNode()
|
||||
image, video_path, audio_path, info = node.load_media("test_id")
|
||||
image, info = LocalImageLoaderNode().load_media("test_id")
|
||||
|
||||
# Check returns
|
||||
assert isinstance(image, torch.Tensor)
|
||||
assert image.shape == (1, 1, 1, 4) # Empty tensor
|
||||
assert video_path == "/path/to/video.mp4"
|
||||
assert audio_path == "/path/to/audio.mp3"
|
||||
assert image.shape == (1, 1, 1, 4)
|
||||
assert info == ""
|
||||
|
||||
def test_is_changed(self):
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Tests for the Project File Path node."""
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from kikotools.tools.project_file_path.logic import (
|
||||
build_project_path,
|
||||
sanitize_component,
|
||||
sanitize_filename,
|
||||
)
|
||||
from kikotools.tools.project_file_path.node import ProjectFilePathNode
|
||||
|
||||
TODAY = date(2026, 10, 13)
|
||||
|
||||
|
||||
class TestSanitizeComponent:
|
||||
@pytest.mark.parametrize(
|
||||
"raw, expected",
|
||||
[
|
||||
("MyProject", "MyProject"),
|
||||
("my project", "my_project"),
|
||||
('a<b>c:d"e|f?g*h', "abcdefgh"),
|
||||
("tab\there\x00", "tabhere"),
|
||||
(" .hidden. ", "hidden"),
|
||||
("", ""),
|
||||
],
|
||||
)
|
||||
def test_cleans_single_part(self, raw, expected):
|
||||
assert sanitize_component(raw) == expected
|
||||
|
||||
def test_keeps_nested_folders(self):
|
||||
assert sanitize_component("renders/final") == "renders/final"
|
||||
assert sanitize_component("renders\\final") == "renders/final"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw", ["../../etc", "a/../../b", "..", "./.", "/abs/path", "C:\\x"]
|
||||
)
|
||||
def test_never_climbs_or_is_absolute(self, raw):
|
||||
parts = sanitize_component(raw).split("/")
|
||||
assert ".." not in parts
|
||||
assert not sanitize_component(raw).startswith("/")
|
||||
assert ":" not in sanitize_component(raw)
|
||||
|
||||
def test_traversal_parts_are_dropped(self):
|
||||
assert sanitize_component("a/../../b") == "a/b"
|
||||
|
||||
|
||||
class TestSanitizeFilename:
|
||||
@pytest.mark.parametrize(
|
||||
"raw, expected",
|
||||
[
|
||||
("image", "image"),
|
||||
("image.png", "image"),
|
||||
("clip.MP4", "clip"),
|
||||
("render_v1.5", "render_v1.5"), # not a file extension
|
||||
("my file", "my_file"),
|
||||
("../../evil", "evil"),
|
||||
("a/b\\c", "abc"),
|
||||
],
|
||||
)
|
||||
def test_cleans(self, raw, expected):
|
||||
assert sanitize_filename(raw) == expected
|
||||
|
||||
|
||||
class TestBuildProjectPath:
|
||||
def test_basic(self):
|
||||
assert (
|
||||
build_project_path(
|
||||
"", "MyProject", "images", "image", separator="forward_slash"
|
||||
)
|
||||
== "MyProject/images/image"
|
||||
)
|
||||
|
||||
def test_root_prefix(self):
|
||||
assert (
|
||||
build_project_path("work", "P", "s", "f", separator="forward_slash")
|
||||
== "work/P/s/f"
|
||||
)
|
||||
|
||||
def test_empty_parts_skipped(self):
|
||||
assert build_project_path("", "P", "", "f", separator="forward_slash") == "P/f"
|
||||
|
||||
def test_date_folder_between_project_and_subfolder(self):
|
||||
assert (
|
||||
build_project_path(
|
||||
"",
|
||||
"P",
|
||||
"images",
|
||||
"f",
|
||||
date_folder=True,
|
||||
separator="forward_slash",
|
||||
today=TODAY,
|
||||
)
|
||||
== "P/2026-10-13/images/f"
|
||||
)
|
||||
|
||||
def test_backslash_separator_applied(self):
|
||||
assert (
|
||||
build_project_path("", "P", "a/b", "f", separator="backslash")
|
||||
== "P\\a\\b\\f"
|
||||
)
|
||||
|
||||
def test_auto_uses_os_separator(self, monkeypatch):
|
||||
monkeypatch.setattr("os.sep", "\\")
|
||||
assert build_project_path("", "P", "s", "f", separator="auto") == "P\\s\\f"
|
||||
monkeypatch.setattr("os.sep", "/")
|
||||
assert build_project_path("", "P", "s", "f", separator="auto") == "P/s/f"
|
||||
|
||||
def test_traversal_cannot_escape(self):
|
||||
path = build_project_path(
|
||||
"../..", "../x", "../../y", "../z", separator="forward_slash"
|
||||
)
|
||||
assert path == "x/y/z"
|
||||
|
||||
@pytest.mark.parametrize("filename", ["", " ", ".png", "///"])
|
||||
def test_empty_filename_raises(self, filename):
|
||||
with pytest.raises(ValueError, match="filename"):
|
||||
build_project_path("", "P", "s", filename)
|
||||
|
||||
def test_unknown_separator_raises(self):
|
||||
with pytest.raises(ValueError, match="separator"):
|
||||
build_project_path("", "P", "s", "f", separator="pipe")
|
||||
|
||||
|
||||
class TestNode:
|
||||
def test_input_types_match_djz_plus_date(self):
|
||||
inputs = ProjectFilePathNode.INPUT_TYPES()
|
||||
required = inputs["required"]
|
||||
assert list(required) == ["root", "project_name", "subfolder", "filename"]
|
||||
assert required["root"][1]["default"] == ""
|
||||
assert inputs["optional"]["date_folder"][1]["default"] is False
|
||||
assert inputs["optional"]["separator"][0] == [
|
||||
"auto",
|
||||
"forward_slash",
|
||||
"backslash",
|
||||
]
|
||||
|
||||
def test_metadata(self):
|
||||
assert ProjectFilePathNode.RETURN_TYPES == ("STRING",)
|
||||
assert ProjectFilePathNode.RETURN_NAMES == ("path",)
|
||||
assert ProjectFilePathNode.CATEGORY == "🫶 ComfyAssets/🛠️ Utils"
|
||||
|
||||
def test_generate_path(self):
|
||||
result = ProjectFilePathNode().generate_path(
|
||||
"", "My Project", "images", "image.png", separator="forward_slash"
|
||||
)
|
||||
assert result == ("My_Project/images/image",)
|
||||
|
||||
def test_generate_path_error_is_value_error(self):
|
||||
with pytest.raises(ValueError):
|
||||
ProjectFilePathNode().generate_path("", "P", "s", "")
|
||||
|
||||
def test_is_changed_stable_without_date_folder(self):
|
||||
# Same inputs -> same value, so ComfyUI can cache the node
|
||||
a = ProjectFilePathNode.IS_CHANGED(date_folder=False)
|
||||
b = ProjectFilePathNode.IS_CHANGED(date_folder=False)
|
||||
assert a == b
|
||||
|
||||
def test_is_changed_tracks_date(self, monkeypatch):
|
||||
from kikotools.tools.project_file_path import node as node_mod
|
||||
|
||||
monkeypatch.setattr(node_mod, "_today", lambda: TODAY)
|
||||
first = ProjectFilePathNode.IS_CHANGED(date_folder=True)
|
||||
monkeypatch.setattr(node_mod, "_today", lambda: date(2026, 10, 14))
|
||||
assert ProjectFilePathNode.IS_CHANGED(date_folder=True) != first
|
||||
|
||||
|
||||
def test_registered():
|
||||
from kikotools import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
||||
|
||||
assert NODE_CLASS_MAPPINGS["KikoProjectFilePath"] is ProjectFilePathNode
|
||||
assert NODE_DISPLAY_NAME_MAPPINGS["KikoProjectFilePath"] == "Project File Path"
|
||||
@@ -334,3 +334,21 @@ class TestIntegrationScenarios:
|
||||
assert abs(height - expected_height) <= 8
|
||||
assert width % 8 == 0
|
||||
assert height % 8 == 0
|
||||
|
||||
|
||||
class TestMinimumDimensionClamp:
|
||||
def test_tiny_image_never_rounds_to_zero(self):
|
||||
from kikotools.tools.resolution_calculator.logic import (
|
||||
calculate_scaled_dimensions,
|
||||
)
|
||||
|
||||
assert calculate_scaled_dimensions(4, 4, 0.1) == (8, 8)
|
||||
|
||||
def test_end_to_end_tiny_input(self):
|
||||
from kikotools.tools.resolution_calculator.logic import (
|
||||
calculate_resolution_from_input,
|
||||
)
|
||||
|
||||
image = torch.zeros(1, 8, 16, 3)
|
||||
w, h = calculate_resolution_from_input(0.1, image=image)
|
||||
assert w >= 8 and h >= 8
|
||||
|
||||
@@ -43,7 +43,7 @@ class TestSeedHistoryNode:
|
||||
assert "min" in seed_config[1]
|
||||
assert "max" in seed_config[1]
|
||||
assert seed_config[1]["min"] == 0
|
||||
assert seed_config[1]["max"] == 0xFFFFFFFFFFFFFFFF
|
||||
assert seed_config[1]["max"] == 0xFFFFFFFF # 2**32 - 1
|
||||
|
||||
# Test return types
|
||||
assert SeedHistoryNode.RETURN_TYPES == ("INT",)
|
||||
@@ -56,7 +56,7 @@ class TestSeedHistoryNode:
|
||||
node = SeedHistoryNode()
|
||||
|
||||
# Test various valid seeds
|
||||
test_seeds = [0, 12345, 999999, 0xFFFFFFFFFFFFFFFF]
|
||||
test_seeds = [0, 12345, 999999, 0xFFFFFFFF] # 2**32 - 1
|
||||
|
||||
for seed in test_seeds:
|
||||
result = node.output_seed(seed)
|
||||
@@ -73,7 +73,7 @@ class TestSeedHistoryNode:
|
||||
assert result == (12345,) # Fallback
|
||||
|
||||
# Test seeds too large
|
||||
result = node.output_seed(0xFFFFFFFFFFFFFFFF + 1)
|
||||
result = node.output_seed(0xFFFFFFFF + 1) # 2**32
|
||||
assert result == (12345,) # Fallback
|
||||
|
||||
def test_generate_new_seed(self):
|
||||
@@ -100,11 +100,11 @@ class TestSeedHistoryNode:
|
||||
# Valid seeds
|
||||
assert node.validate_seed_input(0)
|
||||
assert node.validate_seed_input(12345)
|
||||
assert node.validate_seed_input(0xFFFFFFFFFFFFFFFF)
|
||||
assert node.validate_seed_input(0xFFFFFFFF) # 2**32 - 1
|
||||
|
||||
# Invalid seeds
|
||||
assert not node.validate_seed_input(-1)
|
||||
assert not node.validate_seed_input(0xFFFFFFFFFFFFFFFF + 1)
|
||||
assert not node.validate_seed_input(0xFFFFFFFF + 1) # 2**32
|
||||
assert not node.validate_seed_input(None)
|
||||
|
||||
def test_get_seed_info(self):
|
||||
@@ -132,7 +132,7 @@ class TestSeedHistoryNode:
|
||||
range_info = node.get_seed_range_info()
|
||||
assert "Valid range" in range_info
|
||||
# Check for the hex representation which should be in the string
|
||||
assert "0xffffffffffffffff" in range_info.lower()
|
||||
assert "0xffffffff" in range_info.lower() # 2**32 - 1
|
||||
|
||||
def test_class_methods(self):
|
||||
"""Test class methods."""
|
||||
@@ -143,9 +143,9 @@ class TestSeedHistoryNode:
|
||||
# Test range checking
|
||||
assert SeedHistoryNode.is_seed_in_range(0)
|
||||
assert SeedHistoryNode.is_seed_in_range(12345)
|
||||
assert SeedHistoryNode.is_seed_in_range(0xFFFFFFFFFFFFFFFF)
|
||||
assert SeedHistoryNode.is_seed_in_range(0xFFFFFFFF) # 2**32 - 1
|
||||
assert not SeedHistoryNode.is_seed_in_range(-1)
|
||||
assert not SeedHistoryNode.is_seed_in_range(0xFFFFFFFFFFFFFFFF + 1)
|
||||
assert not SeedHistoryNode.is_seed_in_range(0xFFFFFFFF + 1) # 2**32
|
||||
|
||||
|
||||
class TestSeedHistoryLogic:
|
||||
@@ -168,11 +168,11 @@ class TestSeedHistoryLogic:
|
||||
# Valid seeds
|
||||
assert validate_seed_value(0)
|
||||
assert validate_seed_value(12345)
|
||||
assert validate_seed_value(0xFFFFFFFFFFFFFFFF)
|
||||
assert validate_seed_value(0xFFFFFFFF) # 2**32 - 1
|
||||
|
||||
# Invalid seeds
|
||||
assert not validate_seed_value(-1)
|
||||
assert not validate_seed_value(0xFFFFFFFFFFFFFFFF + 1)
|
||||
assert not validate_seed_value(0xFFFFFFFF + 1) # 2**32
|
||||
assert not validate_seed_value(None)
|
||||
assert not validate_seed_value("invalid")
|
||||
assert not validate_seed_value([])
|
||||
@@ -182,7 +182,7 @@ class TestSeedHistoryLogic:
|
||||
# Valid seeds should pass through
|
||||
assert sanitize_seed_value(12345) == 12345
|
||||
assert sanitize_seed_value(0) == 0
|
||||
assert sanitize_seed_value(0xFFFFFFFFFFFFFFFF) == 0xFFFFFFFFFFFFFFFF
|
||||
assert sanitize_seed_value(0xFFFFFFFF) == 0xFFFFFFFF # 2**32 - 1
|
||||
|
||||
# String numbers should convert
|
||||
assert sanitize_seed_value("12345") == 12345
|
||||
@@ -190,7 +190,7 @@ class TestSeedHistoryLogic:
|
||||
|
||||
# Out of range should clamp
|
||||
assert sanitize_seed_value(-100) == 0
|
||||
assert sanitize_seed_value(0xFFFFFFFFFFFFFFFF + 100) == 0xFFFFFFFFFFFFFFFF
|
||||
assert sanitize_seed_value(0xFFFFFFFF + 100) == 0xFFFFFFFF # clamp to 2**32 - 1
|
||||
|
||||
# Invalid should raise
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Behavioural tests for width_height_selector logic and presets."""
|
||||
|
||||
import pytest
|
||||
|
||||
from kikotools.tools.width_height_selector import logic, presets
|
||||
from kikotools.tools.width_height_selector.presets import PresetMetadata
|
||||
|
||||
|
||||
class TestGetPresetDimensions:
|
||||
def test_known_preset(self):
|
||||
assert logic.get_preset_dimensions("1024×1024", 1, 2) == (1024, 1024)
|
||||
|
||||
def test_custom_uses_given_values(self):
|
||||
assert logic.get_preset_dimensions("custom", 333, 444) == (333, 444)
|
||||
|
||||
def test_unknown_preset_falls_back_to_custom_values(self):
|
||||
assert logic.get_preset_dimensions("nope", 333, 444) == (333, 444)
|
||||
|
||||
|
||||
class TestSwapAndAspect:
|
||||
def test_swap(self):
|
||||
assert logic.apply_swap_logic(1, 2, True) == (2, 1)
|
||||
assert logic.apply_swap_logic(1, 2, False) == (1, 2)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"w,h,expected",
|
||||
[
|
||||
(1920, 1080, "16:9"),
|
||||
(1024, 1024, "1:1"),
|
||||
(0, 5, "Invalid"),
|
||||
(5, -1, "Invalid"),
|
||||
],
|
||||
)
|
||||
def test_aspect_ratio(self, w, h, expected):
|
||||
assert logic.calculate_aspect_ratio(w, h) == expected
|
||||
|
||||
|
||||
class TestValidateAndSanitize:
|
||||
@pytest.mark.parametrize(
|
||||
"w,h,ok",
|
||||
[
|
||||
(1024, 1024, True),
|
||||
(64, 8192, True),
|
||||
(0, 64, False),
|
||||
(-8, 64, False),
|
||||
(1025, 1024, False),
|
||||
(1024, 1030, False),
|
||||
(56, 64, False),
|
||||
(8200, 64, False),
|
||||
(64, 8200, False),
|
||||
],
|
||||
)
|
||||
def test_validate_dimensions(self, w, h, ok):
|
||||
assert logic.validate_dimensions(w, h) is ok
|
||||
|
||||
def test_sanitize_clamps_and_rounds(self):
|
||||
assert logic.sanitize_dimensions(10, 10) == (64, 64)
|
||||
assert logic.sanitize_dimensions(99999, 99999) == (8192, 8192)
|
||||
assert logic.sanitize_dimensions(1027, 1028) == (1024, 1032)
|
||||
|
||||
def test_sanitized_output_is_always_valid(self):
|
||||
for raw in (-5, 0, 63, 65, 1001, 5000, 20000):
|
||||
w, h = logic.sanitize_dimensions(raw, raw)
|
||||
assert logic.validate_dimensions(w, h)
|
||||
|
||||
|
||||
class TestDimensionInfo:
|
||||
def test_info_for_preset_without_swap(self):
|
||||
info = logic.get_dimension_info("832×1216", 0, 0, False)
|
||||
assert info["width"] == 832 and info["height"] == 1216
|
||||
assert info["is_portrait"] and not info["is_landscape"]
|
||||
assert info["is_valid"] is True
|
||||
assert info["aspect_ratio"] == "13:19"
|
||||
assert info["megapixels"] == 1.01
|
||||
assert info["swap_applied"] is False
|
||||
|
||||
def test_info_with_swap_custom(self):
|
||||
info = logic.get_dimension_info("custom", 1920, 1080, True)
|
||||
assert (info["width"], info["height"]) == (1080, 1920)
|
||||
assert info["is_portrait"]
|
||||
assert info["preset_used"] == "custom"
|
||||
|
||||
def test_square_and_invalid(self):
|
||||
info = logic.get_dimension_info("custom", 100, 100, False)
|
||||
assert info["is_square"] and not info["is_valid"]
|
||||
|
||||
|
||||
class TestStringHelpers:
|
||||
def test_format(self):
|
||||
assert logic.format_dimension_string(1920, 1080) == "1920×1080"
|
||||
|
||||
@pytest.mark.parametrize("s", ["1920×1080", "1920x1080", " 1920 x 1080 "])
|
||||
def test_parse_ok(self, s):
|
||||
assert logic.parse_dimension_string(s) == (1920, 1080)
|
||||
|
||||
@pytest.mark.parametrize("s", ["1920", "1x2x3", "axb", "×", ""])
|
||||
def test_parse_errors(self, s):
|
||||
with pytest.raises(ValueError):
|
||||
logic.parse_dimension_string(s)
|
||||
|
||||
def test_roundtrip(self):
|
||||
assert logic.parse_dimension_string(
|
||||
logic.format_dimension_string(640, 480)
|
||||
) == (640, 480)
|
||||
|
||||
|
||||
class TestScaleAndSuggestions:
|
||||
def test_scale_factor_average(self):
|
||||
assert logic.get_optimal_scale_factor(100, 100, 200, 400) == pytest.approx(3.0)
|
||||
|
||||
def test_scale_factor_invalid_current(self):
|
||||
assert logic.get_optimal_scale_factor(0, 10, 5, 5) == 1.0
|
||||
assert logic.get_optimal_scale_factor(10, -1, 5, 5) == 1.0
|
||||
|
||||
def test_suggestions_closest_ratio_first(self):
|
||||
result = logic.suggest_similar_presets(1024, 1024, 3)
|
||||
assert len(result) == 3
|
||||
assert result[0] in presets.PRESET_OPTIONS
|
||||
w, h = presets.PRESET_OPTIONS[result[0]]
|
||||
assert w == h
|
||||
|
||||
def test_suggestions_respect_limit_and_skip_custom(self):
|
||||
result = logic.suggest_similar_presets(1920, 1080, 5)
|
||||
assert len(result) == 5 and "custom" not in result
|
||||
|
||||
def test_suggestions_invalid_dims(self):
|
||||
assert logic.suggest_similar_presets(0, 10) == []
|
||||
assert logic.suggest_similar_presets(10, -1) == []
|
||||
|
||||
|
||||
class TestPresetsModule:
|
||||
def test_aspect_ratio_helper(self):
|
||||
assert presets.calculate_aspect_ratio(1920, 1080) == (
|
||||
"16:9",
|
||||
pytest.approx(16 / 9),
|
||||
)
|
||||
|
||||
def test_group_filters(self):
|
||||
sdxl = presets.get_presets_by_model_group("SDXL")
|
||||
assert sdxl and all(m.model_group == "SDXL" for m in sdxl.values())
|
||||
assert presets.get_presets_by_model_group("Nope") == {}
|
||||
|
||||
def test_aspect_filter(self):
|
||||
squares = presets.get_presets_by_aspect_ratio("1:1")
|
||||
assert "1024×1024" in squares
|
||||
|
||||
def test_category_filter(self):
|
||||
portraits = presets.get_presets_by_category("Portrait")
|
||||
assert "832×1216" in portraits
|
||||
|
||||
def test_metadata_lookup_and_default(self):
|
||||
assert presets.get_preset_metadata("1024×1024").width == 1024
|
||||
default = presets.get_preset_metadata("missing")
|
||||
assert (default.width, default.height, default.category) == (0, 0, "Custom")
|
||||
|
||||
def test_category_lookup(self):
|
||||
assert presets.get_preset_category("832×1216") == "Portrait"
|
||||
assert presets.get_preset_category("???") == "Unknown"
|
||||
|
||||
def test_category_lookup_via_category_table(self, monkeypatch):
|
||||
monkeypatch.setattr(presets, "PRESET_METADATA", {})
|
||||
monkeypatch.setattr(presets, "PRESET_CATEGORIES", {"Special": ["x"]})
|
||||
assert presets.get_preset_category("x") == "Special"
|
||||
|
||||
def test_model_recommendation(self):
|
||||
assert presets.get_model_recommendation("1024×1024") == "Optimized for SDXL"
|
||||
assert presets.get_model_recommendation("?") == "Custom dimensions"
|
||||
|
||||
def test_shipped_presets_valid(self):
|
||||
assert presets.validate_preset_dimensions() is True
|
||||
assert presets.validate_metadata_consistency() is True
|
||||
assert set(presets.PRESET_OPTIONS) >= {"custom"}
|
||||
|
||||
def test_preset_options_match_metadata(self):
|
||||
for name, meta in presets.PRESET_METADATA.items():
|
||||
assert presets.PRESET_OPTIONS[name] == (meta.width, meta.height)
|
||||
|
||||
def _patch(self, monkeypatch, meta):
|
||||
monkeypatch.setattr(presets, "PRESET_METADATA", {"bad": meta})
|
||||
|
||||
def test_validation_rejects_non_multiple_of_8(self, monkeypatch):
|
||||
self._patch(
|
||||
monkeypatch, PresetMetadata(1001, 1024, "1:1", 1.0, 1.0, "g", "c", "d")
|
||||
)
|
||||
assert presets.validate_preset_dimensions() is False
|
||||
|
||||
def test_validation_rejects_out_of_bounds(self, monkeypatch):
|
||||
self._patch(
|
||||
monkeypatch, PresetMetadata(16, 1024, "1:1", 1.0, 1.0, "g", "c", "d")
|
||||
)
|
||||
assert presets.validate_preset_dimensions() is False
|
||||
|
||||
def test_consistency_rejects_bad_aspect(self, monkeypatch):
|
||||
self._patch(
|
||||
monkeypatch, PresetMetadata(1024, 512, "1:1", 1.0, 0.5, "g", "c", "d")
|
||||
)
|
||||
assert presets.validate_metadata_consistency() is False
|
||||
|
||||
def test_consistency_rejects_bad_megapixels(self, monkeypatch):
|
||||
self._patch(
|
||||
monkeypatch, PresetMetadata(1024, 1024, "1:1", 1.0, 9.0, "g", "c", "d")
|
||||
)
|
||||
assert presets.validate_metadata_consistency() is False
|
||||
@@ -24,7 +24,8 @@ class TestFluxSamplerParamsLogic:
|
||||
assert parse_string_to_list("1.0, 2.5, 3.7") == [1.0, 2.5, 3.7]
|
||||
assert parse_string_to_list("5") == [5.0]
|
||||
assert parse_string_to_list("") == []
|
||||
assert parse_string_to_list("1.0, invalid, 3.0") == [1.0, 3.0]
|
||||
with pytest.raises(ValueError, match="invalid"):
|
||||
parse_string_to_list("1.0, invalid, 3.0")
|
||||
|
||||
def test_parse_seed_string(self):
|
||||
"""Test parsing seed strings."""
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
"""Hardening and node-level tests for Flux Sampler Params."""
|
||||
|
||||
import math
|
||||
import sys
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from kikotools.tools.xyz_helpers.flux_sampler_params import FluxSamplerParamsNode
|
||||
from kikotools.tools.xyz_helpers.flux_sampler_params.logic import (
|
||||
is_schnell_model,
|
||||
parse_seed_string,
|
||||
parse_steps_list,
|
||||
parse_string_to_list,
|
||||
validate_flux_params,
|
||||
)
|
||||
|
||||
|
||||
def _model(guidance_embed):
|
||||
model = Mock()
|
||||
model.model.model_config.unet_config = {"guidance_embed": guidance_embed}
|
||||
return model
|
||||
|
||||
|
||||
class TestParsing:
|
||||
def test_float_list_bad_token_names_field_and_token(self):
|
||||
with pytest.raises(ValueError, match=r"guidance.*'x'"):
|
||||
parse_string_to_list("1.0, x", field="guidance")
|
||||
|
||||
def test_float_list_alone_bad(self):
|
||||
with pytest.raises(ValueError, match="abc"):
|
||||
parse_string_to_list("abc")
|
||||
|
||||
def test_float_list_rejects_nan_inf(self):
|
||||
with pytest.raises(ValueError):
|
||||
parse_string_to_list("nan")
|
||||
with pytest.raises(ValueError):
|
||||
parse_string_to_list("inf")
|
||||
|
||||
def test_float_list_empty_is_empty(self):
|
||||
assert parse_string_to_list(" ") == []
|
||||
assert parse_string_to_list("1, ,2") == [1.0, 2.0]
|
||||
|
||||
def test_steps_list_ints(self):
|
||||
assert parse_steps_list("20, 30.0") == [20, 30]
|
||||
|
||||
def test_steps_list_rejects_fraction_and_nonpositive(self):
|
||||
with pytest.raises(ValueError, match="steps.*20.5"):
|
||||
parse_steps_list("20.5")
|
||||
with pytest.raises(ValueError, match="steps"):
|
||||
parse_steps_list("0")
|
||||
with pytest.raises(ValueError, match="steps.*abc"):
|
||||
parse_steps_list("abc")
|
||||
|
||||
def test_seed_invalid_token_raises(self):
|
||||
with pytest.raises(ValueError, match="12.5"):
|
||||
parse_seed_string("1, 12.5")
|
||||
|
||||
def test_seed_question_mark_is_exact_token(self):
|
||||
with pytest.raises(ValueError, match="1\\?"):
|
||||
parse_seed_string("1?")
|
||||
|
||||
def test_seed_random_and_fixed(self):
|
||||
seeds = parse_seed_string("7, ?\n8")
|
||||
assert seeds[0] == 7 and seeds[2] == 8 and 0 <= seeds[1] <= 999999
|
||||
|
||||
def test_seed_negative_rejected(self):
|
||||
with pytest.raises(ValueError, match="-5"):
|
||||
parse_seed_string("-5")
|
||||
|
||||
def test_seed_empty_is_single_random(self):
|
||||
seeds = parse_seed_string(" ")
|
||||
assert len(seeds) == 1
|
||||
|
||||
def test_validate_raises_naming_field(self):
|
||||
with pytest.raises(ValueError, match="denoise"):
|
||||
validate_flux_params("20", "3.5", "", "", "oops")
|
||||
with pytest.raises(ValueError, match="steps"):
|
||||
validate_flux_params("abc", "3.5", "", "", "1.0")
|
||||
|
||||
|
||||
class TestSchnellDetection:
|
||||
def test_dev_has_guidance_embed(self):
|
||||
assert is_schnell_model(_model(True)) is False
|
||||
|
||||
def test_schnell_lacks_guidance_embed(self):
|
||||
assert is_schnell_model(_model(False)) is True
|
||||
|
||||
def test_missing_key_defaults_to_dev(self):
|
||||
model = Mock()
|
||||
model.model.model_config.unet_config = {}
|
||||
assert is_schnell_model(model) is False
|
||||
|
||||
def test_unexpected_objects_default_to_dev(self):
|
||||
assert is_schnell_model(Mock()) is False
|
||||
assert is_schnell_model(None) is False
|
||||
assert is_schnell_model(object()) is False
|
||||
|
||||
|
||||
class TestIsChanged:
|
||||
def test_random_seed_is_nan(self):
|
||||
assert math.isnan(FluxSamplerParamsNode.IS_CHANGED(seed="1, ?"))
|
||||
|
||||
def test_empty_seed_is_nan(self):
|
||||
assert math.isnan(FluxSamplerParamsNode.IS_CHANGED(seed=""))
|
||||
|
||||
def test_fixed_seed_is_stable(self):
|
||||
a = FluxSamplerParamsNode.IS_CHANGED(seed="1,2", steps="20")
|
||||
b = FluxSamplerParamsNode.IS_CHANGED(seed="1,2", steps="20")
|
||||
assert a == b
|
||||
assert a != FluxSamplerParamsNode.IS_CHANGED(seed="1,2", steps="30")
|
||||
|
||||
def test_nan_never_equal_so_reruns(self):
|
||||
a = FluxSamplerParamsNode.IS_CHANGED(seed="?")
|
||||
assert a != a
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def comfy_env():
|
||||
"""Fake ComfyUI modules; sampling returns a tiny latent."""
|
||||
samplers = MagicMock()
|
||||
samplers.KSampler.SAMPLERS = ["euler", "ddim"]
|
||||
samplers.KSampler.SCHEDULERS = ["simple", "normal"]
|
||||
custom = MagicMock()
|
||||
sample_calls = []
|
||||
|
||||
def sample(noise, guider, sampler, sigmas, latent):
|
||||
sample_calls.append(noise)
|
||||
return (None, {"samples": torch.zeros(1, 4, 8, 8)})
|
||||
|
||||
custom.SamplerCustomAdvanced.return_value.sample.side_effect = sample
|
||||
adv = MagicMock()
|
||||
nodes_mod = MagicMock()
|
||||
comfy = MagicMock()
|
||||
comfy.samplers = samplers
|
||||
mods = {
|
||||
"comfy": comfy,
|
||||
"comfy.samplers": samplers,
|
||||
"comfy.model_base": MagicMock(),
|
||||
"comfy.model_management": MagicMock(),
|
||||
"comfy.utils": MagicMock(),
|
||||
"comfy_extras": MagicMock(),
|
||||
"comfy_extras.nodes_custom_sampler": custom,
|
||||
"comfy_extras.nodes_model_advanced": adv,
|
||||
"node_helpers": MagicMock(),
|
||||
"nodes": nodes_mod,
|
||||
}
|
||||
with patch.dict(sys.modules, mods):
|
||||
yield {"adv": adv, "nodes": nodes_mod, "calls": sample_calls}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def latent():
|
||||
return {"samples": torch.zeros(1, 4, 8, 8)}
|
||||
|
||||
|
||||
def _run(node, model, latent, **over):
|
||||
args = dict(
|
||||
model=model,
|
||||
conditioning={"text": ["p1"], "encoded": ["c1"]},
|
||||
latent_image=latent,
|
||||
seed="1",
|
||||
sampler="euler",
|
||||
scheduler="simple",
|
||||
steps="10",
|
||||
guidance="3.5",
|
||||
max_shift="",
|
||||
base_shift="",
|
||||
denoise="1.0",
|
||||
)
|
||||
args.update(over)
|
||||
return node.process_batch(**args)
|
||||
|
||||
|
||||
class TestProcessBatch:
|
||||
def test_happy_path_dev(self, comfy_env, latent):
|
||||
out, params = _run(FluxSamplerParamsNode(), _model(True), latent)
|
||||
assert len(params) == 1
|
||||
assert params[0]["seed"] == 1 and params[0]["steps"] == 10
|
||||
assert params[0]["prompt"] == "p1"
|
||||
assert params[0]["width"] == 64
|
||||
comfy_env["adv"].ModelSamplingFlux.return_value.patch.assert_called_once()
|
||||
comfy_env[
|
||||
"adv"
|
||||
].ModelSamplingAuraFlow.return_value.patch_aura.assert_not_called()
|
||||
|
||||
def test_schnell_uses_aura_patch_and_zero_max_shift(self, comfy_env, latent):
|
||||
_, params = _run(FluxSamplerParamsNode(), _model(False), latent)
|
||||
comfy_env[
|
||||
"adv"
|
||||
].ModelSamplingAuraFlow.return_value.patch_aura.assert_called_once()
|
||||
assert params[0]["max_shift"] == 0.0
|
||||
|
||||
def test_batches_cartesian_product(self, comfy_env, latent):
|
||||
out, params = _run(
|
||||
FluxSamplerParamsNode(),
|
||||
_model(True),
|
||||
latent,
|
||||
seed="1,2",
|
||||
sampler="euler,ddim",
|
||||
steps="5,10",
|
||||
)
|
||||
assert len(params) == 8
|
||||
assert out["samples"].shape[0] == 8
|
||||
|
||||
def test_plain_conditioning(self, comfy_env, latent):
|
||||
_, params = _run(
|
||||
FluxSamplerParamsNode(), _model(True), latent, conditioning="raw"
|
||||
)
|
||||
assert params[0]["prompt"] is None
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field,value",
|
||||
[
|
||||
("steps", "abc"),
|
||||
("steps", "20,x"),
|
||||
("guidance", "high"),
|
||||
("denoise", "zz"),
|
||||
("seed", "12.5"),
|
||||
],
|
||||
)
|
||||
def test_bad_input_raises_naming_field(self, comfy_env, latent, field, value):
|
||||
with pytest.raises(ValueError, match=value.split(",")[-1]):
|
||||
_run(FluxSamplerParamsNode(), _model(True), latent, **{field: value})
|
||||
|
||||
def test_empty_lora_list_raises(self, comfy_env, latent):
|
||||
with pytest.raises(ValueError, match="(?i)lora"):
|
||||
_run(
|
||||
FluxSamplerParamsNode(),
|
||||
_model(True),
|
||||
latent,
|
||||
loras={"loras": [], "strengths": []},
|
||||
)
|
||||
|
||||
def test_loras_applied_with_strengths(self, comfy_env, latent):
|
||||
loader = comfy_env["nodes"].LoraLoader.return_value
|
||||
loader.load_lora.return_value = (Mock(),)
|
||||
loras = {
|
||||
"loras": ["a.safetensors", "b.safetensors"],
|
||||
"strengths": [[0.5, 1.0], [0.7]],
|
||||
"batch_info": {"index": 0, "total": 2},
|
||||
}
|
||||
_, params = _run(FluxSamplerParamsNode(), _model(True), latent, loras=loras)
|
||||
assert [(p["lora"], p["lora_strength"]) for p in params] == [
|
||||
("a.safetensors", 0.5),
|
||||
("a.safetensors", 1.0),
|
||||
("b.safetensors", 0.7),
|
||||
]
|
||||
assert params[0]["lora_batch"] == "Batch 1/2"
|
||||
assert loader.load_lora.call_count == 3
|
||||
|
||||
def test_missing_comfy_modules_raises(self, latent):
|
||||
with patch.dict(sys.modules, {"comfy.samplers": None}):
|
||||
with pytest.raises(ValueError, match="(?i)not available"):
|
||||
_run(FluxSamplerParamsNode(), _model(True), latent)
|
||||
@@ -165,9 +165,7 @@ class TestLoRAFolderBatchLogic:
|
||||
# Mock the import
|
||||
import sys
|
||||
|
||||
sys.modules["folder_paths"] = mock_folder_paths
|
||||
|
||||
try:
|
||||
with patch.dict(sys.modules, {"folder_paths": mock_folder_paths}):
|
||||
# Test scanning from root - should find all .safetensors files
|
||||
results = scan_folder_for_loras(".")
|
||||
assert len(results) == 5
|
||||
@@ -184,10 +182,6 @@ class TestLoRAFolderBatchLogic:
|
||||
assert "flux/flux-lora.safetensors" in results
|
||||
assert "flux/style/style-lora.safetensors" in results
|
||||
assert "flux/character/char-lora.safetensors" in results
|
||||
finally:
|
||||
# Clean up the mock
|
||||
if "folder_paths" in sys.modules:
|
||||
del sys.modules["folder_paths"]
|
||||
|
||||
|
||||
class TestLoRAFolderBatchNode:
|
||||
@@ -274,14 +268,12 @@ class TestLoRAFolderBatchNode:
|
||||
"lora_count",
|
||||
)
|
||||
|
||||
def test_is_changed(self):
|
||||
"""Test IS_CHANGED method returns unique value."""
|
||||
result1 = LoRAFolderBatchNode.IS_CHANGED()
|
||||
import time
|
||||
|
||||
time.sleep(0.01)
|
||||
result2 = LoRAFolderBatchNode.IS_CHANGED()
|
||||
assert result1 != result2
|
||||
def test_is_changed_is_stable_for_same_inputs(self):
|
||||
"""IS_CHANGED must not force re-execution when nothing changed."""
|
||||
kwargs = dict(folder_path="x", strength="1.0", batch_mode="sequential")
|
||||
assert LoRAFolderBatchNode.IS_CHANGED(**kwargs) == (
|
||||
LoRAFolderBatchNode.IS_CHANGED(**kwargs)
|
||||
)
|
||||
|
||||
def test_create_lora_params_batched(self):
|
||||
"""Test the batched LoRA params creation."""
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
"""Hardening tests for LoRA Folder Batch (bad input, containment, IS_CHANGED)."""
|
||||
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from kikotools.tools.xyz_helpers.lora_folder_batch import LoRAFolderBatchNode
|
||||
from kikotools.tools.xyz_helpers.lora_folder_batch.logic import (
|
||||
MAX_STRENGTH_VALUES,
|
||||
create_lora_params,
|
||||
parse_strength_string,
|
||||
scan_folder_for_loras,
|
||||
sort_lora_files,
|
||||
validate_folder_path,
|
||||
)
|
||||
|
||||
|
||||
def _touch(path, mtime=None):
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w") as f:
|
||||
f.write("x")
|
||||
if mtime is not None:
|
||||
os.utime(path, (mtime, mtime))
|
||||
|
||||
|
||||
def _fake_folder_paths(*roots):
|
||||
fp = MagicMock()
|
||||
fp.folder_names_and_paths = {"loras": [list(roots)]}
|
||||
return patch.dict(sys.modules, {"folder_paths": fp})
|
||||
|
||||
|
||||
class TestParseStrengthErrors:
|
||||
@pytest.mark.parametrize("bad", ["0.5...1.0+0", "0.5...1.0+-0.1", "0.5...1.0+-1"])
|
||||
def test_non_positive_step_raises(self, bad):
|
||||
with pytest.raises(ValueError, match="step"):
|
||||
parse_strength_string(bad)
|
||||
|
||||
def test_reversed_range_raises(self):
|
||||
with pytest.raises(ValueError, match="reversed|start"):
|
||||
parse_strength_string("1.0...0.5")
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad", ["abc", "0.5, x", "a...b", "1...2...3", "1...2+1+1", "1...2+x", "nan"]
|
||||
)
|
||||
def test_malformed_raises(self, bad):
|
||||
with pytest.raises(ValueError):
|
||||
parse_strength_string(bad)
|
||||
|
||||
def test_infinite_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
parse_strength_string("0...inf")
|
||||
|
||||
def test_too_many_values_raises(self):
|
||||
with pytest.raises(ValueError, match=str(MAX_STRENGTH_VALUES)):
|
||||
parse_strength_string("0...1000+0.0001")
|
||||
|
||||
def test_exactly_cap_is_allowed(self):
|
||||
values = parse_strength_string(f"0...{MAX_STRENGTH_VALUES - 1}+1")
|
||||
assert len(values) == MAX_STRENGTH_VALUES
|
||||
|
||||
def test_only_commas_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
parse_strength_string(" , ,")
|
||||
|
||||
def test_valid_ranges_still_work(self):
|
||||
assert parse_strength_string("0.5...1.0+0.25") == [0.5, 0.75, 1.0]
|
||||
assert parse_strength_string("0.8...1.0") == [0.8, 0.9, 1.0]
|
||||
assert parse_strength_string("1.0...1.0") == [1.0]
|
||||
assert parse_strength_string("") == [1.0]
|
||||
|
||||
def test_create_lora_params_rejects_empty_strengths(self):
|
||||
with pytest.raises(ValueError, match="strength"):
|
||||
create_lora_params(["a.safetensors"], [], "sequential")
|
||||
|
||||
|
||||
class TestScanContainment:
|
||||
def test_sibling_directory_with_shared_prefix_not_contained(self, tmp_path):
|
||||
loras = tmp_path / "loras"
|
||||
backup = tmp_path / "loras_backup"
|
||||
_touch(str(loras / "a.safetensors"))
|
||||
_touch(str(backup / "b.safetensors"))
|
||||
with _fake_folder_paths(str(loras)):
|
||||
result = scan_folder_for_loras(str(backup))
|
||||
# Not inside the loras root, so names are relative to the scanned folder
|
||||
assert result == ["b.safetensors"]
|
||||
|
||||
def test_absolute_path_inside_second_root(self, tmp_path):
|
||||
r1, r2 = tmp_path / "r1", tmp_path / "r2"
|
||||
_touch(str(r1 / "a.safetensors"))
|
||||
_touch(str(r2 / "sub" / "b.safetensors"))
|
||||
with _fake_folder_paths(str(r1), str(r2)):
|
||||
result = scan_folder_for_loras(str(r2 / "sub"))
|
||||
assert result == ["sub/b.safetensors"]
|
||||
|
||||
def test_relative_searches_all_roots(self, tmp_path):
|
||||
r1, r2 = tmp_path / "r1", tmp_path / "r2"
|
||||
_touch(str(r1 / "flux" / "a.safetensors"))
|
||||
_touch(str(r2 / "flux" / "b.safetensors"))
|
||||
_touch(str(r2 / "only2" / "c.safetensors"))
|
||||
with _fake_folder_paths(str(r1), str(r2)):
|
||||
assert scan_folder_for_loras("flux") == [
|
||||
"flux/a.safetensors",
|
||||
"flux/b.safetensors",
|
||||
]
|
||||
assert scan_folder_for_loras("only2") == ["only2/c.safetensors"]
|
||||
|
||||
def test_relative_traversal_is_rejected(self, tmp_path):
|
||||
root = tmp_path / "loras"
|
||||
_touch(str(root / "a.safetensors"))
|
||||
_touch(str(tmp_path / "secret" / "s.safetensors"))
|
||||
with _fake_folder_paths(str(root)):
|
||||
assert scan_folder_for_loras("../secret") == []
|
||||
|
||||
def test_symlink_escape_not_treated_as_contained(self, tmp_path):
|
||||
root = tmp_path / "loras"
|
||||
outside = tmp_path / "outside"
|
||||
_touch(str(outside / "o.safetensors"))
|
||||
root.mkdir()
|
||||
os.symlink(str(outside), str(root / "link"))
|
||||
with _fake_folder_paths(str(root)):
|
||||
assert scan_folder_for_loras("link") == []
|
||||
|
||||
def test_missing_folder_returns_empty(self, tmp_path):
|
||||
with _fake_folder_paths(str(tmp_path)):
|
||||
assert scan_folder_for_loras("nope") == []
|
||||
|
||||
def test_validate_folder_path_uses_all_roots(self, tmp_path):
|
||||
r1, r2 = tmp_path / "r1", tmp_path / "r2"
|
||||
r1.mkdir()
|
||||
(r2 / "flux").mkdir(parents=True)
|
||||
with _fake_folder_paths(str(r1), str(r2)):
|
||||
assert validate_folder_path("flux") is True
|
||||
assert validate_folder_path(".") is True
|
||||
assert validate_folder_path("missing") is False
|
||||
assert validate_folder_path(str(r2)) is True
|
||||
|
||||
def test_validate_folder_path_no_roots(self):
|
||||
with _fake_folder_paths():
|
||||
assert validate_folder_path("flux") is False
|
||||
|
||||
|
||||
class TestSortLoraFiles:
|
||||
def test_orders(self):
|
||||
files = ["b10.safetensors", "b2.safetensors", "a.safetensors"]
|
||||
assert sort_lora_files(files, "alphabetical")[0] == "a.safetensors"
|
||||
assert sort_lora_files(files, "oldest")[0] == "a.safetensors"
|
||||
assert sort_lora_files(files, "newest")[-1] == "a.safetensors"
|
||||
assert sort_lora_files(files, "natural")[1] == "b2.safetensors"
|
||||
assert sort_lora_files(files, "other") == files
|
||||
|
||||
|
||||
class TestIsChanged:
|
||||
def _changed(self, **kw):
|
||||
base = dict(folder_path="flux", strength="1.0", batch_mode="sequential")
|
||||
base.update(kw)
|
||||
return LoRAFolderBatchNode.IS_CHANGED(**base)
|
||||
|
||||
def test_stable_when_nothing_changes(self, tmp_path):
|
||||
_touch(str(tmp_path / "flux" / "a.safetensors"), 1000)
|
||||
with _fake_folder_paths(str(tmp_path)):
|
||||
assert self._changed() == self._changed()
|
||||
|
||||
def test_changes_when_file_added(self, tmp_path):
|
||||
_touch(str(tmp_path / "flux" / "a.safetensors"), 1000)
|
||||
with _fake_folder_paths(str(tmp_path)):
|
||||
before = self._changed()
|
||||
_touch(str(tmp_path / "flux" / "b.safetensors"), 1000)
|
||||
assert self._changed() != before
|
||||
|
||||
def test_changes_when_mtime_changes(self, tmp_path):
|
||||
p = str(tmp_path / "flux" / "a.safetensors")
|
||||
_touch(p, 1000)
|
||||
with _fake_folder_paths(str(tmp_path)):
|
||||
before = self._changed()
|
||||
os.utime(p, (2000, 2000))
|
||||
assert self._changed() != before
|
||||
|
||||
def test_changes_with_inputs(self, tmp_path):
|
||||
_touch(str(tmp_path / "flux" / "a.safetensors"), 1000)
|
||||
with _fake_folder_paths(str(tmp_path)):
|
||||
assert self._changed(strength="0.5") != self._changed()
|
||||
assert self._changed(include_pattern="a") != self._changed()
|
||||
|
||||
def test_pattern_filters_fingerprint(self, tmp_path):
|
||||
_touch(str(tmp_path / "flux" / "a.safetensors"), 1000)
|
||||
_touch(str(tmp_path / "flux" / "skip.safetensors"), 1000)
|
||||
with _fake_folder_paths(str(tmp_path)):
|
||||
before = self._changed(exclude_pattern="skip")
|
||||
os.utime(str(tmp_path / "flux" / "skip.safetensors"), (5, 5))
|
||||
assert self._changed(exclude_pattern="skip") == before
|
||||
|
||||
def test_not_nan_or_time_based(self, tmp_path):
|
||||
with _fake_folder_paths(str(tmp_path)):
|
||||
value = self._changed()
|
||||
assert not (isinstance(value, float) and math.isnan(value))
|
||||
assert isinstance(value, str)
|
||||
|
||||
def test_missing_folder_still_returns_string(self, tmp_path):
|
||||
with _fake_folder_paths(str(tmp_path)):
|
||||
assert isinstance(self._changed(folder_path="nope"), str)
|
||||
|
||||
|
||||
class TestNodeBadStrength:
|
||||
def test_bad_strength_surfaces_error(self):
|
||||
node = LoRAFolderBatchNode()
|
||||
with (
|
||||
patch(
|
||||
"kikotools.tools.xyz_helpers.lora_folder_batch.node.validate_folder_path",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"kikotools.tools.xyz_helpers.lora_folder_batch.node.scan_folder_for_loras",
|
||||
return_value=["a.safetensors"],
|
||||
),
|
||||
):
|
||||
with pytest.raises(ValueError, match="step"):
|
||||
node.batch_loras("x", "0.5...1.0+0", "sequential")
|
||||
|
||||
|
||||
NODE = "kikotools.tools.xyz_helpers.lora_folder_batch.node"
|
||||
|
||||
|
||||
def _run_node(files, **kwargs):
|
||||
node = LoRAFolderBatchNode()
|
||||
args = dict(folder_path="x", strength="1.0", batch_mode="sequential")
|
||||
args.update(kwargs)
|
||||
with (
|
||||
patch(f"{NODE}.validate_folder_path", return_value=True),
|
||||
patch(f"{NODE}.scan_folder_for_loras", return_value=files),
|
||||
):
|
||||
return node.batch_loras(**args)
|
||||
|
||||
|
||||
class TestNodeBehaviour:
|
||||
FILES = [f"m-{i:06d}.safetensors" for i in range(1, 8)]
|
||||
|
||||
def test_invalid_folder_raises(self):
|
||||
node = LoRAFolderBatchNode()
|
||||
with patch(f"{NODE}.validate_folder_path", return_value=False):
|
||||
with pytest.raises(ValueError, match="Invalid or inaccessible"):
|
||||
node.batch_loras("nope", "1.0", "sequential")
|
||||
|
||||
def test_filters_applied(self):
|
||||
params, text, count = _run_node(
|
||||
self.FILES, include_pattern="00000[1-3]", exclude_pattern="000002"
|
||||
)
|
||||
assert count == 2
|
||||
assert params["loras"] == [self.FILES[0], self.FILES[2]]
|
||||
|
||||
def test_filter_removes_everything(self):
|
||||
params, text, count = _run_node(self.FILES, include_pattern="zzz")
|
||||
assert (params, text, count) == ({"loras": [], "strengths": []}, "", 0)
|
||||
|
||||
def test_max_loras_limits(self):
|
||||
_, _, count = _run_node(self.FILES, max_loras=3)
|
||||
assert count == 3
|
||||
|
||||
def test_sort_order_newest(self):
|
||||
params, _, _ = _run_node(self.FILES, sort_order="newest")
|
||||
assert params["loras"][0] == self.FILES[-1]
|
||||
|
||||
def test_combinatorial_strengths(self):
|
||||
params, _, count = _run_node(
|
||||
self.FILES[:2], strength="0.5,1.0", batch_mode="combinatorial"
|
||||
)
|
||||
assert params["strengths"] == [[0.5, 1.0], [0.5, 1.0]]
|
||||
assert count == 2
|
||||
|
||||
def test_many_combinations_warning_path(self):
|
||||
files = [f"l{i}.safetensors" for i in range(120)]
|
||||
_, _, count = _run_node(files, max_loras=500)
|
||||
assert count == 120
|
||||
|
||||
def test_auto_batch_selects_batch(self):
|
||||
params, text, count = _run_node(
|
||||
self.FILES, auto_batch="enabled", batch_size=3, batch_index=1
|
||||
)
|
||||
assert count == 3
|
||||
assert params["loras"] == self.FILES[3:6]
|
||||
assert text.startswith("=== Batch 2/3 (LoRAs 4-6) ===")
|
||||
|
||||
def test_auto_batch_index_out_of_range_uses_first(self):
|
||||
params, text, _ = _run_node(
|
||||
self.FILES, auto_batch="enabled", batch_size=3, batch_index=9
|
||||
)
|
||||
assert params["loras"] == self.FILES[:3]
|
||||
assert "Batch 1/3" in text
|
||||
|
||||
def test_files_without_epoch_listed_by_name(self):
|
||||
_, text, _ = _run_node(["plain.safetensors"])
|
||||
assert text == "plain"
|
||||
|
||||
|
||||
class TestGetLoraFolders:
|
||||
def test_lists_subfolders(self, tmp_path):
|
||||
(tmp_path / "flux" / "style").mkdir(parents=True)
|
||||
with _fake_folder_paths(str(tmp_path)):
|
||||
from kikotools.tools.xyz_helpers.lora_folder_batch.logic import (
|
||||
get_lora_folders,
|
||||
)
|
||||
|
||||
folders = get_lora_folders()
|
||||
assert folders[0] == "."
|
||||
assert set(folders) == {".", "flux", os.path.join("flux", "style")}
|
||||
|
||||
def test_falls_back_without_roots(self):
|
||||
from kikotools.tools.xyz_helpers.lora_folder_batch.logic import (
|
||||
get_lora_folders,
|
||||
)
|
||||
|
||||
with patch.dict(
|
||||
sys.modules, {"folder_paths": MagicMock(folder_names_and_paths={})}
|
||||
):
|
||||
assert get_lora_folders()[0] == "."
|
||||
@@ -0,0 +1,296 @@
|
||||
"""Additional behavioural tests for plot_sampler_params logic and node."""
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from kikotools.tools.xyz_helpers.plot_sampler_params import logic
|
||||
from kikotools.tools.xyz_helpers.plot_sampler_params.node import PlotParametersNode
|
||||
|
||||
|
||||
def make_params(n, **overrides):
|
||||
out = []
|
||||
for i in range(n):
|
||||
p = {
|
||||
"time": 1.0 + i,
|
||||
"seed": i,
|
||||
"steps": 20,
|
||||
"denoise": 1.0,
|
||||
"sampler": "euler",
|
||||
"scheduler": "simple",
|
||||
"guidance": 3.5,
|
||||
"max_shift": 1.15,
|
||||
"base_shift": 0.5,
|
||||
"width": 64,
|
||||
"height": 64,
|
||||
"prompt": "a cat sitting on a very long sofa " * 6,
|
||||
}
|
||||
p.update(overrides)
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
|
||||
class TestSortAndGroup:
|
||||
def test_sort_none_keeps_order(self):
|
||||
params = [{"seed": 3}, {"seed": 1}]
|
||||
assert logic.sort_parameters(params, "none") == (params, [0, 1])
|
||||
|
||||
def test_sort_returns_original_indices(self):
|
||||
params = [{"seed": 3}, {"seed": 1}, {"seed": 2}]
|
||||
sorted_params, idx = logic.sort_parameters(params, "seed")
|
||||
assert [p["seed"] for p in sorted_params] == [1, 2, 3]
|
||||
assert idx == [1, 2, 0]
|
||||
|
||||
def test_sort_missing_key_defaults_to_zero(self):
|
||||
params = [{"seed": 5}, {}]
|
||||
_, idx = logic.sort_parameters(params, "seed")
|
||||
assert idx == [1, 0]
|
||||
|
||||
def test_sort_incomparable_values_falls_back(self):
|
||||
params = [{"seed": "a"}, {"seed": 1}]
|
||||
result, idx = logic.sort_parameters(params, "seed")
|
||||
assert result == params and idx == [0, 1]
|
||||
|
||||
def test_group_none(self):
|
||||
params = [{"a": 1}]
|
||||
assert logic.group_by_value(params, "none") == (params, [0], -1)
|
||||
|
||||
def test_group_interleaves_columns(self):
|
||||
params = [
|
||||
{"sampler": "a", "n": 0},
|
||||
{"sampler": "a", "n": 1},
|
||||
{"sampler": "b", "n": 2},
|
||||
{"sampler": "b", "n": 3},
|
||||
]
|
||||
out, idx, groups = logic.group_by_value(params, "sampler")
|
||||
assert groups == 2
|
||||
assert idx == [0, 2, 1, 3]
|
||||
assert [p["n"] for p in out] == [0, 2, 1, 3]
|
||||
|
||||
def test_group_missing_key_goes_to_unknown(self):
|
||||
out, idx, groups = logic.group_by_value([{"x": 1}, {"x": 2}], "sampler")
|
||||
assert groups == 1 and idx == [0, 1]
|
||||
|
||||
def test_group_unhashable_value_falls_back(self):
|
||||
params = [{"k": [1]}, {"k": [2]}]
|
||||
assert logic.group_by_value(params, "k") == (params, [0, 1], -1)
|
||||
|
||||
def test_group_empty_params_falls_back(self):
|
||||
assert logic.group_by_value([], "sampler") == ([], [], -1)
|
||||
|
||||
|
||||
class TestChangingParams:
|
||||
def test_empty(self):
|
||||
assert logic.identify_changing_parameters([]) == {}
|
||||
|
||||
def test_detects_changes_and_ignores_time(self):
|
||||
params = [
|
||||
{"time": 1, "seed": 1, "steps": 20},
|
||||
{"time": 2, "seed": 2, "steps": 20},
|
||||
]
|
||||
changing = logic.identify_changing_parameters(params)
|
||||
assert changing == {"seed": True, "steps": False}
|
||||
|
||||
def test_list_and_dict_values_compared_by_content(self):
|
||||
params = [
|
||||
{"a": [1, 2], "b": {"x": 1}},
|
||||
{"a": [1, 2], "b": {"x": 2}},
|
||||
]
|
||||
changing = logic.identify_changing_parameters(params)
|
||||
assert changing == {"a": False, "b": True}
|
||||
|
||||
def test_prompt_always_included(self):
|
||||
changing = logic.identify_changing_parameters(
|
||||
[{"prompt": "same"}, {"prompt": "same"}]
|
||||
)
|
||||
assert changing["prompt"] is True
|
||||
|
||||
def test_filter_keeps_only_changing(self):
|
||||
params = [{"seed": 1, "steps": 20}, {"seed": 2, "steps": 20}]
|
||||
assert logic.filter_changing_params(params) == [{"seed": 1}, {"seed": 2}]
|
||||
|
||||
|
||||
class TestFormatting:
|
||||
def test_changes_only_skips_prompt(self):
|
||||
text = logic.format_parameter_text({"seed": 1, "prompt": "p"}, "changes only")
|
||||
assert text == "seed: 1"
|
||||
|
||||
def test_full_with_defaults_na(self):
|
||||
text = logic.format_parameter_text({})
|
||||
assert "denoise: N/A" in text
|
||||
assert "time:" not in text
|
||||
|
||||
def test_full_with_time_line(self):
|
||||
text = logic.format_parameter_text(make_params(1)[0])
|
||||
first = text.split("\n")[0]
|
||||
assert first.startswith("time: 1.00s, seed: 0, steps: 20, size: 64×64")
|
||||
|
||||
def test_lora_short_path_and_batch(self):
|
||||
text = logic.format_parameter_text(
|
||||
{"lora": "a.safetensors", "lora_strength": 0.8, "lora_batch": "2/3"}
|
||||
)
|
||||
assert text.split("\n")[-1] == "LoRA: a, str: 0.8 [2/3]"
|
||||
|
||||
def test_lora_long_path_shows_parent_and_filename(self):
|
||||
text = logic.format_parameter_text(
|
||||
{"lora": "C:\\models\\loras\\style\\x.safetensors", "lora_strength": 1}
|
||||
)
|
||||
assert "LoRA: style/x, str: 1" in text
|
||||
|
||||
def test_empty_lora_omitted(self):
|
||||
assert "LoRA" not in logic.format_parameter_text({"lora": ""})
|
||||
|
||||
def test_wrap_empty(self):
|
||||
assert logic.wrap_prompt_text("", 10) == []
|
||||
|
||||
def test_wrap_full_respects_width(self):
|
||||
lines = logic.wrap_prompt_text("word " * 20, 12)
|
||||
assert len(lines) > 1 and all(len(l) <= 12 for l in lines)
|
||||
|
||||
def test_wrap_excerpt_truncates_to_64_words(self):
|
||||
lines = logic.wrap_prompt_text(" ".join(["w"] * 100), 1000, "excerpt")
|
||||
assert lines[0].endswith("...")
|
||||
assert len(lines[0].rstrip(".").split()) == 64
|
||||
|
||||
def test_wrap_excerpt_short_prompt_unchanged(self):
|
||||
assert logic.wrap_prompt_text("a b c", 100, "excerpt") == ["a b c"]
|
||||
|
||||
def test_text_dimensions(self):
|
||||
assert logic.calculate_text_dimensions("a\nb\nc", 20, 512) == (30, 12, 3)
|
||||
|
||||
|
||||
class TestGridAndValidation:
|
||||
@pytest.mark.parametrize("cols", [-1, 0])
|
||||
def test_auto_grid(self, cols):
|
||||
assert logic.calculate_grid_dimensions(9, cols) == (3, 3)
|
||||
|
||||
def test_auto_grid_single_image(self):
|
||||
assert logic.calculate_grid_dimensions(1, -1) == (1, 1)
|
||||
|
||||
def test_explicit_columns_capped_by_count(self):
|
||||
assert logic.calculate_grid_dimensions(3, 10) == (1, 3)
|
||||
assert logic.calculate_grid_dimensions(5, 2) == (3, 2)
|
||||
|
||||
def test_validate_mismatch(self):
|
||||
assert (
|
||||
logic.validate_plot_parameters((3, 8, 8, 3), 2, "none", "none", -1) is False
|
||||
)
|
||||
|
||||
def test_validate_warns_but_passes(self, caplog):
|
||||
with caplog.at_level(logging.WARNING):
|
||||
ok = logic.validate_plot_parameters((1, 8, 8, 3), 1, "bogus", "bogus", 5000)
|
||||
assert ok is True
|
||||
assert len(caplog.records) == 3
|
||||
|
||||
|
||||
class TestPlotNode:
|
||||
@pytest.fixture
|
||||
def node(self):
|
||||
return PlotParametersNode()
|
||||
|
||||
def images(self, n, size=64):
|
||||
return torch.rand(n, size, size, 3)
|
||||
|
||||
def test_mismatched_lengths_raise(self, node):
|
||||
with pytest.raises(ValueError):
|
||||
node.plot_parameters(
|
||||
self.images(2), make_params(3), "none", "none", -1, "false", "false"
|
||||
)
|
||||
|
||||
def test_no_text_no_grid_stacks_batch(self, node):
|
||||
(out,) = node.plot_parameters(
|
||||
self.images(3), make_params(3), "none", "none", -1, "false", "false"
|
||||
)
|
||||
assert out.shape == (3, 64, 64, 3)
|
||||
|
||||
def test_params_text_extends_height(self, node):
|
||||
(out,) = node.plot_parameters(
|
||||
self.images(2), make_params(2), "none", "none", -1, "false", "true"
|
||||
)
|
||||
assert out.shape[0] == 2 and out.shape[1] > 64 and out.shape[2] == 64
|
||||
|
||||
def test_prompt_text_added_and_heights_equalised(self, node):
|
||||
params = make_params(2)
|
||||
params[1]["prompt"] = "short"
|
||||
(out,) = node.plot_parameters(
|
||||
self.images(2), params, "none", "none", -1, "true", "false"
|
||||
)
|
||||
assert out.shape[0] == 2 and out.shape[1] > 64
|
||||
|
||||
def test_excerpt_prompt(self, node):
|
||||
(out,) = node.plot_parameters(
|
||||
self.images(1), make_params(1), "none", "none", -1, "excerpt", "false"
|
||||
)
|
||||
assert out.shape[1] > 64
|
||||
|
||||
def test_changes_only_mode(self, node):
|
||||
(out,) = node.plot_parameters(
|
||||
self.images(2), make_params(2), "none", "none", -1, "false", "changes only"
|
||||
)
|
||||
assert out.shape[0] == 2
|
||||
|
||||
def test_sort_reorders_images(self, node):
|
||||
imgs = torch.zeros(3, 64, 64, 3)
|
||||
for i in range(3):
|
||||
imgs[i] = i / 10
|
||||
params = [{"seed": 3}, {"seed": 1}, {"seed": 2}]
|
||||
(out,) = node.plot_parameters(
|
||||
imgs, params, "seed", "none", -1, "false", "false"
|
||||
)
|
||||
assert out[:, 0, 0, 0].tolist() == pytest.approx([0.1, 0.2, 0.0])
|
||||
|
||||
def test_grid_creation_with_padding(self, node):
|
||||
(out,) = node.plot_parameters(
|
||||
self.images(3), make_params(3), "none", "none", 2, "false", "false"
|
||||
)
|
||||
assert out.shape == (1, 128, 128, 3)
|
||||
|
||||
def test_square_layout_for_cols_zero(self, node):
|
||||
(out,) = node.plot_parameters(
|
||||
self.images(4), make_params(4), "none", "none", 0, "false", "false"
|
||||
)
|
||||
assert out.shape == (1, 128, 128, 3)
|
||||
|
||||
def test_group_by_value_sets_columns(self, node):
|
||||
params = make_params(4)
|
||||
params[0]["sampler"] = params[1]["sampler"] = "a"
|
||||
params[2]["sampler"] = params[3]["sampler"] = "b"
|
||||
(out,) = node.plot_parameters(
|
||||
self.images(4), params, "none", "sampler", 5, "false", "false"
|
||||
)
|
||||
assert out.shape == (1, 128, 128, 3)
|
||||
|
||||
def test_nan_values_cleaned(self, node):
|
||||
imgs = self.images(1)
|
||||
imgs[0, 0, 0, 0] = float("nan")
|
||||
(out,) = node.plot_parameters(
|
||||
imgs, make_params(1), "none", "none", -1, "false", "false"
|
||||
)
|
||||
assert torch.isfinite(out).all()
|
||||
assert out.min() >= 0 and out.max() <= 1
|
||||
|
||||
def test_font_fallback_when_truetype_missing(self, node, monkeypatch):
|
||||
monkeypatch.setattr(node, "_get_font_path", lambda: "/does/not/exist.ttf")
|
||||
(out,) = node.plot_parameters(
|
||||
self.images(1), make_params(1), "none", "none", -1, "false", "true"
|
||||
)
|
||||
assert out.shape[1] > 64
|
||||
|
||||
def test_get_font_path_returns_string_default(self, node, monkeypatch):
|
||||
import os
|
||||
|
||||
monkeypatch.setattr(os.path, "exists", lambda p: False)
|
||||
assert node._get_font_path() == "arial.ttf"
|
||||
|
||||
def test_get_font_path_returns_first_existing(self, node, monkeypatch):
|
||||
import os
|
||||
|
||||
monkeypatch.setattr(os.path, "exists", lambda p: p.endswith("cour.ttf"))
|
||||
assert node._get_font_path().endswith("cour.ttf")
|
||||
|
||||
def test_very_narrow_images_do_not_crash_on_zero_font_size(self, node):
|
||||
(out,) = node.plot_parameters(
|
||||
torch.rand(1, 8, 8, 3), make_params(1), "none", "none", -1, "false", "false"
|
||||
)
|
||||
assert out.shape == (1, 8, 8, 3)
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Hardening tests for Text Encode Sampler Params."""
|
||||
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from kikotools.tools.xyz_helpers.text_encode_sampler_params import (
|
||||
TextEncodeSamplerParamsNode,
|
||||
)
|
||||
from kikotools.tools.xyz_helpers.text_encode_sampler_params.logic import (
|
||||
encode_prompts,
|
||||
split_prompts,
|
||||
)
|
||||
|
||||
|
||||
class TestSplitPrompts:
|
||||
def test_trailing_separator_without_newline(self):
|
||||
assert split_prompts("a\n---") == ["a"]
|
||||
|
||||
def test_leading_separator(self):
|
||||
assert split_prompts("---\na\n---\nb") == ["a", "b"]
|
||||
|
||||
def test_separator_mid_line_does_not_split(self):
|
||||
assert split_prompts("x, ----\ny") == ["x, ----\ny"]
|
||||
|
||||
def test_inline_dashes_do_not_split(self):
|
||||
assert split_prompts("a --- b") == ["a --- b"]
|
||||
|
||||
def test_surrounding_spaces_allowed(self):
|
||||
assert split_prompts("a\n --- \nb") == ["a", "b"]
|
||||
|
||||
def test_windows_line_endings(self):
|
||||
assert split_prompts("a\r\n---\r\nb") == ["a", "b"]
|
||||
|
||||
def test_mixed_chars_are_not_a_separator(self):
|
||||
assert split_prompts("a\n-*-\nb") == ["a\n-*-\nb"]
|
||||
|
||||
def test_all_separator_types(self):
|
||||
assert split_prompts("a\n***\nb\n===\nc\n~~~\nd") == ["a", "b", "c", "d"]
|
||||
|
||||
def test_only_separators_yields_no_prompts(self):
|
||||
assert split_prompts("---\n---") == []
|
||||
|
||||
def test_empty(self):
|
||||
assert split_prompts("") == []
|
||||
|
||||
|
||||
def _nodes_module(encode):
|
||||
mod = MagicMock()
|
||||
mod.CLIPTextEncode.return_value.encode = encode
|
||||
return patch.dict(sys.modules, {"nodes": mod})
|
||||
|
||||
|
||||
class TestEncodePrompts:
|
||||
def test_alignment_preserved(self):
|
||||
with _nodes_module(lambda clip, text: (f"enc:{text}",)):
|
||||
assert encode_prompts(["a", "b"], object()) == ["enc:a", "enc:b"]
|
||||
|
||||
def test_failure_raises_with_index(self):
|
||||
def encode(clip, text):
|
||||
if text == "b":
|
||||
raise RuntimeError("boom")
|
||||
return (f"enc:{text}",)
|
||||
|
||||
with _nodes_module(encode):
|
||||
with pytest.raises(ValueError, match="prompt 2"):
|
||||
encode_prompts(["a", "b", "c"], object())
|
||||
|
||||
def test_none_result_raises(self):
|
||||
with _nodes_module(lambda clip, text: (None,)):
|
||||
with pytest.raises(ValueError, match="prompt 1"):
|
||||
encode_prompts(["a"], object())
|
||||
|
||||
|
||||
class TestNode:
|
||||
def test_node_raises_when_encoding_fails(self):
|
||||
def encode(clip, text):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
with _nodes_module(encode):
|
||||
with pytest.raises(ValueError, match="prompt 1"):
|
||||
TextEncodeSamplerParamsNode().encode_prompts("a\n---\nb", object())
|
||||
|
||||
def test_node_success_keeps_text_and_encoded_aligned(self):
|
||||
with _nodes_module(lambda clip, text: (f"enc:{text}",)):
|
||||
(cond,) = TextEncodeSamplerParamsNode().encode_prompts(
|
||||
"a\n---\nb", object()
|
||||
)
|
||||
assert cond["text"] == ["a", "b"]
|
||||
assert cond["encoded"] == ["enc:a", "enc:b"]
|
||||
|
||||
def test_invalid_text_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
TextEncodeSamplerParamsNode().encode_prompts(" ", object())
|
||||
|
||||
def test_only_separators_returns_empty_conditioning(self):
|
||||
(cond,) = TextEncodeSamplerParamsNode().encode_prompts("---\n---", object())
|
||||
assert cond == {"text": [], "encoded": []}
|
||||
+25
-4
@@ -138,8 +138,16 @@ app.registerExtension({
|
||||
const result = onExecuted?.apply(this, arguments);
|
||||
|
||||
if (this.status) {
|
||||
// Remember the user's color only if no flash is already pending,
|
||||
// otherwise we would capture our own temporary flash color
|
||||
if (this.geminiFlashTimeout) {
|
||||
clearTimeout(this.geminiFlashTimeout);
|
||||
} else {
|
||||
this.geminiPrevBgcolor = this.bgcolor;
|
||||
}
|
||||
|
||||
// Check if there was an error in the output
|
||||
const outputs = message.output;
|
||||
const outputs = message?.output;
|
||||
if (outputs && outputs.prompt && outputs.prompt[0] && outputs.prompt[0].startsWith("Error:")) {
|
||||
this.status.value = "Error - Check output";
|
||||
this.bgcolor = "#552222";
|
||||
@@ -148,17 +156,30 @@ app.registerExtension({
|
||||
this.bgcolor = "#225522";
|
||||
}
|
||||
|
||||
// Reset color after delay
|
||||
setTimeout(() => {
|
||||
this.bgcolor = "";
|
||||
// Restore the previous color after delay
|
||||
this.geminiFlashTimeout = setTimeout(() => {
|
||||
this.geminiFlashTimeout = null;
|
||||
this.bgcolor = this.geminiPrevBgcolor;
|
||||
this.geminiPrevBgcolor = undefined;
|
||||
if (this.status) {
|
||||
this.status.value = "Ready";
|
||||
}
|
||||
this.setDirtyCanvas?.(true, true);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// Clear any pending color-restore timer when the node is removed
|
||||
const onRemoved = nodeType.prototype.onRemoved;
|
||||
nodeType.prototype.onRemoved = function() {
|
||||
if (this.geminiFlashTimeout) {
|
||||
clearTimeout(this.geminiFlashTimeout);
|
||||
this.geminiFlashTimeout = null;
|
||||
}
|
||||
return onRemoved?.apply(this, arguments);
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
+108
-200
@@ -22,10 +22,91 @@ const colorShade = (col, amt) => {
|
||||
return `#${rr}${gg}${bb}`;
|
||||
};
|
||||
|
||||
// One lazily-created hidden color input, shared by every menu entry.
|
||||
// applyPicked holds what the currently open picker should do on change.
|
||||
let picker;
|
||||
let applyPicked;
|
||||
|
||||
const isEnabled = () => {
|
||||
const setting = app.ui.settings.getSettingValue("kikotools.custom_colors.enabled");
|
||||
return setting !== undefined ? setting : true;
|
||||
};
|
||||
|
||||
const getSettings = () => ({
|
||||
showFull: app.ui.settings.getSettingValue("kikotools.custom_colors.show_full") !== false,
|
||||
showTitle: app.ui.settings.getSettingValue("kikotools.custom_colors.show_title") !== false,
|
||||
showBG: app.ui.settings.getSettingValue("kikotools.custom_colors.show_bg") !== false,
|
||||
autoShade: app.ui.settings.getSettingValue("kikotools.custom_colors.auto_shade") !== false,
|
||||
});
|
||||
|
||||
// Helper function to apply color to node(s)
|
||||
// Uses setColorOption like the built-in colors when possible
|
||||
const applyColorToNodes = (colorValue, colorType, node) => {
|
||||
if (!colorValue) return;
|
||||
|
||||
const graphcanvas = LGraphCanvas.active_canvas;
|
||||
const nodes = (!graphcanvas.selected_nodes || Object.keys(graphcanvas.selected_nodes).length <= 1)
|
||||
? [node]
|
||||
: Object.values(graphcanvas.selected_nodes);
|
||||
const titleColor = getSettings().autoShade ? colorShade(colorValue, 20) : colorValue;
|
||||
|
||||
for (const n of nodes) {
|
||||
switch (colorType) {
|
||||
case 'full':
|
||||
if (n.setColorOption) {
|
||||
n.setColorOption({ color: titleColor, bgcolor: colorValue });
|
||||
} else {
|
||||
n.color = titleColor;
|
||||
n.bgcolor = colorValue;
|
||||
}
|
||||
break;
|
||||
case 'title':
|
||||
n.color = colorValue;
|
||||
break;
|
||||
case 'bg':
|
||||
n.bgcolor = colorValue;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
node.setDirtyCanvas(true, true);
|
||||
};
|
||||
|
||||
// Groups have a single color
|
||||
const applyColorToGroup = (colorValue, group) => {
|
||||
if (!colorValue) return;
|
||||
if (group.setColorOption) {
|
||||
group.setColorOption({ groupcolor: colorValue });
|
||||
} else {
|
||||
group.color = colorValue;
|
||||
}
|
||||
app.canvas.setDirty(true, true);
|
||||
};
|
||||
|
||||
// Open the shared hidden color input; apply(color) runs when a color is picked
|
||||
const openPicker = (initialColor, apply) => {
|
||||
if (!picker) {
|
||||
picker = $el("input", {
|
||||
type: "color",
|
||||
parent: document.body,
|
||||
style: { display: "none" },
|
||||
});
|
||||
picker.onchange = () => applyPicked?.(picker.value);
|
||||
}
|
||||
applyPicked = apply;
|
||||
picker.value = initialColor || "#000000";
|
||||
picker.click();
|
||||
};
|
||||
|
||||
// Wrap menu options in the "Custom Colors" submenu (or nothing if empty)
|
||||
const customColorsMenu = (options) => options.length === 0 ? [] : [
|
||||
null, // separator
|
||||
{ content: "🫶 Custom Colors", submenu: { options } },
|
||||
];
|
||||
|
||||
app.registerExtension({
|
||||
name: "kikotools.customColors",
|
||||
async init() {
|
||||
// Register settings
|
||||
app.ui.settings.addSetting({
|
||||
id: "kikotools.custom_colors.enabled",
|
||||
name: "🫶 Custom Colors: Enable",
|
||||
@@ -67,213 +148,40 @@ app.registerExtension({
|
||||
});
|
||||
},
|
||||
|
||||
setup() {
|
||||
let pickerFull, pickerTitle, pickerBG;
|
||||
let activeNode;
|
||||
// New API: return items to append to node right-click menu
|
||||
getNodeMenuItems(node) {
|
||||
if (!isEnabled()) return [];
|
||||
|
||||
// Check if feature is enabled
|
||||
const isEnabled = () => {
|
||||
const setting = app.ui.settings.getSettingValue("kikotools.custom_colors.enabled");
|
||||
return setting !== undefined ? setting : true;
|
||||
};
|
||||
const settings = getSettings();
|
||||
const options = [];
|
||||
|
||||
const getSettings = () => ({
|
||||
showFull: app.ui.settings.getSettingValue("kikotools.custom_colors.show_full") !== false,
|
||||
showTitle: app.ui.settings.getSettingValue("kikotools.custom_colors.show_title") !== false,
|
||||
showBG: app.ui.settings.getSettingValue("kikotools.custom_colors.show_bg") !== false,
|
||||
autoShade: app.ui.settings.getSettingValue("kikotools.custom_colors.auto_shade") !== false,
|
||||
const addOption = (label, type, initialColor) => options.push({
|
||||
content: label,
|
||||
callback: () => openPicker(initialColor, (color) => applyColorToNodes(color, type, node)),
|
||||
});
|
||||
|
||||
// Helper function to apply color to node(s)
|
||||
// Uses setColorOption like the built-in colors when possible
|
||||
const applyColorToNodes = (colorValue, colorType, node) => {
|
||||
if (!colorValue) return;
|
||||
if (settings.showFull) addOption("🫶 Custom Full", 'full', node.bgcolor);
|
||||
if (settings.showTitle) addOption("🫶 Custom Title", 'title', node.color);
|
||||
if (settings.showBG) addOption("🫶 Custom BG", 'bg', node.bgcolor);
|
||||
|
||||
const graphcanvas = LGraphCanvas.active_canvas;
|
||||
const nodes = (!graphcanvas.selected_nodes || Object.keys(graphcanvas.selected_nodes).length <= 1)
|
||||
? [node]
|
||||
: Object.values(graphcanvas.selected_nodes);
|
||||
return customColorsMenu(options);
|
||||
},
|
||||
|
||||
for (const n of nodes) {
|
||||
if (n.constructor === LiteGraph.LGraphGroup) {
|
||||
// For groups, use setColorOption if available
|
||||
if (colorType === 'full' || colorType === 'bg') {
|
||||
if (n.setColorOption) {
|
||||
n.setColorOption({ groupcolor: colorValue });
|
||||
} else {
|
||||
n.color = colorValue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For regular nodes
|
||||
switch(colorType) {
|
||||
case 'full':
|
||||
// Use setColorOption if available (like built-in colors)
|
||||
if (n.setColorOption) {
|
||||
n.setColorOption({
|
||||
color: colorShade(colorValue, 20),
|
||||
bgcolor: colorValue
|
||||
});
|
||||
} else {
|
||||
n.color = colorShade(colorValue, 20);
|
||||
n.bgcolor = colorValue;
|
||||
}
|
||||
break;
|
||||
case 'title':
|
||||
n.color = colorValue;
|
||||
break;
|
||||
case 'bg':
|
||||
n.bgcolor = colorValue;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Groups have no node menu hook; like the frontend's own Comfy.GroupOptions,
|
||||
// find the group under the mouse when the canvas menu opens.
|
||||
getCanvasMenuItems(canvas) {
|
||||
if (!isEnabled()) return [];
|
||||
|
||||
node.setDirtyCanvas(true, true);
|
||||
};
|
||||
const [x, y] = canvas.graph_mouse ?? [];
|
||||
const group = canvas.graph?.getGroupOnPos?.(x, y);
|
||||
if (!group) return [];
|
||||
|
||||
// Create color picker input if not exists
|
||||
const createPicker = (type) => {
|
||||
const picker = $el("input", {
|
||||
type: "color",
|
||||
parent: document.body,
|
||||
style: {
|
||||
display: "none",
|
||||
},
|
||||
});
|
||||
const settings = getSettings();
|
||||
if (!settings.showFull && !settings.showBG) return [];
|
||||
|
||||
picker.onchange = () => {
|
||||
if (activeNode) {
|
||||
applyColorToNodes(picker.value, type, activeNode);
|
||||
}
|
||||
};
|
||||
|
||||
return picker;
|
||||
};
|
||||
|
||||
// Hook into the node colors menu
|
||||
const onMenuNodeColors = LGraphCanvas.onMenuNodeColors;
|
||||
LGraphCanvas.onMenuNodeColors = function (value, options, e, menu, node) {
|
||||
const r = onMenuNodeColors.apply(this, arguments);
|
||||
|
||||
// Only add custom options if enabled
|
||||
if (!isEnabled()) return r;
|
||||
|
||||
const settings = getSettings();
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const menus = document.querySelectorAll(".litecontextmenu");
|
||||
for (let i = menus.length - 1; i >= 0; i--) {
|
||||
const menu = menus[i];
|
||||
if (!menu || !menu.firstElementChild) continue;
|
||||
|
||||
// Check for "No color" in various ways to be compatible with different frontend versions
|
||||
const firstChild = menu.firstElementChild;
|
||||
const textContent = firstChild.textContent || '';
|
||||
const innerHTML = firstChild.innerHTML || '';
|
||||
const valueContent = firstChild.value?.content || '';
|
||||
|
||||
const isColorMenu = textContent.includes("No color") ||
|
||||
innerHTML.includes("No color") ||
|
||||
valueContent.includes("No color");
|
||||
|
||||
if (isColorMenu) {
|
||||
|
||||
// Add Custom Full option
|
||||
if (settings.showFull) {
|
||||
$el(
|
||||
"div.litemenu-entry.submenu",
|
||||
{
|
||||
parent: menu,
|
||||
$: (el) => {
|
||||
el.onclick = () => {
|
||||
LiteGraph.closeAllContextMenus();
|
||||
if (!pickerFull) {
|
||||
pickerFull = createPicker('full');
|
||||
}
|
||||
activeNode = node;
|
||||
pickerFull.value = node.bgcolor || "#000000";
|
||||
pickerFull.click();
|
||||
};
|
||||
},
|
||||
},
|
||||
[
|
||||
$el("span", {
|
||||
style: {
|
||||
paddingLeft: "4px",
|
||||
display: "block",
|
||||
},
|
||||
textContent: "🫶 Custom Full",
|
||||
}),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// Add Custom Title option
|
||||
if (settings.showTitle) {
|
||||
$el(
|
||||
"div.litemenu-entry.submenu",
|
||||
{
|
||||
parent: menu,
|
||||
$: (el) => {
|
||||
el.onclick = () => {
|
||||
LiteGraph.closeAllContextMenus();
|
||||
if (!pickerTitle) {
|
||||
pickerTitle = createPicker('title');
|
||||
}
|
||||
activeNode = node;
|
||||
pickerTitle.value = node.color || "#000000";
|
||||
pickerTitle.click();
|
||||
};
|
||||
},
|
||||
},
|
||||
[
|
||||
$el("span", {
|
||||
style: {
|
||||
paddingLeft: "4px",
|
||||
display: "block",
|
||||
},
|
||||
textContent: "🫶 Custom Title",
|
||||
}),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// Add Custom BG option
|
||||
if (settings.showBG) {
|
||||
$el(
|
||||
"div.litemenu-entry.submenu",
|
||||
{
|
||||
parent: menu,
|
||||
$: (el) => {
|
||||
el.onclick = () => {
|
||||
LiteGraph.closeAllContextMenus();
|
||||
if (!pickerBG) {
|
||||
pickerBG = createPicker('bg');
|
||||
}
|
||||
activeNode = node;
|
||||
pickerBG.value = node.bgcolor || "#000000";
|
||||
pickerBG.click();
|
||||
};
|
||||
},
|
||||
},
|
||||
[
|
||||
$el("span", {
|
||||
style: {
|
||||
paddingLeft: "4px",
|
||||
display: "block",
|
||||
},
|
||||
textContent: "🫶 Custom BG",
|
||||
}),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
return r;
|
||||
};
|
||||
return customColorsMenu([{
|
||||
content: "🫶 Custom Group Color",
|
||||
callback: () => openPicker(group.color, (color) => applyColorToGroup(color, group)),
|
||||
}]);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4,6 +4,31 @@ import { api } from "../../scripts/api.js";
|
||||
// Adds follow execution feature when enabled in settings
|
||||
// Adds menu options to toggle follow execution and go to executing node
|
||||
|
||||
let followExecution = false;
|
||||
let isEnabled = false;
|
||||
|
||||
const checkEnabled = () => {
|
||||
const setting = app.ui.settings.getSettingValue("kikotools.follow_execution.enabled");
|
||||
isEnabled = setting !== undefined ? setting : false;
|
||||
if (!isEnabled && followExecution) {
|
||||
followExecution = false;
|
||||
}
|
||||
};
|
||||
|
||||
const checkAutoStart = () => {
|
||||
const autoStart = app.ui.settings.getSettingValue("kikotools.follow_execution.auto_start");
|
||||
if (autoStart && isEnabled) {
|
||||
followExecution = true;
|
||||
}
|
||||
};
|
||||
|
||||
const centerNode = (id) => {
|
||||
if (!followExecution || !id || !isEnabled) return;
|
||||
const node = app.graph.getNodeById(id);
|
||||
if (!node) return;
|
||||
app.canvas.centerOnNode(node);
|
||||
};
|
||||
|
||||
app.registerExtension({
|
||||
name: "kikotools.followExecution",
|
||||
async init() {
|
||||
@@ -34,40 +59,9 @@ app.registerExtension({
|
||||
},
|
||||
|
||||
async setup() {
|
||||
let followExecution = false;
|
||||
let isEnabled = false;
|
||||
|
||||
// Check if the feature is enabled in settings
|
||||
const checkEnabled = () => {
|
||||
const setting = app.ui.settings.getSettingValue("kikotools.follow_execution.enabled");
|
||||
isEnabled = setting !== undefined ? setting : false;
|
||||
|
||||
// If disabled, turn off follow execution
|
||||
if (!isEnabled && followExecution) {
|
||||
followExecution = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Check for auto-start setting
|
||||
const checkAutoStart = () => {
|
||||
const autoStart = app.ui.settings.getSettingValue("kikotools.follow_execution.auto_start");
|
||||
if (autoStart && isEnabled) {
|
||||
followExecution = true;
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize settings on startup
|
||||
checkEnabled();
|
||||
checkAutoStart();
|
||||
|
||||
// Center on the executing node
|
||||
const centerNode = (id) => {
|
||||
if (!followExecution || !id || !isEnabled) return;
|
||||
const node = app.graph.getNodeById(id);
|
||||
if (!node) return;
|
||||
app.canvas.centerOnNode(node);
|
||||
};
|
||||
|
||||
// Listen for execution events
|
||||
api.addEventListener("executing", ({ detail }) => centerNode(detail));
|
||||
|
||||
@@ -76,21 +70,16 @@ app.registerExtension({
|
||||
checkEnabled();
|
||||
checkAutoStart();
|
||||
});
|
||||
},
|
||||
|
||||
// Extend canvas menu options
|
||||
const orig = LGraphCanvas.prototype.getCanvasMenuOptions;
|
||||
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
|
||||
const options = orig.apply(this, arguments);
|
||||
// New API: return items to append to canvas right-click menu
|
||||
getCanvasMenuItems() {
|
||||
checkEnabled();
|
||||
if (!isEnabled) return [];
|
||||
|
||||
// Check if feature is enabled before adding menu items
|
||||
checkEnabled();
|
||||
if (!isEnabled) return options;
|
||||
|
||||
// Add separator
|
||||
options.push(null);
|
||||
|
||||
// Add follow execution toggle
|
||||
options.push({
|
||||
const items = [
|
||||
null, // separator
|
||||
{
|
||||
content: followExecution ? "🫶 Stop following execution" : "🫶 Follow execution",
|
||||
callback: () => {
|
||||
followExecution = !followExecution;
|
||||
@@ -98,60 +87,55 @@ app.registerExtension({
|
||||
centerNode(app.runningNodeId);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Add go to executing node option if a node is currently executing
|
||||
if (app.runningNodeId) {
|
||||
items.push({
|
||||
content: "🫶 Show executing node",
|
||||
callback: () => {
|
||||
const node = app.graph.getNodeById(app.runningNodeId);
|
||||
if (node) app.canvas.centerOnNode(node);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Add go to executing node option if a node is currently executing
|
||||
if (app.runningNodeId) {
|
||||
options.push({
|
||||
content: "🫶 Show executing node",
|
||||
callback: () => {
|
||||
const node = app.graph.getNodeById(app.runningNodeId);
|
||||
if (!node) return;
|
||||
app.canvas.centerOnNode(node);
|
||||
},
|
||||
});
|
||||
}
|
||||
// Add go to node by type submenu
|
||||
const showGoToNode = app.ui.settings.getSettingValue("kikotools.follow_execution.show_goto_node");
|
||||
if (showGoToNode !== false) {
|
||||
const nodes = app.graph._nodes;
|
||||
const types = nodes.reduce((p, n) => {
|
||||
if (n.type in p) {
|
||||
p[n.type].push(n);
|
||||
} else {
|
||||
p[n.type] = [n];
|
||||
}
|
||||
return p;
|
||||
}, {});
|
||||
|
||||
// Add go to node by type submenu
|
||||
const showGoToNode = app.ui.settings.getSettingValue("kikotools.follow_execution.show_goto_node");
|
||||
if (showGoToNode !== false) { // Default to true if not set
|
||||
const nodes = app.graph._nodes;
|
||||
const types = nodes.reduce((p, n) => {
|
||||
if (n.type in p) {
|
||||
p[n.type].push(n);
|
||||
} else {
|
||||
p[n.type] = [n];
|
||||
}
|
||||
return p;
|
||||
}, {});
|
||||
items.push({
|
||||
content: "🫶 Go to node",
|
||||
submenu: {
|
||||
options: Object.keys(types)
|
||||
.sort()
|
||||
.map((t) => ({
|
||||
content: t,
|
||||
submenu: {
|
||||
options: types[t]
|
||||
.sort((a, b) => a.pos[0] - b.pos[0])
|
||||
.map((n) => ({
|
||||
content: `${n.getTitle()} - #${n.id} (${Math.round(n.pos[0])}, ${Math.round(n.pos[1])})`,
|
||||
callback: () => {
|
||||
app.canvas.centerOnNode(n);
|
||||
},
|
||||
})),
|
||||
},
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
options.push({
|
||||
content: "🫶 Go to node",
|
||||
has_submenu: true,
|
||||
submenu: {
|
||||
options: Object.keys(types)
|
||||
.sort()
|
||||
.map((t) => ({
|
||||
content: t,
|
||||
has_submenu: true,
|
||||
submenu: {
|
||||
options: types[t]
|
||||
.sort((a, b) => {
|
||||
return a.pos[0] - b.pos[0];
|
||||
})
|
||||
.map((n) => ({
|
||||
content: `${n.getTitle()} - #${n.id} (${Math.round(n.pos[0])}, ${Math.round(n.pos[1])})`,
|
||||
callback: () => {
|
||||
app.canvas.centerOnNode(n);
|
||||
},
|
||||
})),
|
||||
},
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return options;
|
||||
};
|
||||
return items;
|
||||
},
|
||||
});
|
||||
|
||||
+129
-20
@@ -1,6 +1,34 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { api } from "/scripts/api.js";
|
||||
|
||||
// Escape a value for safe interpolation into HTML text or attribute contexts
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// Build a readable error message from a non-OK fetch response
|
||||
async function describeHttpError(response) {
|
||||
let detail = '';
|
||||
try {
|
||||
const body = await response.json();
|
||||
detail = body?.error || body?.message || '';
|
||||
} catch (e) {
|
||||
// Non-JSON body; fall back to the status code
|
||||
}
|
||||
if (response.status === 403) {
|
||||
return detail || 'Access denied: path is outside the allowed folders';
|
||||
}
|
||||
if (response.status === 400) {
|
||||
return detail || 'Invalid path or request';
|
||||
}
|
||||
return detail || `Request failed (HTTP ${response.status})`;
|
||||
}
|
||||
|
||||
// Setup global lightbox for image preview
|
||||
function setupGlobalLightbox() {
|
||||
if (document.getElementById('kiko-image-lightbox')) return;
|
||||
@@ -511,7 +539,22 @@ app.registerExtension({
|
||||
const debouncedLayout = debounce(applyMasonryLayout, 20);
|
||||
|
||||
// Set up ResizeObserver for responsive layout
|
||||
new ResizeObserver(debouncedLayout).observe(cardHolder);
|
||||
this.kikoResizeObserver = new ResizeObserver(debouncedLayout);
|
||||
this.kikoResizeObserver.observe(cardHolder);
|
||||
|
||||
const originalOnRemoved = this.onRemoved;
|
||||
this.onRemoved = function () {
|
||||
if (this.kikoResizeObserver) {
|
||||
this.kikoResizeObserver.disconnect();
|
||||
this.kikoResizeObserver = null;
|
||||
}
|
||||
return originalOnRemoved?.apply(this, arguments);
|
||||
};
|
||||
|
||||
const showStatus = (message) => {
|
||||
statusMessage.style.display = 'block';
|
||||
statusMessage.textContent = message;
|
||||
};
|
||||
|
||||
// Autocomplete functionality
|
||||
let autocompleteSelectedIndex = -1;
|
||||
@@ -521,6 +564,10 @@ app.registerExtension({
|
||||
async function fetchDirectorySuggestions(path) {
|
||||
try {
|
||||
const response = await api.fetchApi(`/kiko_local_image_loader/list_directories?path=${encodeURIComponent(path)}`);
|
||||
if (!response.ok) {
|
||||
// Disallowed or invalid paths simply yield no suggestions
|
||||
return [];
|
||||
}
|
||||
const data = await response.json();
|
||||
return data.directories || [];
|
||||
} catch (error) {
|
||||
@@ -545,11 +592,11 @@ app.registerExtension({
|
||||
const parentPath = parts.length > 0 ? '/' + parts.join('/') : '';
|
||||
|
||||
return `
|
||||
<div class="kiko-autocomplete-item" data-index="${index}" data-path="${path}">
|
||||
<div class="kiko-autocomplete-item" data-index="${index}" data-path="${escapeHtml(path)}">
|
||||
<div class="folder-icon">${smallFolderSVG}</div>
|
||||
<div class="path-text">
|
||||
<span class="folder-name">${folderName}</span>
|
||||
${parentPath ? `<span class="parent-path"> — ${parentPath}</span>` : ''}
|
||||
<span class="folder-name">${escapeHtml(folderName)}</span>
|
||||
${parentPath ? `<span class="parent-path"> — ${escapeHtml(parentPath)}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -668,6 +715,9 @@ app.registerExtension({
|
||||
async function loadSavedPaths() {
|
||||
try {
|
||||
const response = await api.fetchApi('/kiko_local_image_loader/get_saved_paths');
|
||||
if (!response.ok) {
|
||||
throw new Error(await describeHttpError(response));
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
pathPresets.innerHTML = '<option value="">-- Saved Paths --</option>';
|
||||
@@ -683,27 +733,41 @@ app.registerExtension({
|
||||
}
|
||||
}
|
||||
|
||||
// Save current path
|
||||
savePathBtn.onclick = async () => {
|
||||
if (!currentDirectory) return;
|
||||
|
||||
// Add a directory to the saved paths (the backend only serves
|
||||
// media inside saved paths + ComfyUI input/output).
|
||||
// Returns true on success.
|
||||
async function addSavedPath(directory) {
|
||||
try {
|
||||
const response = await api.fetchApi('/kiko_local_image_loader/get_saved_paths');
|
||||
if (!response.ok) {
|
||||
throw new Error(await describeHttpError(response));
|
||||
}
|
||||
const data = await response.json();
|
||||
const savedPaths = data.saved_paths || [];
|
||||
|
||||
if (!savedPaths.includes(currentDirectory)) {
|
||||
savedPaths.push(currentDirectory);
|
||||
await api.fetchApi('/kiko_local_image_loader/save_paths', {
|
||||
if (!savedPaths.includes(directory)) {
|
||||
const saveResponse = await api.fetchApi('/kiko_local_image_loader/save_paths', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ paths: savedPaths })
|
||||
body: JSON.stringify({ paths: [...savedPaths, directory] })
|
||||
});
|
||||
if (!saveResponse.ok) {
|
||||
throw new Error(await describeHttpError(saveResponse));
|
||||
}
|
||||
await loadSavedPaths();
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error saving path:', error);
|
||||
showStatus(`Error saving path: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Save current path
|
||||
savePathBtn.onclick = async () => {
|
||||
if (!currentDirectory) return;
|
||||
await addSavedPath(currentDirectory);
|
||||
};
|
||||
|
||||
// Delete selected saved path
|
||||
@@ -713,20 +777,27 @@ app.registerExtension({
|
||||
|
||||
try {
|
||||
const response = await api.fetchApi('/kiko_local_image_loader/get_saved_paths');
|
||||
if (!response.ok) {
|
||||
throw new Error(await describeHttpError(response));
|
||||
}
|
||||
const data = await response.json();
|
||||
const savedPaths = data.saved_paths || [];
|
||||
|
||||
const updatedPaths = savedPaths.filter(p => p !== selectedPath);
|
||||
if (updatedPaths.length !== savedPaths.length) {
|
||||
await api.fetchApi('/kiko_local_image_loader/save_paths', {
|
||||
const saveResponse = await api.fetchApi('/kiko_local_image_loader/save_paths', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ paths: updatedPaths })
|
||||
});
|
||||
if (!saveResponse.ok) {
|
||||
throw new Error(await describeHttpError(saveResponse));
|
||||
}
|
||||
await loadSavedPaths();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting path:', error);
|
||||
showStatus(`Error deleting path: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -737,6 +808,28 @@ app.registerExtension({
|
||||
}
|
||||
};
|
||||
|
||||
// Called when the user browses to a folder that isn't a saved
|
||||
// path yet (backend answered 403). statusMessage is visible.
|
||||
// Adding a folder is an explicit click (never automatic), and the
|
||||
// full path is shown so the user sees exactly what they expose.
|
||||
// The server rejects "/" and the home folder itself.
|
||||
function handleFolderNotAllowed(directory, page) {
|
||||
statusMessage.textContent = 'This folder is not in your saved paths: ';
|
||||
const pathLabel = document.createElement('code');
|
||||
pathLabel.textContent = directory;
|
||||
const addBtn = document.createElement('button');
|
||||
addBtn.textContent = 'Add folder & open';
|
||||
addBtn.style.marginLeft = '8px';
|
||||
addBtn.onclick = async () => {
|
||||
addBtn.disabled = true;
|
||||
// On failure addSavedPath shows the server's reason
|
||||
if (await addSavedPath(directory)) {
|
||||
loadImages(directory, page);
|
||||
}
|
||||
};
|
||||
statusMessage.append(pathLabel, addBtn);
|
||||
}
|
||||
|
||||
// Load images from directory
|
||||
async function loadImages(directory, page = 1) {
|
||||
if (!directory) return;
|
||||
@@ -757,6 +850,14 @@ app.registerExtension({
|
||||
|
||||
try {
|
||||
const response = await api.fetchApi(`/kiko_local_image_loader/images?${params}`);
|
||||
if (response.status === 403) {
|
||||
handleFolderNotAllowed(directory, page);
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
statusMessage.textContent = await describeHttpError(response);
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
@@ -820,7 +921,7 @@ app.registerExtension({
|
||||
<div class="kiko-card-media-wrapper">
|
||||
<div class="kiko-folder-card">
|
||||
<div class="kiko-folder-icon">${folderSVG}</div>
|
||||
<div class="kiko-folder-name">${item.name}</div>
|
||||
<div class="kiko-folder-name">${escapeHtml(item.name)}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -831,9 +932,9 @@ app.registerExtension({
|
||||
const thumbnailUrl = `/kiko_local_image_loader/thumbnail?filepath=${encodeURIComponent(item.path)}`;
|
||||
card.innerHTML = `
|
||||
<div class="kiko-card-media-wrapper">
|
||||
<img src="${thumbnailUrl}" alt="${item.name}" />
|
||||
<img src="${escapeHtml(thumbnailUrl)}" alt="${escapeHtml(item.name)}" />
|
||||
</div>
|
||||
<div class="kiko-card-info-panel">${item.name}</div>
|
||||
<div class="kiko-card-info-panel">${escapeHtml(item.name)}</div>
|
||||
`;
|
||||
|
||||
// Trigger layout when image loads
|
||||
@@ -856,10 +957,10 @@ app.registerExtension({
|
||||
const thumbnailUrl = `/kiko_local_image_loader/thumbnail?filepath=${encodeURIComponent(item.path)}`;
|
||||
card.innerHTML = `
|
||||
<div class="kiko-card-media-wrapper">
|
||||
<img src="${thumbnailUrl}" alt="${item.name}" />
|
||||
<img src="${escapeHtml(thumbnailUrl)}" alt="${escapeHtml(item.name)}" />
|
||||
<div class="kiko-video-card-overlay">${videoSVG}</div>
|
||||
</div>
|
||||
<div class="kiko-card-info-panel">${item.name}</div>
|
||||
<div class="kiko-card-info-panel">${escapeHtml(item.name)}</div>
|
||||
`;
|
||||
|
||||
const img = card.querySelector('img');
|
||||
@@ -882,7 +983,7 @@ app.registerExtension({
|
||||
<div class="kiko-card-media-wrapper">
|
||||
<div class="kiko-audio-card">
|
||||
<div class="kiko-audio-icon">${audioSVG}</div>
|
||||
<div class="kiko-audio-name">${item.name}</div>
|
||||
<div class="kiko-audio-name">${escapeHtml(item.name)}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -924,7 +1025,7 @@ app.registerExtension({
|
||||
|
||||
// Send to backend
|
||||
try {
|
||||
await api.fetchApi('/kiko_local_image_loader/set_node_selection', {
|
||||
const response = await api.fetchApi('/kiko_local_image_loader/set_node_selection', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -933,11 +1034,16 @@ app.registerExtension({
|
||||
type: type
|
||||
})
|
||||
});
|
||||
if (!response.ok) {
|
||||
showStatus(`Selection rejected: ${await describeHttpError(response)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Trigger node update
|
||||
node.setDirtyCanvas(true);
|
||||
} catch (error) {
|
||||
console.error('Error setting selection:', error);
|
||||
showStatus('Error setting selection');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -997,10 +1103,13 @@ app.registerExtension({
|
||||
|
||||
// Try to load last path
|
||||
api.fetchApi('/kiko_local_image_loader/get_last_path').then(async response => {
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
if (data.last_path) {
|
||||
loadImages(data.last_path);
|
||||
}
|
||||
}).catch(error => {
|
||||
console.error('Error loading last path:', error);
|
||||
});
|
||||
|
||||
return r;
|
||||
|
||||
+49
-86
@@ -377,6 +377,26 @@ function injectStyles() {
|
||||
}
|
||||
}
|
||||
|
||||
// Escape a value for safe interpolation into HTML text or attribute contexts
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// Build a ComfyUI /api/view URL with every parameter encoded
|
||||
function buildViewUrl(filename, subfolder, type) {
|
||||
const params = new URLSearchParams({ filename: filename ?? '' });
|
||||
if (subfolder && subfolder.trim()) {
|
||||
params.set('subfolder', subfolder);
|
||||
}
|
||||
params.set('type', type || 'output');
|
||||
return `${window.location.origin}/api/view?${params.toString()}`;
|
||||
}
|
||||
|
||||
// Format file size for display
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
@@ -388,8 +408,6 @@ function formatFileSize(bytes) {
|
||||
|
||||
// Open image in new tab
|
||||
function openImageInTab(imagePath, subfolder = '', enablePopup = true) {
|
||||
console.log(`KikoSaveImage: Attempting to open image: ${imagePath}, subfolder: ${subfolder}`);
|
||||
|
||||
// Note: enablePopup parameter is kept for compatibility but not used
|
||||
// since popup now controls viewer visibility, not individual image clicks
|
||||
|
||||
@@ -403,16 +421,12 @@ function openImageInTab(imagePath, subfolder = '', enablePopup = true) {
|
||||
fullPath = `${basePath}/api/view?filename=${encodeURIComponent(imagePath)}&type=output`;
|
||||
}
|
||||
|
||||
console.log(`KikoSaveImage: Opening URL: ${fullPath}`);
|
||||
|
||||
// Extract clean filename for window name (remove timestamp and batch number)
|
||||
const cleanName = imagePath.split('_').slice(0, -2).join('_') || 'KikoSaveImage';
|
||||
|
||||
// Try to open the image with a clean window name
|
||||
const newWindow = window.open(fullPath, cleanName.replace(/[^a-zA-Z0-9]/g, '_'));
|
||||
if (newWindow) {
|
||||
console.log('KikoSaveImage: Successfully opened new window');
|
||||
} else {
|
||||
if (!newWindow) {
|
||||
console.error('KikoSaveImage: Failed to open new window - popup blocked?');
|
||||
// Fallback: copy URL to clipboard and alert user
|
||||
navigator.clipboard.writeText(fullPath).then(() => {
|
||||
@@ -435,6 +449,10 @@ class KikoImageViewer extends HTMLElement {
|
||||
this.dragOffset = { x: 0, y: 0 };
|
||||
this.lastHeaderClick = 0;
|
||||
|
||||
// Bind document-level drag handlers once so they can be removed later
|
||||
this.boundHandleDrag = this.handleDrag.bind(this);
|
||||
this.boundEndDrag = this.endDrag.bind(this);
|
||||
|
||||
// Always start at default position
|
||||
}
|
||||
|
||||
@@ -442,6 +460,16 @@ class KikoImageViewer extends HTMLElement {
|
||||
return ['data'];
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
document.addEventListener('mousemove', this.boundHandleDrag);
|
||||
document.addEventListener('mouseup', this.boundEndDrag);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
document.removeEventListener('mousemove', this.boundHandleDrag);
|
||||
document.removeEventListener('mouseup', this.boundEndDrag);
|
||||
}
|
||||
|
||||
attributeChangedCallback(name, oldValue, newValue) {
|
||||
if (name === 'data' && newValue) {
|
||||
try {
|
||||
@@ -455,8 +483,6 @@ class KikoImageViewer extends HTMLElement {
|
||||
}
|
||||
|
||||
setImageData(data) {
|
||||
console.log('KikoImageViewer: setImageData called with:', data);
|
||||
console.log('KikoImageViewer: First image data:', data[0]);
|
||||
this.imageData = data;
|
||||
this.autoUnrollOnNewImages();
|
||||
this.render();
|
||||
@@ -513,9 +539,7 @@ class KikoImageViewer extends HTMLElement {
|
||||
});
|
||||
});
|
||||
|
||||
// Global mouse events for dragging
|
||||
document.addEventListener('mousemove', this.handleDrag.bind(this));
|
||||
document.addEventListener('mouseup', this.endDrag.bind(this));
|
||||
// Document-level drag listeners are managed in connectedCallback/disconnectedCallback
|
||||
}
|
||||
|
||||
startDrag(e) {
|
||||
@@ -697,12 +721,9 @@ class KikoImageViewer extends HTMLElement {
|
||||
openAllImagesWithDelay() {
|
||||
const selectedIndices = Array.from(this.selectedImages);
|
||||
if (selectedIndices.length === 0) {
|
||||
console.log('KikoSaveImage: No images selected for opening');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`KikoSaveImage: Opening ${selectedIndices.length} images with delay`);
|
||||
|
||||
// Open images with 150ms delay between each to prevent popup blocking
|
||||
selectedIndices.forEach((index, i) => {
|
||||
setTimeout(() => {
|
||||
@@ -716,12 +737,7 @@ class KikoImageViewer extends HTMLElement {
|
||||
|
||||
downloadImage(imageData) {
|
||||
// Construct proper image URL handling subfolder
|
||||
let imageUrl;
|
||||
if (imageData.subfolder && imageData.subfolder.trim()) {
|
||||
imageUrl = `${window.location.origin}/api/view?filename=${encodeURIComponent(imageData.filename)}&subfolder=${encodeURIComponent(imageData.subfolder)}&type=${imageData.type}`;
|
||||
} else {
|
||||
imageUrl = `${window.location.origin}/api/view?filename=${encodeURIComponent(imageData.filename)}&type=${imageData.type}`;
|
||||
}
|
||||
const imageUrl = buildViewUrl(imageData.filename, imageData.subfolder, imageData.type);
|
||||
|
||||
// Create temporary link and trigger download
|
||||
const link = document.createElement('a');
|
||||
@@ -748,7 +764,7 @@ class KikoImageViewer extends HTMLElement {
|
||||
}
|
||||
|
||||
const header = this.imageData.length === 1
|
||||
? `Saved Image (${this.imageData[0].format})`
|
||||
? `Saved Image (${escapeHtml(this.imageData[0].format)})`
|
||||
: `Saved Images (${this.imageData.length} files)`;
|
||||
|
||||
const containerStyle = this.isMinimized ? 'style="display: none;"' : '';
|
||||
@@ -791,15 +807,10 @@ class KikoImageViewer extends HTMLElement {
|
||||
|
||||
createImageItem(data, index) {
|
||||
// Construct proper image URL handling subfolder
|
||||
let imageUrl;
|
||||
if (data.subfolder && data.subfolder.trim()) {
|
||||
// ComfyUI expects subfolder to be passed separately
|
||||
imageUrl = `${window.location.origin}/api/view?filename=${encodeURIComponent(data.filename)}&subfolder=${encodeURIComponent(data.subfolder)}&type=${data.type}`;
|
||||
} else {
|
||||
imageUrl = `${window.location.origin}/api/view?filename=${encodeURIComponent(data.filename)}&type=${data.type}`;
|
||||
}
|
||||
// ComfyUI expects subfolder to be passed separately
|
||||
const imageUrl = buildViewUrl(data.filename, data.subfolder, data.type);
|
||||
|
||||
const formatClass = `kiko-format-${data.format.toLowerCase()}`;
|
||||
const formatClass = `kiko-format-${String(data.format ?? '').toLowerCase().replace(/[^a-z0-9_-]/g, '')}`;
|
||||
|
||||
// Format file size
|
||||
const fileSize = data.file_size ? formatFileSize(data.file_size) : 'Unknown';
|
||||
@@ -818,7 +829,7 @@ class KikoImageViewer extends HTMLElement {
|
||||
|
||||
return `
|
||||
<div class="kiko-image-item" data-index="${index}">
|
||||
<img src="${imageUrl}" alt="${data.filename}" loading="lazy" />
|
||||
<img src="${escapeHtml(imageUrl)}" alt="${escapeHtml(data.filename)}" loading="lazy" />
|
||||
|
||||
${this.imageData.length > 1 ? `
|
||||
<div class="kiko-image-selector" data-index="${index}" title="Select image"></div>
|
||||
@@ -829,14 +840,14 @@ class KikoImageViewer extends HTMLElement {
|
||||
</div>
|
||||
|
||||
<div class="kiko-image-info">
|
||||
<div class="kiko-image-filename" title="${data.filename}">
|
||||
${data.filename}
|
||||
<div class="kiko-image-filename" title="${escapeHtml(data.filename)}">
|
||||
${escapeHtml(data.filename)}
|
||||
</div>
|
||||
<div class="kiko-image-details">
|
||||
<div class="kiko-image-stats">
|
||||
<span class="kiko-format-badge ${formatClass}">${data.format}</span>
|
||||
${data.dimensions || 'Unknown'} • ${fileSize}
|
||||
${qualityInfo ? ` • ${qualityInfo}` : ''}
|
||||
<span class="kiko-format-badge ${formatClass}">${escapeHtml(data.format)}</span>
|
||||
${escapeHtml(data.dimensions || 'Unknown')} • ${escapeHtml(fileSize)}
|
||||
${qualityInfo ? ` • ${escapeHtml(qualityInfo)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -852,7 +863,6 @@ class KikoImageViewer extends HTMLElement {
|
||||
item.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
console.log(`KikoImageViewer: Clicking image ${index}: ${data.filename}`);
|
||||
openImageInTab(data.filename, data.subfolder || '');
|
||||
});
|
||||
}
|
||||
@@ -872,8 +882,7 @@ function createImagePreview(imageData) {
|
||||
|
||||
// Create image element
|
||||
const img = document.createElement('img');
|
||||
const imagePath = `/api/view?filename=${imageData.filename}&type=${imageData.type}`;
|
||||
img.src = imagePath;
|
||||
img.src = buildViewUrl(imageData.filename, imageData.subfolder, imageData.type);
|
||||
img.alt = imageData.filename;
|
||||
|
||||
// Create info overlay
|
||||
@@ -1012,17 +1021,13 @@ app.registerExtension({
|
||||
// Replace the standard image viewer with our custom web component
|
||||
const originalOnExecuted = nodeType.prototype.onExecuted;
|
||||
nodeType.prototype.onExecuted = function(message) {
|
||||
console.log('KikoSaveImage: onExecuted called with message:', message);
|
||||
|
||||
// Skip the original ComfyUI preview system
|
||||
// Don't call originalOnExecuted to prevent default image display
|
||||
|
||||
// Use our custom web component instead
|
||||
if (message && message.kiko_enhanced && message.kiko_enhanced.length > 0) {
|
||||
console.log('KikoSaveImage: Creating custom image viewer');
|
||||
this.createCustomImageViewer(message.kiko_enhanced);
|
||||
} else {
|
||||
console.log('KikoSaveImage: No enhanced data found, falling back to standard preview');
|
||||
// Fall back to standard behavior if no enhanced data
|
||||
if (originalOnExecuted) {
|
||||
originalOnExecuted.apply(this, arguments);
|
||||
@@ -1032,14 +1037,10 @@ app.registerExtension({
|
||||
|
||||
// Add method to create custom image viewer
|
||||
nodeType.prototype.createCustomImageViewer = function(imageData) {
|
||||
console.log('KikoSaveImage: Creating custom viewer for', imageData.length, 'images');
|
||||
|
||||
// Check if popup is enabled for any image (use first image's popup setting)
|
||||
const popupEnabled = imageData.length > 0 ? imageData[0].popup : true;
|
||||
console.log('KikoSaveImage: Popup enabled:', popupEnabled);
|
||||
|
||||
if (!popupEnabled) {
|
||||
console.log('KikoSaveImage: Popup disabled, not showing custom viewer');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1049,7 +1050,6 @@ app.registerExtension({
|
||||
// Update existing viewer with new data
|
||||
existingViewer.setImageData(imageData);
|
||||
existingViewer.show(); // Make sure it's visible
|
||||
console.log('KikoSaveImage: Updated existing viewer');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1072,8 +1072,6 @@ app.registerExtension({
|
||||
|
||||
// Append to body (floating window) - always visible
|
||||
document.body.appendChild(viewer);
|
||||
|
||||
console.log('KikoSaveImage: Custom viewer created successfully');
|
||||
};
|
||||
|
||||
// Add method to create toggle button
|
||||
@@ -1137,50 +1135,16 @@ app.registerExtension({
|
||||
|
||||
nodeElement.appendChild(toggleButton);
|
||||
this.kikoToggleButton = toggleButton;
|
||||
|
||||
console.log('KikoSaveImage: Toggle button created');
|
||||
};
|
||||
|
||||
// Legacy method kept for compatibility (not used with web component)
|
||||
nodeType.prototype.enhanceImagePreviews = function(imageData) {
|
||||
console.log('KikoSaveImage: Legacy enhanceImagePreviews called (should use web component instead)');
|
||||
// No-op: superseded by the kiko-image-viewer web component
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Export helper functions to global scope for debugging
|
||||
window.kikoSaveImageTest = function() {
|
||||
console.log('KikoSaveImage: Testing click functionality...');
|
||||
|
||||
// Find all images in the document
|
||||
const allImages = document.querySelectorAll('img');
|
||||
console.log(`Found ${allImages.length} images in document`);
|
||||
|
||||
// Try to find images that look like our saved images
|
||||
allImages.forEach((img, index) => {
|
||||
console.log(`Image ${index}: src = ${img.src}`);
|
||||
|
||||
// Add test click handler to all images
|
||||
img.style.border = '2px solid red';
|
||||
img.style.cursor = 'pointer';
|
||||
img.title = 'TEST: Click to open in new tab';
|
||||
|
||||
// Remove old handlers and add new one
|
||||
const newImg = img.cloneNode(true);
|
||||
img.parentNode.replaceChild(newImg, img);
|
||||
|
||||
newImg.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
console.log('TEST CLICK WORKED!', newImg.src);
|
||||
window.open(newImg.src, '_blank');
|
||||
}, true);
|
||||
});
|
||||
|
||||
console.log('KikoSaveImage: Test setup complete. All images should now be clickable with red borders.');
|
||||
};
|
||||
|
||||
// 🎉 RESET VIEWER WINDOWS 🎉
|
||||
window.kikoResetViewer = function() {
|
||||
console.log('🎉 Resetting KikoSaveImage viewer windows...');
|
||||
@@ -1219,4 +1183,3 @@ console.log('🎉 KikoSaveImage: Extension loaded!');
|
||||
console.log('💡 Helper functions available:');
|
||||
console.log(' 📞 kikoResetViewer() - Remove existing viewer windows');
|
||||
console.log(' 🚀 kikoForceViewer() - Show demo viewer');
|
||||
console.log(' 🧪 kikoSaveImageTest() - Test image click functionality');
|
||||
|
||||
@@ -448,8 +448,10 @@ const KikoWorkflowTimerExtension = {
|
||||
KikoGlobalTimer.registerNode(this);
|
||||
};
|
||||
|
||||
const origOnRemoved = nodeType.prototype.onRemoved;
|
||||
nodeType.prototype.onRemoved = function() {
|
||||
KikoGlobalTimer.unregisterNode(this);
|
||||
return origOnRemoved?.apply(this, arguments);
|
||||
};
|
||||
|
||||
const origOnSerialize = nodeType.prototype.onSerialize;
|
||||
@@ -461,7 +463,7 @@ const KikoWorkflowTimerExtension = {
|
||||
const origOnConfigure = nodeType.prototype.onConfigure;
|
||||
nodeType.prototype.onConfigure = function(info) {
|
||||
origOnConfigure?.apply(this, arguments);
|
||||
this.properties = info.properties || {};
|
||||
this.properties = { ...(this.properties || {}), ...(info?.properties || {}) };
|
||||
|
||||
if (this.timerDisplay) {
|
||||
this.timerDisplay.textContent = this.properties.elapsed_time_str || "00:00:000";
|
||||
|
||||
+15
-36
@@ -6,44 +6,22 @@ import { app } from "../../scripts/app.js";
|
||||
app.registerExtension({
|
||||
name: "ComfyAssets.Extensions",
|
||||
|
||||
async setup() {
|
||||
// Wait for the canvas to be ready
|
||||
setTimeout(() => {
|
||||
const getNodeMenuOptions = LGraphCanvas.prototype.getNodeMenuOptions;
|
||||
// New API: return items to append to node right-click menu
|
||||
getNodeMenuItems(node) {
|
||||
// Only add our menu items to ComfyAssets nodes
|
||||
if (!node.constructor.category || !node.constructor.category.includes("ComfyAssets")) {
|
||||
return [];
|
||||
}
|
||||
|
||||
LGraphCanvas.prototype.getNodeMenuOptions = function (node) {
|
||||
const options = getNodeMenuOptions.apply(this, arguments);
|
||||
|
||||
// Only add our menu items to ComfyAssets nodes
|
||||
if (node.constructor.category && node.constructor.category.includes("ComfyAssets")) {
|
||||
node.setDirtyCanvas(true, true);
|
||||
|
||||
// Find the position before the last separator (usually before "Remove")
|
||||
let insertIndex = options.length - 1;
|
||||
for (let i = options.length - 1; i >= 0; i--) {
|
||||
if (options[i] === null) {
|
||||
insertIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Insert our custom menu items
|
||||
const kikoOptions = [
|
||||
null, // separator
|
||||
{
|
||||
content: "🎨 Node Dimensions",
|
||||
callback: () => {
|
||||
KikoToolsExtensions.showNodeDimensionsDialog(node);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
options.splice(insertIndex, 0, ...kikoOptions);
|
||||
return [
|
||||
null, // separator
|
||||
{
|
||||
content: "🎨 Node Dimensions",
|
||||
callback: () => {
|
||||
KikoToolsExtensions.showNodeDimensionsDialog(node);
|
||||
}
|
||||
|
||||
return options;
|
||||
};
|
||||
}, 500);
|
||||
}
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
@@ -71,6 +49,7 @@ class KikoToolsExtensions {
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
|
||||
`;
|
||||
|
||||
// innerHTML used here with hardcoded templates only (numeric node dimensions)
|
||||
dialog.innerHTML = htmlContent;
|
||||
|
||||
// Create button container
|
||||
|
||||
+10
-9
@@ -296,7 +296,7 @@ app.registerExtension({
|
||||
|
||||
// Generate new random seed
|
||||
nodeType.prototype.generateRandomSeed = function () {
|
||||
const newSeed = Math.floor(Math.random() * 0xFFFFFFFFFFFFFFFF);
|
||||
const newSeed = Math.floor(Math.random() * 0xFFFFFFFF); // 2**32 - 1
|
||||
|
||||
const seedWidget = this.widgets?.find(w => w.name === "seed");
|
||||
if (seedWidget) {
|
||||
@@ -371,14 +371,15 @@ app.registerExtension({
|
||||
});
|
||||
|
||||
const timeAgo = this.formatTimeAgo(item.timestamp);
|
||||
entryDiv.innerHTML = `
|
||||
<div style="color: #fff; font-weight: bold; margin-bottom: 1px;">
|
||||
🎲 ${item.seed}
|
||||
</div>
|
||||
<div style="color: #999; font-size: 8px;">
|
||||
⏰ ${timeAgo}
|
||||
</div>
|
||||
`;
|
||||
// Build with textContent: history comes from localStorage and is untrusted
|
||||
const seedDiv = document.createElement("div");
|
||||
seedDiv.style.cssText = "color: #fff; font-weight: bold; margin-bottom: 1px;";
|
||||
seedDiv.textContent = `🎲 ${Number(item.seed)}`;
|
||||
const timeDiv = document.createElement("div");
|
||||
timeDiv.style.cssText = "color: #999; font-size: 8px;";
|
||||
timeDiv.textContent = `⏰ ${timeAgo}`;
|
||||
entryDiv.appendChild(seedDiv);
|
||||
entryDiv.appendChild(timeDiv);
|
||||
|
||||
this.historyDisplay.appendChild(entryDiv);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user