Compare commits

...
Author SHA1 Message Date
Vito Sansevero b5694ecfe5 feat(ui): migrate Node Dimensions to shared dialog controls 2026-10-16 13:00:36 -07:00
Vito 1255c4e9cc Merge pull request #91 from ComfyAssets/feat/shared-dimension-swap
feat(ui): unify dimension swap controls for both nodes
2026-10-16 12:45:22 -07:00
Vito Sansevero 26557a99b6 feat(ui): unify dimension swap controls for both nodes 2026-10-16 12:35:23 -07:00
Vito 848674d474 Merge pull request #89 from ComfyAssets/feat/shared-workflow-timer
feat(ui): migrate Workflow Timer to shared controls
2026-10-16 12:23:27 -07:00
Vito Sansevero 0c47dc6085 feat(ui): migrate Workflow Timer to shared controls 2026-10-16 12:18:02 -07:00
Vito ee7d1e9011 Merge pull request #87 from ComfyAssets/feat/shared-seed-history
feat(ui): migrate Seed History to shared controls
2026-10-16 12:00:07 -07:00
Vito Sansevero b7a29a1731 feat(ui): migrate Seed History to shared controls 2026-10-16 11:55:57 -07:00
Vito 3cabbffb40 Merge pull request #85 from ComfyAssets/feat/shared-image-viewer
feat(ui): migrate Save Image viewer to shared window controls
2026-10-16 11:37:46 -07:00
Vito Sansevero 47dc177517 feat(ui): migrate saved-image viewer to shared window controls 2026-10-16 11:30:11 -07:00
Vito ddb356fe17 Merge pull request #83 from ComfyAssets/feat/live-image-loader-ui
feat(ui): ship shared ComfyAssets controls in Local Image Loader
2026-10-16 11:08:36 -07:00
Vito Sansevero 9e2b271824 fix(ui): keep gallery thumbnails large and adjustable 2026-10-16 11:03:33 -07:00
Vito Sansevero 2e7fac4b70 docs(ui): capture the loader in both ComfyUI renderers 2026-10-16 10:53:07 -07:00
Vito Sansevero 740eccec27 feat(ui): migrate Local Image Loader to shared ComfyAssets controls 2026-10-16 10:52:28 -07:00
Vito 55bce1004b Merge pull request #79 from ComfyAssets/dependabot/github_actions/actions/setup-node-7
build(deps): bump actions/setup-node from 6 to 7
2026-10-16 09:18:54 -07:00
Vito 63a8596b81 Merge branch 'main' into dependabot/github_actions/actions/setup-node-7 2026-10-16 09:14:46 -07:00
Vito 11494c747c Merge pull request #78 from ComfyAssets/dependabot/github_actions/actions/setup-python-7
build(deps): bump actions/setup-python from 6 to 7
2026-10-16 09:14:32 -07:00
Vito 88a9238ee2 Merge branch 'main' into dependabot/github_actions/actions/setup-python-7 2026-10-16 09:10:46 -07:00
Vito 3879f18d79 Merge pull request #72 from ComfyAssets/feat/checkpoint-folder-iterator
feat(xyz_helpers): Checkpoint Folder Iterator and Model Compare Sheet
2026-10-16 09:10:28 -07:00
Vito Sansevero 64f70b6970 fix(model_compare_sheet): hide staging files from output image discovery 2026-10-16 09:03:56 -07:00
Vito Sansevero ae0fb7f229 fix(model_compare_sheet): replace rerun tiles without retaining stale batches 2026-10-16 08:59:29 -07:00
Vito ddf73a667e Merge branch 'main' into feat/checkpoint-folder-iterator 2026-10-16 08:57:09 -07:00
dependabot[bot] 46999c8d90 build(deps): bump actions/setup-node from 6 to 7
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6 to 7.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-16 15:47:59 +00:00
dependabot[bot] 852864a270 build(deps): bump actions/setup-python from 6 to 7
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 6 to 7.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-16 15:47:47 +00:00
Vito c182c775f4 Merge pull request #77 from ComfyAssets/test/merge-confidence
test: add extension workflows coverage and required merge checks
2026-10-16 08:46:26 -07:00
Vito Sansevero 02108be2d1 test: cover gallery pagination and directory autocomplete deterministically 2026-10-16 08:42:23 -07:00
Vito Sansevero 2a9fbe78c7 test: allow same-origin WebKit blob requests in network guard 2026-10-16 08:36:38 -07:00
Vito Sansevero 5a8a8b62e4 test: add extension workflows coverage and required merge checks 2026-10-16 08:32:11 -07:00
Vito Sansevero 335cc53961 fix(model_compare_sheet): reject run_name segments on both separators
- Split run_name on "/" and "\" and reject empty, "." and ".." parts, so
  a Windows-style "..\foo" can no longer resolve outside model_compare
  and let the index-0 archive step rename an unrelated output folder
- Reject drive-letter names and require the resolved directory to sit
  strictly below output/model_compare after symlinks are followed
- Nested run names such as project/date/sweep still work
2026-10-16 08:11:17 -07:00
Vito Sansevero c5e00c130b feat(xyz_helpers): add Checkpoint Folder Iterator and Model Compare Sheet
- Checkpoint Folder Iterator loads one checkpoint per queued run from a
  folder (relative to models/checkpoints or absolute), so a whole folder
  can be swept with only one model in memory at a time
- Model Compare Sheet saves each run's image as a tile under
  output/model_compare/<run_name>/ and rebuilds a labeled contact sheet
  from disk every run, so the grid survives between independent prompts
- Starting a sweep at index 0 archives stale tiles to a timestamped folder
- Running past the last checkpoint raises a clear error instead of
  wrapping, so an over-queued batch never restarts and archives a
  finished sweep
- Checkpoints load from the scanned absolute path, so folders outside the
  configured roots work as the tooltip promises
- Extract the LoRA Folder Batch folder-scanning, natural-sort and regex
  filter helpers into kikotools/base/model_folders.py so both tools share
  one implementation
2026-10-16 08:08:00 -07:00
Vito 78f1498ea8 Merge pull request #71 from ComfyAssets/release/1.1.2
Release 1.1.2 - ComfyUI scanner fixes
2026-10-15 04:21:22 -07:00
Vito Sansevero c65f3aad03 chore(release): 1.1.2 with .comfyignore for registry archive
- Add .comfyignore so `comfy node publish` ships only the runtime
  package: __init__.py, kikotools/, web/, examples/, README, LICENSE,
  pyproject.toml, requirements.txt
- Exclude tests/, scripts/, .github/, dev tooling configs, contributor
  docs, and the stray example.py reference file (not imported anywhere)
- Registry scanner flagged 1.1.1 on five info-level pattern matches;
  two were in tests/ and the historical Model.eval matches came from
  example.py, none of which should be in the published archive
- Bump version to 1.1.2
2026-10-15 04:16:17 -07:00
Vito 9a55c23282 Merge pull request #70 from ComfyAssets/release/1.1.1
Release 1.1.1
2026-10-13 18:36:23 -07:00
Vito Sansevero e90bde5849 chore: release 1.1.1
Gemini Prompt node migrated from the deprecated google-generativeai SDK to google-genai.
2026-10-13 18:29:24 -07:00
Vito Sansevero 49523e10de chore(gemini_prompt): migrate to google-genai SDK
- google-generativeai is deprecated and emits a FutureWarning at node
  load; google-genai is Google's supported replacement
- Image analysis now uses genai.Client().models.generate_content
  instead of the module-level configure/GenerativeModel API
- Model listing reads supported_actions, the new SDK's name for
  supported_generation_methods
- Install instructions, requirements.txt, and README updated to the
  new package name
2026-10-13 18:27:52 -07:00
Vito 8e6cf4233a Merge pull request #69 from ComfyAssets/release/1.1.0
Release 1.1.0
2026-10-13 08:12:57 -07:00
Vito Sansevero 6301cf90f8 chore: release 1.1.0
Security hardening and fixes (#64), Project File Path node (#65, #67), runtime state untracked (#66), dependency bumps (#68).
2026-10-13 08:01:04 -07:00
Vito f87eb959b3 Merge pull request #68 from ComfyAssets/chore/dependency-bumps
Apply pending Dependabot bumps (#56-#63)
2026-10-13 07:47:06 -07:00
Vito Sansevero 7783ed8a16 build(deps): apply pending Dependabot bumps
Combines #56-#63:
- torch >=2.11.0, pillow >=12.2.0, mypy >=1.20.0, pytest-mock >=3.15.1,
  pre-commit >=4.5.1 in both pyproject.toml and requirements-dev.txt
  (Dependabot only edited pyproject, but CI installs requirements-dev.txt)
- actions/checkout v7, actions/cache v6, softprops/action-gh-release v3
  across all workflows, including the new test/coverage/js jobs
2026-10-13 07:44:16 -07:00
Vito 726fcee761 Merge pull request #66 from ComfyAssets/chore/untrack-runtime-state
Stop tracking runtime state files
2026-10-13 07:42:21 -07:00
Vito 98f3defe50 Merge pull request #67 from ComfyAssets/fix/project-file-path-output-label
Project File Path: label output STRING like djz-nodes
2026-10-13 07:41:55 -07:00
Vito Sansevero 143a6c47f6 fix(project_file_path): label the output STRING like djz-nodes
Drop RETURN_NAMES so ComfyUI shows the output as STRING, matching the original Project File Path Generator. Links connect by slot index, so existing workflows are unaffected.
2026-10-13 07:38:33 -07:00
Vito Sansevero 76a52765c7 chore: stop tracking runtime state files
The image loader's config.json/selections.json (one user's saved folders
and selections) and the Gemini model cache were committed, so every
install shipped them as defaults, and running from a symlinked checkout
dirtied the tree. Untrack them and ignore them, plus gemini_config.json
which would hold an API key. All loaders already handle the files being
absent.
2026-10-13 07:37:27 -07:00
Vito f308b23631 Merge pull request #65 from ComfyAssets/feature/project-file-path
Add Project File Path node
2026-10-13 07:32:25 -07:00
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
165 changed files with 13247 additions and 5975 deletions
+28
View File
@@ -0,0 +1,28 @@
# Files excluded from the Comfy Registry archive (gitignore syntax).
# Everything here is development-only; the published node needs
# __init__.py, kikotools/, web/, examples/, README, LICENSE, pyproject.toml.
# Test suite and tooling
tests/
scripts/
.github/
Makefile
pytest.ini
mypy.ini
.flake8
.pre-commit-config.yaml
.secrets.baseline
requirements-dev.txt
# Reference / scratch code not imported by the node
example.py
# Agent and contributor docs
CLAUDE.md
CONTRIBUTING.md
CODE_OF_CONDUCT.md
tools/
coverage/
TESTING.md
AGENTS.md
+3 -1
View File
@@ -2,6 +2,7 @@
max-line-length = 127
max-complexity = 10
exclude =
node_modules,
.git,
__pycache__,
.mypy_cache,
@@ -11,7 +12,8 @@ exclude =
build,
dist,
*.egg-info,
.tox
.tox,
referance
ignore =
# W503: line break before binary operator (conflicts with Black)
W503,
+16
View File
@@ -0,0 +1,16 @@
{
"required_status_checks": {
"strict": true,
"contexts": [
"py3.10 on ubuntu-latest", "py3.11 on ubuntu-latest", "py3.12 on ubuntu-latest", "py3.13 on ubuntu-latest",
"py3.10 on windows-latest", "py3.11 on windows-latest", "py3.12 on windows-latest", "py3.13 on windows-latest",
"py3.10 on macos-latest", "py3.11 on macos-latest", "py3.12 on macos-latest", "py3.13 on macos-latest",
"coverage gate (85%)", "frontend syntax", "frontend workflows and coverage", "code-quality"
]
},
"enforce_admins": true,
"required_pull_request_reviews": null,
"restrictions": null,
"allow_force_pushes": false,
"allow_deletions": false
}
+4
View File
@@ -8,3 +8,7 @@ updates:
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/tools/testing"
schedule:
interval: "weekly"
+9 -7
View File
@@ -13,15 +13,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
- name: Set up Python 3.10
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: '3.10'
- name: Cache pip dependencies
uses: actions/cache@v4
uses: actions/cache@v6
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-quality-${{ hashFiles('**/requirements-dev.txt') }}
@@ -31,6 +31,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install torch --index-url https://download.pytorch.org/whl/cpu
pip install -r requirements-dev.txt
- name: Format check with Black
@@ -141,10 +142,10 @@ jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
- name: Set up Python 3.10
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: '3.10'
@@ -172,16 +173,17 @@ jobs:
architecture:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
- name: Set up Python 3.10
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: '3.10'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install torch --index-url https://download.pytorch.org/whl/cpu
pip install -r requirements-dev.txt
- name: Check architecture compliance
+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@v7
with:
submodules: true
- name: Publish Custom Node
+10 -37
View File
@@ -10,55 +10,28 @@ on:
jobs:
create-release:
outputs:
version: ${{ steps.get_version.outputs.version }}
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
- name: Set up Python 3.10
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: '3.10'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install torch --index-url https://download.pytorch.org/whl/cpu
pip install -r requirements-dev.txt
- name: Run full test suite
run: |
python -c "
import sys
import os
sys.path.insert(0, os.getcwd())
# Run comprehensive tests before release
from kikotools.base import ComfyAssetsBaseNode
from kikotools.tools.resolution_calculator.logic import extract_dimensions, calculate_scaled_dimensions
from kikotools.tools.resolution_calculator.node import ResolutionCalculatorNode
import torch
print('Running pre-release validation...')
# Test all major functionality
node = ResolutionCalculatorNode()
# Test various scenarios
test_cases = [
(torch.randn(1, 512, 512, 3), 2.0),
(torch.randn(1, 1024, 1024, 3), 1.5),
(torch.randn(1, 1216, 832, 3), 1.53), # User scenario
]
for i, (image, scale) in enumerate(test_cases):
width, height = node.calculate_resolution(scale, image=image)
print(f'✓ Test case {i+1}: {image.shape[2]}×{image.shape[1]} → {width}×{height} (scale: {scale})')
assert width % 8 == 0 and height % 8 == 0
print('🎉 All pre-release tests passed!')
"
- name: Run full test suite and coverage gate
run: python -m pytest tests --cov=kikotools --cov-report=term-missing
- name: Extract version from tag
id: get_version
@@ -100,7 +73,7 @@ jobs:
### 📊 Technical Details
- **Nodes**: 1 (Resolution Calculator)
- **Test Coverage**: 100%
- **Python Coverage Gate**: 85% combined statement/branch coverage
- **Python Support**: 3.8+
- **ComfyUI Compatibility**: Latest
@@ -114,7 +87,7 @@ jobs:
EOF
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ steps.get_version.outputs.version }}
name: ComfyUI-KikoTools ${{ steps.get_version.outputs.version }}
@@ -147,4 +120,4 @@ jobs:
echo "- Resolution Calculator tool"
echo "- Complete documentation"
echo "- Example workflows"
echo "- 100% test coverage"
echo "- Enforced Python coverage and frontend regression checks"
+108 -451
View File
@@ -11,478 +11,135 @@ 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@v7
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python-version }}
cache: pip
cache-dependency-path: requirements-dev.txt
- name: Cache pip dependencies
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements-dev.txt') }}
restore-keys: |
${{ runner.os }}-pip-
# The default Linux torch wheel bundles CUDA (~2.5 GB); tests only need CPU.
# Windows and macOS default wheels are already CPU-only.
- name: Install CPU torch (Linux)
if: runner.os == 'Linux'
run: |
python -m pip install --upgrade pip
pip install torch --index-url https://download.pytorch.org/whl/cpu
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
- name: Run core functionality tests
run: |
python -c "
import sys
import os
sys.path.insert(0, os.getcwd())
- name: Run tests
run: python -m pytest tests --junitxml=test-results/pytest.xml
# Test imports
from kikotools.base import ComfyAssetsBaseNode
from kikotools.tools.resolution_calculator.logic import extract_dimensions, calculate_scaled_dimensions
from kikotools.tools.resolution_calculator.node import ResolutionCalculatorNode
import torch
print('✓ All imports successful')
# Test base node
assert '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:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Set up Python 3.10
uses: actions/setup-python@v6
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
with:
python-version: "3.10"
name: python-${{ matrix.os }}-${{ matrix.python-version }}
path: test-results/
if-no-files-found: ignore
retention-days: 14
- name: Test package structure
run: |
# Check required files exist
test -f README.md || (echo "README.md missing" && exit 1)
test -f LICENSE || (echo "LICENSE missing" && exit 1)
test -f requirements-dev.txt || (echo "requirements-dev.txt missing" && exit 1)
# Check directory structure
test -d kikotools || (echo "kikotools directory missing" && exit 1)
test -d kikotools/base || (echo "kikotools/base directory missing" && exit 1)
test -d kikotools/tools || (echo "kikotools/tools directory missing" && exit 1)
test -d kikotools/tools/resolution_calculator || (echo "resolution_calculator directory missing" && exit 1)
test -d kikotools/tools/width_height_selector || (echo "width_height_selector directory missing" && exit 1)
test -d kikotools/tools/sampler_combo || (echo "sampler_combo directory missing" && exit 1)
test -d kikotools/tools/seed_history || (echo "seed_history directory missing" && exit 1)
test -d tests || (echo "tests directory missing" && exit 1)
test -d examples || (echo "examples directory missing" && exit 1)
test -d web || (echo "web directory missing" && exit 1)
# Check key files
test -f kikotools/__init__.py || (echo "kikotools/__init__.py missing" && exit 1)
test -f kikotools/base/base_node.py || (echo "base_node.py missing" && exit 1)
# Resolution Calculator files
test -f kikotools/tools/resolution_calculator/node.py || (echo "resolution_calculator node.py missing" && exit 1)
test -f kikotools/tools/resolution_calculator/logic.py || (echo "resolution_calculator logic.py missing" && exit 1)
# Width Height Selector files
test -f kikotools/tools/width_height_selector/node.py || (echo "width_height_selector node.py missing" && exit 1)
test -f kikotools/tools/width_height_selector/logic.py || (echo "width_height_selector logic.py missing" && exit 1)
test -f kikotools/tools/width_height_selector/presets.py || (echo "width_height_selector presets.py missing" && exit 1)
# Sampler Combo files
test -f kikotools/tools/sampler_combo/node.py || (echo "sampler_combo node.py missing" && exit 1)
test -f kikotools/tools/sampler_combo/logic.py || (echo "sampler_combo logic.py missing" && exit 1)
# Seed History files
test -f kikotools/tools/seed_history/node.py || (echo "seed_history node.py missing" && exit 1)
test -f kikotools/tools/seed_history/logic.py || (echo "seed_history logic.py missing" && exit 1)
# 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:
coverage:
name: coverage gate (85%)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v7
- name: Test documentation completeness
- uses: actions/setup-python@v7
with:
python-version: "3.12"
cache: pip
cache-dependency-path: requirements-dev.txt
- name: Install dependencies
run: |
# Check documentation files for all tools
test -f examples/documentation/resolution_calculator.md || (echo "Resolution calculator docs missing" && exit 1)
test -f examples/workflows/resolution_calculator_example.json || (echo "Resolution calculator workflow missing" && exit 1)
test -f examples/documentation/width_height_selector.md || (echo "Width height selector docs missing" && exit 1)
test -f examples/workflows/width_height_selector_example.json || (echo "Width height selector workflow missing" && exit 1)
test -f examples/documentation/sampler_combo.md || (echo "Sampler combo docs missing" && exit 1)
test -f examples/workflows/sampler_combo_example.json || (echo "Sampler combo workflow missing" && exit 1)
test -f examples/documentation/seed_history.md || (echo "Seed history docs missing" && exit 1)
test -f examples/workflows/seed_history_example.json || (echo "Seed history workflow missing" && exit 1)
python -m pip install --upgrade pip
pip install torch --index-url https://download.pytorch.org/whl/cpu
pip install -r requirements-dev.txt
# Check README has key sections
grep -q "Installation" README.md || (echo "README missing Installation section" && exit 1)
grep -q "Quick Start" README.md || (echo "README missing Quick Start section" && exit 1)
grep -q "Development" README.md || (echo "README missing Development section" && exit 1)
grep -q "Contributing" README.md || (echo "README missing Contributing section" && exit 1)
# fail_under = 85 lives in pyproject.toml [tool.coverage.report]
- name: Measure coverage
run: python -m pytest tests --cov=kikotools --cov-report=term-missing --cov-report=html --cov-report=xml
echo "✓ Documentation tests passed"
- name: Upload coverage reports
if: always()
uses: actions/upload-artifact@v7
with:
name: python-coverage
path: |
htmlcov/
coverage.xml
if-no-files-found: ignore
retention-days: 14
js-check:
name: frontend syntax
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: "24"
# Check every shipped module, including files without behavioral tests.
- name: Check all shipped JavaScript modules parse
run: |
shopt -s globstar nullglob
for f in web/**/*.js web/**/*.mjs; do
node --input-type=module --check < "$f" || { echo "Syntax error in $f"; exit 1; }
done
frontend:
name: frontend workflows and coverage
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: "3.12"
cache: pip
cache-dependency-path: requirements-dev.txt
- name: Install Python test dependencies
run: |
python -m pip install --upgrade pip
pip install torch --index-url https://download.pytorch.org/whl/cpu
pip install -r requirements-dev.txt
- uses: actions/setup-node@v7
with:
node-version: "24"
cache: npm
cache-dependency-path: tools/testing/package-lock.json
- run: npm ci --prefix tools/testing --ignore-scripts
- run: npx --prefix tools/testing playwright install --with-deps chromium firefox webkit
- name: JavaScript regressions, Chromium workflows, and coverage gate
run: npm run --prefix tools/testing test:coverage
- name: Firefox and WebKit workflows
run: npm run --prefix tools/testing test:browser -- --project firefox --project webkit
- name: Upload frontend reports
if: always()
uses: actions/upload-artifact@v7
with:
name: frontend-test-reports
path: |
tools/testing/test-results/
coverage/js/
!coverage/js/raw/
if-no-files-found: ignore
retention-days: 14
+11
View File
@@ -164,3 +164,14 @@ experiments/
# Gemini model cache
.gemini_models_cache.json
referance/
# Runtime state written inside the package (repo is symlinked into custom_nodes)
kikotools/tools/local_image_loader/config.json
kikotools/tools/local_image_loader/selections.json
gemini_config.json
# Frontend test tooling and reports
node_modules/
coverage/
playwright-report/
test-results/
+20 -3
View File
@@ -4,10 +4,9 @@
repos:
# Python code formatting with Black
- repo: https://github.com/psf/black
rev: 25.1.0
rev: 26.10.1
hooks:
- id: black
language_version: python3.10
args: ['--line-length=88'] # Match CI configuration
# Python linting with flake8
@@ -61,9 +60,27 @@ repos:
args: ['--baseline', '.secrets.baseline']
exclude: '^(tests/|\.git/)'
- repo: local
hooks:
- id: python-regressions
name: Python regression suite
entry: python -m pytest tests
language: system
pass_filenames: false
always_run: true
stages: [pre-push]
- id: frontend-regressions
name: JavaScript regression suite
entry: npm test --prefix tools/testing
language: system
pass_filenames: false
always_run: true
stages: [pre-push]
# Configuration for specific hooks
default_stages: [pre-commit]
default_language_version:
python: python3.10
python: python3
# Run hooks on all files by default
fail_fast: false
+46
View File
@@ -0,0 +1,46 @@
# Repository Guidelines
## Project Structure & Module Organization
`kikotools/tools/<tool>/logic.py` contains reusable logic; `node.py` defines the
ComfyUI node. Shared classes live in `kikotools/base/`. Register nodes in
`kikotools/__init__.py`; the root `__init__.py` exposes the plugin and API routes.
`web/` contains browser extensions. Tests live in `tests/unit/`, `tests/js/`, and
`tests/browser/`. Examples are in `examples/`.
## Development Commands
Install Python dependencies with `python -m pip install -r requirements-dev.txt`.
Run `python -m pytest tests` or `make test` for the complete Python suite.
Use `python -m pytest tests --cov=kikotools` for the 85% statement/branch gate.
Format with `black .`; use `flake8 .` for lint findings.
Frontend tooling needs Node.js 24 and `npm ci --prefix tools/testing
--ignore-scripts`. Run `npm test --prefix tools/testing` for JavaScript tests,
`npm run --prefix tools/testing test:coverage` for coverage and Chromium workflows,
and `npm run --prefix tools/testing test:browser` for all browser projects.
See `TESTING.md` for browser installation, environment overrides, and artifacts.
## Coding Style & Naming
Use four-space Python indentation and Black's 88-character formatting.
Use `snake_case` for Python functions/modules, `PascalCase` for classes, and
`UPPER_CASE` for constants. Keep node interfaces separate from logic. Preserve
existing ComfyUI lifecycle hooks when extending widgets and clean up timers,
listeners, and observers when nodes are removed.
## Testing Guidelines
Write regression tests first and verify failure before implementing fixes.
Name Python tests `test_*.py` and JavaScript tests `*.test.mjs`; put browser
workflows in `tests/browser/*.spec.mjs`. Test behavior and persistence, including
failure recovery. Keep fixtures disposable and external services mocked.
Never lower coverage floors to make a change pass.
## Commits & Pull Requests
Use Conventional Commits, such as `fix(local_image_loader): preserve error
status`. Link the tracked issue, describe observable behavior, and report the
commands and results used for validation. Include screenshots for visual changes.
Required checks must pass before merging. Keep credentials and runtime files out
of commits; restart ComfyUI after installing Python changes.
+4 -1
View File
@@ -357,9 +357,12 @@ Brief description of changes.
### Review Process
See [TESTING.md](TESTING.md) for automated suites, coverage gates, required merge
checks, and the remaining ComfyUI/GPU/service boundaries.
1. **Automated Checks**: CI runs tests and quality checks
2. **Code Review**: Maintainer reviews code and provides feedback
3. **Testing**: Manual testing in ComfyUI environment
3. **Testing**: Run relevant automated suites; use live ComfyUI checks for host, GPU, service, or visual changes
4. **Merge**: After approval, changes are merged to main
## 🎨 Code Style Guidelines
+20 -9
View File
@@ -1,6 +1,6 @@
# ComfyUI-KikoTools Development Makefile
.PHONY: help install test test-fast lint format type-check quality-check clean setup dev-test release-test
.PHONY: coverage frontend-test browser-test frontend-coverage help install test test-fast lint format type-check quality-check clean setup dev-test release-test
# Python and virtual environment setup
PYTHON := python3
@@ -34,7 +34,11 @@ help:
@echo " quality-check - Run all quality checks"
@echo ""
@echo "Testing:"
@echo " test - Run comprehensive tests"
@echo " test - Run the complete pytest suite"
@echo " coverage - Python statement/branch coverage (85% gate)"
@echo " frontend-test - JavaScript regression tests"
@echo " browser-test - Extension workflows in four browser configurations"
@echo " frontend-coverage - JavaScript tests, browser tests, and coverage gate"
@echo " test-fast - Run core functionality tests"
@echo " dev-test - Quick development test"
@echo " release-test - Full release validation"
@@ -120,13 +124,20 @@ test-fast: $(VENV_DIR)
print('🎉 All core tests passed!'); \
"
test: test-fast
@echo "Running comprehensive test suite..."
@echo "✅ Test case 1: 512×512 → 1024×1024 (scale: 2.0)"
@echo "✅ Test case 2: 1024×1024 → 1536×1536 (scale: 1.5)"
@echo "✅ Test case 3: 832×1216 → 1272×1864 (scale: 1.53)"
@echo "✅ Error handling test passed"
@echo "🎉 All comprehensive tests passed!"
test: $(VENV_DIR)
@$(PYTHON_CMD) -m pytest tests
coverage: $(VENV_DIR)
@$(PYTHON_CMD) -m pytest tests --cov=kikotools --cov-report=term-missing --cov-report=html
frontend-test:
npm test --prefix tools/testing
browser-test:
npm run --prefix tools/testing test:browser
frontend-coverage:
npm run --prefix tools/testing test:coverage
release-test: quality-check test
@echo "Running release validation..."
+47 -5
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
@@ -49,6 +50,8 @@ Advanced parameter management tools adapted from [comfyui-essentials-nodes](http
|------|-------------|----------|
| [🎛️ Flux Sampler Params](#️-flux-sampler-params) | FLUX-optimized parameter generator with batch support | 🧰 xyz-helpers |
| [📁 LoRA Folder Batch](#-lora-folder-batch) | Batch process multiple LoRAs from folders | 🧰 xyz-helpers |
| [🔁 Checkpoint Folder Iterator](#-checkpoint-folder-iterator) | Load one checkpoint per queued run from a folder | 🧰 xyz-helpers |
| [🧩 Model Compare Sheet](#-model-compare-sheet) | Save each run's image and rebuild a labeled contact sheet | 🧰 xyz-helpers |
| [📊 Plot Parameters](#-plot-parameters) | Visualize parameter effects with graphs | 🧰 xyz-helpers |
| [🎯 Sampler Select Helper](#-sampler-select-helper) | Intelligent sampler selection with recommendations | 🧰 xyz-helpers |
| [📅 Scheduler Select Helper](#-scheduler-select-helper) | Optimal scheduler selection for samplers | 🧰 xyz-helpers |
@@ -300,6 +303,28 @@ Automated batch processing for multiple LoRA models from folders.
![LoRA Folder Batch Example](examples/workflows/xyz_helpers_lora_testing.png)
#### 🔁 Checkpoint Folder Iterator
Run the same prompt through every checkpoint in a folder, one model per queued run.
- **folder**: relative to `models/checkpoints`, or an absolute path anywhere on disk; regex include/exclude filters
- **index** with `control_after_generate`: set 0 + *increment*, then queue a batch count equal to the checkpoint count
- Queuing past the last checkpoint fails that run with a clear message instead of wrapping around, so the finished sweep is never restarted or archived by accident
- Outputs MODEL/CLIP/VAE for the indexed file plus `ckpt_name`, `label`, `index`, `count`
- Only one checkpoint is loaded at a time, so large folders do not exhaust RAM
#### 🧩 Model Compare Sheet
Output node that pairs with Checkpoint Folder Iterator.
- Saves each run's image(s) to `output/model_compare/<run_name>/<NN>_<label>.png`
- Rebuilds `grid.png` from every tile saved so far, each labeled with its filename
- `columns`, `tile_width`, `font_size` control the layout; the grid is also returned as IMAGE
- The node preview (and anything else that reads it) gets `grid_preview.png`, at most 1024 px wide; `grid.png` keeps full resolution
- After the last queued run the preview shows the complete sheet
- Wire Project File Path into `run_name` for `project/date/sweep` folders
- A run at index 0 moves tiles already in the folder to `<run_name>_<timestamp>`, so an interrupted sweep never leaks into the new grid
- Rerunning a nonzero index replaces its complete batch, even when the label or batch size changes; other indexes are preserved
- Replacement PNGs are fully written before existing tiles are changed; a failed tile write preserves the prior tiles and grid
#### 📊 Plot Parameters
Visual analysis tool for understanding parameter relationships and effects.
@@ -426,6 +451,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.**
@@ -526,7 +563,7 @@ Time: 150.0ms
### 🔧 Architecture Highlights
- **Modular Design**: Each tool is self-contained and independently testable
- **Test-Driven Development**: 100% test coverage with comprehensive unit tests
- **Test-Driven Development**: Regression tests for Python logic, JavaScript extensions, and browser workflows
- **Clean Interfaces**: Standardized input/output patterns across all tools
- **Separation of Concerns**: Clear distinction between logic, UI, and integration layers
- **SOLID Principles**: Extensible architecture following software engineering best practices
@@ -990,8 +1027,8 @@ Following **Test-Driven Development (TDD)**:
# Test structure
tests/
├── unit/ # Individual component tests
├── integration/ # ComfyUI workflow tests
└── fixtures/ # Test data and workflows
├── js/ # Extension and coverage-gate regression tests
└── browser/ # Browser workflows and disposable host fixtures
```
### Adding New Tools
@@ -1051,10 +1088,10 @@ MIT License - see [LICENSE](LICENSE) file for details.
- **Presets**: 26 curated resolution presets
- **Interactive Features**: 8+ (swap buttons, history UI, popup viewers, parameter visualization)
- **AI Integration**: Gemini API with 40+ model support
- **Test Coverage**: 100% (470+ comprehensive tests)
- **Test Coverage**: Enforced 85% Python statement/branch coverage and per-file frontend coverage floors
- **Python Version**: 3.8+
- **ComfyUI Compatibility**: Latest
- **Dependencies**: Minimal (PyTorch, NumPy, Pillow, google-generativeai for Gemini)
- **Dependencies**: Minimal (PyTorch, NumPy, Pillow, google-genai for Gemini)
## 🙏 Attribution
@@ -1080,3 +1117,8 @@ All adaptations maintain compatibility while adding new features and optimizatio
[⭐ Star this repo](https://github.com/ComfyAssets/ComfyUI-KikoTools) • [🐛 Report Bug](https://github.com/ComfyAssets/ComfyUI-KikoTools/issues) • [💡 Request Feature](https://github.com/ComfyAssets/ComfyUI-KikoTools/issues)
</div>
## Automated testing
See [TESTING.md](TESTING.md) for Python and JavaScript tests, cross-browser
workflows, coverage gates, CI reports, and required merge checks.
+129
View File
@@ -0,0 +1,129 @@
# Testing and merge confidence
Use regression tests before fixing a bug. Confirm that the test fails on the
original implementation, then run the relevant suites after the fix.
## What runs
| Layer | Real components | Replaced boundaries |
| --- | --- | --- |
| Python | Tool logic, tensors, image files, API validation | ComfyUI services and external models where needed |
| JavaScript | Production extension hooks and callback behavior | Minimal host objects; controlled timers for focused regressions |
| Browser | All shipped extension imports and lifecycle registration; real plugin startup, HTTP routes, PNG thumbnails, selection persistence, and image loading | ComfyUI graph/widgets/settings/event interfaces and its saved-image serving endpoint |
Browser workflows cover swaps, local image browsing/selection, pagination,
sorting, hidden folders, directory autocomplete, saved paths,
denied paths and network recovery, display output, seed persistence/cleanup, and
workflow timing, autocomplete insertion, and saved-image previews. They run in Chromium, Firefox, WebKit, and touch Chromium.
The harness binds to localhost and writes configuration, selections, and fixture
images only inside a temporary directory. External browser requests fail tests;
custom-word downloads are disabled in the fixture. No credentials, model
weights, running ComfyUI, or GPU are required.
## Run locally
Use Python 3.10–3.13 and Node.js 24. Install the CPU PyTorch wheel on Linux if you
do not need CUDA, then install the development dependencies:
```sh
python -m pip install torch --index-url https://download.pytorch.org/whl/cpu
python -m pip install -r requirements-dev.txt
python -m pytest tests
python -m pytest tests --cov=kikotools --cov-report=term-missing
npm ci --prefix tools/testing --ignore-scripts
npx --prefix tools/testing playwright install --with-deps chromium firefox webkit
npm test --prefix tools/testing
npm run --prefix tools/testing test:coverage
npm run --prefix tools/testing test:browser -- --project firefox --project webkit
```
`make test` runs the full pytest suite and propagates failures. `make coverage`,
`make frontend-test`, `make browser-test`, and `make frontend-coverage` provide
shortcuts. The Python Make targets use the existing virtual-environment setup.
Install local commit and push hooks with `pre-commit install --hook-type
pre-commit --hook-type pre-push`. The push hooks run Python and JavaScript
regressions; CI additionally runs all browser workflows and coverage gates.
For a quick browser check, use `test:browser -- --project chromium`.
`KIKO_TEST_PYTHON` selects a Python executable with the development dependencies;
`KIKO_TEST_PORT` changes the default port 8766. Servers are never reused.
Unsupported Linux distributions may lack WebKit libraries; Ubuntu CI validates
all engines.
## Coverage and diagnosis
Python CI checks all twelve Python/OS combinations and enforces 85% combined
statement/branch coverage. Every production `web/*.js` file appears in frontend
coverage, including code without behavioral coverage. V8 unit coverage and
Chromium desktop/touch coverage are merged through c8. The committed per-file
floors are an honest starting point, not a claim that every feature is tested.
`npm run --prefix tools/testing coverage:update` reruns the suites and raises
floors with two percentage points of runtime allowance. It cannot lower existing
floors; new source files require 80% line coverage when added with this command.
Missing measurements, missing baselines, and per-file regressions fail CI.
CI artifacts include Python JUnit results, Python HTML/XML coverage, and
`frontend-test-reports` with frontend HTML coverage and browser failure traces
and screenshots. Open traces using Playwright's `show-trace` command. Local
frontend coverage is at `coverage/js/index.html`.
Real ComfyUI/LiteGraph compatibility, GPU execution, cloud inference, network
model downloads, and visual design still need targeted live checks when changed.
The browser host is a test double; these workflows do not run the real ComfyUI
frontend or certify every node's UI. Type checking and security jobs retain their
existing advisory behavior.
### Actual ComfyUI frontend smoke test
The Local Image Loader also has an opt-in suite using the actual ComfyUI frontend,
graph, DOM widgets, renderer switch and palette settings. Start a local ComfyUI
0.39.0 with frontend 1.55.14 and KikoTools installed, then run:
```sh
KIKO_COMFY_URL=http://127.0.0.1:8188/ node \
tools/testing/node_modules/@playwright/test/cli.js test \
--config tools/testing/comfyui.config.mjs
```
The suite requires those exact versions and records them in its artifacts. It
serves the current checkout's extensions to a separate browser context and loads
only KikoTools extensions. Media requests use the existing real Python routes
with disposable fixture data on port 8771. Other HTTP writes are intercepted,
so user settings, saved workflows and selections are not written to the live
server. It does not execute a workflow or download models.
Checks cover Classic/Nodes 2.0/live switching, theme changes, workflow save/reload
and removal for the loader. Save Image additionally checks real image output,
window close/reopen through the node control across renderer switches, theme
changes, exclusion of the launcher from serialized values and owner removal.
Its `/view` requests also use disposable fixture output rather than live files.
Seed History checks keyboard-operable rows through renderer switches, list growth
and scrolling on resize, palettes, saved history/node dimensions, serialization
and polling cleanup on removal.
Workflow Timer covers execution events, Classic/Nodes 2.0 switching, themes,
custom node colors, persisted time/dimensions and the actual settings picker.
Both dimension swap nodes cover keyboard swaps across renderers and zoom,
responsive button sizing, palettes, native workflow values/saved sizes and
removal cleanup. Latent batch size is preserved.
Screenshots/traces appear in
`tools/testing/test-results/comfyui/`. This opt-in smoke suite is separate from
the required fixture CI job; fully provisioned, pinned ComfyUI CI and testing
alongside other node packs remain follow-up work in issue #81.
## Required merge checks
The manifest in `.github/branch-protection.json` requires all Python matrix jobs,
Python coverage, frontend syntax, frontend workflows/coverage, and the existing
`code-quality` job (including Black and fatal lint checks). Administrators follow
the same checks and branches must be current with main. No extra human approval
is required. Workflows run on every pull request without path filters.
After the checks pass, an administrator applies and verifies the manifest:
```sh
gh api --method PUT repos/ComfyAssets/ComfyUI-KikoTools/branches/main/protection \
--input .github/branch-protection.json
gh api repos/ComfyAssets/ComfyUI-KikoTools/branches/main/protection/required_status_checks
```
Keep the manifest synchronized with job names when changing CI.
+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()
Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

+3 -3
View File
@@ -21,7 +21,7 @@ Get your free Gemini API key from [Google AI Studio](https://makersuite.google.c
### 2. Install Dependencies
```bash
pip install google-generativeai
pip install google-genai
```
### 3. Configure API Key
@@ -204,6 +204,6 @@ Errors are displayed in the prompt output for easy debugging.
- Verify API key is valid
- Image might be too large (resize if needed)
**Import error for google-generativeai**:
- Run `pip install google-generativeai` in your ComfyUI environment
**Import error for google-genai**:
- Run `pip install google-genai` in your ComfyUI environment
- Restart ComfyUI after installation
+23
View File
@@ -3,7 +3,14 @@ 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.checkpoint_folder_iterator import CheckpointFolderIteratorNode
from .tools.display_any import DisplayAnyNode
from .tools.display_text import DisplayTextNode
from .tools.embedding_autocomplete import KikoEmbeddingAutocomplete
@@ -16,6 +23,8 @@ from .tools.kiko_purge_vram import KikoPurgeVRAM
from .tools.kiko_workflow_timer import KikoWorkflowTimerNode
from .tools.kiko_save_image import KikoSaveImageNode
from .tools.local_image_loader import LocalImageLoaderNode
from .tools.project_file_path import ProjectFilePathNode
from .tools.model_compare_sheet import ModelCompareSheetNode
from .tools.model_downloader import ModelDownloaderNode
from .tools.resolution_calculator import ResolutionCalculatorNode
from .tools.sampler_combo import SamplerComboCompactNode, SamplerComboNode
@@ -34,6 +43,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,
@@ -58,13 +71,20 @@ NODE_CLASS_MAPPINGS = {
"FluxSamplerParams": FluxSamplerParamsNode,
"PlotParameters+": PlotParametersNode,
"LoRAFolderBatch": LoRAFolderBatchNode,
"CheckpointFolderIterator": CheckpointFolderIteratorNode,
"ModelCompareSheet": ModelCompareSheetNode,
"WidthHeightToVec2": WidthHeightToVec2Node,
"KikoWorkflowTimer": KikoWorkflowTimerNode,
"KikoProjectFilePath": ProjectFilePathNode,
# Note: KikoEmbeddingAutocomplete is not registered as a node
# It's a settings-only feature accessed through ComfyUI settings menu
}
NODE_DISPLAY_NAME_MAPPINGS = {
"ImageBatchToImageList": "Image Batch to Image List",
"ImageListToImageBatch": "Image List to Image Batch",
"LatentBatchToLatentList": "Latent Batch to Latent List",
"LatentListToLatentBatch": "Latent List to Latent Batch",
"BatchPrompts": "Batch Prompts",
"ResolutionCalculator": "Resolution Calculator",
"WidthHeightSelector": "Width Height Selector",
@@ -89,8 +109,11 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"FluxSamplerParams": "Flux Sampler Parameters",
"PlotParameters+": "Plot Parameters",
"LoRAFolderBatch": "LoRA Folder Batch",
"CheckpointFolderIterator": "Checkpoint Folder Iterator",
"ModelCompareSheet": "Model Compare Sheet",
"WidthHeightToVec2": "Width Height to VEC2",
"KikoWorkflowTimer": "Workflow Timer",
"KikoProjectFilePath": "Project File Path",
# KikoEmbeddingAutocomplete removed - settings only, not a node
}
+106
View File
@@ -0,0 +1,106 @@
"""Scan ComfyUI model folders (loras, checkpoints, ...) for .safetensors files."""
import logging
import os
import re
from typing import Dict, List, Optional, Tuple
logger = logging.getLogger(__name__)
def get_model_roots(folder_key: str) -> List[str]:
"""Return every configured root directory for a folder_paths key."""
try:
import folder_paths
entry = folder_paths.folder_names_and_paths.get(folder_key)
return list(entry[0]) if entry else []
except (ImportError, AttributeError, IndexError, TypeError):
return []
def is_within(path: str, root: str) -> bool:
"""True if realpath `path` equals or is inside realpath `root`."""
try:
real_path, real_root = os.path.realpath(path), os.path.realpath(root)
return os.path.commonpath([real_path, real_root]) == real_root
except ValueError: # different drives or mixed absolute/relative
return False
def resolve_scan_targets(
folder_path: str, folder_key: str
) -> List[Tuple[str, Optional[str]]]:
"""
Resolve a folder setting to (directory, base_root) pairs to scan.
base_root is the model root the directory lives in, or None when the
directory is an absolute path outside every known root.
"""
roots = get_model_roots(folder_key)
if os.path.isabs(folder_path):
base = next((r for r in roots if is_within(folder_path, r)), None)
return [(folder_path, base)]
targets = []
for root in roots:
candidate = os.path.join(root, folder_path)
if not is_within(candidate, root):
logger.warning(f"Folder escapes the {folder_key} directory: {folder_path}")
continue
targets.append((candidate, root))
return targets
def scan_model_entries(folder_path: str, folder_key: str) -> List[Tuple[str, str]]:
"""Return naturally sorted (relative_name, absolute_path) for every .safetensors."""
entries: Dict[str, str] = {}
for directory, base in resolve_scan_targets(folder_path, folder_key):
if not os.path.isdir(directory):
continue
for root, _, files in os.walk(directory):
for file in files:
if not file.endswith(".safetensors"):
continue
full = os.path.join(root, file)
try:
rel = os.path.relpath(full, base or directory)
except ValueError:
rel = os.path.relpath(full, directory)
entries.setdefault(rel.replace("\\", "/"), full)
return [(name, entries[name]) for name in natural_sort(list(entries))]
def natural_sort(items: List[str]) -> List[str]:
"""Sort strings so embedded numbers compare numerically (v2 < v10)."""
def natural_key(text):
parts = []
for part in re.split(r"(\d+)", text):
if not part:
continue
parts.append((0, int(part)) if part.isdigit() else (1, part.lower()))
return parts
return sorted(items, key=natural_key)
def filter_by_pattern(
names: List[str], include_pattern: str = "", exclude_pattern: str = ""
) -> List[str]:
"""Keep names matching include_pattern (if set) and not exclude_pattern (if set)."""
filtered = list(names)
if include_pattern:
try:
include_re = re.compile(include_pattern)
filtered = [f for f in filtered if include_re.search(f)]
except re.error as e:
logger.error(f"Invalid include pattern: {e}")
if exclude_pattern:
try:
exclude_re = re.compile(exclude_pattern)
filtered = [f for f in filtered if not exclude_re.search(f)]
except re.error as e:
logger.error(f"Invalid exclude pattern: {e}")
return filtered
-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}")
@@ -0,0 +1,5 @@
"""Checkpoint Folder Iterator module."""
from .node import CheckpointFolderIteratorNode
__all__ = ["CheckpointFolderIteratorNode"]
@@ -0,0 +1,52 @@
"""Logic for the Checkpoint Folder Iterator node."""
import os
from typing import List, Sequence, Tuple, TypeVar
from ...base.model_folders import filter_by_pattern, scan_model_entries
T = TypeVar("T")
def list_checkpoint_entries(
folder_path: str, include_pattern: str = "", exclude_pattern: str = ""
) -> List[Tuple[str, str]]:
"""(name, absolute_path) pairs for every checkpoint, filtered, in natural order.
The absolute path is what gets loaded, so folders outside the configured
checkpoints roots work too; the name is relative to the root (or to the
folder itself when it lives outside every root) and is used for labels.
"""
entries = scan_model_entries(folder_path, "checkpoints")
names = [name for name, _ in entries]
kept = set(filter_by_pattern(names, include_pattern, exclude_pattern))
return [(name, path) for name, path in entries if name in kept]
def list_checkpoints(
folder_path: str, include_pattern: str = "", exclude_pattern: str = ""
) -> List[str]:
"""Checkpoint names (relative to the checkpoints roots) in natural order."""
entries = list_checkpoint_entries(folder_path, include_pattern, exclude_pattern)
return [name for name, _ in entries]
def pick_checkpoint(items: Sequence[T], index: int) -> T:
"""Return items[index], or raise when the sweep has run past the end.
Wrapping would silently restart the sweep (and, downstream, archive the
grid that just finished), so an over-queued run fails loudly instead.
"""
if not items:
raise ValueError("No checkpoints found")
if not 0 <= index < len(items):
raise ValueError(
f"Index {index} is past the end: only {len(items)} checkpoints matched. "
"The sweep is complete; reset index to 0 to start a new one."
)
return items[index]
def label_for(ckpt_name: str) -> str:
"""Label drawn under a tile for this checkpoint. Default: the file stem."""
return os.path.splitext(os.path.basename(ckpt_name))[0]
@@ -0,0 +1,80 @@
"""Checkpoint Folder Iterator node for ComfyUI."""
from typing import Tuple
from ...base.base_node import ComfyAssetsBaseNode
from .logic import label_for, list_checkpoint_entries, pick_checkpoint
class CheckpointFolderIteratorNode(ComfyAssetsBaseNode):
"""
Load one checkpoint from a folder per queued run.
Set index to 0 with "increment" and queue as many runs as there are
checkpoints: each run loads the next file, so only one model is in
memory at a time. A run past the last checkpoint fails with a clear
error rather than wrapping around, so over-queuing never restarts the
sweep. Pairs with Model Compare Sheet to build a labeled grid.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"folder": (
"STRING",
{
"default": ".",
"multiline": False,
"tooltip": "Folder relative to models/checkpoints (or absolute)",
},
),
"index": (
"INT",
{
"default": 0,
"min": 0,
"max": 9999,
"control_after_generate": True,
"tooltip": "Which checkpoint to load; errors past the end",
},
),
},
"optional": {
"include_pattern": (
"STRING",
{"default": "", "tooltip": "Regex to include (empty = all)"},
),
"exclude_pattern": (
"STRING",
{"default": "", "tooltip": "Regex to exclude (empty = none)"},
),
},
}
RETURN_TYPES = ("MODEL", "CLIP", "VAE", "STRING", "STRING", "INT", "INT")
RETURN_NAMES = ("model", "clip", "vae", "ckpt_name", "label", "index", "count")
FUNCTION = "load"
CATEGORY = "🫶 ComfyAssets/🧰 xyz-helpers"
def load(
self,
folder: str,
index: int,
include_pattern: str = "",
exclude_pattern: str = "",
) -> Tuple[object, object, object, str, str, int, int]:
import folder_paths
import comfy.sd
entries = list_checkpoint_entries(folder, include_pattern, exclude_pattern)
ckpt_name, ckpt_path = pick_checkpoint(entries, index)
self.log_info(f"Loading {index + 1}/{len(entries)}: {ckpt_name}")
model, clip, vae = comfy.sd.load_checkpoint_guess_config(
ckpt_path,
output_vae=True,
output_clip=True,
embedding_directory=folder_paths.get_folder_paths("embeddings"),
)[:3]
return model, clip, vae, ckpt_name, label_for(ckpt_name), index, len(entries)
+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
@@ -1,89 +0,0 @@
{
"models": [
"gemini-2.5-pro",
"gemini-2.5-flash",
"gemini-2.5-flash-lite",
"gemini-2.5-pro-preview-03-25",
"gemini-2.5-flash-preview-05-20",
"gemini-2.5-pro-preview-05-06",
"gemini-2.5-pro-preview-06-05",
"gemini-2.5-flash-lite-preview-06-17",
"gemini-2.0-flash",
"gemini-2.0-flash-001",
"gemini-2.0-flash-lite-001",
"gemini-2.0-flash-lite",
"gemini-2.5-flash-preview-tts",
"gemini-2.5-pro-preview-tts",
"gemini-2.0-flash-preview-image-generation",
"gemini-2.0-flash-exp",
"gemini-2.0-flash-exp-image-generation",
"gemini-2.0-flash-lite-preview-02-05",
"gemini-2.0-flash-lite-preview",
"gemini-2.0-pro-exp",
"gemini-2.0-pro-exp-02-05",
"learnlm-2.0-flash-experimental",
"gemini-1.5-pro-latest",
"gemini-1.5-pro-002",
"gemini-1.5-pro",
"gemini-1.5-flash-latest",
"gemini-1.5-flash",
"gemini-1.5-flash-002",
"gemini-1.5-flash-8b",
"gemini-1.5-flash-8b-001",
"gemini-1.5-flash-8b-latest",
"gemini-2.0-flash-thinking-exp-01-21",
"gemini-2.0-flash-thinking-exp",
"gemini-2.0-flash-thinking-exp-1219",
"gemma-3-1b-it",
"gemma-3-4b-it",
"gemma-3-12b-it",
"gemma-3-27b-it",
"gemma-3n-e4b-it",
"gemma-3n-e2b-it",
"gemini-exp-1206"
],
"descriptions": {
"gemini-1.5-pro-latest": "Gemini 1.5 Pro Latest",
"gemini-1.5-pro-002": "Gemini 1.5 Pro 002",
"gemini-1.5-pro": "Gemini 1.5 Pro",
"gemini-1.5-flash-latest": "Gemini 1.5 Flash Latest",
"gemini-1.5-flash": "Gemini 1.5 Flash",
"gemini-1.5-flash-002": "Gemini 1.5 Flash 002",
"gemini-1.5-flash-8b": "Gemini 1.5 Flash-8B",
"gemini-1.5-flash-8b-001": "Gemini 1.5 Flash-8B 001",
"gemini-1.5-flash-8b-latest": "Gemini 1.5 Flash-8B Latest",
"gemini-2.5-pro-preview-03-25": "Gemini 2.5 Pro Preview 03-25",
"gemini-2.5-flash-preview-05-20": "Gemini 2.5 Flash Preview 05-20",
"gemini-2.5-flash": "Gemini 2.5 Flash",
"gemini-2.5-flash-lite-preview-06-17": "Gemini 2.5 Flash-Lite Preview 06-17",
"gemini-2.5-pro-preview-05-06": "Gemini 2.5 Pro Preview 05-06",
"gemini-2.5-pro-preview-06-05": "Gemini 2.5 Pro Preview",
"gemini-2.5-pro": "Gemini 2.5 Pro",
"gemini-2.0-flash-exp": "Gemini 2.0 Flash Experimental",
"gemini-2.0-flash": "Gemini 2.0 Flash",
"gemini-2.0-flash-001": "Gemini 2.0 Flash 001",
"gemini-2.0-flash-exp-image-generation": "Gemini 2.0 Flash (Image Generation) Experimental",
"gemini-2.0-flash-lite-001": "Gemini 2.0 Flash-Lite 001",
"gemini-2.0-flash-lite": "Gemini 2.0 Flash-Lite",
"gemini-2.0-flash-preview-image-generation": "Gemini 2.0 Flash Preview Image Generation",
"gemini-2.0-flash-lite-preview-02-05": "Gemini 2.0 Flash-Lite Preview 02-05",
"gemini-2.0-flash-lite-preview": "Gemini 2.0 Flash-Lite Preview",
"gemini-2.0-pro-exp": "Gemini 2.0 Pro Experimental",
"gemini-2.0-pro-exp-02-05": "Gemini 2.0 Pro Experimental 02-05",
"gemini-exp-1206": "Gemini Experimental 1206",
"gemini-2.0-flash-thinking-exp-01-21": "Gemini 2.5 Flash Preview 05-20",
"gemini-2.0-flash-thinking-exp": "Gemini 2.5 Flash Preview 05-20",
"gemini-2.0-flash-thinking-exp-1219": "Gemini 2.5 Flash Preview 05-20",
"gemini-2.5-flash-preview-tts": "Gemini 2.5 Flash Preview TTS",
"gemini-2.5-pro-preview-tts": "Gemini 2.5 Pro Preview TTS",
"learnlm-2.0-flash-experimental": "LearnLM 2.0 Flash Experimental",
"gemma-3-1b-it": "Gemma 3 1B",
"gemma-3-4b-it": "Gemma 3 4B",
"gemma-3-12b-it": "Gemma 3 12B",
"gemma-3-27b-it": "Gemma 3 27B",
"gemma-3n-e4b-it": "Gemma 3n E4B",
"gemma-3n-e2b-it": "Gemma 3n E2B",
"gemini-2.5-flash-lite": "Gemini 2.5 Flash-Lite"
},
"timestamp": 1754568195.1098156
}
+9 -15
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:
@@ -115,30 +115,24 @@ def analyze_image_with_gemini(
else:
system_prompt = PROMPT_TEMPLATES.get(prompt_type, PROMPT_TEMPLATES["flux"])
# Here we would normally make the API call to Gemini
# For now, we'll import the google-generativeai library
try:
import google.generativeai as genai
from google import genai
except ImportError:
return (
"",
"google-generativeai library not installed. Please run: pip install google-generativeai",
"google-genai library not installed. Please run: pip install google-genai",
)
try:
# Configure Gemini
genai.configure(api_key=api_key)
client = genai.Client(api_key=api_key)
# Create model
model = genai.GenerativeModel(model_name)
# Generate content
response = model.generate_content(
[
response = client.models.generate_content(
model=model_name,
contents=[
system_prompt,
pil_image,
"Analyze this image and generate an appropriate prompt according to the instructions.",
]
],
)
# Extract text from response
+7 -7
View File
@@ -59,10 +59,10 @@ def _fetch_models_from_api(
Tuple of (model_names_list, model_descriptions_dict)
"""
try:
import google.generativeai as genai
from google import genai
except ImportError:
if not silent:
logger.error("google-generativeai not installed")
logger.error("google-genai not installed")
return [], {}
# Get API key
@@ -77,15 +77,15 @@ def _fetch_models_from_api(
return [], {}
try:
genai.configure(api_key=api_key)
client = genai.Client(api_key=api_key)
models = []
descriptions = {}
# Fetch all models
for model in genai.list_models():
for model in client.models.list():
# Only include models that support generateContent
if "generateContent" in model.supported_generation_methods:
if "generateContent" in (model.supported_actions or []):
# Remove "models/" prefix from name
model_name = model.name.replace("models/", "")
models.append(model_name)
@@ -154,7 +154,7 @@ def _load_cache() -> Optional[Dict]:
return None
try:
with open(CACHE_FILE, "r") as f:
with open(CACHE_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
# Check if cache is expired
@@ -176,7 +176,7 @@ def _save_cache(models: List[str], descriptions: Dict[str, str]) -> None:
"timestamp": time.time(),
}
with open(CACHE_FILE, "w") as f:
with open(CACHE_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
except Exception as e:
logger.warning(f"Failed to save cache: {e}")
+1 -1
View File
@@ -63,7 +63,7 @@ Supports multiple prompt formats:
- Video: Motion and temporal descriptions for video generation
Requires Gemini API key (set GEMINI_API_KEY env var or provide in node).
Install: pip install google-generativeai
Install: pip install google-genai
"""
def generate_prompt(
+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,12 +0,0 @@
{
"last_path": "/home/vito/ai-apps/ComfyUI/output/vids/images",
"saved_paths": [
"/home/vito/ai-apps/ComfyUI-3.12/output/2025-05-01",
"/home/vito/ai-apps/ComfyUI-3.12/output/",
"/home/vito/Downloads/vito",
"/home/vito/ai-apps/ComfyUI/output/2025-06-08",
"/home/vito/ai-apps/ComfyUI/output",
"/home/vito/Downloads",
"/home/vito/Downloads/images"
]
}
+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
@@ -1,77 +0,0 @@
{
"57": {
"image": {
"path": "/home/vito/ai-apps/ComfyUI-3.12/output/2025-05-01/HiDream_00001_.png"
}
},
"58": {
"image": {
"path": "/home/vito/ai-apps/ComfyUI-3.12/output/CharacterName_00016_.png"
}
},
"18": {
"image": {
"path": "/home/vito/ai-apps/ComfyUI/output/ComfyUI_00002_.png"
}
},
"445": {
"image": {
"path": "/home/vito/ai-apps/ComfyUI/output/ComfyUI_00002_.png"
}
},
"23": {
"image": {
"path": "/home/vito/ai-apps/ComfyUI/output/Z_Image_Char/Image_00098_.png"
}
},
"69": {
"image": {
"path": "/home/vito/Downloads/KikoSave_00086.png"
}
},
"52": {
"image": {
"path": "/home/vito/ai-apps/ComfyUI/output/kiko/XXX/images/kiko_00038_.png"
}
},
"38": {
"image": {
"path": "/home/vito/Downloads/vito/IMG_20160422_163419.jpg"
}
},
"170": {
"image": {
"path": "/home/vito/ai-apps/ComfyUI/output/Z_Image_Char/Image_00326_.png"
}
},
"214": {
"image": {
"path": "/home/vito/ai-apps/ComfyUI/output/Z_Image_Char/Image_00329_.png"
}
},
"299": {
"image": {
"path": "/home/vito/Downloads/images/KikoSave_00016.png"
}
},
"527": {
"image": {
"path": "/home/vito/Downloads/ComfyUI_temp_sktzg_00012_.png"
}
},
"522": {
"image": {
"path": "/home/vito/Downloads/KikoSave_00086.png"
}
},
"517": {
"image": {
"path": "/home/vito/ai-apps/ComfyUI/output/vids/images/kittybear_00004.png"
}
},
"144": {
"image": {
"path": "/home/vito/ai-apps/ComfyUI/output/2025-04-24/ComfyUI_00002_.png"
}
}
}
@@ -0,0 +1,5 @@
"""Model Compare Sheet module."""
from .node import ModelCompareSheetNode
__all__ = ["ModelCompareSheetNode"]
@@ -0,0 +1,163 @@
"""Logic for the Model Compare Sheet node: tile files on disk into a labeled grid."""
import math
import os
import re
from datetime import datetime
from typing import List, Optional, Tuple
from PIL import Image, ImageDraw, ImageFont
from ...base.model_folders import is_within, natural_sort
GRID_FILENAME = "grid.png"
PREVIEW_FILENAME = "grid_preview.png"
PREVIEW_WIDTH = 1024
BACKGROUND = (28, 28, 28)
LABEL_COLOR = (235, 235, 235)
def resolve_run_dir(output_dir: str, run_name: str) -> str:
"""Directory for one compare run, always strictly inside output/model_compare.
run_name may be nested ("project/date/sweep"). Every segment must be a plain
name: no empty, "." or ".." parts, on either separator, and the resolved
path must stay below model_compare even after symlinks are followed.
"""
parts = re.split(r"[\\/]", run_name)
if not run_name or os.path.isabs(run_name) or ":" in run_name:
raise ValueError(f"Invalid run_name: {run_name!r}")
if any(part in ("", ".", "..") for part in parts):
raise ValueError(f"Invalid run_name: {run_name!r}")
base = os.path.join(output_dir, "model_compare")
run_dir = os.path.join(base, *parts)
if not is_within(run_dir, base) or os.path.realpath(run_dir) == os.path.realpath(
base
):
raise ValueError(f"Invalid run_name: {run_name!r}")
return run_dir
def tile_filename(index: int, label: str, batch_index: int, batch_size: int) -> str:
"""'03_label.png', or '03_label_2.png' for the second image of a batch."""
safe = re.sub(r"[^A-Za-z0-9._-]+", "_", label)
suffix = f"_{batch_index + 1}" if batch_size > 1 else ""
return f"{index:02d}_{safe}{suffix}.png"
def label_from_filename(filename: str) -> str:
stem = os.path.splitext(os.path.basename(filename))[0]
return re.sub(r"^\d+_", "", stem)
def list_tiles(run_dir: str) -> List[str]:
"""PNG tiles in a run directory in natural order, excluding the grid itself."""
names = [
f
for f in os.listdir(run_dir)
if f.lower().endswith(".png")
and f not in (GRID_FILENAME, PREVIEW_FILENAME)
and os.path.isfile(os.path.join(run_dir, f))
]
return [os.path.join(run_dir, f) for f in natural_sort(names)]
def replace_index_tiles(
run_dir: str, index: int, staged_dir: str, names: List[str]
) -> None:
"""Publish one index's staged tiles, restoring old files on a rename failure."""
previous_dir = os.path.join(staged_dir, "previous")
os.mkdir(previous_dir)
prefix = f"{index:02d}_"
old_tiles = [
path
for path in list_tiles(run_dir)
if os.path.basename(path).startswith(prefix)
]
moved: List[Tuple[str, str]] = []
published: List[str] = []
try:
for path in old_tiles:
backup = os.path.join(previous_dir, os.path.basename(path) + ".previous")
os.replace(path, backup)
moved.append((path, backup))
for name in names:
destination = os.path.join(run_dir, name)
os.replace(os.path.join(staged_dir, name + ".tmp"), destination)
published.append(destination)
except OSError:
for path in published:
os.remove(path)
for original, backup in reversed(moved):
os.replace(backup, original)
raise
def archive_previous_sweep(run_dir: str) -> Optional[str]:
"""Rename a run folder that already holds tiles to '<run_dir>_<timestamp>'.
Called when a sweep starts over (index 0) so stale tiles from an
interrupted sweep never end up in the new grid. Returns the archive path,
or None when there was nothing to archive.
"""
if not os.path.isdir(run_dir) or not list_tiles(run_dir):
return None
stamp = datetime.fromtimestamp(os.path.getmtime(run_dir)).strftime("%Y%m%d-%H%M%S")
archive = f"{run_dir}_{stamp}"
n = 1
while os.path.exists(archive):
n += 1
archive = f"{run_dir}_{stamp}_{n}"
os.rename(run_dir, archive)
return archive
def _font(font_size: int) -> ImageFont.ImageFont:
try:
return ImageFont.truetype("DejaVuSans.ttf", font_size)
except OSError:
return ImageFont.load_default(size=font_size)
def _fit_label(draw: ImageDraw.ImageDraw, text: str, font, max_width: int) -> str:
while text and draw.textlength(text, font=font) > max_width:
text = text[:-2] + "…" if len(text) > 1 else ""
return text
def build_contact_sheet(
paths: List[str], columns: int, tile_width: int, font_size: int
) -> Image.Image:
"""Tile the images at `paths` into a grid with each file's label underneath."""
tiles = []
for path in paths:
with Image.open(path) as im:
im = im.convert("RGB")
tile_height = max(1, round(im.height * tile_width / im.width))
tiles.append((im.resize((tile_width, tile_height), Image.LANCZOS), path))
band = font_size * 2
cell_height = max(t.height for t, _ in tiles) + band
cols = min(len(tiles), columns)
rows = math.ceil(len(tiles) / cols)
sheet = Image.new("RGB", (cols * tile_width, rows * cell_height), BACKGROUND)
draw = ImageDraw.Draw(sheet)
font = _font(font_size)
for i, (tile, path) in enumerate(tiles):
x = (i % cols) * tile_width
y = (i // cols) * cell_height
sheet.paste(tile, (x, y))
label = _fit_label(draw, label_from_filename(path), font, tile_width - 8)
text_x = x + (tile_width - draw.textlength(label, font=font)) / 2
text_y = y + tile.height + (band - font_size) / 2
draw.text((text_x, text_y), label, fill=LABEL_COLOR, font=font)
return sheet
def preview_of(sheet: Image.Image) -> Image.Image:
"""A copy of the sheet no wider than PREVIEW_WIDTH, for the node preview and galleries."""
if sheet.width <= PREVIEW_WIDTH:
return sheet
height = max(1, round(sheet.height * PREVIEW_WIDTH / sheet.width))
return sheet.resize((PREVIEW_WIDTH, height), Image.LANCZOS)
+125
View File
@@ -0,0 +1,125 @@
"""Model Compare Sheet node for ComfyUI."""
import json
import os
from tempfile import TemporaryDirectory
from typing import Any, Dict, Optional
import numpy as np
import torch
from PIL import Image
from PIL.PngImagePlugin import PngInfo
from ...base.base_node import ComfyAssetsBaseNode
from .logic import (
GRID_FILENAME,
PREVIEW_FILENAME,
archive_previous_sweep,
build_contact_sheet,
list_tiles,
preview_of,
replace_index_tiles,
resolve_run_dir,
tile_filename,
)
class ModelCompareSheetNode(ComfyAssetsBaseNode):
"""
Save each run's image into output/model_compare/<run_name>/ and rebuild a
labeled contact sheet from every tile saved so far. Pair with Checkpoint
Folder Iterator: after the last queued run the grid holds every model.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE",),
"label": ("STRING", {"forceInput": True}),
"index": ("INT", {"forceInput": True}),
"run_name": (
"STRING",
{
"default": "illustrious",
"tooltip": "Subfolder of output/model_compare for this run",
},
),
"columns": ("INT", {"default": 5, "min": 1, "max": 32}),
"tile_width": ("INT", {"default": 512, "min": 64, "max": 4096}),
"font_size": ("INT", {"default": 28, "min": 8, "max": 256}),
},
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("grid",)
FUNCTION = "collect"
OUTPUT_NODE = True
CATEGORY = "🫶 ComfyAssets/🧰 xyz-helpers"
def collect(
self,
images: torch.Tensor,
label: str,
index: int,
run_name: str,
columns: int,
tile_width: int,
font_size: int,
prompt: Optional[Dict] = None,
extra_pnginfo: Optional[Dict] = None,
) -> Dict[str, Any]:
import folder_paths
if images.shape[0] == 0:
raise ValueError("images must contain at least one image")
output_dir = folder_paths.get_output_directory()
run_dir = resolve_run_dir(output_dir, run_name)
metadata = PngInfo()
if prompt is not None:
metadata.add_text("prompt", json.dumps(prompt))
for key, value in (extra_pnginfo or {}).items():
metadata.add_text(key, json.dumps(value))
parent = os.path.dirname(run_dir)
os.makedirs(parent, exist_ok=True)
with TemporaryDirectory(prefix=".model-compare-", dir=parent) as staging:
names = []
for i, image in enumerate(images):
array = (image.cpu().numpy().clip(0, 1) * 255).round().astype(np.uint8)
name = tile_filename(index, label, i, images.shape[0])
with Image.fromarray(array) as tile:
tile.save(
os.path.join(staging, name + ".tmp"),
format="PNG",
pnginfo=metadata,
)
names.append(name)
if index == 0:
archived = archive_previous_sweep(run_dir)
if archived:
self.log_info(f"New sweep: previous tiles moved to {archived}")
os.makedirs(run_dir, exist_ok=True)
replace_index_tiles(run_dir, index, staging, names)
sheet = build_contact_sheet(list_tiles(run_dir), columns, tile_width, font_size)
sheet.save(os.path.join(run_dir, GRID_FILENAME))
preview_of(sheet).save(os.path.join(run_dir, PREVIEW_FILENAME))
self.log_info(f"Contact sheet: {run_dir}/{GRID_FILENAME}")
grid = torch.from_numpy(np.asarray(sheet).astype(np.float32) / 255.0)[None]
subfolder = os.path.relpath(run_dir, output_dir)
return {
"ui": {
"images": [
{
"filename": PREVIEW_FILENAME,
"subfolder": subfolder,
"type": "output",
}
]
},
"result": (grid,),
}
+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)
+89
View File
@@ -0,0 +1,89 @@
"""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",)
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,24 @@
"""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, Tuple
import logging
from ....base.model_folders import (
filter_by_pattern,
natural_sort,
resolve_scan_targets,
scan_model_entries,
)
logger = logging.getLogger(__name__)
MAX_STRENGTH_VALUES = 1000
DEFAULT_RANGE_STEP = 0.1
_RANGE_EPSILON = 1e-4
def get_lora_folders() -> List[str]:
"""
@@ -35,106 +47,43 @@ def get_lora_folders() -> List[str]:
return [".", "flux", "sdxl", "sd15"]
def scan_folder_for_loras(folder_path: str) -> List[str]: # noqa: C901
def _scan_entries(folder_path: str) -> List[Tuple[str, str]]:
"""Return (relative_name, absolute_path) for every .safetensors found."""
return scan_model_entries(folder_path, "loras")
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: Path to folder to scan (absolute or relative to models/loras)
folder_path: Folder to scan (absolute or relative to the loras roots)
Returns:
List of LoRA filenames relative to models/loras directory
Naturally sorted LoRA names relative to their loras root (or to the
scanned folder when it lies outside every root)
"""
try:
import folder_paths
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
# 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}")
return []
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]:
@@ -165,133 +114,89 @@ def sort_lora_files(lora_files: List[str], sort_order: str) -> List[str]:
return lora_files
def natural_sort(items: List[str]) -> List[str]:
"""
Sort strings naturally, handling numbers properly.
Args:
items: List of strings to sort
Returns:
Naturally sorted list
"""
def natural_key(text):
def atoi(text):
return int(text) if text.isdigit() else text
# Split on digits and filter out empty strings
parts = [atoi(c) for c in re.split(r"(\d+)", text) if c]
# Convert to tuple of (type_order, value) to ensure consistent comparison
# Integers get type_order 0, strings get type_order 1
typed_parts = []
for part in parts:
if isinstance(part, int):
typed_parts.append((0, part))
else:
typed_parts.append((1, part))
return typed_parts
return sorted(items, key=natural_key)
def filter_loras_by_pattern(
lora_files: List[str], include_pattern: str = "", exclude_pattern: str = ""
) -> List[str]:
"""
Filter LoRA files by include/exclude patterns.
Args:
lora_files: List of LoRA filenames
include_pattern: Regex pattern to include (empty = include all)
exclude_pattern: Regex pattern to exclude (empty = exclude none)
Returns:
Filtered list of LoRA files
"""
filtered = lora_files.copy()
# Apply include pattern
if include_pattern:
try:
include_re = re.compile(include_pattern)
filtered = [f for f in filtered if include_re.search(f)]
except re.error as e:
logger.error(f"Invalid include pattern: {e}")
# Apply exclude pattern
if exclude_pattern:
try:
exclude_re = re.compile(exclude_pattern)
filtered = [f for f in filtered if not exclude_re.search(f)]
except re.error as e:
logger.error(f"Invalid exclude pattern: {e}")
return filtered
"""Filter LoRA names by include/exclude regex (empty pattern = no filter)."""
return filter_by_pattern(lora_files, include_pattern, exclude_pattern)
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 +217,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 +320,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, "loras")
)
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
+10 -16
View File
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "kikotools"
description = "Simple tools for ComfyUI"
version = "1.0.28"
version = "1.1.2"
license = {text = "MIT"}
dependencies = []
@@ -14,17 +14,19 @@ dev = [
# Testing framework
"pytest>=7.0.0",
"pytest-cov>=4.0.0",
"pytest-mock>=3.10.0",
"pytest-mock>=3.15.1",
"pytest-asyncio>=0.21.0",
"aiohttp>=3.8.0",
# Code quality
"black>=23.0.0",
"flake8>=6.0.0",
"mypy>=1.0.0",
"mypy>=1.20.0",
# Development utilities
"pre-commit>=3.0.0",
"pre-commit>=4.5.1",
# ComfyUI testing (mock dependencies for unit tests)
"torch>=2.0.0",
"torch>=2.11.0",
"numpy>=1.24.0",
"pillow>=9.0.0"
"pillow>=12.2.0"
]
[project.urls]
@@ -69,21 +71,13 @@ 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]
branch = true
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
+7 -5
View File
@@ -3,17 +3,19 @@
# Testing framework
pytest>=7.0.0
pytest-cov>=4.0.0
pytest-mock>=3.10.0
pytest-mock>=3.15.1
pytest-asyncio>=0.21.0
aiohttp>=3.8.0
# Code quality
black>=23.0.0
flake8>=6.0.0
mypy>=1.0.0
mypy>=1.20.0
# Development utilities
pre-commit>=3.0.0
pre-commit>=4.5.1
# ComfyUI testing (mock dependencies for unit tests)
torch>=2.0.0
torch>=2.11.0
numpy>=1.24.0
pillow>=9.0.0
pillow>=12.2.0
+1 -1
View File
@@ -1,4 +1,4 @@
# Runtime dependencies for ComfyUI-KikoTools
# Gemini API integration (optional - only needed for Gemini Prompt node)
google-generativeai
google-genai
+118
View File
@@ -0,0 +1,118 @@
// A test double for the ComfyUI host; extension implementations load unchanged.
const extensions = [];
const settings = new Map();
const settingDefinitions = new Map();
const nodes = new Map();
let nextId = 1;
function el(tag, props = {}, children = []) {
const element = document.createElement(tag);
for (const [name, value] of Object.entries(props)) {
if (name === 'style') Object.assign(element.style, value);
else if (name === 'parent') value.appendChild(element);
else element[name] = value;
}
element.append(...children);
return element;
}
const app = {
registerExtension(extension) {
if (extensions.some(item => item.name === extension.name)) throw new Error(`Duplicate extension ${extension.name}`);
extensions.push(extension);
},
ui: { settings: {
addSetting(setting) { settingDefinitions.set(setting.id, setting); if (!settings.has(setting.id)) settings.set(setting.id, setting.defaultValue); },
getSettingValue: id => settings.get(id),
setSettingValue(id, value) { settings.set(id, value); settingDefinitions.get(id)?.onChange?.(value); },
}, dialog: { show: text => { const dialog = el('div', { className: 'host-dialog', textContent: text }); document.body.append(dialog); } } },
graph: {
_nodes: [],
add(node) { this._nodes.push(node); nodes.set(String(node.id), node); },
getNodeById: id => nodes.get(String(id)),
setDirtyCanvas() {},
},
canvas: { selected_nodes: {}, centerOnNode(node) { app.centeredNode = node.id; } },
};
const api = new EventTarget();
api.fetchApi = (path, options) => fetch(path, options);
api.getEmbeddings = async () => [];
function widget(node, name, value, multiline = false) {
const inputEl = el(multiline ? 'textarea' : 'input', { 'ariaLabel': name });
const result = { name, inputEl, options: {}, callback() {}, onRemove() { inputEl.remove(); } };
Object.defineProperty(result, 'value', { get: () => inputEl.value, set: value => { inputEl.value = value; } });
result.value = value ?? '';
inputEl.addEventListener('change', () => result.callback(result.value, app.canvas, node, [0, 0], null));
node.widgets.push(result);
node.element.append(inputEl);
return result;
}
const widgets = { STRING(node, name, data) { return { widget: widget(node, name, '', data[1]?.multiline) }; } };
class HostNode {
constructor(name, initial) {
this.id = nextId++;
this.type = name;
this.title = name;
this.widgets = [];
this.size = [500, 300];
this.pos = [0, 0];
this.flags = {};
this.inputs = [];
this.properties = {};
this.graph = app.graph;
this.element = el('section', { className: 'host-node' });
this.element.dataset.node = name;
this.domElement = this.element;
this.element.style.cssText = 'position:relative;width:min(800px,95vw);min-height:120px;margin:12px;border:1px solid #888;';
document.querySelector('#nodes').append(this.element);
for (const [key, value] of Object.entries(initial)) {
const entry = name === 'TextInput' && key === 'text'
? widgets.STRING(this, key, ['STRING', { multiline: true }], app).widget
: widget(this, key, value);
entry.value = value;
if (key === 'preset') entry.options.values = ['custom', '832×1216 - portrait', '1216×832 - landscape'];
}
}
addDOMWidget(name, type, element) { this.element.append(element); const result = { name, element, onRemove: () => element.remove() }; this.widgets.push(result); return result; }
addWidget(type, name, value, callback, options = {}) {
if (type !== 'button') { const entry = widget(this, name, value); entry.callback = callback; return entry; }
const button = el('button', { textContent: name, onclick: () => callback() });
this.element.append(button);
const result = { type, name, value, callback, options, onRemove: () => button.remove() };
this.widgets.push(result);
return result;
}
addCustomWidget(entry) { this.widgets.push(entry); if (entry.type === 'button') { const button = el('button', { textContent: entry.text, onclick: () => entry.callback() }); this.element.append(button); entry.onRemove = () => button.remove(); } return entry; }
setSize(size) { this.size = size; }
computeSize() { return this.size; }
onResize(size) { this.size = size; }
setDirtyCanvas() {}
configure(info) { this.properties = info.properties || {}; }
onNodeCreated() { this.created = true; }
onExecuted() { this.executions = (this.executions || 0) + 1; }
onRemoved() { this.removed = true; }
}
async function createNode(name, initial = {}) {
class Node extends HostNode {}
Node.category = 'ComfyAssets';
for (const extension of extensions) await extension.beforeRegisterNodeDef?.(Node, { name }, app);
const node = new Node(name, initial);
app.graph.add(node);
node.onNodeCreated();
return node.id;
}
function removeNode(id) {
const node = nodes.get(String(id));
node.onRemoved?.();
for (const entry of node.widgets) { entry.onRemoved?.(); entry.onRemove?.(); }
node.element.remove();
nodes.delete(String(id));
app.graph._nodes = app.graph._nodes.filter(item => item !== node);
return node;
}
window.kikoHost = { app, api, widgets, el, extensions, settings, settingDefinitions, nodes, createNode, removeNode,
emit: (name, detail) => api.dispatchEvent(new CustomEvent(name, { detail })) };
const manifest = await (await fetch('/__test__/state')).json();
for (const name of manifest.extensions) await import(`/extensions/kikotools/${name}`);
for (const extension of extensions) await extension.init?.(app);
for (const extension of extensions) await extension.setup?.(app);
window.kikoHost.ready = true;
document.body.dataset.ready = 'true';
+182
View File
@@ -0,0 +1,182 @@
"""Run actual node routes against disposable files and a minimal ComfyUI host."""
import argparse
import asyncio
import importlib.util
import json
import shutil
from pathlib import Path
import sys
import tempfile
import types
from aiohttp import web
from PIL import Image
REPOSITORY = Path(__file__).resolve().parents[2]
FIXTURES = Path(__file__).parent
def create_app(root: Path) -> web.Application:
"""Import the real plugin and replace only ComfyUI host services."""
app = web.Application()
routes = web.RouteTableDef()
server = types.ModuleType("server")
server.PromptServer = types.SimpleNamespace(
instance=types.SimpleNamespace(routes=routes, send_sync=lambda *args: None)
)
sys.modules["server"] = server
folder_paths = types.ModuleType("folder_paths")
folder_paths.base_path = str(root)
folder_paths.models_dir = str(root / "models")
folder_paths.folder_names_and_paths = {}
for kind in ("input", "output", "temp"):
directory = root / kind
directory.mkdir()
setattr(folder_paths, f"get_{kind}_directory", lambda path=directory: str(path))
folder_paths.get_filename_list = lambda kind: {
"embeddings": ["portraits/cat.pt", "landscapes/forest.safetensors"],
"loras": ["styles/ink.safetensors"],
}.get(kind, [])
folder_paths.get_folder_paths = lambda kind: [str(root / "models" / kind)]
sys.modules["folder_paths"] = folder_paths
spec = importlib.util.spec_from_file_location(
"kiko_browser_plugin", REPOSITORY / "__init__.py"
)
plugin = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = plugin
spec.loader.exec_module(plugin)
loader = sys.modules["kiko_browser_plugin.kikotools.tools.local_image_loader.node"]
loader.CONFIG_FILE = str(root / "config.json")
loader.SELECTIONS_FILE = str(root / "selections.json")
def reset(extra_images: int = 0) -> None:
(root / "selections.json").unlink(missing_ok=True)
for kind in ("input", "output"):
shutil.rmtree(root / kind)
(root / kind).mkdir()
loader.save_config({"saved_paths": [], "last_path": str(root / "input")})
for path, color in [
("input/cat & dog.png", "red"),
("input/sub/forest.png", "green"),
("input/.hidden/hidden.png", "green"),
("outside/secret.png", "blue"),
]:
target = root / path
target.parent.mkdir(parents=True, exist_ok=True)
with Image.new("RGB", (16, 12), color) as image:
image.save(target)
for index in range(extra_images):
target = root / "input" / "bulk" / f"{index:04}.png"
target.parent.mkdir(exist_ok=True)
shutil.copyfile(root / "input" / "cat & dog.png", target)
@routes.post("/__test__/reset")
async def reset_route(request: web.Request) -> web.Response:
data = await request.json() if request.can_read_body else {}
extra_images = data.get("extra_images", 0)
if (
isinstance(extra_images, bool)
or not isinstance(extra_images, int)
or not 0 <= extra_images <= 60
):
raise web.HTTPBadRequest()
await asyncio.to_thread(reset, extra_images)
return web.json_response({"success": True})
@routes.get("/__test__/state")
async def state(request: web.Request) -> web.Response:
return web.json_response(
{
"input": str(root / "input"),
"outside": str(root / "outside"),
"selections": loader.load_selections(),
"config": loader.load_config(),
"nodes": sorted(plugin.NODE_CLASS_MAPPINGS),
"extensions": sorted(
path.name for path in (REPOSITORY / "web").glob("*.js")
),
}
)
@routes.post("/__test__/execute/{name}")
async def execute(request: web.Request) -> web.Response:
name = request.match_info["name"]
data = await request.json()
if name not in {
"DisplayAny",
"DisplayText",
"KikoLocalImageLoader",
"KikoSaveImage",
}:
raise web.HTTPNotFound()
if name == "KikoSaveImage":
data["images"] = loader.load_image_from_path(
str(root / "input" / "cat & dog.png")
)[0]
cls = plugin.NODE_CLASS_MAPPINGS[name]
result = await asyncio.to_thread(getattr(cls(), cls.FUNCTION), **data)
if name == "KikoLocalImageLoader":
tensor, info = result
return web.json_response(
{"shape": list(tensor.shape), "info": json.loads(info) if info else {}}
)
return web.json_response(result)
@routes.get("/__test__/")
async def index(request: web.Request) -> web.Response:
return web.Response(
text='<!doctype html><html><head><meta name="viewport" content="width=device-width, initial-scale=1"><title>KikoTools extension test host</title></head><body><main id="nodes"></main><script type="module" src="/__test__/host.mjs"></script></body></html>',
content_type="text/html",
)
@routes.get("/__test__/host.mjs")
async def host(request: web.Request) -> web.Response:
return web.FileResponse(FIXTURES / "host.mjs")
@routes.get("/scripts/{name}.js")
async def host_import(request: web.Request) -> web.Response:
exports = {
"app": "export const app = window.kikoHost.app;",
"api": "export const api = window.kikoHost.api;",
"widgets": "export const ComfyWidgets = window.kikoHost.widgets;",
"ui": "export const $el = window.kikoHost.el;",
}
if request.match_info["name"] not in exports:
raise web.HTTPNotFound()
return web.Response(
text=exports[request.match_info["name"]], content_type="text/javascript"
)
@routes.get("/view")
@routes.get("/api/view")
async def comfy_view(request: web.Request) -> web.Response:
directory = root / "output"
path = (
directory
/ request.query.get("subfolder", "")
/ request.query.get("filename", "")
).resolve()
if not path.is_relative_to(directory) or not path.is_file():
raise web.HTTPNotFound()
return web.FileResponse(path)
reset()
app.add_routes(routes)
app.router.add_static("/extensions/kikotools", REPOSITORY / "web")
return app
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--port", type=int, default=8766)
args = parser.parse_args()
with tempfile.TemporaryDirectory(prefix="kiko-browser-") as directory:
web.run_app(
create_app(Path(directory).resolve()), host="127.0.0.1", port=args.port
)
if __name__ == "__main__":
main()
+989
View File
@@ -0,0 +1,989 @@
import { test, expect } from '../../tools/testing/node_modules/@playwright/test/index.mjs';
import { isFixtureURL } from '../../tools/testing/network-policy.mjs';
import { startCoverage, saveCoverage } from '../../tools/testing/browser-coverage.mjs';
test.beforeEach(async ({ page, request, context, browserName, baseURL }) => {
await startCoverage(page, browserName);
await expect(await request.post('/__test__/reset')).toBeOK();
page.errors = [];
page.on('pageerror', error => page.errors.push(error.message));
page.on('response', response => { if (response.status() >= 500) page.errors.push(`${response.status()} ${response.url()}`); });
await context.route('**/*', route => {
const url = new URL(route.request().url());
if (isFixtureURL(url.href, baseURL)) return route.continue();
page.errors.push(`Unexpected external request: ${url.origin}`);
return route.abort();
});
await page.addInitScript(() => localStorage.setItem('kikotools.embedding_autocomplete.settings', JSON.stringify({ showCustomWords: false, customWordsUrl: '' })));
});
test.afterEach(async ({ page, browserName }) => {
await saveCoverage(page, browserName);
expect(page.errors).toEqual([]);
});
async function openHost(page) {
await page.goto('/__test__/');
await expect(page.locator('body')).toHaveAttribute('data-ready', 'true');
}
async function createNode(page, name, values = {}) {
return page.evaluate(({ name, values }) => kikoHost.createNode(name, values), { name, values });
}
async function state(request) { return (await (await request.get('/__test__/state')).json()); }
test('plugin startup registers every frontend extension and exposes real autocomplete routes', async ({ page, request }) => {
await openHost(page);
const manifest = await state(request);
expect(manifest.nodes).toEqual(expect.arrayContaining(['DisplayAny', 'DisplayText', 'WidthHeightSelector', 'KikoLocalImageLoader', 'KikoSaveImage']));
const registered = await page.evaluate(() => kikoHost.extensions.map(extension => extension.name));
expect(registered).toHaveLength(manifest.extensions.length);
expect(new Set(registered).size).toBe(registered.length);
const embeddings = await (await request.get('/kikotools/autocomplete/embeddings')).json();
expect(embeddings).toContainEqual(expect.objectContaining({ name: 'cat', path: 'portraits/cat' }));
});
test('shared loader controls follow host themes, resize and support keyboard selection', async ({ page }) => {
await openHost(page);
await createNode(page, 'KikoLocalImageLoader');
const loader = page.locator('.ca-image-browser');
await expect(loader).toBeVisible();
await page.evaluate(() => document.body.style.setProperty('--bg-color', '#202020'));
await expect(loader).toHaveAttribute('data-ca-theme', 'dark');
await page.evaluate(() => document.body.style.setProperty('--bg-color', '#fafafa'));
await expect(loader).toHaveAttribute('data-ca-theme', 'light');
const toggle = loader.getByRole('switch', { name: 'Hide dot folders' });
await toggle.focus();
await page.keyboard.press('Space');
await expect(toggle).not.toBeChecked();
await expect(page.locator('.kiko-folder-name').filter({ hasText: /^\.hidden$/ })).toBeVisible();
await loader.getByRole('button', { name: 'Select image cat & dog.png', exact: true }).focus();
await page.keyboard.press('Enter');
await expect(loader.locator('[data-selection]')).toContainText('cat & dog.png');
for (const width of [320, 480, 800]) {
await loader.evaluate((element, width) => { element.style.width = `${width}px`; }, width);
expect(await loader.evaluate(element => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1);
}
await loader.evaluate(element => element.style.removeProperty('width'));
await loader.getByRole('radio', { name: 'List', exact: true }).check();
await expect(loader.locator('.kiko-cardholder')).toHaveAttribute('data-view', 'list');
await loader.getByRole('searchbox', { name: 'Filter this page' }).fill('not-in-folder');
await expect(loader.getByText('No matching items on this page')).toBeVisible();
await loader.getByRole('searchbox', { name: 'Filter this page' }).fill('cat');
await expect(loader.locator('.kiko-gallery-card')).toHaveCount(1);
});
test('a slower directory response cannot overwrite the latest folder', async ({ page, request }) => {
await openHost(page);
await createNode(page, 'KikoLocalImageLoader');
await expect(page.getByAltText('cat & dog.png', { exact: true })).toBeVisible();
const info = await state(request);
const original = await (await request.get(`/kiko_local_image_loader/images?directory=${encodeURIComponent(info.input)}`)).json();
const held = [];
await page.route('**/kiko_local_image_loader/images?*', route => {
if (new URL(route.request().url()).searchParams.get('directory') === info.input) held.push(route);
else return route.continue();
});
await page.locator('.kiko-refresh').click();
await expect.poll(() => held.length).toBe(1);
await page.locator('.kiko-path-input').fill(`${info.input}/sub`);
await page.locator('.kiko-browse').click();
await expect(page.getByAltText('forest.png', { exact: true })).toBeVisible();
await held[0].fulfill({ json: original });
await page.waitForTimeout(80);
await expect(page.locator('.kiko-path-input')).toHaveValue(`${info.input}/sub`);
await expect(page.getByAltText('cat & dog.png', { exact: true })).toHaveCount(0);
});
test('large galleries scroll instead of squeezing thumbnails and size is adjustable', async ({ page, request }) => {
await expect(await request.post('/__test__/reset', { data: { extra_images: 52 } })).toBeOK();
await openHost(page);
await createNode(page, 'KikoLocalImageLoader');
await page.locator('.kiko-folder-name').filter({ hasText: /^bulk$/ }).click();
await expect(page.locator('.kiko-card-info-panel')).toHaveCount(50);
const loader = page.locator('.ca-image-browser');
await loader.evaluate(element => { element.style.width = '800px'; element.style.height = '650px'; });
const size = loader.getByRole('slider', { name: 'Thumbnail size' });
await expect(size).toHaveValue('224');
const media = loader.locator('.kiko-card-media-wrapper').nth(1);
const before = await media.boundingBox();
expect(before.height).toBeGreaterThanOrEqual(200);
expect(await loader.locator('.kiko-cardholder').evaluate(element => element.scrollHeight > element.clientHeight)).toBe(true);
await size.fill('320');
await size.dispatchEvent('input');
expect((await media.boundingBox()).height).toBeGreaterThan(before.height);
await expect(loader.locator('.ca-range output')).toHaveText('320 px');
await size.focus();
await page.keyboard.press('ArrowLeft');
await expect(size).toHaveValue('304');
await loader.evaluate(element => { element.style.width = '320px'; });
expect(await loader.evaluate(element => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1);
await expect(media.locator('img')).toHaveCSS('object-fit', 'contain');
});
test('removing a loader cancels delayed autocomplete and detaches theme observers', async ({ page }) => {
await openHost(page);
const id = await createNode(page, 'KikoLocalImageLoader');
await expect(page.getByAltText('cat & dog.png', { exact: true })).toBeVisible();
await page.evaluate(() => document.body.style.setProperty('--bg-color', '#202020'));
await expect(page.locator('.ca-image-browser')).toHaveAttribute('data-ca-theme', 'dark');
await page.clock.install();
const suggestions = [];
page.on('request', request => { if (request.url().includes('/list_directories?')) suggestions.push(request.url()); });
await page.locator('.kiko-path-input').fill('/pending');
await page.evaluate(id => {
window.removedGallery = document.querySelector('.ca-image-browser');
kikoHost.removeNode(id);
document.body.style.setProperty('--bg-color', '#fafafa');
}, id);
await page.clock.fastForward(500);
expect(suggestions).toEqual([]);
expect(await page.evaluate(() => window.removedGallery.dataset.caTheme)).toBe('dark');
await expect(page.locator('.ca-image-browser')).toHaveCount(0);
});
test('actual swap handlers preserve preset values and custom dimensions', async ({ page }) => {
await openHost(page);
const id = await createNode(page, 'WidthHeightSelector', { preset: '832×1216 - portrait', width: 832, height: 1216 });
const swapped = await page.evaluate(id => {
const node = kikoHost.nodes.get(String(id));
node.swapDimensions();
return node.widgets.filter(widget => ['preset', 'width', 'height'].includes(widget.name)).map(widget => widget.value);
}, id);
expect(swapped).toEqual(['1216×832 - landscape', '1216', '832']);
await createNode(page, 'EmptyLatentBatch', { preset: 'custom', width: 640, height: 960 });
const latent = page.locator('[data-node="EmptyLatentBatch"]');
await latent.getByRole('button', { name: /Swap/ }).click();
await expect(latent.getByLabel('width', { exact: true })).toHaveValue('960');
await expect(latent.getByLabel('height', { exact: true })).toHaveValue('640');
});
for (const name of ['WidthHeightSelector', 'EmptyLatentBatch']) {
test(`${name}: shared swap supports keyboard, callbacks and live themes`, async ({ page }) => {
await openHost(page);
const id = await createNode(page, name, { preset: '832×1216 - portrait', width: 832, height: 1216, batch_size: 3 });
await page.evaluate(id => {
const node = kikoHost.nodes.get(String(id));
node.element.style.width = '220px';
window.swapNotifications = [];
for (const widget of node.widgets.filter(widget => ['width', 'height'].includes(widget.name))) {
widget.callback = function (value, canvas, owner) { swapNotifications.push({ name: this.name, value, canvas: canvas === kikoHost.app.canvas, owner: owner === node }); };
}
}, id);
const panel = page.locator('.ca-dimension-swap');
const button = panel.getByRole('button', { name: 'Swap width and height', exact: true });
await expect(button).toHaveCSS('background-color', 'rgb(8, 119, 187)');
for (const theme of ['light', 'dark']) {
await page.evaluate(theme => document.body.style.setProperty('--bg-color', theme === 'light' ? '#fff' : '#202020'), theme);
await expect(panel).toHaveAttribute('data-ca-theme', theme);
}
await button.focus();
await page.keyboard.press('Enter');
expect(await page.evaluate(() => swapNotifications)).toEqual([
{ name: 'width', value: '1216', canvas: true, owner: true },
{ name: 'height', value: '832', canvas: true, owner: true },
]);
await page.keyboard.press('Space');
const node = page.locator(`[data-node="${name}"]`);
await expect(node.getByLabel('width', { exact: true })).toHaveValue('832');
await expect(node.getByLabel('height', { exact: true })).toHaveValue('1216');
await expect(node.getByLabel('batch_size', { exact: true })).toHaveValue('3');
expect(await panel.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true);
});
test(`${name}: swap mounting is idempotent and removal disposes its control`, async ({ page }) => {
await openHost(page);
const id = await createNode(page, name, { preset: 'custom', width: 640, height: 960 });
await page.evaluate(id => {
const node = kikoHost.nodes.get(String(id));
node.createSwapButton();
node.createSwapButton();
}, id);
await expect(page.locator('.ca-dimension-swap')).toHaveCount(1);
await page.evaluate(id => {
const node = kikoHost.nodes.get(String(id));
window.detachedSwap = node.swapButtonWidget.element;
window.swapTheme = detachedSwap.dataset.caTheme;
window.removedSwapNode = kikoHost.removeNode(id);
detachedSwap.querySelector('button').click();
document.body.style.setProperty('--bg-color', swapTheme === 'light' ? '#202020' : '#fff');
}, id);
expect(await page.evaluate(() => ({ aborted: removedSwapNode.swapScope.signal.aborted, theme: detachedSwap.dataset.caTheme, width: removedSwapNode.widgets.find(widget => widget.name === 'width').value, removed: removedSwapNode.removed }))).toEqual({ aborted: true, theme: await page.evaluate(() => swapTheme), width: '640', removed: true });
await expect(page.locator('.ca-dimension-swap')).toHaveCount(0);
});
}
test('local image selection persists and the real node loads the selected PNG tensor', async ({ page, request }) => {
await openHost(page);
const id = await createNode(page, 'KikoLocalImageLoader');
const image = page.getByAltText('cat & dog.png', { exact: true });
await expect(image).toBeVisible();
await expect.poll(() => image.evaluate(image => image.complete && image.naturalWidth > 0)).toBe(true);
const selected = page.waitForResponse(response => response.url().endsWith('/set_node_selection'));
await image.click();
expect((await selected).ok()).toBe(true);
const selectedPath = `${(await state(request)).input}/cat & dog.png`;
expect((await state(request)).selections[String(id)].image.path).toBe(selectedPath);
const executed = await request.post('/__test__/execute/KikoLocalImageLoader', { data: { unique_id: String(id) } });
await expect(executed).toBeOK();
expect((await executed.json()).shape).toEqual([1, 12, 16, 3]);
await page.reload();
await expect(page.locator('body')).toHaveAttribute('data-ready', 'true');
expect(await createNode(page, 'KikoLocalImageLoader')).toBe(id);
await expect(page.getByAltText('cat & dog.png', { exact: true })).toBeVisible();
expect((await state(request)).selections[String(id)].image.path).toBe(selectedPath);
});
test('gallery navigates folders and saves and removes paths through the actual routes', async ({ page, request }) => {
await openHost(page);
await createNode(page, 'KikoLocalImageLoader');
await page.locator('.kiko-folder-name').filter({ hasText: /^sub$/ }).click();
await expect(page.getByAltText('forest.png', { exact: true })).toBeVisible();
await page.locator('.kiko-save-path').click();
await expect.poll(async () => (await state(request)).config.saved_paths).toHaveLength(1);
const saved = (await state(request)).config.saved_paths[0];
await page.reload();
await expect(page.locator('body')).toHaveAttribute('data-ready', 'true');
await createNode(page, 'KikoLocalImageLoader');
await expect(page.locator('.kiko-path-presets option')).toHaveCount(2);
await page.locator('.kiko-path-presets').selectOption(saved);
await page.locator('.kiko-delete-path').click();
await expect.poll(async () => (await state(request)).config.saved_paths).toEqual([]);
await page.locator('.kiko-up-folder').click();
await expect(page.getByAltText('cat & dog.png', { exact: true })).toBeVisible();
});
test('gallery rejects disallowed folders and retries after a network error', async ({ page, request }) => {
await openHost(page);
const id = await createNode(page, 'KikoLocalImageLoader');
const info = await state(request);
await expect(page.getByAltText('cat & dog.png', { exact: true })).toBeVisible();
await page.locator('.kiko-path-input').fill(info.outside);
const denied = page.waitForResponse(response => response.url().includes('/images?'));
await page.locator('.kiko-browse').click();
expect((await denied).status()).toBe(403);
await expect(page.locator('.kiko-status-message')).toBeVisible();
const deniedSelection = await request.post('/kiko_local_image_loader/set_node_selection', { data: { node_id: id, path: `${info.outside}/secret.png`, type: 'image' } });
expect(deniedSelection.status()).toBe(403);
expect((await state(request)).selections).toEqual({});
await page.locator('.kiko-path-input').fill(info.input);
await page.route('**/kiko_local_image_loader/images?*', route => route.abort('failed'), { times: 1 });
await page.locator('.kiko-browse').click();
await expect(page.locator('.kiko-status-message')).toHaveText('Error loading directory');
await page.locator('.kiko-browse').click();
await expect(page.locator('.kiko-status-message')).toBeHidden();
await expect(page.getByAltText('cat & dog.png', { exact: true })).toBeVisible();
});
test('real display node output renders as text and replaces previous widgets', async ({ page, request }) => {
await openHost(page);
const id = await createNode(page, 'DisplayText');
const text = 'Positive prompt: <img src=x onerror="window.injected=true">\nNegative prompt: blur';
const response = await request.post('/__test__/execute/DisplayText', { data: { text } });
await expect(response).toBeOK();
const data = await response.json();
await page.evaluate(({ id, message }) => kikoHost.nodes.get(String(id)).onExecuted(message), { id, message: data.ui });
await expect(page.getByLabel('text_positive')).toHaveValue('<img src=x onerror="window.injected=true">');
await expect(page.getByLabel('text_negative')).toHaveValue('blur');
expect(await page.evaluate(() => window.injected)).toBeUndefined();
const next = await (await request.post('/__test__/execute/DisplayText', { data: { text: 'Replacement' } })).json();
await page.evaluate(({ id, message }) => kikoHost.nodes.get(String(id)).onExecuted(message), { id, message: next.ui });
await expect(page.locator('textarea')).toHaveCount(1);
await expect(page.getByLabel('text_display')).toHaveValue('Replacement');
await expect(page.getByLabel('text_display')).toHaveAttribute('readonly', '');
});
test('seed history persists across reloads and removal clears its watcher', async ({ page }) => {
await openHost(page);
const id = await createNode(page, 'SeedHistory', { seed: 42 });
await page.getByRole('button', { name: /Generate/ }).click();
const saved = await page.evaluate(() => JSON.parse(localStorage.getItem('comfyui_kikotools_seed_history')));
expect(saved).toHaveLength(1);
expect(saved[0].seed).toBeGreaterThanOrEqual(0);
await page.reload();
await expect(page.locator('body')).toHaveAttribute('data-ready', 'true');
await createNode(page, 'SeedHistory', { seed: 42 });
expect(await page.evaluate(() => kikoHost.nodes.get('1').seedHistory[0].seed)).toBe(saved[0].seed);
await page.getByRole('button', { name: /Clear/ }).click();
expect(await page.evaluate(() => JSON.parse(localStorage.getItem('comfyui_kikotools_seed_history')))).toEqual([]);
await expect.poll(() => page.evaluate(() => Boolean(kikoHost.nodes.get('1').seedValueWatcher))).toBe(true);
expect(await page.evaluate(id => {
const node = kikoHost.removeNode(id);
return { watcher: node.seedValueWatcher, registered: window.seedHistoryNodes.includes(node), removed: node.removed };
}, id)).toEqual({ watcher: null, registered: false, removed: true });
});
test('shared seed history supports keyboard selection, themes and local status feedback', async ({ page }) => {
await openHost(page);
await page.evaluate(() => Object.defineProperty(crypto, 'randomUUID', { value: undefined }));
const id = await createNode(page, 'SeedHistory', { seed: 42 });
await page.evaluate(id => {
const node = kikoHost.nodes.get(String(id));
node.addSeedToHistory(123);
node.addSeedToHistory(456);
}, id);
const panel = page.locator('.ca-seed-history');
await expect(panel).toHaveCount(1);
for (const theme of ['light', 'dark']) {
await page.evaluate(theme => document.body.style.setProperty('--bg-color', theme === 'light' ? '#ffffff' : '#202020'), theme);
await expect(panel).toHaveAttribute('data-ca-theme', theme);
}
const entry = panel.getByRole('button', { name: 'Use seed 123', exact: true });
await entry.focus();
await page.keyboard.press('Enter');
await expect(entry).toHaveAttribute('aria-pressed', 'true');
await expect(panel.getByRole('status')).toHaveText('Loaded: 123');
expect(await page.evaluate(id => Number(kikoHost.nodes.get(String(id)).widgets.find(widget => widget.name === 'seed').value), id)).toBe(123);
await page.evaluate(id => kikoHost.nodes.get(String(id)).addSeedToHistory(789), id);
await expect(entry).toBeFocused();
await panel.getByRole('button', { name: 'Generate', exact: true }).click();
await expect(panel.getByRole('status')).toContainText('Generated:');
await panel.getByRole('button', { name: 'Clear', exact: true }).click();
await expect(panel.getByRole('status')).toHaveText('History cleared');
await expect(panel.getByText('No seeds tracked')).toBeVisible();
expect(await page.evaluate(() => JSON.parse(localStorage.getItem('comfyui_kikotools_seed_history')))).toEqual([]);
});
test('seed history auto-hide protects keyboard focus and its disclosure reopens with Space', async ({ page }) => {
await openHost(page);
await page.clock.install();
const id = await createNode(page, 'SeedHistory', { seed: 42 });
await page.evaluate(id => kikoHost.nodes.get(String(id)).addSeedToHistory(123), id);
const panel = page.locator('.ca-seed-history');
const entry = panel.getByRole('button', { name: 'Use seed 123', exact: true });
await entry.focus();
await page.clock.runFor(3000);
await expect(entry).toBeVisible();
await expect(entry).toBeFocused();
await panel.getByRole('button', { name: 'Generate', exact: true }).focus();
await page.clock.runFor(2600);
const restore = panel.getByRole('button', { name: 'Show history', exact: true });
await expect(restore).toHaveAttribute('aria-expanded', 'false');
await restore.focus();
await page.keyboard.press('Space');
await expect(panel.getByRole('list', { name: 'Recent seeds' })).toBeVisible();
await expect(panel.getByRole('button', { name: 'Hide history', exact: true })).toHaveAttribute('aria-expanded', 'true');
await entry.hover();
await page.clock.runFor(3000);
await expect(entry).toBeVisible();
await page.mouse.move(0, 0);
await page.clock.runFor(2600);
await expect(restore).toBeVisible();
});
test('seed history removal cancels delayed setup and retries and disposes theme listeners', async ({ page }) => {
await openHost(page);
await page.clock.install();
const early = await createNode(page, 'SeedHistory', { seed: 42 });
await page.evaluate(id => {
const node = kikoHost.nodes.get(String(id));
window.removedSeedNode = node;
window.seedSetups = 0;
node.setupSeedWidgetCallbacks = () => window.seedSetups++;
node.addSeedToHistory(123);
kikoHost.removeNode(id);
}, early);
await page.clock.runFor(3000);
expect(await page.evaluate(() => ({ setups: seedSetups, watcher: removedSeedNode.seedValueWatcher, hide: removedSeedNode.hideTimer, aborted: removedSeedNode.seedScope.signal.aborted }))).toEqual({ setups: 0, watcher: null, hide: null, aborted: true });
const retry = await createNode(page, 'SeedHistory');
await page.clock.runFor(101);
await page.evaluate(id => {
const node = kikoHost.nodes.get(String(id));
window.detachedSeedPanel = node.seedWidget.element;
window.detachedSeedTheme = detachedSeedPanel.dataset.caTheme;
window.retrySeedNode = kikoHost.removeNode(id);
document.body.style.setProperty('--bg-color', detachedSeedTheme === 'light' ? '#202020' : '#ffffff');
}, retry);
await page.clock.runFor(3000);
expect(await page.evaluate(() => ({ watcher: retrySeedNode.seedValueWatcher, setup: retrySeedNode.seedSetupTimer, theme: detachedSeedPanel.dataset.caTheme === detachedSeedTheme, registered: seedHistoryNodes.length }))).toEqual({ watcher: null, setup: null, theme: true, registered: 0 });
});
test('seed history keeps ten unique valid seeds and restores workflow history safely', async ({ page }) => {
await openHost(page);
await page.evaluate(() => localStorage.setItem('comfyui_kikotools_seed_history', '{broken'));
const id = await createNode(page, 'SeedHistory', { seed: 42 });
expect(await page.evaluate(id => {
const node = kikoHost.nodes.get(String(id));
for (let seed = 0; seed < 12; seed++) node.addSeedToHistory(seed);
node.addSeedToHistory(0);
node.addSeedToHistory(0);
node.addSeedToHistory('invalid');
const data = node.serialize();
node.clearSeedHistory();
node.configure(data);
return node.seedHistory.map(item => item.seed);
}, id)).toEqual([0, 11, 10, 9, 8, 7, 6, 5, 4, 3]);
const panel = page.locator('.ca-seed-history');
await expect(panel.getByRole('button', { name: 'Use seed 0', exact: true })).toBeVisible();
await page.evaluate(id => kikoHost.nodes.get(String(id)).configure({ seedHistory: [{ seed: '<img src=x onerror=alert(1)>', timestamp: 0 }] }), id);
await expect(panel.getByText('No seeds tracked')).toBeVisible();
await expect(panel.locator('img')).toHaveCount(0);
});
test('seed actions pass the host callback context and polling tracks manual values once', async ({ page }) => {
await openHost(page);
await page.clock.install();
const id = await createNode(page, 'SeedHistory', { seed: 42 });
await page.evaluate(id => {
const node = kikoHost.nodes.get(String(id));
const widget = node.widgets.find(widget => widget.name === 'seed');
window.seedCallbacks = [];
widget.callback = function (value, canvas, owner) {
seedCallbacks.push({ value, canvas: canvas === kikoHost.app.canvas, owner: owner === node, widget: this === widget });
};
node.addSeedToHistory(123);
}, id);
const panel = page.locator('.ca-seed-history');
await panel.getByRole('button', { name: 'Use seed 123', exact: true }).click();
await panel.getByRole('button', { name: 'Generate', exact: true }).click();
expect(await page.evaluate(() => seedCallbacks)).toEqual([
{ value: 123, canvas: true, owner: true, widget: true },
{ value: expect.any(Number), canvas: true, owner: true, widget: true },
]);
await page.clock.runFor(101);
await page.evaluate(id => { kikoHost.nodes.get(String(id)).widgets.find(widget => widget.name === 'seed').value = 987; }, id);
await page.clock.runFor(1100);
await expect(panel.getByRole('button', { name: 'Use seed 987', exact: true })).toHaveCount(1);
await page.evaluate(id => {
Storage.prototype.setItem = () => { throw new DOMException('Storage unavailable', 'SecurityError'); };
kikoHost.nodes.get(String(id)).addSeedToHistory(654);
}, id);
await expect(panel.getByRole('button', { name: 'Use seed 654', exact: true })).toBeVisible();
await page.evaluate(id => { kikoHost.nodes.get(String(id)).widgets.find(widget => widget.name === 'seed').value = 555; }, id);
await panel.getByRole('button', { name: 'Clear', exact: true }).click();
await page.clock.runFor(1100);
await expect(panel.getByText('No seeds tracked')).toBeVisible();
});
test('workflow timer responds to execution events and detaches removed nodes', async ({ page }) => {
await page.clock.install();
await openHost(page);
const id = await createNode(page, 'KikoWorkflowTimer');
await page.evaluate(() => kikoHost.emit('execution_start'));
await page.clock.runFor(1234);
await expect(page.locator('.kiko-timer-display')).not.toHaveText('00:00:000');
await page.evaluate(() => kikoHost.emit('execution_interrupted'));
await expect(page.locator('.kiko-timer-display')).not.toHaveClass(/running/);
const stopped = await page.locator('.kiko-timer-display').textContent();
await page.clock.runFor(500);
await expect(page.locator('.kiko-timer-display')).toHaveText(stopped);
expect(await page.evaluate(id => kikoHost.removeNode(id).removed, id)).toBe(true);
await page.evaluate(() => { kikoHost.emit('execution_start'); kikoHost.emit('executing', null); });
await expect(page.locator('.kiko-timer-display')).toHaveCount(0);
});
async function showTimerColorSetting(page) {
await page.evaluate(() => {
const id = 'kikotools.workflow_timer.color';
const definition = kikoHost.settingDefinitions.get(id);
const setValue = value => {
kikoHost.app.ui.settings.setSettingValue(id, value);
const replacement = definition.type(definition.name, setValue, value);
timerColorSetting.replaceWith(replacement);
window.timerColorSetting = replacement;
};
window.timerColorSetting = definition.type(definition.name, setValue, kikoHost.settings.get(id));
document.body.append(timerColorSetting);
});
}
test('shared workflow timer follows themes, saved color and reduced motion', async ({ page }) => {
await openHost(page);
await page.evaluate(() => kikoHost.app.ui.settings.setSettingValue('kikotools.workflow_timer.color', '#ff9966'));
const id = await createNode(page, 'KikoWorkflowTimer');
const panel = page.locator('.ca-workflow-timer');
await expect(panel).toBeVisible();
const timer = panel.getByRole('timer', { name: 'Elapsed workflow time' });
await expect(timer).toHaveCSS('color', 'rgb(255, 153, 102)');
for (const theme of ['light', 'dark']) {
await page.evaluate(theme => document.body.style.setProperty('--bg-color', theme === 'light' ? '#ffffff' : '#202020'), theme);
await expect(panel).toHaveAttribute('data-ca-theme', theme);
}
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.evaluate(() => kikoHost.emit('execution_start'));
await expect(timer).toHaveCSS('animation-name', 'none');
await page.evaluate(() => kikoHost.emit('execution_interrupted'));
await expect(timer).toHaveCSS('animation-name', 'none');
await page.evaluate(id => kikoHost.nodes.get(String(id)).onConfigure({ properties: { elapsed_time_str: '02:03:456' } }), id);
await expect(timer).toHaveText('02:03:456');
expect(await page.evaluate(id => {
const node = kikoHost.nodes.get(String(id));
const saved = { properties: { other: 'kept' } };
node.onSerialize(saved);
node.properties.elapsed_time_str = 'changed after saving';
return saved.properties;
}, id)).toEqual({ other: 'kept', elapsed_time_str: '02:03:456' });
});
test('workflow timers join active execution and apply live appearance until every end event', async ({ page }) => {
await openHost(page);
await page.clock.install();
await createNode(page, 'KikoWorkflowTimer');
await page.evaluate(() => kikoHost.emit('execution_start'));
await page.clock.runFor(1200);
await createNode(page, 'KikoWorkflowTimer');
const displays = page.locator('.kiko-timer-display');
await expect(displays.nth(1)).toHaveClass(/running/);
await expect.poll(() => displays.evaluateAll(elements => new Set(elements.map(element => element.textContent)).size)).toBe(1);
await page.evaluate(() => {
kikoHost.app.ui.settings.setSettingValue('kikotools.workflow_timer.color', '#123456');
kikoHost.app.ui.settings.setSettingValue('kikotools.workflow_timer.glow', false);
});
for (const display of await displays.all()) {
await expect(display).toHaveCSS('color', 'rgb(18, 52, 86)');
await expect(display).toHaveClass(/no-glow/);
}
for (const event of ['executing', 'execution_error', 'execution_interrupted']) {
await page.evaluate(() => kikoHost.emit('execution_start'));
await page.clock.runFor(1234);
await page.evaluate(event => kikoHost.emit(event, null), event);
await expect(displays.nth(0)).not.toHaveClass(/running/);
const stopped = await displays.nth(0).textContent();
await page.clock.runFor(1000);
await expect(displays.nth(1)).toHaveText(stopped);
expect(await page.evaluate(() => kikoHost.app.graph._nodes.map(node => node.properties.elapsed_time_str))).toEqual([stopped, stopped]);
}
});
test('timer color controls validate hex, support keyboard sliders and presets and close with Escape', async ({ page }) => {
await openHost(page);
await createNode(page, 'KikoWorkflowTimer');
await showTimerColorSetting(page);
const trigger = page.getByRole('button', { name: 'Choose timer color', exact: true });
await trigger.focus();
await page.keyboard.press('Enter');
const dialog = page.getByRole('dialog', { name: 'Timer color', exact: true });
await expect(dialog).toBeVisible();
const hex = dialog.getByLabel('Hex color', { exact: true });
await hex.fill('not-a-color');
await hex.press('Tab');
await expect(hex).toHaveAttribute('aria-invalid', 'true');
expect(await page.evaluate(() => kikoHost.settings.get('kikotools.workflow_timer.color'))).toBe('#0877bb');
await hex.fill('112233');
await hex.press('Tab');
await expect(dialog).toBeVisible();
await expect(hex).toHaveAttribute('aria-invalid', 'false');
await expect(page.locator('.kiko-timer-display')).toHaveCSS('color', 'rgb(17, 34, 51)');
await dialog.getByRole('button', { name: 'Use color #4A90D9', exact: true }).focus();
await page.keyboard.press('Space');
await expect(dialog.getByRole('button', { name: 'Use color #4A90D9', exact: true })).toHaveAttribute('aria-pressed', 'true');
const before = await hex.inputValue();
const hue = dialog.getByRole('slider', { name: 'Hue', exact: true });
await hue.hover();
expect(await hex.inputValue()).toBe(before);
await hue.focus();
await page.keyboard.press('ArrowRight');
expect(await hex.inputValue()).not.toBe(before);
await expect(hue).toHaveAttribute('aria-valuetext', /degrees/);
for (const label of ['Saturation', 'Brightness']) {
const slider = dialog.getByRole('slider', { name: label, exact: true });
await slider.focus();
await page.keyboard.press('End');
await expect(slider).toHaveValue('100');
}
await page.keyboard.press('Escape');
await expect(dialog).toBeHidden();
await expect(trigger).toBeFocused();
await trigger.click();
await dialog.getByRole('button', { name: 'Done', exact: true }).click();
await expect(trigger).toBeFocused();
});
test('timer removal releases polling and color setting removal closes and disposes its dialog', async ({ page }) => {
await openHost(page);
await page.clock.install();
await page.evaluate(() => {
window.timerIntervals = new Set();
const start = window.setInterval;
const stop = window.clearInterval;
window.setInterval = (...args) => { const id = start(...args); timerIntervals.add(id); return id; };
window.clearInterval = id => { timerIntervals.delete(id); stop(id); };
});
const first = await createNode(page, 'KikoWorkflowTimer');
const second = await createNode(page, 'KikoWorkflowTimer');
await page.evaluate(() => kikoHost.emit('execution_start'));
expect(await page.evaluate(() => timerIntervals.size)).toBe(1);
await page.evaluate(id => kikoHost.removeNode(id), first);
expect(await page.evaluate(() => timerIntervals.size)).toBe(1);
await page.evaluate(id => { window.removedTimer = kikoHost.removeNode(id); }, second);
expect(await page.evaluate(() => ({ intervals: timerIntervals.size, aborted: removedTimer.timerScope?.signal.aborted }))).toEqual({ intervals: 0, aborted: true });
await showTimerColorSetting(page);
await page.getByRole('button', { name: 'Choose timer color', exact: true }).click();
await expect(page.getByRole('dialog', { name: 'Timer color', exact: true })).toBeVisible();
await page.evaluate(() => {
window.detachedTimerPicker = timerColorSetting;
timerColorSetting.remove();
});
await expect(page.getByRole('dialog', { name: 'Timer color', exact: true })).toHaveCount(0);
expect(await page.evaluate(() => detachedTimerPicker.scope.signal.aborted)).toBe(true);
await page.evaluate(() => document.body.append(detachedTimerPicker));
await page.getByRole('button', { name: 'Choose timer color', exact: true }).click();
await expect(page.getByRole('dialog', { name: 'Timer color', exact: true })).toHaveCount(1);
});
test('autocomplete inserts embedding paths from the real plugin endpoint and cleans up its widget', async ({ page }) => {
await openHost(page);
const id = await createNode(page, 'TextInput', { text: '' });
await expect.poll(() => page.evaluate(() => window.kikotools.autocomplete.embeddings.length)).toBe(2);
const prompt = page.getByLabel('text', { exact: true });
await prompt.fill('embedding:ca');
const suggestions = page.locator('.kikotools-autocomplete-suggestions');
await expect(suggestions).toBeVisible();
await expect(suggestions).toContainText('cat');
await prompt.press('ArrowDown');
await prompt.press('Enter');
await expect(prompt).toHaveValue(/embedding:portraits\/cat/);
await expect(suggestions).toBeHidden();
expect(await page.evaluate(id => {
const node = kikoHost.nodes.get(String(id));
const entry = node.widgets.find(widget => widget.name === 'text');
kikoHost.removeNode(id);
return window.kikotools.autocomplete.activeWidgets.has(entry);
}, id)).toBe(false);
});
test('real image save output opens a preview, closes, and reuses the viewer on execution', async ({ page, request }) => {
await openHost(page);
const id = await createNode(page, 'KikoSaveImage', { format: 'PNG', quality: 90 });
const response = await request.post('/__test__/execute/KikoSaveImage', { data: { filename_prefix: 'Browser image' } });
await expect(response).toBeOK();
const result = await response.json();
expect(result.ui.kiko_enhanced).toHaveLength(1);
const execute = () => page.evaluate(({ id, message }) => kikoHost.nodes.get(String(id)).onExecuted(message), { id, message: result.ui });
await execute();
const viewer = page.locator('kiko-image-viewer');
await expect(viewer).toBeVisible();
await expect(viewer.locator('img')).toHaveCount(1);
await expect.poll(() => viewer.locator('img').evaluate(image => image.complete && image.naturalWidth > 0)).toBe(true);
await viewer.getByTitle('Close', { exact: true }).click();
await expect(viewer).toBeHidden();
await execute();
await expect(viewer).toHaveCount(1);
await expect(viewer).toBeVisible();
await viewer.getByTitle('Minimize', { exact: true }).click();
await expect(viewer.locator('.kiko-viewer-container')).toBeHidden();
});
async function savedImageResult(page, request, count = 1) {
const id = await createNode(page, 'KikoSaveImage', { format: 'PNG', quality: 90 });
const response = await request.post('/__test__/execute/KikoSaveImage', { data: { filename_prefix: 'Window test' } });
await expect(response).toBeOK();
const message = (await response.json()).ui;
message.kiko_enhanced = Array.from({ length: count }, () => ({ ...message.kiko_enhanced[0] }));
await page.evaluate(({ id, message }) => kikoHost.nodes.get(String(id)).onExecuted(message), { id, message });
return { id, message };
}
test('shared saved-image window follows themes and supports keyboard close and reopen', async ({ page, request }) => {
await openHost(page);
const { id } = await savedImageResult(page, request);
const viewer = page.getByRole('dialog', { name: 'Saved images', exact: true });
await expect(viewer).toBeVisible();
await page.evaluate(() => document.body.style.setProperty('--bg-color', '#fafafa'));
await expect(viewer).toHaveAttribute('data-ca-theme', 'light');
await viewer.getByTitle('Minimize', { exact: true }).click();
await expect(viewer.locator('.kiko-viewer-container')).toBeHidden();
await viewer.getByTitle('Restore', { exact: true }).click();
await expect(viewer.locator('.kiko-viewer-container')).toBeVisible();
await viewer.getByTitle('Close', { exact: true }).focus();
await page.keyboard.press('Escape');
await expect(viewer).toBeHidden();
await page.locator(`[data-node="KikoSaveImage"]`).getByRole('button', { name: 'Show images', exact: true }).click();
await expect(viewer).toBeVisible();
expect(await page.evaluate(id => kikoHost.nodes.get(String(id)).kikoViewer.isConnected, id)).toBe(true);
});
test('saved-image ownership transfers safely and deletion cleans up only the owner window', async ({ page, request }) => {
await openHost(page);
const first = await savedImageResult(page, request);
const second = await savedImageResult(page, request);
expect(await page.evaluate(id => Boolean(kikoHost.nodes.get(String(id)).kikoViewer), second.id)).toBe(true);
await page.evaluate(id => kikoHost.removeNode(id), first.id);
await expect(page.locator('kiko-image-viewer')).toBeVisible();
await page.evaluate(id => kikoHost.removeNode(id), second.id);
await expect(page.locator('kiko-image-viewer')).toHaveCount(0);
});
test('saved-image window moves and resizes by keyboard and remains inside a shrinking viewport', async ({ page, request }) => {
await page.setViewportSize({ width: 1200, height: 900 });
await openHost(page);
await savedImageResult(page, request);
const viewer = page.locator('kiko-image-viewer');
const move = viewer.getByRole('button', { name: 'Move image viewer with arrow keys', exact: true });
await move.focus();
const before = await viewer.boundingBox();
await page.keyboard.press('ArrowLeft');
expect((await viewer.boundingBox()).x).toBeLessThan(before.x);
const resize = viewer.getByRole('button', { name: 'Resize image viewer with arrow keys', exact: true });
await resize.focus();
await page.keyboard.press('ArrowRight');
expect((await viewer.boundingBox()).width).toBeGreaterThan(before.width);
const imageBefore = await viewer.locator('img').boundingBox();
await page.keyboard.press('ArrowDown');
expect((await viewer.locator('img').boundingBox()).height).toBeGreaterThan(imageBefore.height);
const info = await viewer.locator('.kiko-image-info').boundingBox();
const content = await viewer.locator('.kiko-viewer-container').boundingBox();
expect(info.y + info.height).toBeLessThanOrEqual(content.y + content.height);
await page.setViewportSize({ width: 360, height: 500 });
await expect.poll(async () => {
const rect = await viewer.boundingBox();
return rect.x >= 0 && rect.y >= 0 && rect.x + rect.width <= 361 && rect.y + rect.height <= 501;
}).toBe(true);
});
test('saved-image batch selection downloads and resets for new execution results', async ({ page, request }) => {
await openHost(page);
const { id, message } = await savedImageResult(page, request, 2);
const viewer = page.locator('kiko-image-viewer');
const choices = viewer.getByRole('checkbox');
await expect(choices).toHaveCount(2);
await choices.first().check();
await expect(viewer.locator('.selected-count')).toHaveText('1');
await viewer.getByRole('button', { name: 'Select all', exact: true }).click();
await expect(choices.nth(1)).toBeChecked();
const download = page.waitForEvent('download');
await viewer.getByRole('button', { name: /Download image 1:/ }).click();
expect((await download).suggestedFilename()).toBe(message.kiko_enhanced[0].filename);
await page.evaluate(({ id, message }) => kikoHost.nodes.get(String(id)).onExecuted(message), { id, message: { kiko_enhanced: [message.kiko_enhanced[0]] } });
expect(await viewer.evaluate(element => element.selectedImages.size)).toBe(0);
await expect(viewer.getByRole('checkbox')).toHaveCount(0);
});
test('saved-image window pointer handles drag resize and cancel without losing reachability', async ({ page, request }) => {
await page.setViewportSize({ width: 1200, height: 900 });
await openHost(page);
await savedImageResult(page, request);
const viewer = page.locator('kiko-image-viewer');
const before = await viewer.boundingBox();
const handle = await viewer.locator('[data-window-move]').boundingBox();
await page.mouse.move(handle.x + 20, handle.y + 15);
await page.mouse.down();
await page.mouse.move(handle.x - 100, handle.y + 35, { steps: 4 });
await page.mouse.up();
const moved = await viewer.boundingBox();
expect(moved.x).toBeLessThan(before.x);
await expect(viewer).not.toHaveClass(/ca-dragging/);
const resize = await viewer.locator('[data-window-resize]').boundingBox();
await page.mouse.move(resize.x + 12, resize.y + 12);
await page.mouse.down();
await page.mouse.move(resize.x + 60, resize.y + 15, { steps: 4 });
await page.mouse.up();
expect((await viewer.boundingBox()).width).toBeGreaterThan(moved.width);
const next = await viewer.locator('[data-window-move]').boundingBox();
await page.mouse.move(next.x + 20, next.y + 15);
await page.mouse.down();
await viewer.dispatchEvent('pointercancel');
await expect(viewer).not.toHaveClass(/ca-dragging/);
await page.mouse.up();
});
test('delayed selected-image opens stop on close replacement and owner removal', async ({ page, request }) => {
await openHost(page);
const { id, message } = await savedImageResult(page, request, 2);
await page.clock.install();
await page.evaluate(() => {
window.openedImages = [];
window.open = url => { window.openedImages.push(url); return {}; };
const viewer = document.querySelector('kiko-image-viewer');
viewer.handleBulkAction('select-all');
viewer.handleBulkAction('open-all');
});
await page.clock.runFor(200);
expect(await page.evaluate(() => window.openedImages.length)).toBe(2);
for (const action of ['close', 'replace', 'remove']) {
await page.evaluate(({ id, message, action }) => {
const node = kikoHost.nodes.get(String(id));
node.onExecuted(message);
const viewer = document.querySelector('kiko-image-viewer');
viewer.handleBulkAction('select-all');
viewer.handleBulkAction('open-all');
if (action === 'close') viewer.close();
if (action === 'replace') node.onExecuted(message);
if (action === 'remove') kikoHost.removeNode(id);
}, { id, message, action });
await page.clock.runFor(500);
expect(await page.evaluate(() => window.openedImages.length)).toBe(2);
}
});
test('Display Any renders real multiline output and restores serialized values without duplicate widgets', async ({ page, request }) => {
await openHost(page);
const id = await createNode(page, 'DisplayAny');
const response = await request.post('/__test__/execute/DisplayAny', { data: { input: Array.from({ length: 45 }, (_, index) => `Line ${index}`).join('\n') } });
await expect(response).toBeOK();
const result = await response.json();
await page.evaluate(({ id, message }) => kikoHost.nodes.get(String(id)).onExecuted(message), { id, message: result.ui });
await expect(page.locator('textarea')).toHaveCount(3);
expect(await page.locator('textarea').evaluateAll(elements => elements.map(element => element.value).join('\n'))).toBe(result.ui.text[0]);
await page.evaluate(id => {
const node = kikoHost.nodes.get(String(id));
node.configure({ widgets_values: ['Restored value'] });
node.onConfigure();
}, id);
await expect(page.locator('textarea')).toHaveCount(1);
await expect(page.getByLabel('display_0')).toHaveValue('Restored value');
});
test('gallery pagination sorting and hidden-folder controls use real directory pages', async ({ page, request }) => {
await expect(await request.post('/__test__/reset', { data: { extra_images: 52 } })).toBeOK();
await openHost(page);
await createNode(page, 'KikoLocalImageLoader');
const folder = name => page.locator('.kiko-folder-name').filter({ hasText: new RegExp(`^${name}$`) });
await expect(folder('sub')).toBeVisible();
await expect(folder('\\.hidden')).toHaveCount(0);
await page.locator('.kiko-hide-dot-folders').uncheck();
await expect(folder('\\.hidden')).toBeVisible();
await folder('bulk').click();
await expect(page.locator('.kiko-card-info-panel')).toHaveCount(50);
await expect(page.locator('.kiko-card-info-panel').first()).toHaveText('0000.png');
await page.locator('.kiko-sort-order').selectOption('desc');
await expect(page.locator('.kiko-card-info-panel').first()).toHaveText('0051.png');
await page.locator('.kiko-pagination').getByRole('button', { name: '2', exact: true }).click();
await expect(page.locator('.kiko-card-info-panel')).toHaveCount(2);
await expect(page.getByAltText('0000.png', { exact: true })).toBeVisible();
await page.locator('.kiko-pagination').getByRole('button', { name: '◀', exact: true }).click();
await expect(page.locator('.kiko-card-info-panel')).toHaveCount(50);
await page.locator('.kiko-pagination').getByRole('button', { name: '▶', exact: true }).click();
await expect(page.locator('.kiko-card-info-panel')).toHaveCount(2);
});
test('gallery directory autocomplete handles keyboard selection escape and navigation', async ({ page, request }) => {
await openHost(page);
await createNode(page, 'KikoLocalImageLoader');
await expect(page.getByAltText('cat & dog.png', { exact: true })).toBeVisible();
const input = page.locator('.kiko-path-input');
const dropdown = page.locator('.kiko-autocomplete-dropdown');
const directory = (await state(request)).input;
await input.fill(`${directory}/su`);
await expect(dropdown).toBeVisible();
await expect(dropdown.locator('.folder-name')).toContainText('sub');
await input.press('ArrowDown');
await expect(dropdown.locator('.selected')).toHaveCount(1);
await input.press('ArrowUp');
await expect(dropdown.locator('.selected')).toHaveCount(0);
await input.press('Escape');
await expect(dropdown).toBeHidden();
await input.fill(`${directory}/su`);
await expect(dropdown).toBeVisible();
await input.press('ArrowDown');
await input.press('Tab');
await expect(input).toBeFocused();
await expect(input).toHaveValue(`${directory}/sub/`);
await expect(dropdown).toBeHidden();
await input.press('Enter');
await expect(page.getByAltText('forest.png', { exact: true })).toBeVisible();
});
async function openDimensions(page) {
await page.evaluate(() => {
const node = kikoHost.app.graph._nodes.at(-1);
node.computeSize = () => [180, 120];
node.setSize = size => { node.size = size; node.applied = (node.applied || 0) + 1; };
const opener = document.createElement('button');
opener.textContent = 'Edit dimensions';
opener.onclick = () => kikoHost.extensions.find(item => item.name === 'ComfyAssets.Extensions').getNodeMenuItems(node)[1].callback();
document.body.append(opener);
});
await page.getByRole('button', { name: 'Edit dimensions', exact: true }).click();
return page.getByRole('dialog', { name: 'Node Dimensions', exact: true });
}
test('Node Dimensions validates, applies once and enforces content minimums', async ({ page }) => {
await openHost(page);
await createNode(page, 'WidthHeightSelector');
const dialog = await openDimensions(page);
await expect(dialog).toBeVisible();
await page.getByRole('button', { name: 'Edit dimensions', exact: true }).evaluate(element => element.focus());
await expect(dialog.getByLabel('Width (px)', { exact: true })).toBeFocused();
for (const value of ['', '-1', '1.5']) {
await dialog.getByLabel('Width (px)', { exact: true }).fill(value);
await dialog.getByRole('button', { name: 'Apply', exact: true }).click();
await expect(dialog.getByText('Enter positive whole numbers for width and height.')).toBeVisible();
expect(await page.evaluate(() => kikoHost.app.graph._nodes.at(-1).applied || 0)).toBe(0);
}
await dialog.getByLabel('Width (px)', { exact: true }).fill('1');
await dialog.getByLabel('Height (px)', { exact: true }).fill('10');
await page.keyboard.press('Enter');
await expect(dialog).toHaveCount(0);
expect(await page.evaluate(() => ({ size: kikoHost.app.graph._nodes.at(-1).size, applied: kikoHost.app.graph._nodes.at(-1).applied }))).toEqual({ size: [180, 120], applied: 1 });
await expect(page.getByRole('button', { name: 'Edit dimensions', exact: true })).toBeFocused();
});
test('Node Dimensions follows themes, fits narrow screens and cleans up on removal', async ({ page }) => {
await openHost(page);
await createNode(page, 'WidthHeightSelector');
const dialog = await openDimensions(page);
for (const [color, theme] of [['#fafafa', 'light'], ['#202020', 'dark']]) {
await page.evaluate(color => document.body.style.setProperty('--bg-color', color), color);
await expect(dialog).toHaveAttribute('data-ca-theme', theme);
}
await page.setViewportSize({ width: 320, height: 480 });
expect(await dialog.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true);
await page.evaluate(() => window.KikoToolsExtensions.showNodeDimensionsDialog(kikoHost.app.graph._nodes.at(-1)));
await expect(dialog).toHaveCount(1);
await page.keyboard.press('Escape');
await expect(dialog).toHaveCount(0);
await page.getByRole('button', { name: 'Edit dimensions', exact: true }).click();
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(dialog).toHaveCount(0);
await page.getByRole('button', { name: 'Edit dimensions', exact: true }).click();
await page.evaluate(() => kikoHost.app.graph._nodes.at(-1).onRemoved());
await expect(dialog).toHaveCount(0);
expect(await page.evaluate(() => kikoHost.app.graph._nodes.at(-1).applied || 0)).toBe(0);
});
test('legacy dialog helpers preserve values, cancellation and manual close', async ({ page }) => {
await openHost(page);
await page.evaluate(() => {
window.dialogCalls = [];
window.openLegacy = () => window.legacyDialog = window.KikoToolsExtensions.createDialog('<label>Legacy value<input value="before"></label>', (dialog, values) => window.dialogCalls.push([dialog.tagName, values]), () => window.dialogCalls.push('cancel'));
window.openLegacy();
});
await page.getByLabel('Legacy value').fill('after');
await page.keyboard.press('Enter');
expect(await page.evaluate(() => window.dialogCalls)).toEqual([['DIALOG', ['after']]]);
await page.evaluate(() => window.openLegacy());
await page.keyboard.press('Escape');
await page.evaluate(() => { window.openLegacy(); window.legacyDialog.close(); });
expect(await page.evaluate(() => window.dialogCalls)).toEqual([['DIALOG', ['after']], 'cancel']);
await page.evaluate(() => window.openLegacy());
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
expect(await page.evaluate(() => window.dialogCalls)).toEqual([['DIALOG', ['after']], 'cancel', 'cancel']);
});
test('dimension dialogs preserve node hooks and restrict menu ownership', async ({ page }) => {
await openHost(page);
await createNode(page, 'WidthHeightSelector');
const result = await page.evaluate(() => {
const extension = kikoHost.extensions.find(item => item.name === 'ComfyAssets.Extensions');
const missing = extension.getNodeMenuItems({ constructor: {} });
const other = extension.getNodeMenuItems({ constructor: { category: 'Other' } });
const node = kikoHost.app.graph._nodes.at(-1);
const original = function (...args) { window.removedArgs = [this === node, ...args]; return 'removed'; };
node.onRemoved = original;
const dialog = window.KikoToolsExtensions.showNodeDimensionsDialog(node);
window.detachedApply = dialog.querySelector('button[type="submit"]');
const returned = node.onRemoved('a', 2);
return { missing, other, returned, restored: node.onRemoved === original, args: window.removedArgs };
});
expect(result).toEqual({ missing: [], other: [], returned: 'removed', restored: true, args: [true, 'a', 2] });
await page.evaluate(() => window.detachedApply.click());
await expect(page.getByRole('dialog')).toHaveCount(0);
await page.evaluate(() => window.KikoToolsExtensions.createDialog('<label>Choice<select><option>chosen</option></select></label>'));
await page.getByRole('button', { name: 'OK', exact: true }).click();
await page.evaluate(() => window.KikoToolsExtensions.createDialog('No fields'));
await page.keyboard.press('Escape');
await expect(page.getByRole('dialog')).toHaveCount(0);
});
test('legacy selects retain Enter confirmation', async ({ page }) => {
await openHost(page);
await page.evaluate(() => window.KikoToolsExtensions.createDialog('<label for="legacy-choice">Choice</label><select id="legacy-choice"><option>chosen</option></select>', (dialog, values) => window.selectedLegacy = values));
await page.evaluate(() => {
window.leakedDialogKeys = 0;
document.addEventListener('keydown', () => window.leakedDialogKeys++);
});
await page.getByLabel('Choice', { exact: true }).focus();
await page.keyboard.press('Enter');
await expect(page.getByRole('dialog')).toHaveCount(0);
expect(await page.evaluate(() => window.selectedLegacy)).toEqual(['chosen']);
expect(await page.evaluate(() => window.leakedDialogKeys)).toBe(0);
});
+467
View File
@@ -0,0 +1,467 @@
import { test, expect } from '../../tools/testing/node_modules/@playwright/test/index.mjs';
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { resolve, sep } from 'node:path';
const fixture = 'http://127.0.0.1:8771';
const web = resolve(fileURLToPath(new URL('../../web/', import.meta.url)));
test.beforeEach(async ({ page, context, request, baseURL }, testInfo) => {
const info = await (await request.get(`${baseURL}system_stats`)).json();
expect(info.system.comfyui_version).toBe('0.39.0');
expect(info.system.required_frontend_version).toBe('1.55.14');
expect(info.system.comfy_package_versions.find(item => item.name === 'comfyui-frontend-package')?.installed).toBe('1.55.14');
await testInfo.attach('host-versions.json', { body: JSON.stringify(info.system, null, 2), contentType: 'application/json' });
await expect(await request.post(`${fixture}/__test__/reset`)).toBeOK();
await context.route('**/*', async route => {
const request = route.request();
const url = new URL(request.url());
const path = url.pathname.replace(/^\/api\//, '/');
if (path.startsWith('/kiko_local_image_loader/') || path === '/view') {
const response = await route.fetch({ url: `${fixture}${path}${url.search}` });
return route.fulfill({ response });
}
if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method())) return route.fulfill({ json: {} });
if (path === '/extensions') {
const response = await route.fetch();
const extensions = (await response.json()).filter(path => path.startsWith('/extensions/kikotools/'));
return route.fulfill({ json: extensions });
}
if (path.startsWith('/extensions/kikotools/')) {
const source = resolve(web, decodeURIComponent(path.slice('/extensions/kikotools/'.length)));
if (!source.startsWith(web + sep)) return route.abort();
if (!/\.(js|css)$/.test(source)) return route.abort();
return route.fulfill({ body: await readFile(source), contentType: source.endsWith('.css') ? 'text/css' : 'text/javascript' });
}
return route.continue();
});
await page.goto('/', { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('status', { name: 'Loading ComfyUI' })).toBeHidden({ timeout: 30000 });
await page.waitForFunction(async () => {
const { app } = await import('/scripts/app.js');
return !!app.graph && !!globalThis.LiteGraph?.registered_node_types?.KikoLocalImageLoader;
});
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
app.graph.clear();
});
});
for (const name of ['WidthHeightSelector', 'EmptyLatentBatch']) {
test(`${name}: shared swap survives renderers, zoom, themes and workflow reload`, async ({ page }, testInfo) => {
await page.evaluate(async name => {
const { app } = await import('/scripts/app.js');
const node = LiteGraph.createNode(name);
app.graph.add(node);
const preset = node.widgets.find(widget => widget.name === 'preset');
const options = typeof preset.options.values === 'function' ? preset.options.values() : preset.options.values;
preset.value = options.find(option => option.startsWith('832×1216'));
preset.callback(preset.value, app.canvas, node, [0, 0], null);
const batch = node.widgets.find(widget => widget.name === 'batch_size');
if (batch) batch.value = 3;
app.canvas.centerOnNode(node);
}, name);
const panel = page.locator('.ca-dimension-swap');
const button = panel.getByRole('button', { name: 'Swap width and height', exact: true });
await expect(button).toBeVisible();
for (const renderer of [false, true, false]) {
const before = await page.evaluate(async ({ name, renderer }) => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);
const node = app.graph._nodes.find(node => node.type === name);
app.canvas.ds.scale = .7;
app.canvas.centerOnNode(node);
return node.widgets.filter(widget => ['width', 'height'].includes(widget.name)).map(widget => widget.value);
}, { name, renderer });
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
await expect(panel).toHaveCount(1);
await button.focus();
await page.keyboard.press('Enter');
expect(await page.evaluate(async name => {
const { app } = await import('/scripts/app.js');
return app.graph._nodes.find(node => node.type === name).widgets.filter(widget => ['width', 'height'].includes(widget.name)).map(widget => widget.value);
}, name)).toEqual([...before].reverse());
}
await page.evaluate(async name => {
const { app } = await import('/scripts/app.js');
const node = app.graph._nodes.find(node => node.type === name);
node.setSize([220, 280]);
app.canvas.ds.scale = 1;
app.canvas.centerOnNode(node);
}, name);
expect(await panel.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true);
const narrow = await button.boundingBox();
await page.evaluate(async name => {
const { app } = await import('/scripts/app.js');
app.graph._nodes.find(node => node.type === name).setSize([500, 300]);
}, name);
await expect.poll(async () => (await button.boundingBox()).width).toBeGreaterThan(narrow.width);
for (const theme of ['light', 'dark']) {
await page.evaluate(async theme => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('Comfy.ColorPalette', theme);
}, theme);
await expect(panel).toHaveAttribute('data-ca-theme', theme);
await page.screenshot({ path: testInfo.outputPath(`${name}-${theme}.png`) });
}
const restored = await page.evaluate(async name => {
const { app } = await import('/scripts/app.js');
const workflow = app.graph.serialize();
const saved = workflow.nodes.find(node => node.type === name);
const nativeCount = name === 'EmptyLatentBatch' ? 4 : 3;
if (saved.widgets_values.slice(nativeCount).some(value => value !== '' && value != null)) throw new Error('Swap UI leaked into workflow values');
await app.loadGraphData(workflow);
const node = app.graph._nodes.find(node => node.type === name);
app.canvas.centerOnNode(node);
return { saved: saved.widgets_values.slice(0, nativeCount), restored: node.widgets.filter(widget => ['preset', 'width', 'height', 'batch_size'].includes(widget.name)).map(widget => widget.value), size: Array.from(node.size), savedSize: Array.from(saved.size) };
}, name);
expect(restored.restored).toEqual(restored.saved);
expect(restored.size).toEqual(restored.savedSize);
await expect(panel).toHaveCount(1);
await page.evaluate(async name => {
const { app } = await import('/scripts/app.js');
const node = app.graph._nodes.find(node => node.type === name);
window.removedSwap = node;
app.graph.remove(node);
}, name);
await expect(panel).toHaveCount(0);
expect(await page.evaluate(() => removedSwap.swapScope.signal.aborted)).toBe(true);
});
}
test('workflow timer preserves appearance and elapsed time through renderer switching and settings', async ({ page }, testInfo) => {
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const { api } = await import('/scripts/api.js');
await app.ui.settings.setSettingValue('kikotools.workflow_timer.color', '#123456');
const node = LiteGraph.createNode('KikoWorkflowTimer');
app.graph.add(node);
node.color = '#334455';
node.bgcolor = '#556677';
node.setSize([440, 240]);
node.properties.elapsed_time_str = '01:02:345';
node.onConfigure({ properties: node.properties });
app.canvas.centerOnNode(node);
api.dispatchEvent(new CustomEvent('execution_start'));
});
const panel = page.locator('.ca-workflow-timer');
const timer = panel.getByRole('timer', { name: 'Elapsed workflow time' });
for (const renderer of [false, true, false]) {
await page.evaluate(async renderer => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);
}, renderer);
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
await expect(panel).toHaveCount(1);
await expect(timer).toHaveClass(/running/);
await expect(timer).toHaveCSS('color', 'rgb(18, 52, 86)');
}
await page.evaluate(async () => {
const { api } = await import('/scripts/api.js');
api.dispatchEvent(new CustomEvent('execution_interrupted'));
});
const stopped = await timer.textContent();
const initialFontSize = await timer.evaluate(element => parseFloat(getComputedStyle(element).fontSize));
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
app.graph._nodes.find(node => node.type === 'KikoWorkflowTimer').setSize([220, 160]);
});
await expect.poll(() => timer.evaluate(element => parseFloat(getComputedStyle(element).fontSize))).toBeLessThan(initialFontSize);
expect(await timer.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true);
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
app.graph._nodes.find(node => node.type === 'KikoWorkflowTimer').setSize([600, 320]);
});
await expect.poll(() => timer.evaluate(element => parseFloat(getComputedStyle(element).fontSize))).toBeGreaterThan(initialFontSize);
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
app.graph._nodes.find(node => node.type === 'KikoWorkflowTimer').setSize([440, 240]);
});
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('kikotools.workflow_timer.color', '#0877bb');
});
for (const theme of ['light', 'dark']) {
await page.evaluate(async theme => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('Comfy.ColorPalette', theme);
}, theme);
await expect(panel).toHaveAttribute('data-ca-theme', theme);
await page.screenshot({ path: testInfo.outputPath(`timer-${theme}.png`) });
}
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const workflow = app.graph.serialize();
await app.loadGraphData(workflow);
const node = app.graph._nodes.find(node => node.type === 'KikoWorkflowTimer');
if (node.color !== '#334455' || node.bgcolor !== '#556677') throw new Error('Custom node colors changed');
if (node.size[0] !== 440 || node.size[1] !== 240) throw new Error('Saved timer dimensions changed');
app.canvas.centerOnNode(node);
});
await expect(timer).toHaveText(stopped);
await page.getByRole('button', { name: 'Settings (Ctrl + ,)', exact: true }).click();
const search = page.getByPlaceholder(/search/i);
await expect(search).toBeVisible();
await search.fill('Workflow Timer');
await page.getByRole('button', { name: 'Choose timer color', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Timer color', exact: true });
await expect(dialog).toBeVisible();
await dialog.getByLabel('Hex color', { exact: true }).fill('#4a90d9');
await page.keyboard.press('Tab');
await expect(timer).toHaveCSS('color', 'rgb(74, 144, 217)');
await page.screenshot({ path: testInfo.outputPath('timer-color-picker.png') });
await page.keyboard.press('Escape');
await expect(dialog).toBeHidden();
await expect(page.getByRole('button', { name: 'Choose timer color', exact: true })).toBeFocused();
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const node = app.graph._nodes.find(node => node.type === 'KikoWorkflowTimer');
window.removedTimer = node;
app.graph.remove(node);
});
await expect(panel).toHaveCount(0);
expect(await page.evaluate(() => removedTimer.timerScope.signal.aborted)).toBe(true);
});
test('seed history survives renderer switching, resizing and workflow reload', async ({ page }, testInfo) => {
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const node = LiteGraph.createNode('SeedHistory');
app.graph.add(node);
node.setSize([380, 560]);
for (let seed = 100; seed < 110; seed++) node.addSeedToHistory(seed);
app.canvas.centerOnNode(node);
});
const panel = page.locator('.ca-seed-history');
for (const renderer of [false, true, false]) {
await page.evaluate(async renderer => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);
}, renderer);
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
await expect(panel).toHaveCount(1);
await panel.getByRole('button', { name: 'Use seed 105', exact: true }).focus();
await page.keyboard.press('Enter');
await expect(panel.getByRole('button', { name: 'Use seed 105', exact: true })).toHaveAttribute('aria-pressed', 'true');
await expect(panel.getByRole('status')).toHaveText('Loaded: 105');
expect(await panel.evaluate(element => element.scrollWidth <= element.clientWidth + 1)).toBe(true);
}
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
app.graph._nodes.find(node => node.type === 'SeedHistory').setSize([280, 350]);
});
const list = panel.getByRole('list', { name: 'Recent seeds' });
await expect.poll(() => list.evaluate(element => element.scrollHeight > element.clientHeight)).toBe(true);
const smallHeight = await list.evaluate(element => element.clientHeight);
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
app.graph._nodes.find(node => node.type === 'SeedHistory').setSize([420, 650]);
});
await expect.poll(() => list.evaluate(element => element.clientHeight)).toBeGreaterThan(smallHeight);
for (const theme of ['light', 'dark']) {
await page.evaluate(async theme => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('Comfy.ColorPalette', theme);
}, theme);
await expect(panel).toHaveAttribute('data-ca-theme', theme);
await page.screenshot({ path: testInfo.outputPath(`seed-${theme}.png`) });
}
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const workflow = app.graph.serialize();
const saved = workflow.nodes.find(node => node.type === 'SeedHistory');
if (saved.widgets_values[0] !== 105 || saved.widgets_values.slice(2).some(value => value !== '' && value != null)) throw new Error('History UI leaked into serialized widget values');
await app.loadGraphData(workflow);
const node = app.graph._nodes.find(node => node.type === 'SeedHistory');
if (node.size[0] !== saved.size[0] || node.size[1] !== saved.size[1]) throw new Error('Saved node size changed');
app.canvas.centerOnNode(node);
});
await expect(panel).toHaveCount(1);
await expect(panel.getByRole('button', { name: 'Use seed 105', exact: true })).toBeVisible();
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const node = app.graph._nodes.find(node => node.type === 'SeedHistory');
window.removedSeedNode = node;
app.graph.remove(node);
});
await expect(panel).toHaveCount(0);
expect(await page.evaluate(() => removedSeedNode.seedScope.signal.aborted && removedSeedNode.seedValueWatcher === null)).toBe(true);
});
test('saved-image window survives real renderer switching and is removed with its owner', async ({ page, request }, testInfo) => {
const result = await request.post(`${fixture}/__test__/execute/KikoSaveImage`, { data: { filename_prefix: 'Real host viewer' } });
await expect(result).toBeOK();
const message = (await result.json()).ui;
const id = await page.evaluate(async message => {
const { app } = await import('/scripts/app.js');
const node = LiteGraph.createNode('KikoSaveImage');
app.graph.add(node);
node.onExecuted(message);
app.canvas.centerOnNode(node);
return node.id;
}, message);
const viewer = page.getByRole('dialog', { name: 'Saved images', exact: true });
await expect(viewer).toBeVisible();
await expect.poll(() => viewer.locator('img').evaluate(image => image.complete && image.naturalWidth > 0)).toBe(true);
for (const renderer of [false, true, false]) {
await page.evaluate(async renderer => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);
}, renderer);
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
await expect(viewer).toHaveCount(1);
await viewer.getByTitle('Close', { exact: true }).click();
await page.getByRole('button', { name: 'Show images', exact: true }).click();
await expect(viewer).toBeVisible();
expect(await page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
return app.graph.serialize().nodes.find(node => String(node.id) === String(id)).widgets_values.includes('Show images');
}, id)).toBe(false);
}
for (const theme of ['light', 'dark']) {
await page.evaluate(async theme => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('Comfy.ColorPalette', theme);
}, theme);
await expect(viewer).toHaveAttribute('data-ca-theme', theme);
const info = await viewer.locator('.kiko-image-info').boundingBox();
const content = await viewer.locator('.kiko-viewer-container').boundingBox();
expect(info.y + info.height).toBeLessThanOrEqual(content.y + content.height);
await page.screenshot({ path: testInfo.outputPath(`viewer-${theme}.png`) });
}
await page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
app.graph.remove(app.graph.getNodeById(id));
}, id);
await expect(viewer).toHaveCount(0);
});
test('actual host preserves the widget through renderer changes, theme changes and save/reload', async ({ page, request }, testInfo) => {
await expect(await request.post(`${fixture}/__test__/reset`, { data: { extra_images: 52 } })).toBeOK();
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', false);
const node = LiteGraph.createNode('KikoLocalImageLoader');
node.pos = [250, 140];
app.graph.add(node);
app.canvas.centerOnNode(node);
});
const loader = page.locator('.ca-image-browser');
await expect(loader).toBeVisible();
await expect(loader.getByAltText('cat & dog.png', { exact: true })).toBeVisible();
for (const renderer of [false, true, false]) {
await page.evaluate(async renderer => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);
}, renderer);
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
await expect(loader).toHaveCount(1);
await expect(loader).toBeVisible();
await loader.getByRole('button', { name: 'Select image cat & dog.png', exact: true }).click();
await expect(loader.locator('[data-selection]')).toHaveText('cat & dog.png');
const node = await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
return app.graph.serialize().nodes.find(node => node.type === 'KikoLocalImageLoader');
});
expect(node).toBeTruthy();
expect(node.widgets_values || []).not.toContainEqual(expect.objectContaining({ className: 'ca-image-browser' }));
await loader.getByRole('button', { name: 'Open folder bulk', exact: true }).click();
await expect(loader.locator('.kiko-card-info-panel')).toHaveCount(50);
const media = await loader.locator('.kiko-card-media-wrapper').nth(1).boundingBox();
expect(media.height / media.width).toBeCloseTo(1, 1);
expect(media.height).toBeGreaterThan(150);
expect(await loader.locator('.kiko-cardholder').evaluate(element => element.clientHeight)).toBeGreaterThanOrEqual(await loader.locator('.kiko-card-media-wrapper').nth(1).evaluate(element => element.offsetHeight));
expect(await loader.locator('.kiko-cardholder').evaluate(element => element.scrollHeight > element.clientHeight)).toBe(true);
await page.screenshot({ path: testInfo.outputPath(renderer ? 'nodes2.png' : 'classic.png') });
await loader.getByRole('button', { name: 'Open folder ..', exact: true }).click();
await expect(loader.getByAltText('cat & dog.png', { exact: true })).toBeVisible();
}
for (const theme of ['light', 'dark']) {
await page.evaluate(async theme => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('Comfy.ColorPalette', theme);
}, theme);
await expect(loader).toHaveAttribute('data-ca-theme', theme);
}
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const workflow = app.graph.serialize();
await app.loadGraphData(workflow);
app.canvas.centerOnNode(app.graph._nodes[0]);
});
await expect(loader).toHaveCount(1);
await expect(loader.getByAltText('cat & dog.png', { exact: true })).toBeVisible();
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
app.graph.remove(app.graph._nodes[0]);
});
await expect(loader).toHaveCount(0);
});
test('Node Dimensions applies and persists across Classic and Nodes 2.0', async ({ page }, testInfo) => {
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const node = LiteGraph.createNode('WidthHeightSelector');
app.graph.add(node);
node.pos = [300, 250];
app.canvas.centerOnNode(node);
});
const dialog = page.getByRole('dialog', { name: 'Node Dimensions', exact: true });
for (const renderer of [false, true, false]) {
await page.evaluate(async renderer => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer);
app.canvas.centerOnNode(app.graph._nodes[0]);
}, renderer);
await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer);
const point = await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const node = app.graph._nodes[0];
const canvas = app.canvas.canvas.getBoundingClientRect();
return { x: canvas.left + (node.pos[0] + app.canvas.ds.offset[0] + 60) * app.canvas.ds.scale, y: canvas.top + (node.pos[1] + app.canvas.ds.offset[1] - 15) * app.canvas.ds.scale };
});
await page.mouse.click(point.x, point.y, { button: 'right' });
await page.getByText('🎨 Node Dimensions', { exact: true }).click();
await expect(dialog).toBeVisible();
await dialog.getByLabel('Width (px)', { exact: true }).fill('900');
await dialog.getByLabel('Height (px)', { exact: true }).fill('600');
await page.keyboard.press('Enter');
await expect(dialog).toHaveCount(0);
expect(await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
return Array.from(app.graph._nodes[0].size);
})).toEqual([900, 600]);
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
window.KikoToolsExtensions.showNodeDimensionsDialog(app.graph._nodes[0]);
});
await dialog.getByLabel('Width (px)', { exact: true }).fill('1200');
await page.keyboard.press('Escape');
expect(await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
return Array.from(app.graph._nodes[0].size);
})).toEqual([900, 600]);
}
const size = await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const workflow = app.graph.serialize();
await app.loadGraphData(workflow);
app.canvas.centerOnNode(app.graph._nodes[0]);
window.KikoToolsExtensions.showNodeDimensionsDialog(app.graph._nodes[0]);
return Array.from(app.graph._nodes[0].size);
});
expect(size).toEqual([900, 600]);
for (const theme of ['light', 'dark']) {
await page.evaluate(async theme => {
const { app } = await import('/scripts/app.js');
await app.ui.settings.setSettingValue('Comfy.ColorPalette', theme);
}, theme);
await expect(dialog).toHaveAttribute('data-ca-theme', theme);
await page.screenshot({ path: testInfo.outputPath(`node-dimensions-${theme}.png`) });
}
await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
app.graph.remove(app.graph._nodes[0]);
});
await expect(dialog).toHaveCount(0);
});
+35
View File
@@ -0,0 +1,35 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
const sourceRoot = join(process.cwd(), 'web');
import { checkCoverage, discoverSources } from '../../tools/testing/frontend-coverage.mjs';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
const metrics = value => Object.fromEntries(['lines', 'branches', 'functions', 'statements'].map(key => [key, { pct: value }]));
const summary = { total: metrics(90), [join(sourceRoot, 'a.js')]: metrics(85) };
const baseline = { 'a.js': { lines: 80, branches: 80, functions: 80, statements: 80 } };
test('coverage gate accepts measured files at or above their reviewed floor', () => {
assert.deepEqual(checkCoverage(summary, baseline, sourceRoot, ['a.js']), []);
});
test('coverage gate rejects a regression even when the aggregate percentage is high', () => {
assert.match(checkCoverage({ ...summary, [join(sourceRoot, 'a.js')]: metrics(70) }, baseline, sourceRoot, ['a.js']).join('\n'), /a.js.*lines/);
});
test('coverage gate rejects absent source files and unreviewed new files', () => {
assert.match(checkCoverage({ total: metrics(100) }, baseline, sourceRoot, ['a.js']).join('\n'), /missing.*a.js/i);
assert.match(checkCoverage(summary, baseline, sourceRoot, ['a.js', 'new.js']).join('\n'), /baseline.*new.js/i);
});
test('coverage gate rejects invalid baselines and missing metrics', () => {
assert.throws(() => checkCoverage(summary, { 'a.js': { lines: -1 } }, sourceRoot, ['a.js']), /baseline/i);
assert.match(checkCoverage({ total: metrics(100), [join(sourceRoot, 'a.js')]: {} }, baseline, sourceRoot, ['a.js']).join('\n'), /lines/);
});
test('source discovery includes nested UI modules in the coverage gate', () => {
const root = mkdtempSync(join(tmpdir(), 'kiko-coverage-'));
try {
mkdirSync(join(root, 'ui'));
writeFileSync(join(root, 'entry.js'), '');
writeFileSync(join(root, 'ui/core.js'), '');
writeFileSync(join(root, 'ui/theme.css'), '');
assert.deepEqual(discoverSources(root), ['entry.js', 'ui/core.js']);
} finally { rmSync(root, { recursive: true, force: true }); }
});
+60
View File
@@ -0,0 +1,60 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { bindDimensionPresets, dimensionsFromPreset, swapDimensions } from '../../web/ui/dimensions.js';
const app = { canvas: {} };
test('resolution parsing accepts supplied formats and rejects invalid dimensions', () => {
assert.deepEqual(dimensionsFromPreset(' 928x1664 - Qwen'), [928, 1664]);
assert.deepEqual(dimensionsFromPreset('1664×928'), [1664, 928]);
for (const value of [null, {}, 'custom', '0x100', '12.5x100', 'x100', '999999999999999999999x100']) assert.equal(dimensionsFromPreset(value), null);
});
test('preset callbacks preserve returns and default host context without changing custom dimensions', () => {
const calls = [];
const node = { widgets: [
{ name: 'preset', callback() { return 'original'; } },
{ name: 'width', value: 64, callback(value, canvas, owner) { calls.push([this.name, value, canvas, owner]); } },
{ name: 'height', value: 128 },
] };
bindDimensionPresets(node, app);
assert.equal(node.widgets[0].callback('custom'), 'original');
assert.equal(node.widgets[0].callback('928×1664 - Qwen'), 'original');
assert.deepEqual(calls, [['width', 928, app.canvas, node]]);
assert.equal(node.widgets[2].value, 1664);
});
test('incomplete widgets and unavailable reverse options are safe', () => {
bindDimensionPresets({ widgets: [] }, app);
swapDimensions({ widgets: [] }, app);
const node = { widgets: [{ name: 'preset', value: '640×960' }, { name: 'width', value: 1 }, { name: 'height', value: 2 }] };
bindDimensionPresets(node, app);
swapDimensions(node, app);
assert.deepEqual(node.widgets.map(widget => widget.value), ['custom', 960, 640]);
node.widgets.pop();
node.widgets[0].callback('640×960');
swapDimensions(node, app);
assert.equal(node.widgets[1].value, 960);
});
test('dynamic and array option lists match reverse dimensions', () => {
for (const options of [['custom', '960x640'], { values: () => ['custom', '960×640'] }]) {
const node = { widgets: [{ name: 'preset', value: '640×960', options }, { name: 'width', value: 1 }, { name: 'height', value: 2 }] };
bindDimensionPresets(node, app);
swapDimensions(node, app);
assert.equal(node.widgets[0].value, Array.isArray(options) ? '960x640' : '960×640');
}
});
test('a throwing or recursive preset callback does not leave swapping locked', () => {
let fail = true;
const node = { widgets: [
{ name: 'preset', value: '640×960', options: { values: ['640×960', '960×640'] }, callback() { swapDimensions(node, app); if (fail) throw new Error('callback failed'); } },
{ name: 'width', value: 640 }, { name: 'height', value: 960 },
] };
bindDimensionPresets(node, app);
assert.throws(() => swapDimensions(node, app), /callback failed/);
fail = false;
swapDimensions(node, app);
assert.deepEqual(node.widgets.map(widget => widget.value), ['640×960', 640, 960]);
});
+212
View File
@@ -0,0 +1,212 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import vm from 'node:vm';
import { bindDimensionPresets, extractResolutionFromPreset, swapDimensions } from '../../web/ui/dimensions.js';
function load(file, extra = {}) {
const extensions = [];
const timers = new Map();
const app = { registerExtension: extension => extensions.push(extension), canvas: {}, graph: { setDirtyCanvas() {} } };
const context = vm.createContext({
app, console: { log() {}, error() {} },
setTimeout: callback => { const id = Symbol(); timers.set(id, callback); return id; },
clearTimeout: id => timers.delete(id),
...extra,
});
const filename = fileURLToPath(new URL(`../../web/${file}`, import.meta.url));
vm.runInContext(readFileSync(filename, 'utf8').replace(/^import .*;\s*$/gm, match => match.replace(/[^\r\n]/g, ' ')), context, { filename });
return { extension: extensions[0], app, timers, context };
}
async function dimensionNode(preset, options = ['custom', '832×1216 - portrait', '1216×832 - landscape'], file = 'width_height_swap.js') {
const { extension, app } = load(file, { bindDimensionPresets, extractResolutionFromPreset, swapDimensions, mountDimensionSwap() {} });
let originalCreated = 0;
class Node {
constructor() {
this.originalPresetCalls = 0;
this.widgets = [
{ name: 'preset', value: preset, options: { values: options }, callback(value, canvas, node) { node.originalPresetCalls++; } },
{ name: 'width', value: 832 }, { name: 'height', value: 1216 },
];
this.graph = app.graph;
}
onNodeCreated() { originalCreated++; }
}
await extension.beforeRegisterNodeDef(Node, { name: file === 'width_height_swap.js' ? 'WidthHeightSelector' : 'EmptyLatentBatch' }, app);
Node.prototype.createSwapButton = () => {};
const node = new Node();
node.onNodeCreated();
assert.equal(originalCreated, 1);
return node;
}
for (const file of ['width_height_swap.js', 'empty_latent_batch_swap.js']) {
test(`${file}: custom dimensions swap and callbacks receive the actual node`, async () => {
const node = await dimensionNode('custom', undefined, file);
const calls = [];
for (const widget of node.widgets.slice(1)) widget.callback = (value, canvas, owner) => calls.push({ value, owner });
node.swapDimensions();
assert.deepEqual(node.widgets.map(widget => widget.value), ['custom', 1216, 832]);
assert.equal(calls.length, 2);
for (const call of calls) assert.equal(call.owner, node);
});
test(`${file}: formatted preset swaps to its matching landscape preset without throwing`, async () => {
const node = await dimensionNode('832×1216 - portrait', undefined, file);
node.swapDimensions();
assert.deepEqual(node.widgets.map(widget => widget.value), ['1216×832 - landscape', 1216, 832]);
});
test(`${file}: a preset without a reverse switches to custom dimensions`, async () => {
const node = await dimensionNode('832×1216 - portrait', ['custom', '832×1216 - portrait'], file);
node.swapDimensions();
assert.deepEqual(node.widgets.map(widget => widget.value), ['custom', 1216, 832]);
});
test(`${file}: preset changes preserve original callbacks and update both dimensions`, async () => {
const node = await dimensionNode('custom', undefined, file);
node.widgets[0].callback('1216×832 - landscape', {}, node);
assert.deepEqual(node.widgets.slice(1).map(widget => widget.value), [1216, 832]);
assert.equal(node.extractResolutionFromPreset('custom'), null);
assert.equal(node.originalPresetCalls, 1);
});
test(`${file}: a preset swap notifies each dimension callback exactly once`, async () => {
const node = await dimensionNode('832×1216 - portrait', undefined, file);
const calls = [];
for (const widget of node.widgets.slice(1)) widget.callback = function (value, canvas, owner) { calls.push({ name: this.name, value, owner }); };
node.swapDimensions();
assert.deepEqual(calls.map(call => [call.name, call.value]), [['width', 1216], ['height', 832]]);
assert.equal(node.originalPresetCalls, 1);
for (const call of calls) assert.equal(call.owner, node);
});
test(`${file}: ASCII resolutions match reversed presets and unfamiliar formatted presets update dimensions`, async () => {
const node = await dimensionNode('832x1216 - portrait', ['custom', '832x1216 - portrait', '1216x832 - landscape'], file);
node.swapDimensions();
assert.deepEqual(node.widgets.map(widget => widget.value), ['1216x832 - landscape', 1216, 832]);
node.widgets[0].callback('2040×1080 - future preset', {}, node);
assert.deepEqual(node.widgets.slice(1).map(widget => widget.value), [2040, 1080]);
});
test(`${file}: malformed presets do not write invalid dimensions`, async () => {
const node = await dimensionNode('invalidx960', undefined, file);
node.swapDimensions();
assert.deepEqual(node.widgets.map(widget => widget.value), ['invalidx960', 832, 1216]);
});
test(`${file}: unrelated node definitions retain their lifecycle methods`, async () => {
const { extension, app } = load(file);
class Node { onNodeCreated() {} }
const original = Node.prototype.onNodeCreated;
await extension.beforeRegisterNodeDef(Node, { name: 'OtherNode' }, app);
assert.equal(Node.prototype.onNodeCreated, original);
});
test(`${file}: creation preserves hook arguments, results and existing canvas hooks`, async () => {
const { extension, app } = load(file, { bindDimensionPresets, extractResolutionFromPreset, swapDimensions, mountDimensionSwap() {} });
class Node {
constructor() { this.widgets = []; }
onNodeCreated(value) { this.received = value; return 'original result'; }
onDrawForeground() {}
onMouseDown() {}
onMouseMove() {}
}
const hooks = [Node.prototype.onDrawForeground, Node.prototype.onMouseDown, Node.prototype.onMouseMove];
await extension.beforeRegisterNodeDef(Node, { name: file === 'width_height_swap.js' ? 'WidthHeightSelector' : 'EmptyLatentBatch' }, app);
const node = new Node();
assert.equal(node.onNodeCreated('original argument'), 'original result');
assert.equal(node.received, 'original argument');
assert.deepEqual([Node.prototype.onDrawForeground, Node.prototype.onMouseDown, Node.prototype.onMouseMove], hooks);
});
test(`${file}: nodes without an existing creation hook initialize safely`, async () => {
const { extension, app } = load(file, { bindDimensionPresets, extractResolutionFromPreset, swapDimensions, mountDimensionSwap() {} });
class Node { constructor() { this.widgets = []; } }
await extension.beforeRegisterNodeDef(Node, { name: file === 'width_height_swap.js' ? 'WidthHeightSelector' : 'EmptyLatentBatch' }, app);
assert.equal(new Node().onNodeCreated(), undefined);
});
}
test('Gemini repeated execution restores the user color and removal cancels its timer', async () => {
const { extension, app, timers } = load('gemini_prompt_ui.js');
let removed = 0;
class Node { onRemoved() { removed++; } }
await extension.beforeRegisterNodeDef(Node, { name: 'GeminiPrompt' }, app);
const node = new Node();
node.status = { value: 'Ready' };
node.bgcolor = '#123456';
node.onExecuted({});
node.onExecuted({ output: { prompt: ['Error: unavailable'] } });
assert.equal(timers.size, 1);
assert.equal(node.bgcolor, '#552222');
[...timers.values()][0]();
assert.equal(node.bgcolor, '#123456');
assert.equal(node.status.value, 'Ready');
timers.clear();
node.onExecuted({});
node.onRemoved();
assert.equal(timers.size, 0);
assert.equal(removed, 1);
});
test('execution following respects enablement and ignores missing nodes', async () => {
const settings = new Map();
const listeners = new Map();
const centered = [];
const extensions = [];
const node = { id: '7', type: 'DisplayAny', title: 'Display' };
const app = {
registerExtension: extension => extensions.push(extension),
ui: { settings: { addSetting: setting => settings.set(setting.id, setting.defaultValue), getSettingValue: id => settings.get(id) } },
graph: { _nodes: [node], getNodeById: id => id === node.id ? node : undefined },
canvas: { centerOnNode: node => centered.push(node) }, runningNodeId: '7',
};
load('kiko_follow_execution.js', { app, api: { addEventListener: (name, callback) => listeners.set(name, callback) } });
const extension = extensions[0];
await extension.init();
await extension.setup();
assert.equal(extension.getCanvasMenuItems().length, 0);
settings.set('kikotools.follow_execution.enabled', true);
settings.set('kikotools.follow_execution.auto_start', true);
listeners.get('execution_start')();
listeners.get('executing')({ detail: '7' });
listeners.get('executing')({ detail: 'missing' });
assert.deepEqual(centered, [node]);
settings.set('kikotools.follow_execution.enabled', false);
listeners.get('execution_start')();
listeners.get('executing')({ detail: '7' });
assert.equal(centered.length, 1);
});
test('gallery HTML escaping handles filenames and attribute delimiters', () => {
const { context } = load('kiko_local_image_loader.js', { document: { getElementById: () => ({}) } });
const escape = vm.runInContext('escapeHtml', context);
assert.equal(escape(null), '');
assert.equal(escape(`<img src="x" onerror='run()'>&`), '&lt;img src=&quot;x&quot; onerror=&#39;run()&#39;&gt;&amp;');
});
test('gallery HTTP errors preserve actionable server detail and handle non-JSON responses', async () => {
const { context } = load('kiko_local_image_loader.js', { document: { getElementById: () => ({}) } });
const describe = vm.runInContext('describeHttpError', context);
assert.equal(await describe({ status: 403, json: async () => ({ error: 'Choose an allowed root' }) }), 'Choose an allowed root');
assert.match(await describe({ status: 403, json: async () => { throw new Error('not JSON'); } }), /allowed folders/);
assert.match(await describe({ status: 400, json: async () => ({}) }), /Invalid path/);
});
test('gallery HTTP errors cover null bodies, message details, and generic status fallbacks', async () => {
const { context } = load('kiko_local_image_loader.js', { document: { getElementById: () => ({}) } });
const describe = vm.runInContext('describeHttpError', context);
for (const [status, body, expected] of [
[400, { error: 'Invalid directory' }, 'Invalid directory'],
[403, { message: 'Not an allowed root' }, 'Not an allowed root'],
[400, null, 'Invalid path or request'],
[500, { error: 'Storage unavailable' }, 'Storage unavailable'],
[404, { message: 'Missing image' }, 'Missing image'],
[502, null, 'Request failed (HTTP 502)'],
[503, {}, 'Request failed (HTTP 503)'],
]) assert.equal(await describe({ status, json: async () => body }), expected);
});
+20
View File
@@ -0,0 +1,20 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { fitWindowRect } from '../../web/ui/floating-window.js';
test('window bounds keep its controls reachable after moving outside the viewport', () => {
const rect = fitWindowRect({ x: -100, y: 900, width: 420, height: 400 }, { width: 1000, height: 700 });
assert.equal(rect.x, 8);
assert.equal(rect.y + rect.height, 692);
});
test('window fitting respects minimum sizes and small viewports', () => {
const small = fitWindowRect({ x: 0, y: 0, width: 10, height: 10 }, { width: 1000, height: 700 });
assert.equal(small.width, 280);
assert.equal(small.height, 200);
const narrow = fitWindowRect({ x: 800, y: 600, width: 900, height: 800 }, { width: 260, height: 180 });
assert.equal(narrow.width, 244);
assert.equal(narrow.height, 164);
assert.equal(narrow.x, 8);
assert.equal(narrow.y, 8);
});
+13
View File
@@ -0,0 +1,13 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { isFixtureURL } from '../../tools/testing/network-policy.mjs';
const base = 'http://127.0.0.1:8766';
test('the network guard allows the test origin and WebKit local blob URLs', () => {
assert.equal(isFixtureURL(`${base}/extensions/kikotools/display_any.js`, base), true);
assert.equal(isFixtureURL(`blob:${base}/local-script`, base), true);
});
test('the network guard denies external destinations and other localhost ports', () => {
for (const url of ['https://example.com/model', 'http://127.0.0.1:9000/private', 'blob:https://example.com/script', 'data:text/plain,fixture']) {
assert.equal(isFixtureURL(url, base), false, url);
}
});
+15
View File
@@ -0,0 +1,15 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { resolveNodeSize } from '../../web/views/node-dimensions.js';
test('node dimensions accept whole pixels and respect content minimums', () => {
assert.deepEqual(resolveNodeSize(['640', '480'], [180, 120]), [640, 480]);
assert.deepEqual(resolveNodeSize(['1', '10'], [180, 120]), [180, 120]);
});
test('node dimensions reject missing, fractional, unsafe and nonpositive values', () => {
for (const value of ['', ' ', '0', '-1', '1.5', 'Infinity', 'NaN', '9007199254740992']) {
assert.equal(resolveNodeSize([value, '480'], [180, 120]), null, value);
assert.equal(resolveNodeSize(['640', value], [180, 120]), null, value);
}
});
+42
View File
@@ -0,0 +1,42 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createScope, themeFromColor } from '../../web/ui/core.js';
test('host background luminance determines mode without assuming palette names', () => {
for (const color of ['#fff', '#F4F4F6', 'rgb(240, 240, 240)']) assert.equal(themeFromColor(color), 'light');
for (const color of ['#222', '#202020', 'rgb(30, 32, 34)']) assert.equal(themeFromColor(color), 'dark');
assert.equal(themeFromColor(''), null);
assert.equal(themeFromColor('transparent'), null);
});
test('scope cancels requests and callbacks, removes listeners and disposes once', () => {
const scope = createScope();
const target = new EventTarget();
let events = 0;
let cleanups = 0;
scope.listen(target, 'change', () => events++);
scope.cleanup(() => cleanups++);
target.dispatchEvent(new Event('change'));
assert.equal(events, 1);
scope.dispose();
scope.dispose();
target.dispatchEvent(new Event('change'));
assert.equal(events, 1);
assert.equal(cleanups, 1);
assert.equal(scope.signal.aborted, true);
});
test('debounced work runs once with the latest value and stops after disposal', async () => {
const scope = createScope();
const values = [];
const change = scope.debounce(value => values.push(value), 1);
change('old');
change('new');
await new Promise(resolve => setTimeout(resolve, 20));
assert.deepEqual(values, ['new']);
change('cancelled');
scope.dispose();
change('after removal');
await new Promise(resolve => setTimeout(resolve, 20));
assert.deepEqual(values, ['new']);
});
+105
View File
@@ -0,0 +1,105 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { WorkflowClock, formatElapsed } from '../../web/ui/workflow-clock.js';
import { normalizeHex, hexToHsv, hsvToHex } from '../../web/ui/color.js';
function fixture() {
let now = 0;
const intervals = new Map();
const settings = new Map();
const clock = new WorkflowClock({ getSettingValue: id => settings.get(id) }, {
Date: { now: () => now },
setInterval(callback) { const id = Symbol(); intervals.set(id, callback); return id; },
clearInterval(id) { intervals.delete(id); },
});
const node = () => ({ properties: {}, timerDisplay: {
textContent: '',
style: { setProperty(name, value) { this[name] = value; } },
classList: { values: new Set(), toggle(name, enabled) { enabled ? this.values.add(name) : this.values.delete(name); } },
} });
return { clock, settings, intervals, node, advance(ms) { now += ms; for (const callback of intervals.values()) callback(); } };
}
test('elapsed formatting handles minutes, milliseconds and invalid durations', () => {
assert.equal(formatElapsed(0), '00:00:000');
assert.equal(formatElapsed(-1), '00:00:000');
assert.equal(formatElapsed(NaN), '00:00:000');
assert.equal(formatElapsed(61234.7), '01:01:234');
assert.equal(formatElapsed(3600000), '60:00:000');
});
test('one clock synchronizes late joining nodes and persists the final duration', () => {
const f = fixture();
const first = f.node();
f.clock.registerNode(first);
f.clock.start();
f.advance(1200);
f.clock.start();
const second = f.node();
f.clock.registerNode(second);
assert.equal(f.intervals.size, 1);
assert.equal(first.timerDisplay.textContent, '00:01:200');
assert.equal(second.timerDisplay.textContent, first.timerDisplay.textContent);
assert.equal(second.timerDisplay.classList.values.has('running'), true);
f.advance(34);
f.clock.stop();
f.clock.stop();
assert.equal(f.intervals.size, 0);
assert.equal(f.clock.intervalId, null);
for (const node of [first, second]) {
assert.equal(node.properties.elapsed_time_str, '00:01:234');
assert.equal(node.timerDisplay.classList.values.has('running'), false);
}
});
test('removing the final node releases polling while late nodes can rejoin an active run', () => {
const f = fixture();
f.clock.start();
assert.equal(f.intervals.size, 0);
f.advance(1000);
const first = f.node();
f.clock.registerNode(first);
assert.equal(first.timerDisplay.textContent, '00:01:000');
assert.equal(f.intervals.size, 1);
f.clock.unregisterNode(first);
assert.equal(f.intervals.size, 0);
f.advance(1000);
const next = f.node();
f.clock.registerNode(next);
assert.equal(next.timerDisplay.textContent, '00:02:000');
f.clock.stop();
f.clock.start();
assert.equal(next.timerDisplay.textContent, '00:00:000');
f.clock.unregisterNode(next);
assert.equal(f.intervals.size, 0);
});
test('configured colors and glow update during a run and saved durations restore', () => {
const f = fixture();
const node = f.node();
node.properties.elapsed_time_str = '02:03:456';
f.clock.registerNode(node);
assert.equal(node.timerDisplay.textContent, '02:03:456');
assert.equal(node.timerDisplay.style['--ca-timer-color'], '#0877bb');
f.clock.start();
f.settings.set('kikotools.workflow_timer.color', '#Ff9966');
f.settings.set('kikotools.workflow_timer.glow', false);
f.clock.refresh();
assert.equal(node.timerDisplay.style['--ca-timer-color'], '#ff9966');
assert.equal(node.timerDisplay.classList.values.has('no-glow'), true);
f.settings.set('kikotools.workflow_timer.color', 'invalid');
f.clock.refresh();
assert.equal(node.timerDisplay.style['--ca-timer-color'], '#0877bb');
});
test('hex colors normalize strictly and HSV conversion round trips representative colors', () => {
assert.equal(normalizeHex('Ff9966'), '#ff9966');
for (const invalid of ['', '#xyzxyz', '#12345', null, {}, '#ffffff; color:red']) assert.equal(normalizeHex(invalid), null);
for (const hex of ['#000000', '#ffffff', '#ff0000', '#00ff00', '#0000ff', '#ffff00', '#00ffff', '#ff00ff', '#ff9966', '#0877bb']) {
const { h, s, v } = hexToHsv(hex);
assert.equal(hsvToHex(h, s, v), hex);
}
assert.equal(hsvToHex(360, 100, 100), '#ff0000');
assert.equal(hsvToHex(-120, 100, 100), '#0000ff');
assert.equal(hsvToHex(0, 120, -1), '#000000');
});
+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
+41
View File
@@ -0,0 +1,41 @@
"""Ensure the documented test command actually invokes pytest and fails closed."""
import json
import os
from pathlib import Path
import shlex
import shutil
import subprocess
import sys
import pytest
@pytest.mark.skipif(
os.name == "nt" or not shutil.which("make"), reason="Requires POSIX make"
)
def test_make_test_runs_pytest_and_propagates_failure(tmp_path: Path) -> None:
recorder = tmp_path / "runner.py"
arguments = tmp_path / "arguments.json"
recorder.write_text(
"import json,sys\n"
f"with open({str(arguments)!r}, 'w') as output:\n"
" json.dump(sys.argv[1:], output)\n"
"sys.exit(7)\n",
encoding="utf-8",
)
result = subprocess.run(
[
"make",
"--no-print-directory",
"-o",
"venv",
"test",
f"PYTHON_CMD={shlex.join([sys.executable, str(recorder)])}",
],
cwd=Path(__file__).resolve().parents[1],
capture_output=True,
text=True,
)
assert json.loads(arguments.read_text()) == ["-m", "pytest", "tests"]
assert result.returncode != 0
View File
+57
View File
@@ -0,0 +1,57 @@
"""Tests for the shared model-folder scanner."""
import os
import sys
from unittest.mock import MagicMock, patch
from kikotools.base.model_folders import scan_model_entries, natural_sort
def _touch(path):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as f:
f.write("x")
def _fake_folder_paths(key, *roots):
fp = MagicMock()
fp.folder_names_and_paths = {key: [list(roots)]}
return patch.dict(sys.modules, {"folder_paths": fp})
class TestScanModelEntries:
def test_scans_the_requested_folder_key(self, tmp_path):
ckpt = tmp_path / "checkpoints"
_touch(str(ckpt / "Illustrious" / "b_v2.safetensors"))
_touch(str(ckpt / "Illustrious" / "a_v10.safetensors"))
_touch(str(ckpt / "Illustrious" / "notes.md"))
with _fake_folder_paths("checkpoints", str(ckpt)):
entries = scan_model_entries("Illustrious", "checkpoints")
assert [name for name, _ in entries] == [
"Illustrious/a_v10.safetensors",
"Illustrious/b_v2.safetensors",
]
assert entries[0][1] == str(ckpt / "Illustrious" / "a_v10.safetensors")
def test_relative_folder_escaping_the_root_is_ignored(self, tmp_path):
ckpt = tmp_path / "checkpoints"
_touch(str(tmp_path / "outside" / "x.safetensors"))
with _fake_folder_paths("checkpoints", str(ckpt)):
assert scan_model_entries("../outside", "checkpoints") == []
def test_unknown_key_returns_empty(self, tmp_path):
with _fake_folder_paths("loras", str(tmp_path)):
assert scan_model_entries(".", "checkpoints") == []
def test_natural_sort_orders_numbers_numerically():
assert natural_sort(["v10", "v2", "v1"]) == ["v1", "v2", "v10"]
def test_natural_sort_ignores_case():
assert natural_sort(["IllustrijGEN3", "aij_v10", "Illustrijv21", "animij_v6"]) == [
"aij_v10",
"animij_v6",
"IllustrijGEN3",
"Illustrijv21",
]

Some files were not shown because too many files have changed in this diff Show More