Compare commits

..
Author SHA1 Message Date
Vito Sansevero d65c11c76c feat(project_file_path): add Project File Path node
Builds a save prefix like MyProject/2026-10-13/images/image for a save
node's filename_prefix, replacing djz-nodes' Project File Path Generator
with a fresh implementation:

- Same inputs, root defaults to empty (prefixes are already relative to
  the output folder, so "output" nested as output/output/...)
- Optional date folder; IS_CHANGED tracks the date instead of forcing a
  re-run on every queue
- Components are split on / and \ with '..' segments dropped, so a path
  can't climb out of the output folder
- Only real file extensions are stripped (render_v1.5 keeps its dot)
- The separator option is applied last instead of being undone by
  normpath; an empty filename raises a clear error
2026-10-13 07:27:49 -07:00
Vito 1879f42f83 Merge pull request #64 from ComfyAssets/fix/review-security-and-coverage
Security hardening, crash fixes, cross-platform CI and 95% coverage
2026-10-13 07:26:02 -07:00
Vito Sansevero c2d85d4ba3 test: compare normalized save subfolder (forward slashes on Windows) 2026-10-13 07:23:11 -07:00
Vito Sansevero 2246484b99 fix: keep console output ASCII so Windows legacy consoles can load the node
A Windows console on a legacy code page (cp1252, or cp437 in cmd)
raises UnicodeEncodeError on emoji. The startup banner printed a 🫶
prefix and display names like "Model Downloader 🌐", which aborts
loading the whole node pack. Same approach as ComfyUI_PromptManager:

- Banner prints ASCII-only display names (emoji stay in the UI names)
- Preset validation messages print "1024x1024" instead of "1024×1024"
- Explicit UTF-8 for text files (Windows defaults to cp1252)
- Regression test imports the package with a strict cp1252/cp437 stdout
2026-10-13 07:20:11 -07:00
Vito Sansevero 2a637322b9 test: fix Windows-only test failures
- Close PIL images and NamedTemporaryFile handles before deleting the
  files; Windows refuses to delete open files (WinError 32)
- Set USERPROFILE alongside HOME: expanduser ignores HOME on Windows
- Compare realpaths in the nested save-prefix test; Windows temp dirs
  use 8.3 short names (RUNNER~1)
2026-10-13 07:18:30 -07:00
Vito Sansevero 684e61638b chore: exclude gitignored referance/ from flake8 2026-10-13 07:13:50 -07:00
Vito Sansevero a0b132ef7b ci: run pytest on Linux, Windows and macOS with an 85% coverage gate
tests.yml only ran inline smoke scripts on Ubuntu, never the pytest
suite, so broken tests went unnoticed on main. Mirror ComfyUI_PromptManager:

- pytest on ubuntu/windows/macos x Python 3.10-3.13 (fail-fast off)
- separate coverage job enforcing fail_under = 85
- syntax check every web/*.js as an ES module
- CPU-only torch on Linux to avoid the ~2.5 GB CUDA wheel
2026-10-13 07:13:08 -07:00
Vito Sansevero 935e94b651 fix(web): restore custom colors on group menus and honor auto-shade
- Groups have no node-menu hook, so the menu-hook refactor dropped their
  custom color entry; add it via getCanvasMenuItems using the group under
  the mouse, as the frontend's own Comfy.GroupOptions does
- The "Auto-shade Title" setting was read but never applied
- Share one hidden color input instead of three
- Don't mark the canvas dirty while merely building the node menu
2026-10-13 07:07:34 -07:00
Vito Sansevero 7513479419 refactor(web): use extension menu hooks instead of patching LiteGraph
Replace monkey-patches of LGraphCanvas.onMenuNodeColors and the
canvas/node menu getters (applied after a 500ms timeout, and scraping
.litecontextmenu DOM for "No color") with the frontend's
getNodeMenuItems/getCanvasMenuItems extension hooks. Custom colors move
into a "Custom Colors" submenu; follow-execution state moves to module
scope so repeated setup() calls no longer stack wrappers.
2026-10-13 06:57:41 -07:00
Vito Sansevero 865dde6cdf feat(web): offer "Add folder & open" when browsing an unsaved folder
The backend now 403s folders outside the saved paths. Show the full path
and add it only on an explicit click; server-side refusals (e.g. a
too-broad folder) are shown in the status line.
2026-10-13 06:56:49 -07:00
Vito Sansevero 30fc6aa7a7 fix(local_image_loader): refuse filesystem roots and home as saved paths
save_paths is reachable without auth, so saving "/" made every media
file on the machine servable. Reject filesystem/drive roots, the home
directory and its ancestors (realpath-resolved, so symlinks count), and
ignore such entries already in config.json.
2026-10-13 06:56:49 -07:00
Vito Sansevero a055543d16 fix(model_downloader): never write into custom_nodes or save code files
ComfyUI registers custom_nodes in folder_names_and_paths, so treating
every registered folder as a download root let a queued prompt drop a
.py file there that runs on the next restart.

- Skip custom_nodes when collecting allowed roots
- save_response only writes model/data extensions (.safetensors, .gguf,
  .ckpt, .pth, .json, ...), so any future code-holding folder is still
  protected
2026-10-13 06:41:13 -07:00
Vito Sansevero 78fd20d89a fix(local_image_loader): load the resolved path that was validated 2026-10-13 06:38:23 -07:00
Vito Sansevero 4b66592715 test: cover gemini, embedding autocomplete, batch prompts, presets and plot nodes 2026-10-13 06:38:20 -07:00
Vito Sansevero dc3fcf65e2 fix: save path escape, global RNG reseed and small node bugs
- KikoSaveImage: reject absolute or '..' filename_prefix directories that
  resolved outside the output folder
- Film grain: use a local torch.Generator instead of reseeding the global
  RNG; strength 0 no longer skips the toe adjustment
- Purge VRAM: IS_CHANGED returns NaN so caching never skips the purge
- Resolution calculator: clamp outputs to >= 8
- Plot sampler params: font size floor of 1 for images under 32px wide
- Gemini prompt: drop Image.fromarray mode= argument (removed in Pillow)
2026-10-13 06:38:20 -07:00
Vito Sansevero 8196e65906 chore: remove dead modules and consolidate test config
- Delete kikotools/core (registry/settings) and batch_prompts/node_simple
  which nothing imports
- pytest.ini used the [tool:pytest] header and was ignored; make it the
  single pytest config (drop the pyproject duplicate), keep
  --strict-markers
- Add pytest-asyncio to dev deps; enforce 85% coverage via
  [tool.coverage.report] fail_under
2026-10-13 06:38:20 -07:00
Vito Sansevero c8de8f26d6 fix(model_downloader): confine downloads to model dirs and harden fetches
- Reject filenames with path components; reduce server-derived names
  (Content-Disposition, CivitAI redirect, URL) to a safe basename
- save_path must resolve inside models_dir or a registered model folder
  (including extra_model_paths.yaml roots); relative paths resolve
  against ComfyUI's base path
- Only http/https for user URLs and redirects; drop the Authorization
  header on cross-host redirects
- Stream to <file>.part and rename on success, removing the partial file
  on any failure, so a dropped connection no longer leaves a truncated
  model that later runs treat as complete
- 60s timeout on every request; shared streaming loop in BaseDownloader
- Custom downloader honours Content-Disposition before the default name
2026-10-13 06:38:20 -07:00
Vito Sansevero 10027fcef9 fix(local_image_loader): confine file routes to saved roots and media types
/view served any readable file (e.g. ~/.ssh/id_rsa) to anyone who could
reach the server; the only guard was a '..' substring check.

- Resolve paths with realpath and require commonpath containment in the
  saved paths or ComfyUI input/output directories
- /view serves image/video/audio only, /thumbnail and load_media images
  only; the extension check applies to the resolved path, so symlinks
  can't smuggle other files
- /images 403s outside the roots; page/per_page validated and clamped
- /list_directories (folder picker) returns directory names only
- load_media refuses disallowed paths from a tampered selections.json
- Route handlers moved to module level so they are unit tested
- Update stale tests to the (image, info) return contract
2026-10-13 06:34:13 -07:00
Vito Sansevero 60ce078aed fix(xyz_helpers): raise on invalid sampler/LoRA input instead of hanging
- LoRA strength strings: step <= 0 looped forever and reversed ranges
  returned [] (ZeroDivisionError later); now raise ValueError, and cap
  generated values at 1000
- Flux sampler params: unparseable steps/guidance/shift/denoise/seed
  tokens raise with the field name instead of being dropped, and an
  empty LoRA folder raises instead of returning (None, [])
- Detect Flux schnell via unet_config guidance_embed (the ModelType.FLOW
  check never matched Flux)
- IS_CHANGED: re-run Flux params when a seed is '?'; LoRA folder batch
  hashes file list + mtimes instead of always re-executing
- LoRA folder scan uses commonpath containment across all lora roots
- Prompt separators must be whole lines; failed encodes raise
2026-10-13 06:33:17 -07:00
Vito Sansevero 37c00b633c fix(web): escape server data in innerHTML and plug listener leaks
- Escape file names, paths and image metadata interpolated into
  innerHTML in the local image loader and save image viewer (XSS via
  crafted file names or a shared workflow's filename_prefix)
- Save image viewer: bind document drag listeners once and remove them
  in disconnectedCallback instead of adding two per run; drop debug
  logging and the window.kikoSaveImageTest helper
- Local image loader: check response.ok and surface 400/403 errors,
  disconnect the ResizeObserver on node removal
- Gemini prompt: restore the user's node color after the run flash
- Workflow timer: chain onRemoved, merge properties in onConfigure
- Seed history: build entries with textContent
2026-10-13 06:32:14 -07:00
Vito c7f24a3262 Merge pull request #55 from ComfyAssets/feature/batch-list-converter
feat: add batch/list conversion nodes for IMAGE and LATENT
2026-02-09 17:46:52 -08:00
Vito Sansevero c69281e795 fix(batch_list_converter): preserve all latent dict keys during split/join
Previously split_latent_batch and join_latent_batch only handled the
"samples" key, silently dropping metadata like noise_mask or batch_index.
This broke inpaint and masked workflows after a round-trip conversion.
2026-02-09 17:11:01 -08:00
Vito Sansevero 3bf8391e88 feat(batch_list_converter): add batch/list conversion nodes for IMAGE and LATENT
Add 4 utility nodes for converting between batched tensors and lists,
eliminating the dependency on Impact Pack for this common operation:
- ImageBatchToImageList (OUTPUT_IS_LIST)
- ImageListToImageBatch (INPUT_IS_LIST)
- LatentBatchToLatentList (OUTPUT_IS_LIST)
- LatentListToLatentBatch (INPUT_IS_LIST)
2026-02-09 16:39:28 -08:00
Vito d13bfe8fb4 Merge pull request #53 from ComfyAssets/feature/seed-range-and-latent-output
fix: 32-bit seed range, latent batch_size output
2026-02-09 16:32:17 -08:00
Vito Sansevero 702d0c889c fix(seed_history): accept legacy kwargs for backward compatibility
Old workflows may still pass 'mode' to output_seed. Using **kwargs
prevents TypeError when ComfyUI invokes with the extra argument.
2026-02-09 16:29:11 -08:00
Vito Sansevero d1a5282ca0 style: fix Black formatting issues
Remove extra blank line in local_image_loader/node.py and wrap long
tuple assertion in test_empty_latent_batch.py.
2026-02-09 16:26:08 -08:00
Vito Sansevero 36212adf73 ci: reduce test matrix to Python 3.11, 3.12, 3.13
Drop 3.8, 3.9, and 3.10 which are no longer needed.
2026-02-09 16:25:30 -08:00
Vito Sansevero 2cb2262293 chore(local_image_loader): update saved paths and selections 2026-02-09 16:22:10 -08:00
Vito Sansevero 8773c8f249 feat(latent): add batch_size as 4th output from EmptyLatentBatchNode
Exposes batch_size as an output so downstream nodes can reference it
directly without needing a separate input.
2026-02-09 16:22:03 -08:00
Vito Sansevero 0a1cbe4990 fix(seeds): reduce seed range from 64-bit to 32-bit across all nodes
JS Math.random() only has 53 bits of integer precision, making the
64-bit range produce non-uniform values. 32-bit (0xFFFFFFFF) is the
standard ComfyUI seed range and works correctly in both Python and JS.

Also removes the redundant mode input from SeedHistoryNode, relying on
ComfyUI's built-in control_after_generate widget instead.
2026-02-09 16:21:56 -08:00
Vito c4a27137e7 Merge pull request #52 from ComfyAssets/feature/height-width-2-vec
Feature/height width 2 vec
2025-12-21 06:49:05 -08:00
Vito 8457db0041 Merge pull request #50 from ComfyAssets/dependabot/github_actions/actions/checkout-6
build(deps): bump actions/checkout from 5 to 6
2025-12-17 07:07:19 -08:00
Vito 8a84927adf Merge pull request #51 from ComfyAssets/dependabot/github_actions/actions/cache-5
build(deps): bump actions/cache from 4 to 5
2025-12-17 07:06:54 -08:00
dependabot[bot] 97ea5b9a7d build(deps): bump actions/cache from 4 to 5
Bumps [actions/cache](https://github.com/actions/cache) from 4 to 5.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-15 16:28:14 +00:00
dependabot[bot] f5f956ab4e build(deps): bump actions/checkout from 5 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-24 16:55:51 +00:00
82 changed files with 6470 additions and 2473 deletions
+2 -1
View File
@@ -11,7 +11,8 @@ exclude =
build,
dist,
*.egg-info,
.tox
.tox,
referance
ignore =
# W503: line break before binary operator (conflicts with Black)
W503,
+4 -4
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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
+13
View File
@@ -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
View File
@@ -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()
+17
View File
@@ -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
}
-95
View File
@@ -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
-201
View File
@@ -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}")
+5 -5
View File
@@ -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
+2 -2
View File
@@ -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:
+2 -2
View File
@@ -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}")
+6 -3
View File
@@ -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
+1 -1
View File
@@ -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",
},
),
+5
View File
@@ -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,
+29 -4
View File
@@ -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/",
+103 -1
View File
@@ -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.
+267 -222
View File
@@ -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"
}
}
}
+297 -10
View File
@@ -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
+74 -177
View File
@@ -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")
+52 -158
View File
@@ -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)
+33 -143
View File
@@ -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)
+12 -10
View File
@@ -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"]
+107
View File
@@ -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)
+90
View File
@@ -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(
+7 -7
View File
@@ -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
+7 -60
View File
@@ -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
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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
+53
View File
@@ -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") == []
+16 -8
View File
@@ -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",
)
+27
View File
@@ -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)
+8
View File
@@ -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
+66 -14
View File
@@ -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))
+19 -30
View File
@@ -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):
+173
View File
@@ -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
+12 -12
View File
@@ -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
View File
@@ -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
View File
@@ -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)),
}]);
},
});
+79 -95
View File
@@ -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
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// 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
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// 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');
+3 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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);
});