Compare commits

...
Author SHA1 Message Date
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
91 changed files with 6148 additions and 2922 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
+1
View File
@@ -2,6 +2,7 @@
max-line-length = 127
max-complexity = 10
exclude =
node_modules,
.git,
__pycache__,
.mypy_cache,
+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@v6
- 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@v5
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@v6
- 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@v6
- 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@v6
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@v6
- 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"
+76 -12
View File
@@ -20,10 +20,10 @@ jobs:
python-version: ["3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v6
- 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
@@ -40,18 +40,27 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt aiohttp
pip install -r requirements-dev.txt
- name: Run tests
run: python -m pytest tests
run: python -m pytest tests --junitxml=test-results/pytest.xml
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
with:
name: python-${{ matrix.os }}-${{ matrix.python-version }}
path: test-results/
if-no-files-found: ignore
retention-days: 14
coverage:
name: coverage gate (85%)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
- uses: actions/setup-python@v7
with:
python-version: "3.12"
cache: pip
@@ -61,21 +70,76 @@ jobs:
run: |
python -m pip install --upgrade pip
pip install torch --index-url https://download.pytorch.org/whl/cpu
pip install -r requirements-dev.txt aiohttp
pip install -r requirements-dev.txt
# fail_under = 85 lives in pyproject.toml [tool.coverage.report]
- name: Measure coverage
run: python -m pytest tests --cov=kikotools --cov-report=term-missing
run: python -m pytest tests --cov=kikotools --cov-report=term-missing --cov-report=html --cov-report=xml
- 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@v6
- uses: actions/checkout@v7
# No JS unit tests yet; at least make sure every module parses
- name: Check web/*.js parse as ES modules
- 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: |
for f in web/*.js; do
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..."
+34 -5
View File
@@ -50,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 |
@@ -301,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.
@@ -539,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
@@ -1003,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
@@ -1064,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
@@ -1093,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.
+126
View File
@@ -0,0 +1,126 @@
# 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.
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.
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: 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
+6
View File
@@ -10,6 +10,7 @@ from .tools.batch_list_converter import (
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
@@ -23,6 +24,7 @@ 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
@@ -69,6 +71,8 @@ NODE_CLASS_MAPPINGS = {
"FluxSamplerParams": FluxSamplerParamsNode,
"PlotParameters+": PlotParametersNode,
"LoRAFolderBatch": LoRAFolderBatchNode,
"CheckpointFolderIterator": CheckpointFolderIteratorNode,
"ModelCompareSheet": ModelCompareSheetNode,
"WidthHeightToVec2": WidthHeightToVec2Node,
"KikoWorkflowTimer": KikoWorkflowTimerNode,
"KikoProjectFilePath": ProjectFilePathNode,
@@ -105,6 +109,8 @@ 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",
+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
@@ -0,0 +1,5 @@
"""Checkpoint Folder Iterator module."""
from .node import CheckpointFolderIteratorNode
__all__ = ["CheckpointFolderIteratorNode"]
@@ -0,0 +1,52 @@
"""Logic for the Checkpoint Folder Iterator node."""
import os
from typing import List, Sequence, Tuple, TypeVar
from ...base.model_folders import filter_by_pattern, scan_model_entries
T = TypeVar("T")
def list_checkpoint_entries(
folder_path: str, include_pattern: str = "", exclude_pattern: str = ""
) -> List[Tuple[str, str]]:
"""(name, absolute_path) pairs for every checkpoint, filtered, in natural order.
The absolute path is what gets loaded, so folders outside the configured
checkpoints roots work too; the name is relative to the root (or to the
folder itself when it lives outside every root) and is used for labels.
"""
entries = scan_model_entries(folder_path, "checkpoints")
names = [name for name, _ in entries]
kept = set(filter_by_pattern(names, include_pattern, exclude_pattern))
return [(name, path) for name, path in entries if name in kept]
def list_checkpoints(
folder_path: str, include_pattern: str = "", exclude_pattern: str = ""
) -> List[str]:
"""Checkpoint names (relative to the checkpoints roots) in natural order."""
entries = list_checkpoint_entries(folder_path, include_pattern, exclude_pattern)
return [name for name, _ in entries]
def pick_checkpoint(items: Sequence[T], index: int) -> T:
"""Return items[index], or raise when the sweep has run past the end.
Wrapping would silently restart the sweep (and, downstream, archive the
grid that just finished), so an over-queued run fails loudly instead.
"""
if not items:
raise ValueError("No checkpoints found")
if not 0 <= index < len(items):
raise ValueError(
f"Index {index} is past the end: only {len(items)} checkpoints matched. "
"The sweep is complete; reset index to 0 to start a new one."
)
return items[index]
def label_for(ckpt_name: str) -> str:
"""Label drawn under a tile for this checkpoint. Default: the file stem."""
return os.path.splitext(os.path.basename(ckpt_name))[0]
@@ -0,0 +1,80 @@
"""Checkpoint Folder Iterator node for ComfyUI."""
from typing import Tuple
from ...base.base_node import ComfyAssetsBaseNode
from .logic import label_for, list_checkpoint_entries, pick_checkpoint
class CheckpointFolderIteratorNode(ComfyAssetsBaseNode):
"""
Load one checkpoint from a folder per queued run.
Set index to 0 with "increment" and queue as many runs as there are
checkpoints: each run loads the next file, so only one model is in
memory at a time. A run past the last checkpoint fails with a clear
error rather than wrapping around, so over-queuing never restarts the
sweep. Pairs with Model Compare Sheet to build a labeled grid.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"folder": (
"STRING",
{
"default": ".",
"multiline": False,
"tooltip": "Folder relative to models/checkpoints (or absolute)",
},
),
"index": (
"INT",
{
"default": 0,
"min": 0,
"max": 9999,
"control_after_generate": True,
"tooltip": "Which checkpoint to load; errors past the end",
},
),
},
"optional": {
"include_pattern": (
"STRING",
{"default": "", "tooltip": "Regex to include (empty = all)"},
),
"exclude_pattern": (
"STRING",
{"default": "", "tooltip": "Regex to exclude (empty = none)"},
),
},
}
RETURN_TYPES = ("MODEL", "CLIP", "VAE", "STRING", "STRING", "INT", "INT")
RETURN_NAMES = ("model", "clip", "vae", "ckpt_name", "label", "index", "count")
FUNCTION = "load"
CATEGORY = "🫶 ComfyAssets/🧰 xyz-helpers"
def load(
self,
folder: str,
index: int,
include_pattern: str = "",
exclude_pattern: str = "",
) -> Tuple[object, object, object, str, str, int, int]:
import folder_paths
import comfy.sd
entries = list_checkpoint_entries(folder, include_pattern, exclude_pattern)
ckpt_name, ckpt_path = pick_checkpoint(entries, index)
self.log_info(f"Loading {index + 1}/{len(entries)}: {ckpt_name}")
model, clip, vae = comfy.sd.load_checkpoint_guess_config(
ckpt_path,
output_vae=True,
output_clip=True,
embedding_directory=folder_paths.get_folder_paths("embeddings"),
)[:3]
return model, clip, vae, ckpt_name, label_for(ckpt_name), index, len(entries)
@@ -1,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
}
+7 -13
View File
@@ -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
+5 -5
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)
+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(
@@ -1,12 +0,0 @@
{
"last_path": "/home/vito/ai-apps/ComfyUI/output/vids",
"saved_paths": [
"/home/vito/ai-apps/ComfyUI-3.12/output/2025-05-01",
"/home/vito/ai-apps/ComfyUI-3.12/output/",
"/home/vito/Downloads/vito",
"/home/vito/ai-apps/ComfyUI/output/2025-06-08",
"/home/vito/ai-apps/ComfyUI/output",
"/home/vito/Downloads",
"/home/vito/Downloads/images"
]
}
@@ -1,87 +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/vids/KikoSave_00005.png"
}
},
"445": {
"image": {
"path": "/home/vito/ai-apps/ComfyUI/output/ComfyUI_00002_.png"
}
},
"23": {
"image": {
"path": "/home/vito/ai-apps/ComfyUI/output/Z_Image_Char/Image_00098_.png"
}
},
"69": {
"image": {
"path": "/home/vito/Downloads/KikoSave_00086.png"
}
},
"52": {
"image": {
"path": "/home/vito/ai-apps/ComfyUI/output/kiko/XXX/images/kiko_00038_.png"
}
},
"38": {
"image": {
"path": "/home/vito/Downloads/vito/IMG_20160422_163419.jpg"
}
},
"170": {
"image": {
"path": "/home/vito/ai-apps/ComfyUI/output/Z_Image_Char/Image_00326_.png"
}
},
"214": {
"image": {
"path": "/home/vito/ai-apps/ComfyUI/output/Z_Image_Char/Image_00329_.png"
}
},
"299": {
"image": {
"path": "/home/vito/Downloads/images/KikoSave_00016.png"
}
},
"527": {
"image": {
"path": "/home/vito/Downloads/ComfyUI_temp_sktzg_00012_.png"
}
},
"522": {
"image": {
"path": "/home/vito/Downloads/KikoSave_00086.png"
}
},
"517": {
"image": {
"path": "/home/vito/ai-apps/ComfyUI/output/vids/KikoSave_00005.png"
}
},
"144": {
"image": {
"path": "/home/vito/ai-apps/ComfyUI/output/2025-04-24/ComfyUI_00002_.png"
}
},
"569": {
"image": {
"path": "/home/vito/ai-apps/ComfyUI/output/vids/KikoSave_00008.png"
}
},
"136": {
"image": {
"path": "/home/vito/ai-apps/ComfyUI/output/vids/KikoSave_00005.png"
}
}
}
@@ -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,),
}
@@ -56,7 +56,6 @@ class ProjectFilePathNode(ComfyAssetsBaseNode):
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("path",)
FUNCTION = "generate_path"
CATEGORY = "🫶 ComfyAssets/🛠️ Utils"
@@ -3,9 +3,16 @@
import math
import os
import re
from typing import List, Dict, Any, Optional, Tuple
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
@@ -40,66 +47,9 @@ def get_lora_folders() -> List[str]:
return [".", "flux", "sdxl", "sd15"]
def _get_lora_roots() -> List[str]:
"""Return every configured loras root directory (empty if unavailable)."""
try:
import folder_paths
entry = folder_paths.folder_names_and_paths.get("loras")
return list(entry[0]) if entry else []
except (ImportError, AttributeError, IndexError, TypeError):
return []
def _is_within(path: str, root: str) -> bool:
"""True if realpath `path` equals or is inside realpath `root`."""
try:
real_path, real_root = os.path.realpath(path), os.path.realpath(root)
return os.path.commonpath([real_path, real_root]) == real_root
except ValueError: # different drives or mixed absolute/relative
return False
def _resolve_scan_targets(folder_path: str) -> List[Tuple[str, Optional[str]]]:
"""
Resolve a folder setting to (directory, base_root) pairs to scan.
base_root is the loras root the directory lives in, or None when the
directory is an absolute path outside every known root.
"""
roots = _get_lora_roots()
if os.path.isabs(folder_path):
base = next((r for r in roots if _is_within(folder_path, r)), None)
return [(folder_path, base)]
targets = []
for root in roots:
candidate = os.path.join(root, folder_path)
if not _is_within(candidate, root):
logger.warning(f"Folder escapes the loras directory: {folder_path}")
continue
targets.append((candidate, root))
return targets
def _scan_entries(folder_path: str) -> List[Tuple[str, str]]:
"""Return (relative_name, absolute_path) for every .safetensors found."""
entries: Dict[str, str] = {}
for directory, base in _resolve_scan_targets(folder_path):
if not os.path.isdir(directory):
continue
for root, _, files in os.walk(directory):
for file in files:
if not file.endswith(".safetensors"):
continue
full = os.path.join(root, file)
try:
rel = os.path.relpath(full, base or directory)
except ValueError:
rel = os.path.relpath(full, directory)
entries.setdefault(rel.replace("\\", "/"), full)
return [(name, entries[name]) for name in natural_sort(list(entries))]
return scan_model_entries(folder_path, "loras")
def scan_folder_for_loras(folder_path: str) -> List[str]:
@@ -164,71 +114,11 @@ 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_finite(token: str, what: str, original: str) -> float:
@@ -438,7 +328,7 @@ def validate_folder_path(folder_path: str) -> bool:
return any(
os.path.isdir(directory)
for directory, _ in _resolve_scan_targets(folder_path)
for directory, _ in resolve_scan_targets(folder_path, "loras")
)
except Exception:
return False
+8 -6
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,18 +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]
@@ -71,6 +72,7 @@ files = ["kikotools"]
exclude = ["tests"]
[tool.coverage.run]
branch = true
source = ["kikotools"]
omit = ["*/tests/*", "*/__init__.py"]
+6 -5
View File
@@ -3,18 +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
View File
+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()
+817
View File
@@ -0,0 +1,817 @@
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.onMouseDown({ canvasX: 475, canvasY: 140 });
return node.widgets.map(widget => widget.value);
}, id);
expect(swapped).toEqual(['1216×832 - landscape', '1216', '832']);
await createNode(page, 'EmptyLatentBatch', { preset: 'custom', width: 640, height: 960 });
await page.getByRole('button', { name: /Swap W/ }).click();
const latent = page.locator('[data-node="EmptyLatentBatch"]');
await expect(latent.getByLabel('width')).toHaveValue('960');
await expect(latent.getByLabel('height')).toHaveValue('640');
});
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();
});
+317
View File
@@ -0,0 +1,317 @@
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();
});
});
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);
});
+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 }); }
});
+160
View File
@@ -0,0 +1,160 @@
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';
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']) {
const { extension, app } = load('width_height_swap.js');
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: 'WidthHeightSelector' }, app);
const node = new Node();
node.onNodeCreated();
assert.equal(originalCreated, 1);
return node;
}
test('custom dimensions swap and callbacks receive the actual node', async () => {
const node = await dimensionNode('custom');
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('formatted preset swaps to its matching landscape preset without throwing', async () => {
const node = await dimensionNode('832×1216 - portrait');
node.swapDimensions();
assert.deepEqual(node.widgets.map(widget => widget.value), ['1216×832 - landscape', 1216, 832]);
});
test('a preset without a reverse switches to custom dimensions', async () => {
const node = await dimensionNode('832×1216 - portrait', ['custom', '832×1216 - portrait']);
node.swapDimensions();
assert.deepEqual(node.widgets.map(widget => widget.value), ['custom', 1216, 832]);
});
test('preset changes preserve original callbacks and update both dimensions', async () => {
const node = await dimensionNode('custom');
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('unrelated node definitions retain their lifecycle methods', async () => {
const { extension, app } = load('width_height_swap.js');
class Node { onNodeCreated() {} }
const original = Node.prototype.onNodeCreated;
await extension.beforeRegisterNodeDef(Node, { name: 'OtherNode' }, app);
assert.equal(Node.prototype.onNodeCreated, original);
});
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);
}
});
+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');
});
+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",
]
@@ -0,0 +1,99 @@
"""Tests for Checkpoint Folder Iterator logic."""
import os
import sys
from unittest.mock import MagicMock, patch
import pytest
from kikotools.tools.checkpoint_folder_iterator.logic import (
label_for,
list_checkpoint_entries,
list_checkpoints,
pick_checkpoint,
)
def _touch(path):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as f:
f.write("x")
def _fake_checkpoints(root):
fp = MagicMock()
fp.folder_names_and_paths = {"checkpoints": [[str(root)]]}
return patch.dict(sys.modules, {"folder_paths": fp})
class TestListCheckpoints:
def test_lists_safetensors_in_natural_order(self, tmp_path):
for name in ("illustrij_v20", "illustrij_v3", "ilustmix_v9"):
_touch(str(tmp_path / "Illustrious" / f"{name}.safetensors"))
_touch(str(tmp_path / "Illustrious" / "illustrij_v20.jpeg"))
with _fake_checkpoints(tmp_path):
names = list_checkpoints("Illustrious")
assert names == [
"Illustrious/illustrij_v3.safetensors",
"Illustrious/illustrij_v20.safetensors",
"Illustrious/ilustmix_v9.safetensors",
]
def test_include_and_exclude_patterns(self, tmp_path):
for name in ("a_v1", "a_v2", "b_v1"):
_touch(str(tmp_path / "X" / f"{name}.safetensors"))
with _fake_checkpoints(tmp_path):
names = list_checkpoints("X", include_pattern="^X/a_", exclude_pattern="v2")
assert names == ["X/a_v1.safetensors"]
class TestListCheckpointEntries:
def test_pairs_each_name_with_its_absolute_path(self, tmp_path):
_touch(str(tmp_path / "X" / "a_v1.safetensors"))
with _fake_checkpoints(tmp_path):
entries = list_checkpoint_entries("X")
assert entries == [
("X/a_v1.safetensors", str(tmp_path / "X" / "a_v1.safetensors"))
]
def test_patterns_apply_to_entries_too(self, tmp_path):
for name in ("a_v1", "a_v2", "b_v1"):
_touch(str(tmp_path / "X" / f"{name}.safetensors"))
with _fake_checkpoints(tmp_path):
entries = list_checkpoint_entries("X", "^X/a_", "v2")
assert [name for name, _ in entries] == ["X/a_v1.safetensors"]
def test_absolute_folder_outside_roots_keeps_real_paths(self, tmp_path):
root = tmp_path / "models" / "checkpoints"
elsewhere = tmp_path / "external_drive" / "ckpts"
_touch(str(elsewhere / "ext_v1.safetensors"))
with _fake_checkpoints(root):
entries = list_checkpoint_entries(str(elsewhere))
assert entries == [
("ext_v1.safetensors", str(elsewhere / "ext_v1.safetensors"))
]
class TestPickCheckpoint:
def test_returns_the_indexed_item(self):
names = ["a", "b", "c"]
assert pick_checkpoint(names, 0) == "a"
assert pick_checkpoint(names, 2) == "c"
def test_index_past_the_end_raises_instead_of_wrapping(self):
with pytest.raises(ValueError, match="past the end.*3 checkpoints"):
pick_checkpoint(["a", "b", "c"], 3)
def test_negative_index_raises(self):
with pytest.raises(ValueError, match="past the end"):
pick_checkpoint(["a"], -1)
def test_empty_list_raises(self):
with pytest.raises(ValueError, match="No checkpoints"):
pick_checkpoint([], 0)
class TestLabelFor:
def test_default_label_is_the_file_stem(self):
assert label_for("Illustrious/illustrij_v20.safetensors") == "illustrij_v20"
assert label_for("Illustrious/reijlita/x_v1.safetensors") == "x_v1"
@@ -0,0 +1,71 @@
"""Tests for the Checkpoint Folder Iterator node."""
import sys
from unittest.mock import MagicMock, patch
import pytest
from kikotools.tools.checkpoint_folder_iterator import CheckpointFolderIteratorNode
NODE = "kikotools.tools.checkpoint_folder_iterator.node"
ENTRIES = [
("X/a_v1.safetensors", "/models/checkpoints/X/a_v1.safetensors"),
("X/b_v2.safetensors", "/models/checkpoints/X/b_v2.safetensors"),
]
def _fake_comfy_modules():
fp = MagicMock()
fp.get_folder_paths = MagicMock(return_value=["/models/embeddings"])
comfy = MagicMock()
comfy.sd.load_checkpoint_guess_config = MagicMock(
return_value=("MODEL", "CLIP", "VAE", "extra")
)
return (
patch.dict(
sys.modules, {"folder_paths": fp, "comfy": comfy, "comfy.sd": comfy.sd}
),
fp,
comfy,
)
class TestCheckpointFolderIteratorNode:
def test_input_index_increments_after_generate(self):
index = CheckpointFolderIteratorNode.INPUT_TYPES()["required"]["index"]
assert index[0] == "INT"
assert index[1]["control_after_generate"] is True
def test_loads_the_indexed_checkpoint_from_its_scanned_path(self):
ctx, fp, comfy = _fake_comfy_modules()
with ctx, patch(f"{NODE}.list_checkpoint_entries", return_value=ENTRIES):
out = CheckpointFolderIteratorNode().load("X", 1, "", "")
comfy.sd.load_checkpoint_guess_config.assert_called_once()
assert comfy.sd.load_checkpoint_guess_config.call_args.args == (
"/models/checkpoints/X/b_v2.safetensors",
)
fp.get_full_path_or_raise.assert_not_called()
assert out == ("MODEL", "CLIP", "VAE", "X/b_v2.safetensors", "b_v2", 1, 2)
def test_absolute_folder_outside_models_dir_loads_by_real_path(self):
ctx, _, comfy = _fake_comfy_modules()
external = [("ext_v1.safetensors", "/mnt/drive/ckpts/ext_v1.safetensors")]
with ctx, patch(f"{NODE}.list_checkpoint_entries", return_value=external):
out = CheckpointFolderIteratorNode().load("/mnt/drive/ckpts", 0, "", "")
assert comfy.sd.load_checkpoint_guess_config.call_args.args == (
"/mnt/drive/ckpts/ext_v1.safetensors",
)
assert out[3:] == ("ext_v1.safetensors", "ext_v1", 0, 1)
def test_index_past_the_end_raises_instead_of_restarting(self):
ctx, _, comfy = _fake_comfy_modules()
with ctx, patch(f"{NODE}.list_checkpoint_entries", return_value=ENTRIES):
with pytest.raises(ValueError, match="past the end"):
CheckpointFolderIteratorNode().load("X", 2, "", "")
comfy.sd.load_checkpoint_guess_config.assert_not_called()
def test_empty_folder_raises_clear_error(self):
ctx, _, _ = _fake_comfy_modules()
with ctx, patch(f"{NODE}.list_checkpoint_entries", return_value=[]):
with pytest.raises(ValueError, match="No checkpoints"):
CheckpointFolderIteratorNode().load("Empty", 0, "", "")
@@ -0,0 +1,112 @@
"""Tests for Model Compare Sheet logic."""
import os
import pytest
from PIL import Image
from kikotools.tools.model_compare_sheet.logic import (
build_contact_sheet,
label_from_filename,
list_tiles,
resolve_run_dir,
tile_filename,
)
class TestResolveRunDir:
def test_run_dir_lives_under_model_compare(self, tmp_path):
assert resolve_run_dir(str(tmp_path), "illustrious") == os.path.join(
str(tmp_path), "model_compare", "illustrious"
)
def test_nested_run_names_are_allowed(self, tmp_path):
assert resolve_run_dir(str(tmp_path), "proj/2026-10-15/sweep") == os.path.join(
str(tmp_path), "model_compare", "proj", "2026-10-15", "sweep"
)
@pytest.mark.parametrize(
"bad",
[
"",
"../escape",
"/abs/path",
"a/../../b",
"..\\escape",
"a\\..\\..\\b",
"C:\\abs",
".",
"./x",
"a/./b",
"a//b",
"x/..",
],
)
def test_rejects_names_that_leave_model_compare(self, tmp_path, bad):
with pytest.raises(ValueError, match="run_name"):
resolve_run_dir(str(tmp_path), bad)
def test_rejects_symlink_escape_into_another_output_folder(self, tmp_path):
base = tmp_path / "model_compare"
base.mkdir()
outside = tmp_path / "unrelated"
outside.mkdir()
try:
(base / "link").symlink_to(outside, target_is_directory=True)
except OSError as exc:
pytest.skip(f"Directory symlinks unavailable: {exc}")
with pytest.raises(ValueError, match="run_name"):
resolve_run_dir(str(tmp_path), "link/run")
class TestTileFilename:
def test_single_image_run(self):
assert tile_filename(3, "illustrij_v20", 0, 1) == "03_illustrij_v20.png"
def test_batch_gets_a_suffix(self):
assert tile_filename(3, "illustrij_v20", 1, 2) == "03_illustrij_v20_2.png"
def test_label_characters_are_sanitized(self):
assert tile_filename(0, "a/b:c d", 0, 1) == "00_a_b_c_d.png"
def test_label_from_filename_strips_index_and_extension():
assert label_from_filename("03_illustrij_v20.png") == "illustrij_v20"
assert label_from_filename("03_illustrij_v20_2.png") == "illustrij_v20_2"
assert label_from_filename("grid.png") == "grid"
def test_list_tiles_skips_grid_and_sorts_naturally(tmp_path):
for name in ("10_b.png", "2_a.png", "grid.png", "grid_preview.png", "notes.txt"):
(tmp_path / name).write_bytes(b"")
assert list_tiles(str(tmp_path)) == [
str(tmp_path / "2_a.png"),
str(tmp_path / "10_b.png"),
]
class TestBuildContactSheet:
def _tiles(self, tmp_path, n, size=(100, 200)):
paths = []
for i in range(n):
p = tmp_path / f"{i:02d}_m{i}.png"
Image.new("RGB", size, (i * 40, 0, 0)).save(p)
paths.append(str(p))
return paths
def test_grid_dimensions_follow_columns_and_tile_width(self, tmp_path):
paths = self._tiles(tmp_path, 3)
sheet = build_contact_sheet(paths, columns=2, tile_width=50, font_size=10)
# 3 tiles in 2 columns = 2 rows; tiles scale to 50x100; label band 2*font
assert sheet.size == (100, 2 * (100 + 20))
def test_fewer_tiles_than_columns_shrinks_width(self, tmp_path):
paths = self._tiles(tmp_path, 1)
sheet = build_contact_sheet(paths, columns=5, tile_width=50, font_size=10)
assert sheet.size == (50, 100 + 20)
def test_label_band_is_drawn(self, tmp_path):
paths = self._tiles(tmp_path, 1)
sheet = build_contact_sheet(paths, columns=1, tile_width=50, font_size=10)
band = sheet.crop((0, 100, 50, 120))
assert len(band.getcolors(maxcolors=4096) or []) > 1
@@ -0,0 +1,329 @@
"""Tests for the Model Compare Sheet node."""
import json
import os
from pathlib import Path
import sys
from unittest.mock import MagicMock, patch
import pytest
import torch
from PIL import Image
from kikotools.tools.model_compare_sheet import ModelCompareSheetNode
def _fake_output_dir(path):
fp = MagicMock()
fp.get_output_directory = MagicMock(return_value=str(path))
return patch.dict(sys.modules, {"folder_paths": fp})
def _image(batch=1, h=64, w=32):
return torch.rand(batch, h, w, 3)
class TestModelCompareSheetNode:
def test_is_an_output_node(self):
assert ModelCompareSheetNode.OUTPUT_NODE is True
def test_saves_tile_and_grid_and_returns_grid(self, tmp_path):
with _fake_output_dir(tmp_path):
out = ModelCompareSheetNode().collect(
_image(), "illustrij_v20", 3, "run1", 4, 32, 8
)
run_dir = tmp_path / "model_compare" / "run1"
assert (run_dir / "03_illustrij_v20.png").exists()
assert (run_dir / "grid.png").exists()
grid = out["result"][0]
assert grid.shape == (1, 64 + 16, 32, 3)
assert out["ui"]["images"][0]["filename"] == "grid_preview.png"
assert out["ui"]["images"][0]["subfolder"] == os.path.join(
"model_compare", "run1"
)
def test_grid_grows_across_runs(self, tmp_path):
with _fake_output_dir(tmp_path):
node = ModelCompareSheetNode()
node.collect(_image(), "a", 0, "run1", 4, 32, 8)
out = node.collect(_image(), "b", 1, "run1", 4, 32, 8)
assert out["result"][0].shape == (1, 64 + 16, 64, 3)
def test_batch_saves_every_image(self, tmp_path):
with _fake_output_dir(tmp_path):
ModelCompareSheetNode().collect(_image(batch=2), "a", 0, "run1", 4, 32, 8)
run_dir = tmp_path / "model_compare" / "run1"
assert (run_dir / "00_a_1.png").exists()
assert (run_dir / "00_a_2.png").exists()
assert Image.open(run_dir / "grid.png").size == (64, 64 + 16)
def test_rerun_of_same_index_overwrites_instead_of_duplicating(self, tmp_path):
with _fake_output_dir(tmp_path):
node = ModelCompareSheetNode()
node.collect(_image(), "a", 0, "run1", 4, 32, 8)
out = node.collect(_image(), "a", 0, "run1", 4, 32, 8)
assert out["result"][0].shape[2] == 32
class TestArchiveOnNewSweep:
def test_index_zero_archives_the_previous_sweep(self, tmp_path):
with _fake_output_dir(tmp_path):
node = ModelCompareSheetNode()
node.collect(_image(), "a", 0, "run1", 4, 32, 8)
node.collect(_image(), "b", 1, "run1", 4, 32, 8)
out = node.collect(_image(), "c", 0, "run1", 4, 32, 8)
run_dir = tmp_path / "model_compare" / "run1"
assert sorted(p.name for p in run_dir.iterdir()) == [
"00_c.png",
"grid.png",
"grid_preview.png",
]
assert out["result"][0].shape[2] == 32
archived = [p for p in run_dir.parent.iterdir() if p.name.startswith("run1_")]
assert len(archived) == 1
assert sorted(p.name for p in archived[0].iterdir()) == [
"00_a.png",
"01_b.png",
"grid.png",
"grid_preview.png",
]
def test_nonzero_index_keeps_existing_tiles(self, tmp_path):
with _fake_output_dir(tmp_path):
node = ModelCompareSheetNode()
node.collect(_image(), "a", 0, "run1", 4, 32, 8)
node.collect(_image(), "b", 3, "run1", 4, 32, 8)
run_dir = tmp_path / "model_compare" / "run1"
assert sorted(p.name for p in run_dir.iterdir()) == [
"00_a.png",
"03_b.png",
"grid.png",
"grid_preview.png",
]
def test_index_zero_on_empty_folder_archives_nothing(self, tmp_path):
with _fake_output_dir(tmp_path):
ModelCompareSheetNode().collect(_image(), "a", 0, "run1", 4, 32, 8)
assert [p.name for p in (tmp_path / "model_compare").iterdir()] == ["run1"]
class TestPreview:
def test_ui_preview_is_downscaled_but_grid_and_output_stay_full_size(
self, tmp_path
):
with _fake_output_dir(tmp_path):
out = ModelCompareSheetNode().collect(
_image(h=64, w=32), "a", 0, "run1", 1, 1200, 8
)
run_dir = tmp_path / "model_compare" / "run1"
assert Image.open(run_dir / "grid.png").size[0] == 1200
assert Image.open(run_dir / "grid_preview.png").size[0] == 1024
assert out["result"][0].shape[2] == 1200
assert out["ui"]["images"][0]["filename"] == "grid_preview.png"
def test_small_grid_preview_is_not_upscaled(self, tmp_path):
with _fake_output_dir(tmp_path):
ModelCompareSheetNode().collect(_image(), "a", 0, "run1", 1, 32, 8)
run_dir = tmp_path / "model_compare" / "run1"
assert Image.open(run_dir / "grid_preview.png").size[0] == 32
class TestReplaceIndex:
@pytest.mark.parametrize(
"old_batch, old_label, new_batch, new_label, expected",
[
(2, "a", 1, "a", ["01_a.png"]),
(1, "a", 1, "b", ["01_b.png"]),
(1, "a", 1, "A", ["01_A.png"]),
(2, "a", 3, "b", ["01_b_1.png", "01_b_2.png", "01_b_3.png"]),
(1, "a", 2, "a", ["01_a_1.png", "01_a_2.png"]),
(3, "a", 2, "a", ["01_a_1.png", "01_a_2.png"]),
],
)
def test_rerun_replaces_only_its_index(
self,
tmp_path: Path,
old_batch: int,
old_label: str,
new_batch: int,
new_label: str,
expected: list[str],
) -> None:
with _fake_output_dir(tmp_path):
node = ModelCompareSheetNode()
node.collect(_image(), "first", 0, "run1", 8, 32, 8)
node.collect(_image(batch=old_batch), old_label, 1, "run1", 8, 32, 8)
node.collect(_image(), "neighbor", 10, "run1", 8, 32, 8)
run_dir = tmp_path / "model_compare" / "run1"
untouched = {
name: (run_dir / name).read_bytes()
for name in ("00_first.png", "10_neighbor.png")
}
(run_dir / "notes.txt").write_text("keep this note")
archived = run_dir.parent / "run1_archived"
archived.mkdir()
(archived / "01_a.png").write_bytes(b"archived tile")
result = node.collect(
torch.ones(new_batch, 64, 32, 3),
new_label,
1,
"run1",
8,
32,
8,
prompt={"model": "replacement"},
extra_pnginfo={"workflow": {"id": "retry"}},
)
assert sorted(path.name for path in run_dir.glob("01_*.png")) == expected
assert result["result"][0].shape == (1, 80, (new_batch + 2) * 32, 3)
assert sorted(path.name for path in run_dir.iterdir()) == sorted(
[
"00_first.png",
"10_neighbor.png",
"grid.png",
"grid_preview.png",
"notes.txt",
*expected,
]
)
for name, data in untouched.items():
assert (run_dir / name).read_bytes() == data
assert (run_dir / "notes.txt").read_text() == "keep this note"
assert (archived / "01_a.png").read_bytes() == b"archived tile"
for name in expected:
with Image.open(run_dir / name) as image:
assert image.getpixel((0, 0)) == (255, 255, 255)
assert json.loads(image.info["prompt"]) == {"model": "replacement"}
assert json.loads(image.info["workflow"]) == {"id": "retry"}
@pytest.mark.parametrize("index", [0, 1])
@pytest.mark.parametrize("failed_image", [1, 2])
def test_failed_tile_write_preserves_the_existing_batch_and_grid(
self, tmp_path: Path, index: int, failed_image: int
) -> None:
with _fake_output_dir(tmp_path):
node = ModelCompareSheetNode()
node.collect(torch.zeros(2, 64, 32, 3), "same", index, "run1", 8, 32, 8)
base = tmp_path / "model_compare"
before = {
str(path.relative_to(base)): path.read_bytes()
for path in base.rglob("*")
if path.is_file()
}
original_save = Image.Image.save
tile_writes = 0
def fail_tile_write(image, path, *args, **kwargs):
nonlocal tile_writes
if Path(path).name.startswith(f"{index:02d}_"):
tile_writes += 1
if tile_writes == failed_image:
raise OSError("disk full")
return original_save(image, path, *args, **kwargs)
with patch.object(Image.Image, "save", new=fail_tile_write):
with pytest.raises(OSError, match="disk full"):
node.collect(
torch.ones(2, 64, 32, 3), "same", index, "run1", 8, 32, 8
)
assert {
str(path.relative_to(base)): path.read_bytes()
for path in base.rglob("*")
if path.is_file()
} == before
assert list(base.iterdir()) == [base / "run1"]
def test_empty_batch_does_not_remove_existing_tiles(self, tmp_path: Path) -> None:
with _fake_output_dir(tmp_path):
node = ModelCompareSheetNode()
node.collect(_image(), "a", 1, "run1", 8, 32, 8)
run_dir = tmp_path / "model_compare" / "run1"
before = {path.name: path.read_bytes() for path in run_dir.iterdir()}
with pytest.raises(ValueError, match="at least one image"):
node.collect(torch.empty(0, 64, 32, 3), "b", 1, "run1", 8, 32, 8)
assert {path.name: path.read_bytes() for path in run_dir.iterdir()} == before
@pytest.mark.parametrize("failed_image", [1, 2])
def test_failed_publication_restores_the_previous_batch(
self, tmp_path: Path, failed_image: int
) -> None:
with _fake_output_dir(tmp_path):
node = ModelCompareSheetNode()
node.collect(torch.zeros(2, 64, 32, 3), "same", 1, "run1", 8, 32, 8)
base = tmp_path / "model_compare"
run_dir = base / "run1"
before = {
str(path.relative_to(base)): path.read_bytes()
for path in base.rglob("*")
if path.is_file()
}
original_replace = os.replace
published = 0
def fail_publication(source, destination):
nonlocal published
if (
Path(destination).parent == run_dir
and Path(source).parent.name != "previous"
):
published += 1
if published == failed_image:
raise OSError("publication failed")
return original_replace(source, destination)
with patch("os.replace", side_effect=fail_publication):
with pytest.raises(OSError, match="publication failed"):
node.collect(torch.ones(2, 64, 32, 3), "same", 1, "run1", 8, 32, 8)
assert {
str(path.relative_to(base)): path.read_bytes()
for path in base.rglob("*")
if path.is_file()
} == before
assert list(base.iterdir()) == [run_dir]
def test_rerun_preserves_directories_with_tile_like_names(
self, tmp_path: Path
) -> None:
with _fake_output_dir(tmp_path):
node = ModelCompareSheetNode()
node.collect(_image(), "a", 1, "run1", 8, 32, 8)
run_dir = tmp_path / "model_compare" / "run1"
notes = run_dir / "01_notes.png"
notes.mkdir()
(notes / "notes.txt").write_text("keep this folder")
node.collect(_image(), "b", 1, "run1", 8, 32, 8)
assert (notes / "notes.txt").read_text() == "keep this folder"
assert (run_dir / "01_b.png").is_file()
assert not (run_dir / "01_a.png").exists()
def test_staging_does_not_expose_temporary_images_to_output_galleries(
self, tmp_path: Path
) -> None:
with _fake_output_dir(tmp_path):
node = ModelCompareSheetNode()
node.collect(_image(), "a", 1, "run1", 8, 32, 8)
base = tmp_path / "model_compare"
run_dir = base / "run1"
temporary_images = []
original_save = Image.Image.save
original_replace = os.replace
def observe_images() -> None:
temporary_images.extend(
path for path in base.rglob("*.png") if path.parent != run_dir
)
def save_and_observe(image, path, *args, **kwargs):
result = original_save(image, path, *args, **kwargs)
observe_images()
return result
def replace_and_observe(source, destination):
result = original_replace(source, destination)
observe_images()
return result
with patch.object(Image.Image, "save", new=save_and_observe):
with patch("os.replace", side_effect=replace_and_observe):
node.collect(_image(batch=2), "b", 1, "run1", 8, 32, 8)
assert temporary_images == []
+4 -4
View File
@@ -187,10 +187,10 @@ class TestGeminiLogic:
assert validate_prompt_type("") is False
assert validate_prompt_type(None) is False
@pytest.mark.skip(reason="Requires google-generativeai library")
@pytest.mark.skip(reason="Requires google-genai library")
def test_analyze_image_with_gemini_success(self):
"""Test successful image analysis with Gemini."""
pass # Skipped as it requires google-generativeai
pass # Skipped as it requires google-genai
def test_analyze_image_no_api_key(self):
"""Test analysis without API key."""
@@ -204,10 +204,10 @@ class TestGeminiLogic:
assert result == ""
assert "API key not found" in error
@pytest.mark.skip(reason="Requires google-generativeai library")
@pytest.mark.skip(reason="Requires google-genai library")
def test_analyze_image_with_custom_prompt(self):
"""Test analysis with custom prompt."""
pass # Skipped as it requires google-generativeai
pass # Skipped as it requires google-genai
class TestPromptTemplates:
+30 -26
View File
@@ -24,17 +24,22 @@ def cache_file(tmp_path, monkeypatch):
def fake_genai(model_specs):
genai = MagicMock()
genai.list_models.return_value = [
genai.Client.return_value.models.list.return_value = [
SimpleNamespace(
name=f"models/{name}",
display_name=display,
supported_generation_methods=methods,
supported_actions=methods,
)
for name, display, methods in model_specs
]
return genai
def sdk_modules(genai):
"""sys.modules patch that makes `from google import genai` return genai."""
return {"google": MagicMock(genai=genai), "google.genai": genai}
class TestSortModels:
def test_newer_versions_come_first(self):
result = models._sort_models(
@@ -111,27 +116,25 @@ class TestCache:
class TestFetchModelsFromApi:
def test_missing_sdk_returns_empty(self):
with patch.dict(sys.modules, {"google.generativeai": None, "google": None}):
with patch.dict(sys.modules, {"google.genai": None, "google": None}):
assert models._fetch_models_from_api("k") == ([], {})
def test_no_api_key_returns_empty(self):
genai = fake_genai([])
google = MagicMock(generativeai=genai)
with (
patch.dict(sys.modules, {"google": google, "google.generativeai": genai}),
patch.dict(sys.modules, sdk_modules(genai)),
patch.object(logic, "get_api_key", return_value=None),
):
assert models._fetch_models_from_api(None, silent=True) == ([], {})
def test_api_key_taken_from_logic_when_not_given(self):
genai = fake_genai([("gemini-2.5-pro", "Pro", ["generateContent"])])
google = MagicMock(generativeai=genai)
with (
patch.dict(sys.modules, {"google": google, "google.generativeai": genai}),
patch.dict(sys.modules, sdk_modules(genai)),
patch.object(logic, "get_api_key", return_value="envkey"),
):
names, _ = models._fetch_models_from_api(None)
genai.configure.assert_called_once_with(api_key="envkey")
genai.Client.assert_called_once_with(api_key="envkey")
assert names == ["gemini-2.5-pro"]
def test_filters_to_generate_content_and_strips_prefix(self):
@@ -142,17 +145,15 @@ class TestFetchModelsFromApi:
("gemini-2.5-flash", "Flash", ["generateContent", "countTokens"]),
]
)
google = MagicMock(generativeai=genai)
with patch.dict(sys.modules, {"google": google, "google.generativeai": genai}):
with patch.dict(sys.modules, sdk_modules(genai)):
names, desc = models._fetch_models_from_api("k")
assert names == ["gemini-2.5-flash", "gemini-1.5-pro"]
assert desc == {"gemini-1.5-pro": "Pro 1.5", "gemini-2.5-flash": "Flash"}
def test_api_error_returns_empty(self):
genai = MagicMock()
genai.list_models.side_effect = RuntimeError("boom")
google = MagicMock(generativeai=genai)
with patch.dict(sys.modules, {"google": google, "google.generativeai": genai}):
genai.Client.return_value.models.list.side_effect = RuntimeError("boom")
with patch.dict(sys.modules, sdk_modules(genai)):
assert models._fetch_models_from_api("k") == ([], {})
@@ -205,20 +206,19 @@ class TestLogicExtra:
def test_missing_sdk_reported(self):
img = np.zeros((1, 4, 4, 3), dtype=np.float32)
with patch.dict(sys.modules, {"google.generativeai": None, "google": None}):
with patch.dict(sys.modules, {"google.genai": None, "google": None}):
out, err = logic.analyze_image_with_gemini(img, "flux", api_key="k")
assert out == "" and "not installed" in err
def _run(self, response=None, error=None, **kwargs):
genai = MagicMock()
model = genai.GenerativeModel.return_value
client_models = genai.Client.return_value.models
if error:
model.generate_content.side_effect = error
client_models.generate_content.side_effect = error
else:
model.generate_content.return_value = response
google = MagicMock(generativeai=genai)
client_models.generate_content.return_value = response
img = np.zeros((1, 4, 4, 3), dtype=np.float32)
with patch.dict(sys.modules, {"google": google, "google.generativeai": genai}):
with patch.dict(sys.modules, sdk_modules(genai)):
result = logic.analyze_image_with_gemini(img, "flux", api_key="k", **kwargs)
return result, genai
@@ -227,24 +227,28 @@ class TestLogicExtra:
SimpleNamespace(text=" hello \n"), model_name="gemini-x"
)
assert (out, err) == ("hello", None)
genai.configure.assert_called_once_with(api_key="k")
genai.GenerativeModel.assert_called_once_with("gemini-x")
genai.Client.assert_called_once_with(api_key="k")
call = genai.Client.return_value.models.generate_content.call_args
assert call.kwargs["model"] == "gemini-x"
def test_custom_prompt_is_sent_as_system_prompt(self):
(_, _), genai = self._run(SimpleNamespace(text="x"), custom_prompt="BE TERSE")
sent = genai.GenerativeModel.return_value.generate_content.call_args[0][0]
sent = genai.Client.return_value.models.generate_content.call_args.kwargs[
"contents"
]
assert sent[0] == "BE TERSE"
def test_unknown_prompt_type_falls_back_to_flux_template(self):
genai = MagicMock()
genai.GenerativeModel.return_value.generate_content.return_value = (
genai.Client.return_value.models.generate_content.return_value = (
SimpleNamespace(text="x")
)
google = MagicMock(generativeai=genai)
img = np.zeros((1, 4, 4, 3), dtype=np.float32)
with patch.dict(sys.modules, {"google": google, "google.generativeai": genai}):
with patch.dict(sys.modules, sdk_modules(genai)):
logic.analyze_image_with_gemini(img, "nonsense", api_key="k")
sent = genai.GenerativeModel.return_value.generate_content.call_args[0][0]
sent = genai.Client.return_value.models.generate_content.call_args.kwargs[
"contents"
]
assert sent[0] == logic.PROMPT_TEMPLATES["flux"]
def test_empty_response(self):
+2 -1
View File
@@ -138,7 +138,8 @@ class TestNode:
def test_metadata(self):
assert ProjectFilePathNode.RETURN_TYPES == ("STRING",)
assert ProjectFilePathNode.RETURN_NAMES == ("path",)
# No custom name: ComfyUI labels the output "STRING", like djz-nodes
assert not hasattr(ProjectFilePathNode, "RETURN_NAMES")
assert ProjectFilePathNode.CATEGORY == "🫶 ComfyAssets/🛠️ Utils"
def test_generate_path(self):
+23
View File
@@ -0,0 +1,23 @@
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { randomUUID } from 'node:crypto';
import { fileURLToPath } from 'node:url';
const repositoryRoot = fileURLToPath(new URL('../../', import.meta.url));
export async function startCoverage(page, browserName) {
if (process.env.KIKO_JS_COVERAGE && browserName === 'chromium') {
await page.coverage.startJSCoverage({ resetOnNavigation: false });
}
}
export async function saveCoverage(page, browserName) {
if (!process.env.KIKO_JS_COVERAGE || browserName !== 'chromium') return;
const entries = await page.coverage.stopJSCoverage();
const result = [];
for (const entry of entries) {
const match = entry.url.match(/\/extensions\/kikotools\/((?:[a-z0-9_-]+\/)*[a-z0-9_-]+\.(?:js|mjs))(?:\?.*)?$/i);
if (match) result.push({ ...entry, url: pathToFileURL(join(repositoryRoot, 'web', match[1])).href });
}
mkdirSync(process.env.KIKO_JS_COVERAGE, { recursive: true });
writeFileSync(join(process.env.KIKO_JS_COVERAGE, `browser-${randomUUID()}.json`), JSON.stringify({ result }));
}
+23
View File
@@ -0,0 +1,23 @@
import { defineConfig } from './node_modules/@playwright/test/index.mjs';
import { fileURLToPath } from 'node:url';
if (!process.env.KIKO_COMFY_URL) throw new Error('Set KIKO_COMFY_URL to a local ComfyUI instance');
const host = new URL(process.env.KIKO_COMFY_URL);
if (!['127.0.0.1', 'localhost', '[::1]'].includes(host.hostname)) throw new Error('The ComfyUI smoke suite only accepts a loopback host');
export default defineConfig({
testDir: '../../tests/comfyui',
outputDir: './test-results/comfyui',
timeout: 60000,
workers: 1,
forbidOnly: Boolean(process.env.CI),
retries: 0,
use: { baseURL: host.href, browserName: 'chromium', viewport: { width: 1600, height: 1100 }, trace: 'retain-on-failure', screenshot: 'only-on-failure' },
webServer: {
command: 'node tools/testing/start-browser-server.mjs --port 8771',
cwd: fileURLToPath(new URL('../../', import.meta.url)),
url: 'http://127.0.0.1:8771/__test__/',
reuseExistingServer: false,
gracefulShutdown: { signal: 'SIGTERM', timeout: 5000 },
},
});
@@ -0,0 +1,122 @@
{
"display_any.js": {
"lines": 84,
"branches": 66,
"functions": 83,
"statements": 84
},
"display_text.js": {
"lines": 71,
"branches": 74,
"functions": 42,
"statements": 71
},
"empty_latent_batch_swap.js": {
"lines": 41,
"branches": 91,
"functions": 64,
"statements": 41
},
"gemini_prompt_ui.js": {
"lines": 52,
"branches": 85,
"functions": 42,
"statements": 52
},
"kikoEmbeddingAutocomplete.js": {
"lines": 44,
"branches": 43,
"functions": 59,
"statements": 44
},
"kiko_custom_colors.js": {
"lines": 46,
"branches": 98,
"functions": 8,
"statements": 46
},
"kiko_follow_execution.js": {
"lines": 60,
"branches": 79,
"functions": 73,
"statements": 60
},
"kiko_local_image_loader.js": {
"lines": 77,
"branches": 73,
"functions": 70,
"statements": 77
},
"kiko_save_image_ui.js": {
"lines": 84,
"branches": 74,
"functions": 78,
"statements": 84
},
"kiko_workflow_timer_ui.js": {
"lines": 98,
"branches": 78,
"functions": 88,
"statements": 98
},
"kikotools_extensions.js": {
"lines": 12,
"branches": 98,
"functions": 0,
"statements": 12
},
"seed_history_ui.js": {
"lines": 95,
"branches": 85,
"functions": 88,
"statements": 95
},
"ui/color-picker.js": {
"lines": 98,
"branches": 94,
"functions": 98,
"statements": 98
},
"ui/color.js": {
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"ui/core.js": {
"lines": 98,
"branches": 92,
"functions": 98,
"statements": 98
},
"ui/floating-window.js": {
"lines": 93,
"branches": 80,
"functions": 98,
"statements": 93
},
"ui/workflow-clock.js": {
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"views/image-browser.js": {
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"views/seed-history.js": {
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"width_height_swap.js": {
"lines": 60,
"branches": 79,
"functions": 73,
"statements": 60
}
}
+81
View File
@@ -0,0 +1,81 @@
import { readFileSync, readdirSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join, relative, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { spawnSync } from 'node:child_process';
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const metrics = ['lines', 'branches', 'functions', 'statements'];
export function discoverSources(root) {
return readdirSync(root, { recursive: true, withFileTypes: true })
.filter(entry => entry.isFile() && /\.(?:js|mjs)$/.test(entry.name))
.map(entry => relative(root, join(entry.parentPath, entry.name)).replaceAll('\\', '/'))
.sort();
}
export function checkCoverage(summary, baseline, sourceRoot, files) {
const failures = [];
for (const file of files) {
const floor = baseline[file];
if (!floor) { failures.push(`Missing baseline for ${file}`); continue; }
const measured = summary[join(sourceRoot, file)];
if (!measured) { failures.push(`Missing coverage for ${file}`); continue; }
for (const metric of metrics) {
if (!Number.isFinite(floor[metric]) || floor[metric] < 0 || floor[metric] > 100) {
throw new Error(`Invalid baseline for ${file}: ${metric}`);
}
if (!Number.isFinite(measured[metric]?.pct) || measured[metric].pct < floor[metric]) {
failures.push(`${file} ${metric}: ${measured[metric]?.pct ?? 'missing'} < ${floor[metric]}`);
}
}
}
return failures;
}
function run(args, env = {}) {
const result = spawnSync(process.execPath, args, { cwd: repositoryRoot, stdio: 'inherit',
env: { ...process.env, ...env } });
if (result.status !== 0) throw new Error(`Command failed (${result.status}): ${args.join(' ')}`);
}
async function main() {
const tools = dirname(fileURLToPath(import.meta.url));
const reportRoot = join(repositoryRoot, 'coverage/js');
const raw = join(reportRoot, 'raw');
const sourceRoot = join(repositoryRoot, 'web');
const files = discoverSources(sourceRoot);
rmSync(reportRoot, { recursive: true, force: true });
mkdirSync(raw, { recursive: true });
const tests = readdirSync(join(repositoryRoot, 'tests/js')).filter(file => file.endsWith('.test.mjs')).map(file => `tests/js/${file}`);
run(['--test', ...tests], { NODE_V8_COVERAGE: raw });
const playwright = join(tools, 'node_modules/@playwright/test/cli.js');
run([playwright, 'test', '--config', join(tools, 'playwright.config.mjs'),
'--project', 'chromium', '--project', 'touch'], { KIKO_JS_COVERAGE: raw });
run([join(tools, 'node_modules/c8/bin/c8.js'), 'report', '--all', '--src', sourceRoot,
'--include', 'web/**/*.js', '--include', 'web/**/*.mjs', '--temp-directory', raw, '--reports-dir', reportRoot,
'--reporter', 'text', '--reporter', 'json-summary', '--reporter', 'html']);
const summary = JSON.parse(readFileSync(join(reportRoot, 'coverage-summary.json'), 'utf8'));
const baselinePath = join(tools, 'frontend-coverage-baseline.json');
if (process.argv.includes('--update-baseline')) {
const previous = JSON.parse(readFileSync(baselinePath, 'utf8'));
const failures = checkCoverage(summary, previous, sourceRoot, files.filter(file => previous[file]));
if (failures.length) throw new Error(`Baseline updates cannot lower coverage:\n${failures.join('\n')}`);
const next = Object.fromEntries(files.map(file => {
const measured = summary[join(sourceRoot, file)];
if (!measured || metrics.some(metric => !Number.isFinite(measured[metric]?.pct))) {
throw new Error(`Missing coverage for ${file}`);
}
if (Object.keys(previous).length && !previous[file] && measured.lines.pct < 80) {
throw new Error(`New frontend files need at least 80% line coverage: ${file}`);
}
return [file, Object.fromEntries(metrics.map(metric => [metric, Math.max(previous[file]?.[metric] || 0, Math.floor(measured[metric].pct) - 2, 0)]))];
}));
writeFileSync(baselinePath, JSON.stringify(next, null, 2) + '\n');
} else {
const baseline = JSON.parse(readFileSync(baselinePath, 'utf8'));
const failures = checkCoverage(summary, baseline, sourceRoot, files);
if (failures.length) throw new Error(`Frontend coverage regressed:\n${failures.join('\n')}`);
}
console.log(`Frontend coverage passed for ${files.length} files. Report: ${relative(repositoryRoot, reportRoot)}/index.html`);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch(error => { console.error(error.message); process.exitCode = 1; });
}
+4
View File
@@ -0,0 +1,4 @@
export function isFixtureURL(raw, baseURL) {
const url = new URL(raw);
return url.origin === new URL(baseURL).origin && ['http:', 'https:', 'blob:'].includes(url.protocol);
}
+785
View File
@@ -0,0 +1,785 @@
{
"name": "kikotools-test-tools",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "kikotools-test-tools",
"devDependencies": {
"@playwright/test": "1.64.1",
"c8": "12.0.0"
},
"engines": {
"node": ">=24"
}
},
"node_modules/@bcoe/v8-coverage": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@istanbuljs/schema": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz",
"integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@playwright/test": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz",
"integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.64.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
"integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==",
"dev": true,
"license": "MIT"
},
"node_modules/ansi-regex": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz",
"integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/brace-expansion": {
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/c8": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/c8/-/c8-12.0.0.tgz",
"integrity": "sha512-4zpJvrd1nKWutnnKC2pXkFmb6iM1l+ffN//o1CzlTNwW7GSOs9a1xrLqkC48nU8oEkjmPZLPiwMsIaOvoF4Pqg==",
"dev": true,
"license": "ISC",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.1",
"@istanbuljs/schema": "^0.1.3",
"find-up": "^5.0.0",
"foreground-child": "^3.1.1",
"istanbul-lib-coverage": "^3.2.0",
"istanbul-lib-report": "^3.0.1",
"istanbul-reports": "^3.1.6",
"test-exclude": "^8.0.0",
"v8-to-istanbul": "^9.0.0",
"yargs": "^18.0.0",
"yargs-parser": "^21.1.1"
},
"bin": {
"c8": "bin/c8.js"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=23"
},
"peerDependencies": {
"monocart-coverage-reports": "^2"
},
"peerDependenciesMeta": {
"monocart-coverage-reports": {
"optional": true
}
}
},
"node_modules/cliui": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
"integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^7.2.0",
"strip-ansi": "^7.1.0",
"wrap-ansi": "^9.0.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/cliui/node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^10.3.0",
"get-east-asian-width": "^1.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true,
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/emoji-regex": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
"dev": true,
"license": "MIT"
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/find-up": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
"dev": true,
"license": "MIT",
"dependencies": {
"locate-path": "^6.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/foreground-child": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
"dev": true,
"license": "ISC",
"dependencies": {
"cross-spawn": "^7.0.6",
"signal-exit": "^4.0.1"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-east-asian-width": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz",
"integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/glob": {
"version": "13.0.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"minimatch": "^10.2.2",
"minipass": "^7.1.3",
"path-scurry": "^2.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true,
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=8"
}
},
"node_modules/istanbul-lib-report": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"istanbul-lib-coverage": "^3.0.0",
"make-dir": "^4.0.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-reports": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"html-escaper": "^2.0.0",
"istanbul-lib-report": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-locate": "^5.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lru-cache": {
"version": "11.5.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
"integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.5.3"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimatch": {
"version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.8"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minipass": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"yocto-queue": "^0.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-limit": "^3.0.2"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-scurry": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
"integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^11.0.0",
"minipass": "^7.1.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/playwright": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.64.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/string-width": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz",
"integrity": "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"get-east-asian-width": "^1.5.0",
"strip-ansi": "^7.1.2"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/strip-ansi": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.2.2"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/test-exclude": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz",
"integrity": "sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"@istanbuljs/schema": "^0.1.2",
"glob": "^13.0.6",
"minimatch": "^10.2.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/v8-to-istanbul": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
"integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==",
"dev": true,
"license": "ISC",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.12",
"@types/istanbul-lib-coverage": "^2.0.1",
"convert-source-map": "^2.0.0"
},
"engines": {
"node": ">=10.12.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/wrap-ansi": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
"string-width": "^7.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi/node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^10.3.0",
"get-east-asian-width": "^1.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yargs": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.2.0.tgz",
"integrity": "sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^9.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"string-width": "^8.2.1",
"y18n": "^5.0.5",
"yargs-parser": "^22.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=23"
}
},
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/yargs/node_modules/yargs-parser": {
"version": "22.0.0",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
"integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
"dev": true,
"license": "ISC",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=23"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "kikotools-test-tools",
"private": true,
"type": "module",
"engines": { "node": ">=24" },
"scripts": {
"test": "node --test ../../tests/js/*.test.mjs",
"test:browser": "playwright test --config playwright.config.mjs",
"test:coverage": "node frontend-coverage.mjs",
"coverage:update": "node frontend-coverage.mjs --update-baseline"
},
"devDependencies": { "@playwright/test": "1.64.1", "c8": "12.0.0" }
}
+27
View File
@@ -0,0 +1,27 @@
import { defineConfig, devices } from '@playwright/test';
import { fileURLToPath } from 'node:url';
const repositoryRoot = fileURLToPath(new URL('../../', import.meta.url));
const port = Number(process.env.KIKO_TEST_PORT || 8766);
export default defineConfig({
testDir: '../../tests/browser',
outputDir: './test-results',
workers: 1,
fullyParallel: false,
forbidOnly: Boolean(process.env.CI),
retries: 0,
reporter: 'list',
use: { baseURL: `http://127.0.0.1:${port}`, trace: 'retain-on-failure', screenshot: 'only-on-failure' },
webServer: {
command: `node tools/testing/start-browser-server.mjs --port ${port}`,
cwd: repositoryRoot,
url: `http://127.0.0.1:${port}/__test__/`,
reuseExistingServer: false,
gracefulShutdown: { signal: 'SIGTERM', timeout: 5000 },
},
projects: [
{ name: 'chromium', use: { browserName: 'chromium' } },
{ name: 'firefox', use: { browserName: 'firefox' } },
{ name: 'webkit', use: { browserName: 'webkit' } },
{ name: 'touch', use: { ...devices['Pixel 7'], browserName: 'chromium' } },
],
});
+8
View File
@@ -0,0 +1,8 @@
import { spawn } from 'node:child_process';
const child = spawn(process.env.KIKO_TEST_PYTHON || 'python',
['-m', 'tests.browser.server', ...process.argv.slice(2)], { stdio: 'inherit' });
child.on('error', error => { console.error(error.message); process.exitCode = 1; });
child.on('exit', code => { process.exitCode = code ?? 1; });
for (const signal of ['SIGTERM', 'SIGINT']) {
process.once(signal, () => child.kill(signal));
}
+78 -507
View File
@@ -1,5 +1,7 @@
import { app } from "/scripts/app.js";
import { api } from "/scripts/api.js";
import { createScope } from "./ui/core.js";
import { createImageBrowser } from "./views/image-browser.js";
// Escape a value for safe interpolation into HTML text or attribute contexts
function escapeHtml(value) {
@@ -29,131 +31,6 @@ async function describeHttpError(response) {
return detail || `Request failed (HTTP ${response.status})`;
}
// Setup global lightbox for image preview
function setupGlobalLightbox() {
if (document.getElementById('kiko-image-lightbox')) return;
const lightboxId = 'kiko-image-lightbox';
const lightboxHTML = `
<div id="${lightboxId}" class="lightbox-overlay">
<button class="lightbox-close">&times;</button>
<button class="lightbox-prev">&lt;</button>
<button class="lightbox-next">&gt;</button>
<div class="lightbox-content">
<img src="" alt="Preview" style="display: none;">
<video src="" controls autoplay style="display: none;"></video>
<audio src="" controls autoplay style="display: none;"></audio>
</div>
<div class="lightbox-dimensions"></div>
</div>
`;
const lightboxCSS = `
#${lightboxId} {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.85);
display: none;
align-items: center;
justify-content: center;
z-index: 10000;
box-sizing: border-box;
-webkit-user-select: none;
user-select: none;
}
#${lightboxId} .lightbox-content {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
#${lightboxId} img, #${lightboxId} video {
max-width: 95%;
max-height: 95%;
object-fit: contain;
transition: transform 0.1s ease-out;
transform: scale(1) translate(0, 0);
}
#${lightboxId} audio {
width: 80%;
max-width: 600px;
}
#${lightboxId} img {
cursor: grab;
}
#${lightboxId} img.panning {
cursor: grabbing;
}
#${lightboxId} .lightbox-close {
position: absolute;
top: 15px;
right: 20px;
width: 35px;
height: 35px;
background-color: rgba(0,0,0,0.5);
color: #fff;
border-radius: 50%;
border: 2px solid #fff;
font-size: 24px;
line-height: 30px;
text-align: center;
cursor: pointer;
z-index: 10002;
}
#${lightboxId} .lightbox-prev, #${lightboxId} .lightbox-next {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 45px;
height: 60px;
background-color: rgba(0,0,0,0.4);
color: #fff;
border: none;
font-size: 30px;
cursor: pointer;
z-index: 10001;
transition: background-color 0.2s;
}
#${lightboxId} .lightbox-prev:hover, #${lightboxId} .lightbox-next:hover {
background-color: rgba(0,0,0,0.7);
}
#${lightboxId} .lightbox-prev {
left: 15px;
}
#${lightboxId} .lightbox-next {
right: 15px;
}
#${lightboxId} [disabled] {
display: none;
}
#${lightboxId} .lightbox-dimensions {
position: absolute;
bottom: 0px;
left: 50%;
transform: translateX(-50%);
background-color: rgba(0, 0, 0, 0.7);
color: #fff;
padding: 2px 4px;
border-radius: 5px;
font-size: 14px;
z-index: 10001;
}
`;
document.body.insertAdjacentHTML('beforeend', lightboxHTML);
const styleEl = document.createElement('style');
styleEl.textContent = lightboxCSS;
document.head.appendChild(styleEl);
}
setupGlobalLightbox();
app.registerExtension({
name: "KikoTools.LocalImageLoader",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
@@ -162,315 +39,30 @@ app.registerExtension({
nodeType.prototype.onNodeCreated = function () {
const r = onNodeCreated?.apply(this, arguments);
const galleryContainer = document.createElement("div");
const scope = createScope();
const uniqueId = `kiko-gallery-${Math.random().toString(36).substring(2, 9)}`;
const galleryContainer = createImageBrowser(uniqueId, app.ui.settings, scope);
galleryContainer.id = uniqueId;
const folderSVG = `<svg viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="100%" height="100%"><path d="M928 320H488L416 232c-15.1-18.9-38.3-29.9-63.1-29.9H128c-35.3 0-64 28.7-64 64v512c0 35.3 28.7 64 64 64h800c35.3 0 64-28.7 64-64V384c0-35.3-28.7-64-64-64z" fill="#F4D03F"></path></svg>`;
const videoSVG = `<svg viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="100%" height="100%"><path d="M895.9 203.4H128.1c-35.3 0-64 28.7-64 64v489.2c0 35.3 28.7 64 64 64h767.8c35.3 0 64-28.7 64-64V267.4c0-35.3-28.7-64-64-64zM384 691.2V332.8L668.1 512 384 691.2z" fill="#AED6F1"></path></svg>`;
const audioSVG = `<svg viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="100%" height="100%"><path d="M768 256H256c-35.3 0-64 28.7-64 64v384c0 35.3 28.7 64 64 64h512c35.3 0 64-28.7 64-64V320c0-35.3-28.7-64-64-64zM512 665.6c-84.8 0-153.6-68.8-153.6-153.6S427.2 358.4 512 358.4s153.6 68.8 153.6 153.6-68.8 153.6-153.6 153.6z" fill="#A9DFBF"></path><path d="M512 409.6c-56.5 0-102.4 45.9-102.4 102.4s45.9 102.4 102.4 102.4 102.4-45.9 102.4-102.4-45.9-102.4-102.4-102.4z" fill="#A9DFBF"></path></svg>`;
galleryContainer.innerHTML = `
<style>
#${uniqueId} {
width: 100%;
height: 100%;
overflow: hidden;
box-sizing: border-box;
}
#${uniqueId} .kiko-container-wrapper {
width: 100%;
height: 100%;
font-family: sans-serif;
color: #ccc;
box-sizing: border-box;
display: flex;
flex-direction: column;
padding: 5px;
overflow: hidden;
}
#${uniqueId} .kiko-controls {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-bottom: 5px;
align-items: center;
flex-shrink: 0;
}
#${uniqueId} .kiko-controls label {
margin-left: 0px;
font-size: 11px;
white-space: nowrap;
}
#${uniqueId} .kiko-controls input,
#${uniqueId} .kiko-controls select,
#${uniqueId} .kiko-controls button {
background-color: #333;
color: #ccc;
border: 1px solid #555;
border-radius: 4px;
padding: 2px 4px;
font-size: 11px;
}
#${uniqueId} .kiko-controls input[type=text] {
flex-grow: 1;
min-width: 100px;
}
#${uniqueId} .kiko-path-controls {
flex-grow: 1;
display: flex;
gap: 3px;
}
#${uniqueId} .kiko-path-presets {
flex-grow: 1;
}
#${uniqueId} .kiko-controls button {
cursor: pointer;
}
#${uniqueId} .kiko-controls button:hover {
background-color: #444;
}
#${uniqueId} .kiko-controls button:disabled {
background-color: #222;
cursor: not-allowed;
}
#${uniqueId} .kiko-cardholder {
position: relative;
overflow-y: auto;
overflow-x: hidden;
background: #222;
padding: 3px;
border-radius: 5px;
flex-grow: 1;
flex-shrink: 1;
min-height: 200px;
width: 100%;
transition: opacity 0.2s ease-in-out;
}
#${uniqueId} .kiko-gallery-card {
position: absolute;
border: 3px solid transparent;
border-radius: 8px;
box-sizing: border-box;
transition: all 0.3s ease;
cursor: pointer;
display: flex;
flex-direction: column;
background-color: #2a2a2a;
}
#${uniqueId} .kiko-gallery-card.kiko-selected {
border-color: #00FFC9;
}
#${uniqueId} .kiko-card-media-wrapper {
flex-grow: 1;
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-height: 100px;
}
#${uniqueId} .kiko-gallery-card img,
#${uniqueId} .kiko-gallery-card video {
width: 100%;
height: auto;
border-top-left-radius: 5px;
border-top-right-radius: 5px;
display: block;
}
#${uniqueId} .kiko-folder-card,
#${uniqueId} .kiko-audio-card {
background-color: transparent;
flex-grow: 1;
padding: 10px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}
#${uniqueId} .kiko-folder-card:hover,
#${uniqueId} .kiko-audio-card:hover {
background-color: #444;
}
#${uniqueId} .kiko-parent-folder-card {
background-color: transparent;
}
#${uniqueId} .kiko-parent-folder-card:hover {
background-color: #555;
}
#${uniqueId} .kiko-folder-icon,
#${uniqueId} .kiko-audio-icon {
width: 60%;
height: 60%;
margin-bottom: 8px;
}
#${uniqueId} .kiko-folder-name,
#${uniqueId} .kiko-audio-name {
font-size: 12px;
word-break: break-all;
user-select: none;
}
#${uniqueId} .kiko-video-card-overlay {
position: absolute;
top: 5px;
left: 5px;
width: 24px;
height: 24px;
opacity: 0.8;
pointer-events: none;
}
#${uniqueId} .kiko-card-info-panel {
flex-shrink: 0;
background-color: #353535;
padding: 4px;
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
min-height: 24px;
font-size: 10px;
text-align: center;
color: #aaa;
}
#${uniqueId} .kiko-pagination {
display: flex;
justify-content: center;
gap: 3px;
margin-top: 3px;
flex-shrink: 0;
padding-bottom: 2px;
}
#${uniqueId} .kiko-status-message {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: rgba(0, 0, 0, 0.8);
color: white;
padding: 10px;
border-radius: 5px;
display: none;
z-index: 1000;
}
#${uniqueId} .kiko-path-input-wrapper {
position: relative;
flex-grow: 1;
min-width: 100px;
}
#${uniqueId} .kiko-path-input-wrapper input {
width: 100%;
box-sizing: border-box;
}
#${uniqueId} .kiko-autocomplete-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
max-height: 250px;
overflow-y: auto;
background-color: #2a2a2a;
border: 1px solid #555;
border-top: none;
border-radius: 0 0 4px 4px;
z-index: 2000;
display: none;
}
#${uniqueId} .kiko-autocomplete-dropdown.visible {
display: block;
}
#${uniqueId} .kiko-autocomplete-item {
padding: 6px 8px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
border-bottom: 1px solid #333;
}
#${uniqueId} .kiko-autocomplete-item:last-child {
border-bottom: none;
}
#${uniqueId} .kiko-autocomplete-item:hover,
#${uniqueId} .kiko-autocomplete-item.selected {
background-color: #3a3a3a;
}
#${uniqueId} .kiko-autocomplete-item .folder-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
#${uniqueId} .kiko-autocomplete-item .path-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#${uniqueId} .kiko-autocomplete-item .folder-name {
color: #fff;
}
#${uniqueId} .kiko-autocomplete-item .parent-path {
color: #888;
font-size: 10px;
}
</style>
<div class="kiko-container-wrapper">
<!-- Path controls -->
<div class="kiko-controls">
<div class="kiko-path-controls">
<select class="kiko-path-presets" title="Saved paths">
<option value="">-- Saved Paths --</option>
</select>
<button class="kiko-save-path" title="Save current path to favorites">💾</button>
<button class="kiko-delete-path" title="Remove selected path from favorites">✕</button>
</div>
</div>
<!-- Directory input -->
<div class="kiko-controls">
<button class="kiko-up-folder" title="Navigate to parent folder">⬆️</button>
<div class="kiko-path-input-wrapper">
<input type="text" class="kiko-path-input" placeholder="Enter directory path... (type / to browse)" />
<div class="kiko-autocomplete-dropdown"></div>
</div>
<button class="kiko-browse" title="Refresh folder">🔄</button>
</div>
<!-- View options -->
<div class="kiko-controls">
<label>
<input type="checkbox" class="kiko-hide-dot-folders" checked /> Hide .folders
</label>
<select class="kiko-sort-by">
<option value="name">Name</option>
<option value="date">Date</option>
<option value="size">Size</option>
</select>
<select class="kiko-sort-order">
<option value="asc">↑</option>
<option value="desc">↓</option>
</select>
<button class="kiko-refresh">🔄</button>
</div>
<!-- Gallery -->
<div class="kiko-cardholder">
<div class="kiko-status-message">Loading...</div>
</div>
<!-- Pagination -->
<div class="kiko-pagination"></div>
</div>
`;
// Add the widget to the node
this.galleryWidget = this.addDOMWidget("kiko_local_image_gallery", "div", galleryContainer, {
serialize: false,
hideOnZoom: false,
getMinHeight: () => 350,
});
// Set initial size for the node
this.size = [800, 600];
this.size = [800, 900];
this.setSize(this.size);
// Initialize gallery functionality
const node = this;
const fetchApi = (path, options = {}) => api.fetchApi(path, { ...options, signal: scope.signal });
let loadVersion = 0;
const container = galleryContainer.querySelector('.kiko-container-wrapper');
const pathInput = container.querySelector('.kiko-path-input');
const pathPresets = container.querySelector('.kiko-path-presets');
@@ -498,61 +90,19 @@ app.registerExtension({
audio: null
};
// Utility: Debounce function
const debounce = (func, delay) => {
let timeoutId;
return (...args) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func.apply(this, args), delay);
};
};
// Apply responsive masonry layout
const applyMasonryLayout = () => {
const minCardWidth = 120;
const gap = 5;
const containerWidth = cardHolder.clientWidth - 10; // Account for padding
if (containerWidth <= 0) return;
const columnCount = Math.max(1, Math.floor(containerWidth / (minCardWidth + gap)));
const totalGapSpace = (columnCount - 1) * gap;
const actualCardWidth = Math.floor((containerWidth - totalGapSpace) / columnCount);
const columnHeights = new Array(columnCount).fill(0);
const cards = cardHolder.querySelectorAll('.kiko-gallery-card');
cards.forEach(card => {
card.style.width = `${actualCardWidth}px`;
const minHeight = Math.min(...columnHeights);
const columnIndex = columnHeights.indexOf(minHeight);
card.style.left = `${columnIndex * (actualCardWidth + gap)}px`;
card.style.top = `${minHeight}px`;
columnHeights[columnIndex] += card.offsetHeight + gap;
});
// Set container height - let scrollbar handle overflow
const maxHeight = Math.max(...columnHeights);
if (maxHeight > 0) {
cardHolder.style.minHeight = `${Math.min(200, maxHeight)}px`;
}
};
const debouncedLayout = debounce(applyMasonryLayout, 20);
// Set up ResizeObserver for responsive layout
this.kikoResizeObserver = new ResizeObserver(debouncedLayout);
this.kikoResizeObserver.observe(cardHolder);
const debounce = scope.debounce;
const originalOnRemoved = this.onRemoved;
this.onRemoved = function () {
if (this.kikoResizeObserver) {
this.kikoResizeObserver.disconnect();
this.kikoResizeObserver = null;
}
scope.dispose();
return originalOnRemoved?.apply(this, arguments);
};
scope.cleanup(() => { galleryContainer.remove(); });
scope.listen(container.querySelector('.ca-file-filter'), 'input', renderGallery);
const showStatus = (message) => {
if (scope.signal.aborted) return;
statusMessage.style.display = 'block';
statusMessage.dataset.state = 'error';
statusMessage.textContent = message;
};
@@ -563,7 +113,7 @@ app.registerExtension({
async function fetchDirectorySuggestions(path) {
try {
const response = await api.fetchApi(`/kiko_local_image_loader/list_directories?path=${encodeURIComponent(path)}`);
const response = await fetchApi(`/kiko_local_image_loader/list_directories?path=${encodeURIComponent(path)}`);
if (!response.ok) {
// Disallowed or invalid paths simply yield no suggestions
return [];
@@ -571,7 +121,7 @@ app.registerExtension({
const data = await response.json();
return data.directories || [];
} catch (error) {
console.error('Error fetching directories:', error);
if (!scope.signal.aborted) console.error('Error fetching directories:', error);
return [];
}
}
@@ -640,10 +190,13 @@ app.registerExtension({
});
}
let suggestionVersion = 0;
const debouncedAutocomplete = debounce(async (value) => {
const version = ++suggestionVersion;
const suggestions = await fetchDirectorySuggestions(value);
renderAutocompleteSuggestions(suggestions);
if (!scope.signal.aborted && version === suggestionVersion && pathInput.value === value && document.activeElement === pathInput) renderAutocompleteSuggestions(suggestions);
}, 150);
const delayedHideAutocomplete = debounce(hideAutocomplete, 150);
// Path input event handlers for autocomplete
pathInput.addEventListener('input', (e) => {
@@ -664,7 +217,7 @@ app.registerExtension({
pathInput.addEventListener('blur', () => {
// Delay hiding to allow click events to register
setTimeout(hideAutocomplete, 150);
delayedHideAutocomplete();
});
pathInput.addEventListener('keydown', (e) => {
@@ -714,11 +267,12 @@ app.registerExtension({
// Load saved paths
async function loadSavedPaths() {
try {
const response = await api.fetchApi('/kiko_local_image_loader/get_saved_paths');
const response = await fetchApi('/kiko_local_image_loader/get_saved_paths');
if (!response.ok) {
throw new Error(await describeHttpError(response));
}
const data = await response.json();
if (scope.signal.aborted) return;
pathPresets.innerHTML = '<option value="">-- Saved Paths --</option>';
data.saved_paths?.forEach(path => {
@@ -729,7 +283,7 @@ app.registerExtension({
pathPresets.appendChild(option);
});
} catch (error) {
console.error('Error loading saved paths:', error);
if (!scope.signal.aborted) console.error('Error loading saved paths:', error);
}
}
@@ -738,7 +292,7 @@ app.registerExtension({
// Returns true on success.
async function addSavedPath(directory) {
try {
const response = await api.fetchApi('/kiko_local_image_loader/get_saved_paths');
const response = await fetchApi('/kiko_local_image_loader/get_saved_paths');
if (!response.ok) {
throw new Error(await describeHttpError(response));
}
@@ -746,7 +300,7 @@ app.registerExtension({
const savedPaths = data.saved_paths || [];
if (!savedPaths.includes(directory)) {
const saveResponse = await api.fetchApi('/kiko_local_image_loader/save_paths', {
const saveResponse = await fetchApi('/kiko_local_image_loader/save_paths', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paths: [...savedPaths, directory] })
@@ -758,6 +312,7 @@ app.registerExtension({
}
return true;
} catch (error) {
if (scope.signal.aborted) return false;
console.error('Error saving path:', error);
showStatus(`Error saving path: ${error.message}`);
return false;
@@ -776,7 +331,7 @@ app.registerExtension({
if (!selectedPath) return;
try {
const response = await api.fetchApi('/kiko_local_image_loader/get_saved_paths');
const response = await fetchApi('/kiko_local_image_loader/get_saved_paths');
if (!response.ok) {
throw new Error(await describeHttpError(response));
}
@@ -785,7 +340,7 @@ app.registerExtension({
const updatedPaths = savedPaths.filter(p => p !== selectedPath);
if (updatedPaths.length !== savedPaths.length) {
const saveResponse = await api.fetchApi('/kiko_local_image_loader/save_paths', {
const saveResponse = await fetchApi('/kiko_local_image_loader/save_paths', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paths: updatedPaths })
@@ -796,6 +351,7 @@ app.registerExtension({
await loadSavedPaths();
}
} catch (error) {
if (scope.signal.aborted) return;
console.error('Error deleting path:', error);
showStatus(`Error deleting path: ${error.message}`);
}
@@ -819,7 +375,9 @@ app.registerExtension({
pathLabel.textContent = directory;
const addBtn = document.createElement('button');
addBtn.textContent = 'Add folder & open';
addBtn.style.marginLeft = '8px';
addBtn.type = 'button';
addBtn.className = 'ca-primary';
statusMessage.dataset.state = 'error';
addBtn.onclick = async () => {
addBtn.disabled = true;
// On failure addSavedPath shows the server's reason
@@ -832,7 +390,9 @@ app.registerExtension({
// Load images from directory
async function loadImages(directory, page = 1) {
if (!directory) return;
if (!directory || scope.signal.aborted) return;
const version = ++loadVersion;
statusMessage.dataset.state = 'loading';
statusMessage.style.display = 'block';
statusMessage.textContent = 'Loading...';
@@ -849,19 +409,22 @@ app.registerExtension({
});
try {
const response = await api.fetchApi(`/kiko_local_image_loader/images?${params}`);
const response = await fetchApi(`/kiko_local_image_loader/images?${params}`);
if (scope.signal.aborted || version !== loadVersion) return;
if (response.status === 403) {
handleFolderNotAllowed(directory, page);
return;
}
if (!response.ok) {
statusMessage.textContent = await describeHttpError(response);
const message = await describeHttpError(response);
if (version === loadVersion) showStatus(message);
return;
}
const data = await response.json();
if (scope.signal.aborted || version !== loadVersion) return;
if (data.error) {
statusMessage.textContent = data.error;
showStatus(data.error);
return;
}
@@ -879,8 +442,9 @@ app.registerExtension({
statusMessage.style.display = 'none';
} catch (error) {
if (scope.signal.aborted || version !== loadVersion) return;
console.error('Error loading images:', error);
statusMessage.textContent = 'Error loading directory';
showStatus('Error loading directory');
}
}
@@ -888,18 +452,24 @@ app.registerExtension({
function renderGallery() {
cardHolder.innerHTML = '';
if (currentItems.length === 0 && !parentDirectory) {
cardHolder.innerHTML = '<div style="text-align: center; padding: 20px; color: #666;">No items found</div>';
return;
}
// Create items array with parent folder at the beginning if it exists
const query = container.querySelector('.ca-file-filter').value.toLowerCase();
const visibleItems = currentItems.filter(item => item.name.toLowerCase().includes(query));
if (visibleItems.length === 0) {
const empty = document.createElement('div');
empty.className = 'ca-empty';
empty.textContent = query ? 'No matching items on this page' : 'No items found';
cardHolder.append(empty);
}
const itemsToRender = parentDirectory
? [{ type: 'parent_dir', name: '..', path: parentDirectory }, ...currentItems]
: currentItems;
? [{ type: 'parent_dir', name: '..', path: parentDirectory }, ...visibleItems]
: visibleItems;
itemsToRender.forEach((item, index) => {
const card = document.createElement('div');
const card = document.createElement('button');
card.type = 'button';
card.setAttribute('aria-label', item.type === 'dir' || item.type === 'parent_dir' ? `Open folder ${item.name}` : `Select ${item.type} ${item.name}`);
if (!['dir', 'parent_dir'].includes(item.type)) card.setAttribute('aria-pressed', String(selectedPaths[item.type] === item.path));
card.className = 'kiko-gallery-card';
if (item.type === 'parent_dir') {
@@ -938,10 +508,6 @@ app.registerExtension({
`;
// Trigger layout when image loads
const img = card.querySelector('img');
if (img) {
img.onload = debouncedLayout;
}
// Single click to select
card.onclick = () => selectMedia(item, 'image', card);
@@ -963,10 +529,6 @@ app.registerExtension({
<div class="kiko-card-info-panel">${escapeHtml(item.name)}</div>
`;
const img = card.querySelector('img');
if (img) {
img.onload = debouncedLayout;
}
// Single click to select
card.onclick = () => selectMedia(item, 'video', card);
@@ -1008,24 +570,27 @@ app.registerExtension({
cardHolder.appendChild(card);
});
// Apply layout after all cards are added
requestAnimationFrame(debouncedLayout);
container.querySelector('[data-file-count]').textContent = `${visibleItems.length} of ${currentItems.length} items`;
}
// Select media
async function selectMedia(item, type, cardElement) {
// Update selection
const previousPath = selectedPaths[type];
selectedPaths[type] = item.path;
// Update UI
cardHolder.querySelectorAll('.kiko-gallery-card').forEach(card => {
card.classList.remove('kiko-selected');
if (card.hasAttribute('aria-pressed')) card.setAttribute('aria-pressed', 'false');
});
cardElement.classList.add('kiko-selected');
cardElement.setAttribute('aria-pressed', 'true');
// Send to backend
try {
const response = await api.fetchApi('/kiko_local_image_loader/set_node_selection', {
const response = await fetchApi('/kiko_local_image_loader/set_node_selection', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -1035,13 +600,19 @@ app.registerExtension({
})
});
if (!response.ok) {
selectedPaths[type] = previousPath;
renderGallery();
showStatus(`Selection rejected: ${await describeHttpError(response)}`);
return;
}
// Trigger node update
if (scope.signal.aborted) return;
container.querySelector('[data-selection]').textContent = item.name;
node.setDirtyCanvas(true);
} catch (error) {
if (scope.signal.aborted) return;
selectedPaths[type] = previousPath;
renderGallery();
console.error('Error setting selection:', error);
showStatus('Error setting selection');
}
@@ -1056,9 +627,9 @@ app.registerExtension({
const createButton = (text, page) => {
const btn = document.createElement('button');
btn.textContent = text;
btn.style.cssText = 'background: #333; color: #ccc; border: 1px solid #555; padding: 1px 6px; cursor: pointer; font-size: 11px;';
btn.type = 'button';
if (page === currentPage) {
btn.style.background = '#555';
btn.setAttribute('aria-current', 'page');
}
btn.onclick = () => loadImages(currentDirectory, page);
return btn;
@@ -1074,7 +645,7 @@ app.registerExtension({
} else if (i === currentPage - 3 || i === currentPage + 3) {
const span = document.createElement('span');
span.textContent = '...';
span.style.padding = '0 5px';
pagination.appendChild(span);
}
}
@@ -1102,14 +673,14 @@ app.registerExtension({
loadSavedPaths();
// Try to load last path
api.fetchApi('/kiko_local_image_loader/get_last_path').then(async response => {
fetchApi('/kiko_local_image_loader/get_last_path').then(async response => {
if (!response.ok) return;
const data = await response.json();
if (data.last_path) {
if (data.last_path && loadVersion === 0 && !scope.signal.aborted) {
loadImages(data.last_path);
}
}).catch(error => {
console.error('Error loading last path:', error);
if (!scope.signal.aborted) console.error('Error loading last path:', error);
});
return r;
+252 -919
View File
File diff suppressed because it is too large Load Diff
+80 -525
View File
@@ -1,541 +1,96 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { $el } from "../../scripts/ui.js";
import { app } from '../../scripts/app.js';
import { api } from '../../scripts/api.js';
import { createScope, mountTheme } from './ui/core.js';
import { createColorPicker } from './ui/color-picker.js';
import { WorkflowClock } from './ui/workflow-clock.js';
/**
* Sketch-style Color Picker for Settings
*/
class SketchColorPicker {
constructor(initialColor = '#ff9966', onChange = () => {}) {
this.color = this.hexToHsv(initialColor);
this.hex = initialColor;
this.onChange = onChange;
this.isOpen = false;
this.presetColors = [
'#D0021B', '#F5A623', '#F8E71C', '#8B572A', '#7ED321',
'#417505', '#BD10E0', '#9013FE', '#4A90D9', '#50E3C2',
'#B8E986', '#000000', '#4A4A4A', '#9B9B9B', '#FFFFFF',
];
this.createElements();
}
createElements() {
// Swatch button
this.swatch = document.createElement('div');
this.swatch.style.cssText = `
width: 50px; height: 28px; border-radius: 4px;
background: ${this.hex}; cursor: pointer;
box-shadow: 0 0 0 1px rgba(0,0,0,.2), inset 0 0 0 1px rgba(0,0,0,.1);
display: inline-block;
`;
this.swatch.addEventListener('click', (e) => {
e.stopPropagation();
this.toggle();
});
// Popup container
this.popup = document.createElement('div');
this.popup.style.cssText = `
position: absolute; z-index: 10000;
background: #2a2a2a; border-radius: 6px;
box-shadow: 0 0 0 1px rgba(255,255,255,.1), 0 8px 24px rgba(0,0,0,.4);
padding: 12px; display: none; width: 220px;
bottom: 100%; margin-bottom: 8px; right: 0;
`;
// Saturation/Brightness picker
this.satBright = document.createElement('div');
this.satBright.style.cssText = `
width: 196px; height: 140px; position: relative;
border-radius: 4px; cursor: crosshair; margin-bottom: 12px;
`;
this.updateSatBrightBackground();
this.satBrightPointer = document.createElement('div');
this.satBrightPointer.style.cssText = `
width: 14px; height: 14px; border-radius: 50%;
border: 2px solid #fff; box-shadow: 0 0 0 1px rgba(0,0,0,.3), 0 2px 4px rgba(0,0,0,.3);
position: absolute; transform: translate(-50%, -50%);
pointer-events: none;
`;
this.satBright.appendChild(this.satBrightPointer);
// Hue slider
this.hueSlider = document.createElement('div');
this.hueSlider.style.cssText = `
width: 196px; height: 14px; border-radius: 4px;
background: linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%);
position: relative; cursor: pointer; margin-bottom: 12px;
`;
this.huePointer = document.createElement('div');
this.huePointer.style.cssText = `
width: 8px; height: 18px; border-radius: 3px;
background: #fff; border: 1px solid rgba(0,0,0,.3);
position: absolute; top: -2px; transform: translateX(-50%);
pointer-events: none; box-shadow: 0 1px 3px rgba(0,0,0,.3);
`;
this.hueSlider.appendChild(this.huePointer);
// Hex input row
this.hexRow = document.createElement('div');
this.hexRow.style.cssText = 'display: flex; align-items: center; margin-bottom: 12px; gap: 8px;';
this.hexInput = document.createElement('input');
this.hexInput.type = 'text';
this.hexInput.value = this.hex;
this.hexInput.style.cssText = `
flex: 1; padding: 6px 8px; border: 1px solid #444;
border-radius: 4px; font-size: 13px; font-family: monospace;
text-transform: uppercase; background: #1a1a1a; color: #eee;
`;
this.hexInput.addEventListener('change', () => this.setHex(this.hexInput.value));
const hexLabel = document.createElement('span');
hexLabel.textContent = 'Hex';
hexLabel.style.cssText = 'font-size: 12px; color: #888; min-width: 28px;';
this.hexRow.appendChild(this.hexInput);
this.hexRow.appendChild(hexLabel);
// Preset swatches
this.presetsContainer = document.createElement('div');
this.presetsContainer.style.cssText = `
display: flex; flex-wrap: wrap; gap: 6px;
border-top: 1px solid #444; padding-top: 12px;
`;
this.presetColors.forEach(color => {
const preset = document.createElement('div');
preset.style.cssText = `
width: 18px; height: 18px; border-radius: 3px;
background: ${color}; cursor: pointer;
box-shadow: inset 0 0 0 1px rgba(0,0,0,.2);
transition: transform 0.1s;
`;
preset.addEventListener('mouseenter', () => preset.style.transform = 'scale(1.15)');
preset.addEventListener('mouseleave', () => preset.style.transform = 'scale(1)');
preset.addEventListener('click', () => this.setHex(color));
this.presetsContainer.appendChild(preset);
});
// Assemble popup
this.popup.appendChild(this.satBright);
this.popup.appendChild(this.hueSlider);
this.popup.appendChild(this.hexRow);
this.popup.appendChild(this.presetsContainer);
// Event handlers
this.setupDrag(this.satBright, this.handleSatBrightChange.bind(this));
this.setupDrag(this.hueSlider, this.handleHueChange.bind(this));
// Close on outside click
this.closeHandler = (e) => {
if (!this.popup.contains(e.target) && e.target !== this.swatch) {
this.close();
}
};
}
setupDrag(element, handler) {
const onMove = (e) => {
e.preventDefault();
const rect = element.getBoundingClientRect();
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
handler(x, y);
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
// Click and drag behavior
element.addEventListener('mousedown', (e) => {
e.preventDefault();
onMove(e);
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
// Hover preview - color follows mouse pointer
element.addEventListener('mousemove', (e) => {
if (e.buttons === 0) { // Only on hover, not during drag
const rect = element.getBoundingClientRect();
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
handler(x, y);
}
});
}
handleSatBrightChange(x, y) {
this.color.s = x * 100;
this.color.v = (1 - y) * 100;
this.updateFromHsv();
}
handleHueChange(x) {
this.color.h = x * 360;
this.updateFromHsv();
}
updateFromHsv() {
this.hex = this.hsvToHex(this.color.h, this.color.s, this.color.v);
this.updateUI();
this.onChange(this.hex);
}
updateUI() {
this.swatch.style.background = this.hex;
this.updateSatBrightBackground();
this.satBrightPointer.style.left = `${this.color.s}%`;
this.satBrightPointer.style.top = `${100 - this.color.v}%`;
this.huePointer.style.left = `${(this.color.h / 360) * 100}%`;
this.hexInput.value = this.hex.toUpperCase();
}
updateSatBrightBackground() {
const hueColor = this.hsvToHex(this.color.h, 100, 100);
this.satBright.style.background = `
linear-gradient(to top, #000, transparent),
linear-gradient(to right, #fff, ${hueColor})
`;
}
setHex(hex) {
if (!/^#?[0-9A-Fa-f]{6}$/.test(hex)) return;
if (!hex.startsWith('#')) hex = '#' + hex;
this.hex = hex;
this.color = this.hexToHsv(hex);
this.updateUI();
this.onChange(this.hex);
}
toggle() { this.isOpen ? this.close() : this.open(); }
open() {
this.isOpen = true;
this.popup.style.display = 'block';
this.updateUI();
setTimeout(() => document.addEventListener('click', this.closeHandler), 0);
}
close() {
this.isOpen = false;
this.popup.style.display = 'none';
document.removeEventListener('click', this.closeHandler);
}
getElement() {
const container = document.createElement('div');
container.style.cssText = 'position: relative; display: inline-block;';
container.appendChild(this.swatch);
container.appendChild(this.popup);
return container;
}
hexToHsv(hex) {
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
const v = max * 100, d = max - min;
const s = max === 0 ? 0 : (d / max) * 100;
let h = 0;
if (d !== 0) {
switch (max) {
case r: h = ((g - b) / d + (g < b ? 6 : 0)) * 60; break;
case g: h = ((b - r) / d + 2) * 60; break;
case b: h = ((r - g) / d + 4) * 60; break;
}
}
return { h, s, v };
}
hsvToHex(h, s, v) {
s /= 100; v /= 100;
const c = v * s, x = c * (1 - Math.abs(((h / 60) % 2) - 1)), m = v - c;
let r = 0, g = 0, b = 0;
if (h < 60) { r = c; g = x; }
else if (h < 120) { r = x; g = c; }
else if (h < 180) { g = c; b = x; }
else if (h < 240) { g = x; b = c; }
else if (h < 300) { r = x; b = c; }
else { r = c; b = x; }
const toHex = (n) => Math.round((n + m) * 255).toString(16).padStart(2, '0');
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}
}
/**
* Global Timer Manager
*/
const KikoGlobalTimer = {
startTime: 0,
intervalId: null,
isRunning: false,
activeNodes: new Set(),
formatTime(ms) {
if (ms < 0) ms = 0;
const minutes = String(Math.floor(ms / 60000)).padStart(2, '0');
const seconds = String(Math.floor((ms % 60000) / 1000)).padStart(2, '0');
const milliseconds = String(ms % 1000).padStart(3, '0');
return `${minutes}:${seconds}:${milliseconds}`;
},
getTimerColor() {
return app.ui.settings.getSettingValue("kikotools.workflow_timer.color") || '#ff9966';
},
isGlowEnabled() {
const val = app.ui.settings.getSettingValue("kikotools.workflow_timer.glow");
return val !== undefined ? val : true;
},
start() {
if (this.isRunning) return;
this.isRunning = true;
this.startTime = Date.now();
const timerColor = this.getTimerColor();
this.activeNodes.forEach(node => {
if (node.timerDisplay) {
node.timerDisplay.style.color = timerColor;
node.timerDisplay.classList.add('running');
}
});
this.intervalId = setInterval(() => {
const elapsed = Date.now() - this.startTime;
const timeString = this.formatTime(elapsed);
this.activeNodes.forEach(node => {
if (node.timerDisplay) {
node.timerDisplay.textContent = timeString;
}
});
}, 33);
},
stop() {
if (!this.isRunning) return;
this.isRunning = false;
clearInterval(this.intervalId);
const finalTime = Date.now() - this.startTime;
const finalTimeString = this.formatTime(finalTime);
const timerColor = this.getTimerColor();
this.activeNodes.forEach(node => {
if (node.timerDisplay) {
node.timerDisplay.textContent = finalTimeString;
node.timerDisplay.style.color = timerColor;
node.timerDisplay.classList.remove('running');
}
node.properties.elapsed_time_str = finalTimeString;
});
},
updateColor(color) {
if (this.isRunning) return;
this.activeNodes.forEach(node => {
if (node.timerDisplay) {
node.timerDisplay.style.color = color;
}
});
},
updateGlow(enabled) {
this.activeNodes.forEach(node => {
if (node.timerDisplay) {
node.timerDisplay.classList.toggle('no-glow', !enabled);
}
});
},
registerNode(node) { this.activeNodes.add(node); },
unregisterNode(node) { this.activeNodes.delete(node); },
};
/**
* ComfyUI Extension for KikoWorkflow Timer
*/
const KikoWorkflowTimerExtension = {
name: "ComfyAssets.KikoWorkflowTimer",
const clock = new WorkflowClock(app.ui.settings);
let colorPicker;
app.registerExtension({
name: 'ComfyAssets.KikoWorkflowTimer',
async init() {
// Color picker setting with custom render
app.ui.settings.addSetting({
id: "kikotools.workflow_timer.color",
name: "🫶 Workflow Timer: Color",
id: 'kikotools.workflow_timer.color',
name: '🫶 Workflow Timer: Color',
type: (name, setter, value) => {
const container = $el("div", {
style: { display: "flex", alignItems: "center", gap: "10px" }
});
const picker = new SketchColorPicker(value || '#ff9966', (newColor) => {
setter(newColor);
KikoGlobalTimer.updateColor(newColor);
});
container.appendChild(picker.getElement());
// Also show hex value as text
const hexDisplay = $el("span", {
textContent: value || '#ff9966',
style: { color: "#888", fontFamily: "monospace", fontSize: "12px" }
});
container.appendChild(hexDisplay);
// Update hex display when color changes
const origOnChange = picker.onChange;
picker.onChange = (newColor) => {
origOnChange(newColor);
hexDisplay.textContent = newColor.toUpperCase();
};
return container;
if (!colorPicker || colorPicker.scope?.signal.aborted) {
colorPicker = createColorPicker(value, setter, app.ui.settings, 'Timer color');
}
colorPicker.onChange = setter;
return colorPicker;
},
defaultValue: "#ff9966",
tooltip: "Color of the timer display when not running",
defaultValue: '#0877bb',
tooltip: 'Color of the workflow timer display',
onChange: () => clock.refresh(),
});
// Glow toggle
app.ui.settings.addSetting({
id: "kikotools.workflow_timer.glow",
name: "🫶 Workflow Timer: Enable Glow",
type: "boolean",
id: 'kikotools.workflow_timer.glow',
name: '🫶 Workflow Timer: Enable Glow',
type: 'boolean',
defaultValue: true,
tooltip: "Enable pulsing glow effect on the timer",
onChange: (value) => {
KikoGlobalTimer.updateGlow(value);
},
tooltip: 'Enable the pulsing glow unless reduced motion is preferred',
onChange: () => clock.refresh(),
});
},
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "KikoWorkflowTimer") {
const origOnNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
origOnNodeCreated?.apply(this, arguments);
this.bgcolor = "#1a1a2e";
this.color = "#16213e";
this.title = "Workflow Timer";
this.properties = this.properties || {};
this.size = [300, 100];
const container = document.createElement("div");
container.className = "kiko-timer-container";
this.timerDisplay = document.createElement("div");
this.timerDisplay.className = "kiko-timer-display";
this.timerDisplay.textContent = this.properties.elapsed_time_str || "00:00:000";
const timerColor = KikoGlobalTimer.getTimerColor();
this.timerDisplay.style.color = timerColor;
if (!KikoGlobalTimer.isGlowEnabled()) {
this.timerDisplay.classList.add('no-glow');
}
container.appendChild(this.timerDisplay);
this.addDOMWidget("kikoTimer", "Kiko Timer", container, { serialize: false });
KikoGlobalTimer.registerNode(this);
};
const origOnRemoved = nodeType.prototype.onRemoved;
nodeType.prototype.onRemoved = function() {
KikoGlobalTimer.unregisterNode(this);
return origOnRemoved?.apply(this, arguments);
};
const origOnSerialize = nodeType.prototype.onSerialize;
nodeType.prototype.onSerialize = function(o) {
origOnSerialize?.apply(this, arguments);
o.properties = this.properties;
};
const origOnConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function(info) {
origOnConfigure?.apply(this, arguments);
this.properties = { ...(this.properties || {}), ...(info?.properties || {}) };
if (this.timerDisplay) {
this.timerDisplay.textContent = this.properties.elapsed_time_str || "00:00:000";
const timerColor = KikoGlobalTimer.getTimerColor();
this.timerDisplay.style.color = timerColor;
if (!KikoGlobalTimer.isGlowEnabled()) {
this.timerDisplay.classList.add('no-glow');
}
}
};
}
if (nodeData.name !== 'KikoWorkflowTimer') return;
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const result = onNodeCreated?.apply(this, arguments);
this.timerScope = createScope();
this.properties = this.properties || {};
const container = document.createElement('div');
container.className = 'ca-tools ca-workflow-timer kiko-timer-container';
const header = document.createElement('header');
header.className = 'ca-timer-header';
const title = document.createElement('h3');
title.textContent = 'Workflow Timer';
const brand = document.createElement('span');
brand.textContent = 'ComfyAssets';
header.append(title, brand);
this.timerDisplay = document.createElement('div');
this.timerDisplay.className = 'kiko-timer-display';
this.timerDisplay.setAttribute('role', 'timer');
this.timerDisplay.setAttribute('aria-label', 'Elapsed workflow time');
this.timerDisplay.setAttribute('aria-live', 'off');
container.append(header, this.timerDisplay);
mountTheme(container, app.ui.settings, this.timerScope);
this.addDOMWidget('kikoTimer', 'Kiko Timer', container, {
serialize: false, hideOnZoom: false, getMinHeight: () => 90,
});
this.setSize([340, 180]);
clock.registerNode(this);
this.timerScope.cleanup(() => {
clock.unregisterNode(this);
container.remove();
});
return result;
};
const onRemoved = nodeType.prototype.onRemoved;
nodeType.prototype.onRemoved = function () {
this.timerScope.dispose();
return onRemoved?.apply(this, arguments);
};
const onSerialize = nodeType.prototype.onSerialize;
nodeType.prototype.onSerialize = function (data) {
const result = onSerialize?.apply(this, arguments);
data.properties = { ...data.properties, elapsed_time_str: this.properties.elapsed_time_str };
return result;
};
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function (data) {
const result = onConfigure?.apply(this, arguments);
this.properties = { ...(this.properties || {}), ...(data?.properties || {}) };
if (this.timerDisplay) clock.renderNode(this);
return result;
};
},
setup() {
const style = document.createElement("style");
style.textContent = `
@keyframes kiko-timer-pulse {
0%, 100% { text-shadow: 0 0 15px currentColor; opacity: 0.9; }
50% { text-shadow: 0 0 25px currentColor; opacity: 1; }
}
@keyframes kiko-timer-running-pulse {
0%, 100% { text-shadow: 0 0 15px currentColor; opacity: 0.9; }
50% { text-shadow: 0 0 25px currentColor; opacity: 1; }
}
.kiko-timer-container {
width: 100%; height: 100%;
position: relative; display: flex;
align-items: center; justify-content: center;
}
.kiko-timer-display {
text-align: center; width: 100%; height: 100%;
position: absolute; top: 0; left: 0;
background: transparent; border: none;
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', 'Monaco', monospace;
box-sizing: border-box; outline: none; margin: 0;
overflow: hidden; display: flex;
justify-content: center; align-items: center;
font-size: 42px;
animation: kiko-timer-pulse 4s infinite ease-in-out;
transition: color 0.3s ease-in-out;
font-variant-numeric: tabular-nums;
letter-spacing: 0.08em;
white-space: nowrap; font-weight: 600;
}
.kiko-timer-display.no-glow {
animation: none;
text-shadow: none;
}
.kiko-timer-display.running {
animation: kiko-timer-running-pulse 2s infinite ease-in-out;
}
.kiko-timer-display.running.no-glow {
animation: none;
text-shadow: none;
}
`;
document.head.appendChild(style);
api.addEventListener("execution_start", () => KikoGlobalTimer.start());
api.addEventListener("executing", ({ detail }) => {
if (detail === null) KikoGlobalTimer.stop();
});
api.addEventListener("execution_error", () => KikoGlobalTimer.stop());
api.addEventListener("execution_interrupted", () => KikoGlobalTimer.stop());
}
};
app.registerExtension(KikoWorkflowTimerExtension);
api.addEventListener('execution_start', () => clock.start());
api.addEventListener('executing', ({ detail }) => { if (detail === null) clock.stop(); });
for (const event of ['execution_error', 'execution_interrupted']) api.addEventListener(event, () => clock.stop());
},
});
+225 -512
View File
@@ -1,525 +1,238 @@
// ComfyUI-KikoTools - Seed History with Tracking UI
import { app } from "../../scripts/app.js";
import { app } from '../../scripts/app.js';
import { createScope, mountTheme } from './ui/core.js';
import { createSeedHistoryView } from './views/seed-history.js';
const storageKey = 'comfyui_kikotools_seed_history';
function seedValue(value) {
if (typeof value !== 'number' && (typeof value !== 'string' || !value.trim())) return null;
const seed = Number(value);
return Number.isInteger(seed) && seed >= 0 && seed <= 0xFFFFFFFF ? seed : null;
}
function historyValue(value) {
if (!Array.isArray(value)) return [];
const seeds = new Set();
return value.flatMap(item => {
const seed = seedValue(item?.seed);
if (seed === null || seeds.has(seed)) return [];
seeds.add(seed);
return [{ ...item, seed, timestamp: Number.isFinite(item.timestamp) ? item.timestamp : Date.now() }];
}).slice(0, 10);
}
app.registerExtension({
name: "comfyassets.SeedHistory",
async setup() {
// Store reference to all SeedHistory nodes
window.seedHistoryNodes = window.seedHistoryNodes || [];
},
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "SeedHistory") {
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
if (onNodeCreated) {
onNodeCreated.apply(this, arguments);
}
// Initialize seed history
this.seedHistory = this.loadSeedHistory();
this.hideTimer = null;
this.mouseOverHistory = false;
// Register this node in global registry
name: 'comfyassets.SeedHistory',
async setup() {
window.seedHistoryNodes = window.seedHistoryNodes || [];
window.seedHistoryNodes.push(this);
// Create UI container
const uiContainer = document.createElement("div");
uiContainer.style.padding = "8px";
uiContainer.style.backgroundColor = "#1e1e1e";
uiContainer.style.borderRadius = "6px";
uiContainer.style.marginTop = "6px";
uiContainer.style.border = "1px solid #404040";
this.buildSeedInterface(uiContainer);
// Add as widget
this.seedWidget = this.addDOMWidget(
"seed_history_ui",
"div",
uiContainer,
);
// Set node size
if (!this.hasBeenResized) {
this.size = [280, 320];
}
// Track manual resizing
const originalResize = this.onResize;
this.onResize = function (size) {
this.hasBeenResized = true;
if (originalResize) {
originalResize.call(this, size);
}
};
// Hook into seed widget callbacks to track all changes
setTimeout(() => {
this.setupSeedWidgetCallbacks();
}, 100);
// Hook directly into widget value changes
const originalOnWidgetChange = this.onWidgetChange;
this.onWidgetChange = function(name, value, oldValue, widget) {
if (name === "seed" && value !== oldValue) {
this.addSeedToHistory(value);
}
if (originalOnWidgetChange) {
return originalOnWidgetChange.call(this, name, value, oldValue, widget);
}
};
// Save/load data
const originalSerialize = this.serialize;
this.serialize = function () {
const data = originalSerialize ? originalSerialize.call(this) : {};
data.hasBeenResized = this.hasBeenResized;
data.seedHistory = this.seedHistory;
return data;
};
const originalConfigure = this.configure;
this.configure = function (data) {
if (originalConfigure) {
originalConfigure.call(this, data);
}
if (data.hasBeenResized) {
this.hasBeenResized = data.hasBeenResized;
}
if (data.seedHistory) {
this.seedHistory = data.seedHistory;
this.refreshHistoryDisplay();
}
};
// Cleanup interval on node removal
const originalOnRemoved = this.onRemoved;
this.onRemoved = function () {
if (this.seedValueWatcher) {
clearInterval(this.seedValueWatcher);
},
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name !== 'SeedHistory') return;
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const result = onNodeCreated?.apply(this, arguments);
this.seedScope = createScope();
this.seedHistory = this.loadSeedHistory();
this.hideTimer = null;
this.seedSetupTimer = null;
this.seedValueWatcher = null;
}
// Clean up deduplication tracking
if (this.lastAddedSeed) {
this.lastAddedSeed = null;
}
// Remove from global registry
if (window.seedHistoryNodes) {
const index = window.seedHistoryNodes.indexOf(this);
if (index !== -1) {
window.seedHistoryNodes.splice(index, 1);
}
}
if (originalOnRemoved) {
originalOnRemoved.call(this);
}
this.mouseOverHistory = false;
window.seedHistoryNodes = window.seedHistoryNodes || [];
window.seedHistoryNodes.push(this);
this.buildSeedInterface();
this.seedWidget = this.addDOMWidget('seed_history_ui', 'div', this.seedView.element, {
serialize: false, hideOnZoom: false,
getMinHeight: () => 200,
});
if (!this.hasBeenResized) this.setSize([320, 420]);
const originalResize = this.onResize;
this.onResize = function () {
this.hasBeenResized = true;
return originalResize?.apply(this, arguments);
};
this.seedSetupTimer = setTimeout(() => {
this.seedSetupTimer = null;
this.setupSeedWidgetCallbacks();
}, 100);
const originalOnWidgetChange = this.onWidgetChange;
this.onWidgetChange = function (name, value, oldValue) {
if (name === 'seed' && value !== oldValue) this.addSeedToHistory(value);
return originalOnWidgetChange?.apply(this, arguments);
};
const originalSerialize = this.serialize;
this.serialize = function () {
const data = originalSerialize?.apply(this, arguments) || {};
data.hasBeenResized = this.hasBeenResized;
data.seedHistory = this.seedHistory;
return data;
};
const originalConfigure = this.configure;
this.configure = function (data) {
const result = originalConfigure?.apply(this, arguments);
if (data.hasBeenResized !== undefined) this.hasBeenResized = data.hasBeenResized;
if (data.seedHistory !== undefined) {
this.seedHistory = historyValue(data.seedHistory);
this.refreshHistoryDisplay();
}
return result;
};
this.seedScope.cleanup(() => {
clearTimeout(this.seedSetupTimer);
clearInterval(this.seedValueWatcher);
this.seedSetupTimer = null;
this.seedValueWatcher = null;
this.cancelAutoHide();
this.lastAddedSeed = null;
window.seedHistoryNodes = window.seedHistoryNodes.filter(node => node !== this);
this.seedView.element.remove();
});
const originalOnRemoved = this.onRemoved;
this.onRemoved = function () {
this.seedScope.dispose();
return originalOnRemoved?.apply(this, arguments);
};
this.setDirtyCanvas(true, true);
return result;
};
this.setDirtyCanvas(true, true);
};
// Setup seed widget callbacks to track increment/decrement/randomize
nodeType.prototype.setupSeedWidgetCallbacks = function () {
// Find the seed widget
const seedWidget = this.widgets?.find(w => w.name === "seed");
if (!seedWidget) {
setTimeout(() => this.setupSeedWidgetCallbacks(), 500);
return;
}
// Store the last known seed value to detect changes
this.lastSeedValue = seedWidget.value;
// Monitor for value changes that might not trigger callback
this.seedValueWatcher = setInterval(() => {
if (seedWidget.value !== this.lastSeedValue) {
nodeType.prototype.setupSeedWidgetCallbacks = function () {
if (this.seedScope.signal.aborted || this.seedValueWatcher !== null) return;
const seedWidget = this.widgets?.find(widget => widget.name === 'seed');
if (!seedWidget) {
this.seedSetupTimer = setTimeout(() => {
this.seedSetupTimer = null;
this.setupSeedWidgetCallbacks();
}, 500);
return;
}
this.lastSeedValue = seedWidget.value;
this.addSeedToHistory(seedWidget.value);
}
}, 1000);
};
this.seedValueWatcher = setInterval(() => {
if (seedWidget.value !== this.lastSeedValue) {
this.lastSeedValue = seedWidget.value;
this.addSeedToHistory(seedWidget.value);
}
}, 1000);
};
// Build the seed interface
nodeType.prototype.buildSeedInterface = function (container) {
container.innerHTML = "";
// Header
const headerDiv = document.createElement("div");
headerDiv.style.marginBottom = "8px";
const titleDiv = document.createElement("div");
titleDiv.style.fontWeight = "bold";
titleDiv.style.color = "#00d4ff";
titleDiv.style.textAlign = "center";
titleDiv.style.padding = "4px";
titleDiv.style.backgroundColor = "rgba(0, 212, 255, 0.1)";
titleDiv.style.borderRadius = "4px";
titleDiv.style.fontSize = "11px";
titleDiv.innerHTML = "🎲 Seed History";
headerDiv.appendChild(titleDiv);
// Action buttons
const buttonDiv = document.createElement("div");
buttonDiv.style.display = "flex";
buttonDiv.style.gap = "4px";
buttonDiv.style.marginTop = "6px";
// Generate button
const generateBtn = document.createElement("button");
generateBtn.textContent = "🎲 Generate";
generateBtn.style.flex = "1";
generateBtn.style.padding = "3px 6px";
generateBtn.style.backgroundColor = "#0088cc";
generateBtn.style.color = "white";
generateBtn.style.border = "none";
generateBtn.style.borderRadius = "3px";
generateBtn.style.cursor = "pointer";
generateBtn.style.fontSize = "10px";
generateBtn.addEventListener("click", () => this.generateRandomSeed());
buttonDiv.appendChild(generateBtn);
// Clear button
const clearBtn = document.createElement("button");
clearBtn.textContent = "🗑️ Clear";
clearBtn.style.flex = "1";
clearBtn.style.padding = "3px 6px";
clearBtn.style.backgroundColor = "#cc4444";
clearBtn.style.color = "white";
clearBtn.style.border = "none";
clearBtn.style.borderRadius = "3px";
clearBtn.style.cursor = "pointer";
clearBtn.style.fontSize = "10px";
clearBtn.addEventListener("click", () => this.clearSeedHistory());
buttonDiv.appendChild(clearBtn);
headerDiv.appendChild(buttonDiv);
container.appendChild(headerDiv);
// History display
const historyDiv = document.createElement("div");
historyDiv.style.maxHeight = "180px";
historyDiv.style.overflowY = "auto";
historyDiv.style.border = "1px solid #333";
historyDiv.style.borderRadius = "4px";
historyDiv.style.backgroundColor = "#2a2a2a";
historyDiv.style.padding = "6px";
historyDiv.style.fontSize = "10px";
historyDiv.style.fontFamily = "monospace";
// Mouse events for auto-hide
historyDiv.addEventListener("mouseenter", () => {
this.mouseOverHistory = true;
this.cancelAutoHide();
});
historyDiv.addEventListener("mouseleave", () => {
this.mouseOverHistory = false;
this.startAutoHide();
});
this.historyDisplay = historyDiv;
container.appendChild(historyDiv);
this.refreshHistoryDisplay();
};
// Load history from storage
nodeType.prototype.loadSeedHistory = function () {
try {
const stored = localStorage.getItem('comfyui_kikotools_seed_history');
return stored ? JSON.parse(stored) : [];
} catch (error) {
return [];
}
};
// Save history to storage
nodeType.prototype.saveSeedHistory = function () {
try {
localStorage.setItem('comfyui_kikotools_seed_history', JSON.stringify(this.seedHistory));
} catch (error) {
// Silently handle storage errors
}
};
// Add seed to history
nodeType.prototype.addSeedToHistory = function (seed) {
if (!seed || seed === 0) return;
const numSeed = typeof seed === 'string' ? parseInt(seed) : seed;
const now = Date.now();
// Deduplication: prevent adding the same seed within 500ms window
if (!this.lastAddedSeed) {
this.lastAddedSeed = { seed: null, timestamp: 0 };
}
const timeSinceLastAdd = now - this.lastAddedSeed.timestamp;
const isSameSeed = this.lastAddedSeed.seed === numSeed;
const isWithinDupeWindow = timeSinceLastAdd < 500; // 500ms window
if (isSameSeed && isWithinDupeWindow) {
return;
}
// Update deduplication tracking
this.lastAddedSeed = { seed: numSeed, timestamp: now };
// Remove if already exists in history
this.seedHistory = this.seedHistory.filter(item => item.seed !== numSeed);
// Add to front
this.seedHistory.unshift({
seed: numSeed,
timestamp: now,
dateString: new Date().toLocaleString()
});
// Keep only last 10
if (this.seedHistory.length > 10) {
this.seedHistory = this.seedHistory.slice(0, 10);
}
this.saveSeedHistory();
this.refreshHistoryDisplay();
this.startAutoHide();
};
// Generate new random seed
nodeType.prototype.generateRandomSeed = function () {
const newSeed = Math.floor(Math.random() * 0xFFFFFFFF); // 2**32 - 1
const seedWidget = this.widgets?.find(w => w.name === "seed");
if (seedWidget) {
seedWidget.value = newSeed;
if (seedWidget.callback) {
seedWidget.callback(newSeed, this, seedWidget);
}
}
this.addSeedToHistory(newSeed);
this.setDirtyCanvas(true, true);
this.showMessage(`Generated: ${newSeed}`, "success");
};
// Use seed from history
nodeType.prototype.useSeedFromHistory = function (historyItem, index) {
const seedWidget = this.widgets?.find(w => w.name === "seed");
if (seedWidget) {
seedWidget.value = historyItem.seed;
if (seedWidget.callback) {
seedWidget.callback(historyItem.seed, this, seedWidget);
}
}
this.highlightHistoryEntry(index);
this.setDirtyCanvas(true, true);
this.startAutoHide();
this.showMessage(`Loaded: ${historyItem.seed}`, "info");
};
// Clear history
nodeType.prototype.clearSeedHistory = function () {
this.seedHistory = [];
this.saveSeedHistory();
this.refreshHistoryDisplay();
this.showMessage("History cleared", "info");
};
// Refresh history display
nodeType.prototype.refreshHistoryDisplay = function () {
if (!this.historyDisplay) return;
if (!this.seedHistory || this.seedHistory.length === 0) {
this.historyDisplay.innerHTML =
'<div style="color: #888; text-align: center; padding: 15px;">No seeds tracked<br><small>Generate seeds to build history</small></div>';
return;
}
this.historyDisplay.innerHTML = "";
this.seedHistory.forEach((item, index) => {
const entryDiv = document.createElement("div");
entryDiv.style.padding = "4px";
entryDiv.style.marginBottom = "3px";
entryDiv.style.backgroundColor = "#333";
entryDiv.style.borderRadius = "2px";
entryDiv.style.cursor = "pointer";
entryDiv.style.border = "1px solid transparent";
entryDiv.style.lineHeight = "1.2";
entryDiv.addEventListener("mouseenter", () => {
entryDiv.style.backgroundColor = "#444";
entryDiv.style.border = "1px solid #555";
});
entryDiv.addEventListener("mouseleave", () => {
entryDiv.style.backgroundColor = "#333";
entryDiv.style.border = "1px solid transparent";
});
entryDiv.addEventListener("click", () => {
this.useSeedFromHistory(item, index);
});
const timeAgo = this.formatTimeAgo(item.timestamp);
// Build with textContent: history comes from localStorage and is untrusted
const seedDiv = document.createElement("div");
seedDiv.style.cssText = "color: #fff; font-weight: bold; margin-bottom: 1px;";
seedDiv.textContent = `🎲 ${Number(item.seed)}`;
const timeDiv = document.createElement("div");
timeDiv.style.cssText = "color: #999; font-size: 8px;";
timeDiv.textContent = `⏰ ${timeAgo}`;
entryDiv.appendChild(seedDiv);
entryDiv.appendChild(timeDiv);
this.historyDisplay.appendChild(entryDiv);
});
this.startAutoHide();
};
// Highlight selected entry
nodeType.prototype.highlightHistoryEntry = function (index) {
const entries = this.historyDisplay.querySelectorAll('div[style*="cursor: pointer"]');
entries.forEach((entry, i) => {
if (i === index) {
entry.style.backgroundColor = "#006600";
entry.style.border = "1px solid #00aa00";
} else {
entry.style.backgroundColor = "#333";
entry.style.border = "1px solid transparent";
}
});
};
// Auto-hide functionality
nodeType.prototype.startAutoHide = function () {
this.cancelAutoHide();
if (!this.mouseOverHistory) {
this.hideTimer = setTimeout(() => {
this.hideHistorySection();
}, 2500);
}
};
nodeType.prototype.cancelAutoHide = function () {
if (this.hideTimer) {
clearTimeout(this.hideTimer);
this.hideTimer = null;
}
};
nodeType.prototype.hideHistorySection = function () {
if (this.historyDisplay && !this.mouseOverHistory) {
this.historyDisplay.style.display = "none";
if (!this.restoreButton) {
const restoreDiv = document.createElement("div");
restoreDiv.style.padding = "10px";
restoreDiv.style.backgroundColor = "#2a2a2a";
restoreDiv.style.border = "1px solid #333";
restoreDiv.style.borderRadius = "4px";
restoreDiv.style.textAlign = "center";
restoreDiv.style.cursor = "pointer";
restoreDiv.style.color = "#888";
restoreDiv.style.fontSize = "10px";
restoreDiv.innerHTML = "🎲 History auto-hidden<br><small>Click to show</small>";
restoreDiv.addEventListener("mouseenter", () => {
restoreDiv.style.backgroundColor = "#333";
restoreDiv.style.color = "#bbb";
nodeType.prototype.buildSeedInterface = function () {
this.seedView = createSeedHistoryView(this.seedScope, {
generate: () => this.generateRandomSeed(),
clear: () => this.clearSeedHistory(),
select: index => this.useSeedFromHistory(this.seedHistory[index], index),
expand: expanded => expanded ? this.showHistorySection() : this.hideHistorySection(true),
});
restoreDiv.addEventListener("mouseleave", () => {
restoreDiv.style.backgroundColor = "#2a2a2a";
restoreDiv.style.color = "#888";
const { element, list, disclosure } = this.seedView;
this.historyDisplay = list;
this.restoreButton = disclosure;
mountTheme(element, app.ui.settings, this.seedScope);
this.seedScope.listen(list, 'mouseenter', () => {
this.mouseOverHistory = true;
this.cancelAutoHide();
});
restoreDiv.addEventListener("click", () => {
this.showHistorySection();
this.seedScope.listen(list, 'mouseleave', () => {
this.mouseOverHistory = false;
this.startAutoHide();
});
this.seedScope.listen(list, 'focusin', () => this.cancelAutoHide());
this.seedScope.listen(list, 'focusout', () => this.startAutoHide());
this.refreshHistoryDisplay();
};
this.restoreButton = restoreDiv;
this.historyDisplay.parentNode.insertBefore(
restoreDiv,
this.historyDisplay.nextSibling
);
}
}
};
nodeType.prototype.showHistorySection = function () {
if (this.historyDisplay) {
this.historyDisplay.style.display = "block";
if (this.restoreButton && this.restoreButton.parentNode) {
this.restoreButton.parentNode.removeChild(this.restoreButton);
this.restoreButton = null;
}
this.startAutoHide();
}
};
// Format time ago
nodeType.prototype.formatTimeAgo = function (timestamp) {
const now = Date.now();
const diff = now - timestamp;
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days}d ago`;
if (hours > 0) return `${hours}h ago`;
if (minutes > 0) return `${minutes}m ago`;
return `${seconds}s ago`;
};
// Show messages
nodeType.prototype.showMessage = function (message, type = "info") {
const notification = document.createElement("div");
notification.style.position = "fixed";
notification.style.top = "15px";
notification.style.right = "15px";
notification.style.padding = "6px 10px";
notification.style.borderRadius = "3px";
notification.style.color = "white";
notification.style.fontSize = "10px";
notification.style.zIndex = "10000";
notification.style.maxWidth = "200px";
notification.textContent = message;
switch (type) {
case "success":
notification.style.backgroundColor = "#28a745";
break;
case "error":
notification.style.backgroundColor = "#dc3545";
break;
case "warning":
notification.style.backgroundColor = "#ffc107";
break;
default:
notification.style.backgroundColor = "#17a2b8";
}
document.body.appendChild(notification);
setTimeout(() => {
if (notification.parentNode) {
document.body.removeChild(notification);
}
}, 1800);
};
}
},
nodeType.prototype.loadSeedHistory = function () {
try { return historyValue(JSON.parse(localStorage.getItem(storageKey))); }
catch { return []; }
};
nodeType.prototype.saveSeedHistory = function () {
try { localStorage.setItem(storageKey, JSON.stringify(this.seedHistory)); }
catch { /* History remains available in the workflow if browser storage is unavailable. */ }
};
nodeType.prototype.addSeedToHistory = function (value) {
const seed = seedValue(value);
if (seed === null || this.seedScope.signal.aborted) return;
const now = Date.now();
if (this.lastAddedSeed?.seed === seed && now - this.lastAddedSeed.timestamp < 500) return;
this.lastAddedSeed = { seed, timestamp: now };
this.seedHistory = [{ seed, timestamp: now, dateString: new Date(now).toLocaleString() },
...this.seedHistory.filter(item => item.seed !== seed)].slice(0, 10);
this.saveSeedHistory();
this.refreshHistoryDisplay();
};
nodeType.prototype.generateRandomSeed = function () {
const seed = Math.floor(Math.random() * 0xFFFFFFFF);
const widget = this.widgets?.find(widget => widget.name === 'seed');
if (widget) {
widget.value = seed;
widget.callback?.(seed, app.canvas, this, [0, 0], null);
this.lastSeedValue = widget.value;
}
this.addSeedToHistory(seed);
this.setDirtyCanvas(true, true);
this.showMessage(`Generated: ${seed}`, 'success');
};
nodeType.prototype.useSeedFromHistory = function (item, index) {
const widget = this.widgets?.find(widget => widget.name === 'seed');
if (widget) {
widget.value = item.seed;
widget.callback?.(item.seed, app.canvas, this, [0, 0], null);
this.lastSeedValue = widget.value;
}
this.highlightHistoryEntry(index);
this.setDirtyCanvas(true, true);
this.startAutoHide();
this.showMessage(`Loaded: ${item.seed}`);
};
nodeType.prototype.clearSeedHistory = function () {
this.seedHistory = [];
this.lastAddedSeed = null;
this.lastSeedValue = this.widgets?.find(widget => widget.name === 'seed')?.value;
this.saveSeedHistory();
this.showHistorySection();
this.refreshHistoryDisplay();
this.showMessage('History cleared');
};
nodeType.prototype.refreshHistoryDisplay = function () {
if (!this.seedView) return;
const selected = seedValue(this.widgets?.find(widget => widget.name === 'seed')?.value);
this.seedView.render(this.seedHistory, selected, timestamp => this.formatTimeAgo(timestamp));
this.startAutoHide();
};
nodeType.prototype.highlightHistoryEntry = function (index) {
this.historyDisplay.querySelectorAll('button[data-seed-index]').forEach((entry, i) => {
entry.setAttribute('aria-pressed', String(i === index));
});
};
nodeType.prototype.startAutoHide = function () {
this.cancelAutoHide();
if (!this.seedScope.signal.aborted && !this.mouseOverHistory && this.seedHistory.length && !this.historyDisplay.hidden) {
this.hideTimer = setTimeout(() => {
this.hideTimer = null;
this.hideHistorySection();
}, 2500);
}
};
nodeType.prototype.cancelAutoHide = function () {
clearTimeout(this.hideTimer);
this.hideTimer = null;
};
nodeType.prototype.hideHistorySection = function (explicit = false) {
if (this.seedScope.signal.aborted || (!explicit && (this.mouseOverHistory || this.historyDisplay.contains(document.activeElement)))) return;
this.cancelAutoHide();
this.seedView.expand(false);
};
nodeType.prototype.showHistorySection = function () {
this.seedView.expand(true);
this.startAutoHide();
};
nodeType.prototype.formatTimeAgo = function (timestamp) {
const seconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000));
if (seconds >= 86400) return `${Math.floor(seconds / 86400)}d ago`;
if (seconds >= 3600) return `${Math.floor(seconds / 3600)}h ago`;
if (seconds >= 60) return `${Math.floor(seconds / 60)}m ago`;
return `${seconds}s ago`;
};
nodeType.prototype.showMessage = function (message, type = 'info') {
this.seedView.status.textContent = message;
this.seedView.status.dataset.kind = type;
};
},
});
+88
View File
@@ -0,0 +1,88 @@
# ComfyAssets controls: first production consumer
The Local Image Loader uses `core.js` and `controls.css` for the approved shared
blue controls and host-following light/dark themes. `../views/image-browser.js`
composes these primitives; `../kiko_local_image_loader.js` retains the controller,
API calls and existing selection persistence.
Grid thumbnails default to 224 px with square preview areas and preserve the
entire image. The Thumbnail size slider adjusts the grid from 128–384 px. A full
page scrolls within the gallery instead of compressing the rows to fit the node.
Thumbnail size is a per-node view setting for the current session.
New nodes start at 800 × 900 to leave room for the larger previews. Existing
workflows retain their saved node dimensions and can be resized normally.
`createScope()` owns listeners, delayed work and a fetch cancellation signal.
Dispose it on node removal. `mountTheme()` observes host palette changes and
removes its observers/listeners with that scope. The module has no ComfyUI imports;
the extension passes the host settings interface explicitly.
Both projects will use the same theme and controls. This is the first consumer,
not a published package yet. Keep routes, node definitions and business state out
of `ui/`; add abstractions only as demonstrated by the next consumer.
Architecture and control organization were inspired by
[Pixaroma](https://github.com/pixaroma/ComfyUI-Pixaroma). This implementation and
styling are original; none of his toolkit code or assets are vendored.
## Floating windows
`floating-window.js` owns pointer capture, keyboard movement/resizing, viewport
bounds and observer cleanup. `window.css` consumes the same control tokens as the
loader. Kiko Save Image is its first consumer; the controller has no node, API or
gallery dependencies.
Drag the title or bottom-right resize handle. Focus either handle and use arrow
keys (Shift for larger steps). Escape closes the focused window. The viewer stays
nonmodal so the canvas remains usable. Window position and size survive new
results and minimize/reopen within the session; they are not written into a
workflow or persisted across browser reloads.
The owning node controls removal. Another node may take over the shared viewer,
but deleting the previous owner must not remove it. Closing or deleting a viewer,
or replacing its results, cancels delayed bulk-open actions.
## Seed History
`../views/seed-history.js` composes the shared buttons and theme tokens. The
extension keeps seed generation, callbacks, history and workflow persistence.
History entries are native buttons: Tab to an entry and press Enter or Space to
reuse its seed. The selected entry has a visible outline and pressed state.
The list fills the available node space and scrolls as needed. It automatically
hides after 2.5 seconds of inactivity, except while hovered or keyboard-focused.
Use Show history / Hide history to control it explicitly. Feedback appears in
the node's status area instead of temporary overlays on the canvas.
The existing browser storage key and serialized `seedHistory` field are retained.
Up to ten unique valid seeds are kept, including zero; malformed entries are
ignored. Browser storage failures leave in-memory/workflow history usable.
New nodes start at 320 × 420; saved workflows retain their node dimensions.
Node removal disposes theme observers, event listeners, polling and setup/hide
timers through `createScope()`.
## Workflow Timer and color controls
Workflow Timer uses the shared shell and theme without overriding node title or
background colors. New nodes start at 340 × 180; saved dimensions and the
`elapsed_time_str` property are restored with the workflow. Serialization saves
a plain snapshot instead of a reference to live node properties.
`workflow-clock.js` coordinates one polling interval for all visible timer
nodes. New timers join the current execution; the interval stops when the last
node is removed. Completion, interruption and errors freeze the same elapsed
time across nodes. Color/glow changes apply immediately during execution.
The existing `kikotools.workflow_timer.color` and `.glow` settings are retained.
Configured colors are preserved; the default is shared blue. Glow also respects
the system's reduced-motion preference.
`createColorPicker()` provides native Hue/Saturation/Brightness sliders, validated
six-digit hex input and keyboard-operable presets inside a native modal dialog.
Changes apply immediately on input; hovering does not change the saved color.
Done or Escape closes the picker and returns focus to its trigger. Disconnecting
the settings control closes the dialog and disposes its listeners/theme scope.
The setting renderer reuses its control across value updates. Its dialog lives
in the document body so those re-renders preserve focus and native slider
interaction. Cleanup waits one microtask to distinguish synchronous reparenting
from actually closing/removing the settings control.
+126
View File
@@ -0,0 +1,126 @@
import { createButton, createField, createScope, createSlider, mountTheme } from './core.js';
import { hexToHsv, hsvToHex, normalizeHex } from './color.js';
let nextPickerId = 0;
const presets = ['#0877BB', '#D0021B', '#F5A623', '#F8E71C', '#8B572A', '#7ED321',
'#417505', '#BD10E0', '#9013FE', '#4A90D9', '#50E3C2', '#B8E986', '#000000', '#4A4A4A', '#9B9B9B', '#FFFFFF'];
class ColorPicker extends HTMLElement {
connectedCallback() {
if (this.scope && !this.scope.signal.aborted) return;
this.scope = createScope();
this.classList.add('ca-tools', 'ca-color-picker');
mountTheme(this, this.settings, this.scope);
const id = `ca-color-${++nextPickerId}`;
this.trigger = createButton(`Choose ${this.label.toLowerCase()}`, 'ca-color-trigger');
const swatch = document.createElement('span');
swatch.className = 'ca-color-swatch';
swatch.setAttribute('aria-hidden', 'true');
this.trigger.prepend(swatch);
const value = document.createElement('span');
value.className = 'ca-color-value';
this.dialog = document.createElement('dialog');
this.dialog.className = 'ca-tools ca-color-dialog';
mountTheme(this.dialog, this.settings, this.scope);
this.dialog.setAttribute('aria-labelledby', `${id}-title`);
const heading = document.createElement('h2');
heading.id = `${id}-title`;
heading.textContent = this.label;
const hint = document.createElement('p');
hint.textContent = 'Changes apply immediately.';
hint.className = 'ca-color-hint';
this.hexInput = document.createElement('input');
Object.assign(this.hexInput, { type: 'text', maxLength: 7, autocomplete: 'off', spellcheck: false, pattern: '#?[0-9a-fA-F]{6}' });
this.hexInput.setAttribute('aria-invalid', 'false');
this.error = document.createElement('p');
this.error.id = `${id}-error`;
this.error.className = 'ca-color-error';
this.error.setAttribute('role', 'alert');
this.hexInput.setAttribute('aria-describedby', this.error.id);
this.dialog.append(heading, hint, createField('Hex color', this.hexInput, `${id}-hex`), this.error);
this.sliders = [];
for (const [key, label, max, unit] of [['h', 'Hue', 360, 'degrees'], ['s', 'Saturation', 100, '%'], ['v', 'Brightness', 100, '%']]) {
const field = createSlider(label, { id: `${id}-${key}`, min: 0, max, step: 1, value: 0, unit }, this.scope, next => {
this.hsv[key] = next;
this.hex = hsvToHex(this.hsv.h, this.hsv.s, this.hsv.v);
this.sync();
this.onChange(this.hex);
});
this.sliders.push({ field, key, unit });
this.dialog.append(field);
}
const palette = document.createElement('div');
palette.className = 'ca-color-presets';
for (const color of presets) {
const button = createButton('', 'ca-color-preset');
button.setAttribute('aria-label', `Use color ${color}`);
button.style.setProperty('--ca-picked-color', color);
button.dataset.color = color.toLowerCase();
this.scope.listen(button, 'click', () => this.applyHex(color));
palette.append(button);
}
const done = createButton('Done', 'ca-primary');
done.classList.add('ca-color-done');
this.dialog.append(palette, done);
this.replaceChildren(this.trigger, value);
document.body.append(this.dialog);
this.valueDisplay = value;
this.scope.listen(this.trigger, 'click', () => this.dialog.showModal());
this.scope.listen(done, 'click', () => this.dialog.close());
this.scope.listen(this.dialog, 'keydown', event => event.stopPropagation());
this.scope.listen(this.dialog, 'close', () => { if (this.isConnected) this.trigger.focus(); });
this.scope.listen(this.hexInput, 'change', () => this.applyHex(this.hexInput.value));
this.scope.cleanup(() => {
if (this.dialog.open) this.dialog.close();
this.dialog.remove();
this.replaceChildren();
});
this.sync();
}
disconnectedCallback() {
const scope = this.scope;
queueMicrotask(() => { if (!this.isConnected) scope.dispose(); });
}
applyHex(value) {
const hex = normalizeHex(value);
if (!hex) {
this.hexInput.setAttribute('aria-invalid', 'true');
this.error.textContent = 'Use six hexadecimal digits, for example #0877BB.';
return;
}
this.hex = hex;
this.hsv = hexToHsv(hex);
this.sync();
this.onChange(hex);
}
sync() {
this.style.setProperty('--ca-picked-color', this.hex);
this.hexInput.value = this.hex.toUpperCase();
this.hexInput.setAttribute('aria-invalid', 'false');
this.error.textContent = '';
this.valueDisplay.textContent = this.hex.toUpperCase();
for (const { field, key, unit } of this.sliders) {
const value = Math.round(this.hsv[key]);
const input = field.querySelector('input');
input.value = value;
input.setAttribute('aria-valuetext', `${value} ${unit}`);
field.querySelector('output').value = key === 'h' ? `${value}°` : `${value} %`;
}
this.dialog.querySelectorAll('button[data-color]').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.color === this.hex)));
}
}
if (!customElements.get('ca-color-picker')) customElements.define('ca-color-picker', ColorPicker);
export function createColorPicker(value, onChange, settings, label = 'Color') {
const picker = document.createElement('ca-color-picker');
picker.hex = normalizeHex(value) || '#0877bb';
picker.hsv = hexToHsv(picker.hex);
picker.onChange = onChange;
picker.settings = settings;
picker.label = label;
return picker;
}
+27
View File
@@ -0,0 +1,27 @@
export function normalizeHex(value) {
return typeof value === 'string' && /^#?[\da-f]{6}$/i.test(value)
? `#${value.replace(/^#/, '').toLowerCase()}` : null;
}
export function hexToHsv(hex) {
const [r, g, b] = hex.slice(1).match(/../g).map(value => parseInt(value, 16) / 255);
const max = Math.max(r, g, b), min = Math.min(r, g, b);
const delta = max - min;
let h = 0;
if (delta) {
if (max === r) h = (g - b) / delta + (g < b ? 6 : 0);
else if (max === g) h = (b - r) / delta + 2;
else h = (r - g) / delta + 4;
}
return { h: h * 60, s: max ? delta / max * 100 : 0, v: max * 100 };
}
export function hsvToHex(h, s, v) {
h = ((h % 360) + 360) % 360;
s = Math.max(0, Math.min(100, s)) / 100;
v = Math.max(0, Math.min(100, v)) / 100;
const c = v * s, x = c * (1 - Math.abs((h / 60) % 2 - 1)), m = v - c;
const channels = h < 60 ? [c, x, 0] : h < 120 ? [x, c, 0] : h < 180 ? [0, c, x]
: h < 240 ? [0, x, c] : h < 300 ? [x, 0, c] : [c, 0, x];
return '#' + channels.map(value => Math.round((value + m) * 255).toString(16).padStart(2, '0')).join('');
}
+171
View File
@@ -0,0 +1,171 @@
.ca-tools {
color-scheme: dark;
--ca-surface: #2b2b2e;
--ca-field: #202023;
--ca-control: #37373b;
--ca-hover: #45454b;
--ca-text: #eeeef1;
--ca-muted: #b5b5bd;
--ca-line: #48484f;
--ca-border: #7d7d87;
--ca-accent: #0877bb;
--ca-focus: #5bc2ff;
--ca-error: #d52430;
--ca-error-text: #ffadb3;
--ca-error-bg: #391d23;
font: 13px/1.5 Inter, ui-sans-serif, system-ui, sans-serif;
background: var(--ca-surface);
color: var(--ca-text);
}
.ca-tools[data-ca-theme="light"] {
color-scheme: light;
--ca-surface: #ffffff;
--ca-field: #f5f5f7;
--ca-control: #eeeef1;
--ca-hover: #e2e2e8;
--ca-text: #23232b;
--ca-muted: #5c5c68;
--ca-line: #d4d4dc;
--ca-border: #85858f;
--ca-focus: #0066a6;
--ca-error-text: #a20d20;
--ca-error-bg: #fff0f1;
}
.ca-tools :where(div, span, label, input, textarea, button, select, fieldset) { box-sizing: border-box; }
.ca-tools :where(button, input, select, textarea) { font: inherit; color: inherit; accent-color: var(--ca-accent); }
.ca-tools :where(button, select, input:not([type="radio"], [role="switch"]), textarea) { border: 1px solid var(--ca-border); border-radius: 5px; min-block-size: 30px; }
.ca-tools button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 4px 10px; background: var(--ca-control); cursor: pointer; white-space: nowrap; }
.ca-tools button:hover:not(:disabled) { background: var(--ca-hover); }
.ca-tools button:disabled { opacity: .42; cursor: default; }
.ca-tools :where(input:not([type="radio"], [role="switch"]), select, textarea) { background: var(--ca-field); min-inline-size: 0; padding: 4px 8px; }
.ca-tools :where(button, input, select, textarea):focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 2px; }
.ca-tools :where(input, textarea)::placeholder { color: var(--ca-muted); opacity: 1; }
.ca-tools .ca-primary, .ca-tools button[aria-current="page"] { background: var(--ca-accent); border-color: var(--ca-accent); color: #fff; }
.ca-tools .ca-primary:hover:not(:disabled) { background: #096ba6; }
.ca-tools .ca-danger { background: var(--ca-error); border-color: var(--ca-error); color: #fff; }
.ca-tools .ca-danger:hover:not(:disabled) { background: #b51c28; }
.ca-tools .ca-icon-button { min-inline-size: 30px; padding-inline: 6px; }
.ca-tools .ca-field-label { display: block; font-size: 11px; color: var(--ca-muted); }
.ca-tools .ca-field { display: inline-flex; align-items: center; gap: 6px; min-inline-size: 0; }
.ca-tools .ca-switch { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; min-block-size: 30px; font-size: 12px; }
.ca-tools .ca-switch input { appearance: none; position: relative; inline-size: 36px; min-block-size: 24px; border: 1px solid var(--ca-border); border-radius: 20px; background: var(--ca-control); margin: 0; cursor: pointer; flex-shrink: 0; }
.ca-tools .ca-switch input::before { content: ""; position: absolute; inline-size: 16px; block-size: 16px; background: var(--ca-muted); inset-block-start: 3px; inset-inline-start: 3px; border-radius: 50%; }
.ca-tools .ca-switch input:checked { background: var(--ca-accent); border-color: var(--ca-accent); }
.ca-tools .ca-switch input:checked::before { background: #fff; inset-inline-start: 15px; }
.ca-tools .ca-segments { border: 0; padding: 0; margin: 0; display: inline-flex; gap: 2px; min-inline-size: 0; }
.ca-tools .ca-segments label { position: relative; display: inline-flex; min-inline-size: 24px; min-block-size: 30px; cursor: pointer; }
.ca-tools .ca-segments input { position: absolute; inset: 0; margin: 0; opacity: 0; inline-size: 100%; block-size: 100%; cursor: pointer; }
.ca-tools .ca-segments label > span { padding: 4px 12px; background: var(--ca-field); border: 1px solid var(--ca-border); border-radius: 4px; color: var(--ca-text); font-size: 13px; }
.ca-tools .ca-segments input:checked + span { background: var(--ca-accent); border-color: var(--ca-accent); color: #fff; }
.ca-tools .ca-segments input:focus-visible + span { outline: 2px solid var(--ca-focus); outline-offset: 2px; }
.ca-tools .ca-visually-hidden { position: absolute; inline-size: 1px; block-size: 1px; clip-path: inset(50%); overflow: hidden; white-space: nowrap; }
.ca-image-browser { inline-size: 100%; block-size: 100%; min-block-size: 350px; overflow: hidden; border-radius: 6px; container-type: inline-size; contain: size; }
.ca-image-browser .kiko-container-wrapper { display: flex; flex-direction: column; gap: 12px; padding: 12px; block-size: 100%; min-block-size: 350px; overflow: auto; }
.ca-image-browser .ca-browser-header { display: flex; justify-content: space-between; align-items: center; gap: 8px; border-block-end: 1px solid var(--ca-line); padding-block-end: 8px; }
.ca-image-browser .ca-browser-header strong { font-size: 13px; font-weight: 550; }
.ca-image-browser .ca-browser-header span { font-size: 10px; color: var(--ca-muted); }
.ca-image-browser :where(.kiko-controls, .ca-sort, .ca-gallery-tools) { display: flex; align-items: center; gap: 7px; min-inline-size: 0; flex-shrink: 0; }
.ca-image-browser .kiko-path-presets { flex: 1; inline-size: 60px; }
.ca-image-browser .ca-path-row { display: grid; grid-template-columns: 30px minmax(0, 1fr) auto; margin-block-start: 4px; }
.ca-image-browser .kiko-path-input-wrapper { position: relative; min-inline-size: 0; }
.ca-image-browser .kiko-path-input { inline-size: 100%; }
.ca-image-browser .ca-options { justify-content: space-between; flex-wrap: wrap; }
.ca-image-browser .ca-sort { flex-wrap: wrap; }
.ca-image-browser .ca-sort select { font-size: 12px; }
.ca-image-browser .ca-gallery-tools { flex-wrap: wrap; }
.ca-image-browser .ca-file-filter { flex: 1; inline-size: 100px; }
.ca-image-browser .kiko-cardholder { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, var(--ca-thumbnail-size, 224px)), 1fr)); grid-auto-rows: max-content; gap: 8px; align-items: start; align-content: start; overflow: auto; min-block-size: 100px; flex: 1; padding: 3px; }
.ca-image-browser .ca-empty { grid-column: 1 / -1; text-align: center; padding: 24px 8px; color: var(--ca-muted); font-size: 12px; }
.ca-image-browser button.kiko-gallery-card { display: flex; flex-direction: column; justify-content: start; min-inline-size: 0; overflow: hidden; border: 1px solid var(--ca-border); padding: 4px; white-space: normal; background: var(--ca-field); }
.ca-image-browser button.kiko-gallery-card.kiko-selected { border: 2px solid var(--ca-focus); padding: 3px; }
.ca-image-browser .kiko-card-media-wrapper { position: relative; inline-size: 100%; aspect-ratio: 1; flex-shrink: 0; overflow: hidden; border-radius: 3px; }
.ca-image-browser .kiko-card-media-wrapper img { display: block; inline-size: 100%; block-size: 100%; object-fit: contain; }
.ca-image-browser .kiko-card-info-panel { inline-size: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: start; padding: 6px 3px 3px; font-size: 11px; }
.ca-image-browser :where(.kiko-folder-card, .kiko-audio-card) { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; block-size: 100%; inline-size: 100%; background: var(--ca-field); }
.ca-image-browser :where(.kiko-folder-icon, .kiko-audio-icon) { inline-size: 40px; block-size: 40px; }
.ca-image-browser :where(.kiko-folder-name, .kiko-audio-name) { max-inline-size: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; }
.ca-image-browser .kiko-video-card-overlay { position: absolute; inset: 0; display: grid; place-items: center; pointer-events: none; }
.ca-image-browser .kiko-video-card-overlay svg { inline-size: 36px; block-size: 36px; }
.ca-image-browser .kiko-cardholder[data-view="list"] { grid-template-columns: 1fr; }
.ca-image-browser .kiko-cardholder[data-view="list"] button.kiko-gallery-card { display: flex; flex-direction: row; align-items: center; gap: 10px; }
.ca-image-browser .kiko-cardholder[data-view="list"] .kiko-card-media-wrapper { inline-size: 64px; flex-shrink: 0; }
.ca-image-browser .ca-browser-footer { display: flex; align-items: center; justify-content: space-between; gap: 6px; flex-wrap: wrap; font-size: 11px; color: var(--ca-muted); }
.ca-image-browser .kiko-pagination { display: flex; flex-wrap: wrap; gap: 4px; }
.ca-image-browser .kiko-pagination button { font-size: 11px; min-inline-size: 30px; padding-inline: 5px; }
.ca-image-browser .ca-selection { border-block-start: 1px solid var(--ca-line); padding-block-start: 8px; }
.ca-image-browser .ca-selection strong { font-size: 12px; font-weight: 500; overflow-wrap: anywhere; }
.ca-tools .ca-range { display: flex; align-items: center; gap: 10px; flex-shrink: 0; color: var(--ca-muted); font-size: 11px; }
.ca-tools .ca-range input[type="range"] { flex: 1; inline-size: 60px; min-inline-size: 0; margin: 0; padding: 0; border: 0; min-block-size: 30px; cursor: pointer; }
.ca-tools .ca-range output { min-inline-size: 44px; font-variant-numeric: tabular-nums; }
.ca-image-browser .kiko-status-message { padding: 10px; color: var(--ca-muted); border: 1px solid var(--ca-line); border-radius: 5px; font-size: 12px; }
.ca-image-browser .kiko-status-message[data-state="error"] { color: var(--ca-error-text); background: var(--ca-error-bg); border-color: var(--ca-error); }
.ca-image-browser .kiko-status-message code { overflow-wrap: anywhere; }
.ca-image-browser .kiko-status-message button { margin-block-start: 6px; }
.ca-image-browser .kiko-autocomplete-dropdown { display: none; position: absolute; inset-block-start: 100%; inset-inline: 0; max-block-size: 180px; overflow: auto; background: var(--ca-field); border: 1px solid var(--ca-border); border-radius: 5px; z-index: 10; }
.ca-image-browser .kiko-autocomplete-dropdown.visible { display: block; }
.ca-image-browser .kiko-autocomplete-item { display: flex; align-items: center; gap: 7px; padding: 7px; cursor: pointer; }
.ca-image-browser .kiko-autocomplete-item:is(:hover, .selected) { background: var(--ca-hover); }
.ca-image-browser .kiko-autocomplete-item .folder-icon { inline-size: 16px; block-size: 16px; flex-shrink: 0; }
.ca-image-browser .kiko-autocomplete-item .path-text { min-inline-size: 0; overflow-wrap: anywhere; font-size: 12px; }
.ca-image-browser .kiko-autocomplete-item .parent-path { color: var(--ca-muted); }
@container (max-width: 440px) {
.ca-image-browser .ca-options > .ca-switch { flex-basis: 100%; }
.ca-image-browser .ca-sort { inline-size: 100%; gap: 6px; }
.ca-image-browser .ca-sort .ca-field { flex: 1; }
.ca-image-browser .ca-sort .ca-field-label { position: absolute; clip-path: inset(50%); }
.ca-image-browser .ca-sort select { inline-size: 100%; }
}
@media (pointer: coarse) { .ca-tools :where(button, select), .ca-tools .ca-segments label > span, .ca-tools .ca-switch { min-block-size: 40px; } }
.ca-seed-history { inline-size: 100%; block-size: 100%; min-block-size: 200px; display: flex; flex-direction: column; gap: 8px; padding: 10px; border: 1px solid var(--ca-line); border-radius: 6px; overflow: auto; contain: size; }
.ca-seed-history [hidden] { display: none; }
.ca-seed-header { display: flex; align-items: center; justify-content: space-between; gap: 8px; border-block-end: 1px solid var(--ca-line); padding-block-end: 6px; }
.ca-seed-header h3 { margin: 0; font-size: 13px; font-weight: 550; }
.ca-seed-header > span { font-size: 10px; color: var(--ca-muted); }
.ca-seed-actions { display: flex; gap: 7px; flex-wrap: wrap; }
.ca-seed-actions button { flex: 1; }
.ca-seed-history .ca-seed-disclosure { inline-size: 100%; flex-shrink: 0; font-size: 12px; }
.ca-seed-list { flex: 1; min-block-size: 60px; overflow: auto; padding: 3px; margin: 0; list-style: none; border: 1px solid var(--ca-line); border-radius: 5px; background: var(--ca-field); }
.ca-seed-list li + li { margin-block-start: 5px; }
.ca-seed-history button.ca-seed-entry { inline-size: 100%; display: flex; flex-wrap: wrap; justify-content: space-between; gap: 3px 10px; white-space: normal; text-align: start; }
.ca-seed-entry[aria-pressed="true"] { border-color: var(--ca-focus); outline: 1px solid var(--ca-focus); outline-offset: -2px; }
.ca-seed-value { font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
.ca-seed-time { font-size: 11px; color: var(--ca-muted); }
.ca-seed-empty { margin: 0; padding: 12px 4px; color: var(--ca-muted); font-size: 12px; text-align: center; }
.ca-seed-status { min-block-size: 20px; font-size: 11px; overflow-wrap: anywhere; color: var(--ca-muted); flex-shrink: 0; }
.ca-seed-status[data-kind="error"] { color: var(--ca-error-text); background: var(--ca-error-bg); }
.ca-workflow-timer { box-sizing: border-box; inline-size: 100%; block-size: 100%; min-block-size: 90px; padding: 10px; border: 1px solid var(--ca-line); border-radius: 6px; display: flex; flex-direction: column; overflow: hidden; container-type: inline-size; contain: size; }
.ca-timer-header { display: flex; justify-content: space-between; align-items: center; gap: 8px; border-block-end: 1px solid var(--ca-line); padding-block-end: 6px; flex-shrink: 0; }
.ca-timer-header h3 { font-size: 13px; font-weight: 550; margin: 0; }
.ca-timer-header > span { color: var(--ca-muted); font-size: 10px; }
.ca-workflow-timer .kiko-timer-display { flex: 1; min-block-size: 0; display: flex; align-items: center; justify-content: center; color: var(--ca-timer-color, var(--ca-accent)); font: 600 clamp(16px, 10cqi, 64px)/1.3 ui-monospace, monospace; font-variant-numeric: tabular-nums; white-space: nowrap; animation: ca-timer-glow 4s ease-in-out infinite; }
.ca-workflow-timer .kiko-timer-display.running { animation-duration: 2s; }
.ca-workflow-timer .kiko-timer-display.no-glow { animation: none; text-shadow: none; }
@keyframes ca-timer-glow { 0%, 100% { text-shadow: 0 0 8px currentColor; } 50% { text-shadow: 0 0 16px currentColor; } }
@media (prefers-reduced-motion: reduce) { .ca-workflow-timer .kiko-timer-display { animation: none; text-shadow: none; } }
.ca-color-picker { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.ca-color-value { font-size: 12px; color: var(--ca-muted); font-family: ui-monospace, monospace; }
.ca-color-swatch { inline-size: 24px; block-size: 20px; border: 1px solid var(--ca-border); border-radius: 3px; background: var(--ca-picked-color); }
.ca-color-dialog { box-sizing: border-box; inline-size: 360px; max-inline-size: calc(100dvw - 32px); max-block-size: calc(100dvh - 32px); overflow: auto; padding: 16px; border: 1px solid var(--ca-border); border-radius: 8px; background: var(--ca-surface); color: var(--ca-text); box-shadow: 0 8px 28px #00000040; }
.ca-color-dialog[open] { display: flex; flex-direction: column; gap: 12px; }
.ca-color-dialog::backdrop { background: #0008; }
.ca-color-dialog h2 { margin: 0; font-size: 16px; }
.ca-color-dialog :where(.ca-color-hint, .ca-color-error) { margin: 0; font-size: 12px; }
.ca-color-hint { color: var(--ca-muted); }
.ca-color-error { color: var(--ca-error-text); }
.ca-color-error:empty { display: none; }
.ca-color-dialog .ca-field { justify-content: space-between; }
.ca-color-dialog input[type="text"] { inline-size: 130px; font-family: ui-monospace, monospace; }
.ca-color-dialog input[aria-invalid="true"] { border-color: var(--ca-error); }
.ca-color-dialog .ca-range { display: grid; grid-template-columns: 75px minmax(0, 1fr) 65px; gap: 8px; align-items: center; }
.ca-color-dialog .ca-range :where(label, output) { font-size: 11px; color: var(--ca-muted); }
.ca-color-dialog .ca-range input[type="range"] { inline-size: 100%; padding: 0; border: 0; background: transparent; }
.ca-color-presets { display: grid; grid-template-columns: repeat(8, minmax(0, 1fr)); gap: 6px; padding: 3px; }
.ca-color-dialog button.ca-color-preset { min-inline-size: 24px; min-block-size: 30px; padding: 0; background: var(--ca-picked-color); border-color: var(--ca-border); }
.ca-color-dialog button.ca-color-preset[aria-pressed="true"] { outline: 2px solid var(--ca-focus); outline-offset: 2px; }
.ca-color-done { align-self: end; }
@media (pointer: coarse) { .ca-color-dialog button.ca-color-preset { min-block-size: 40px; } }
@media (forced-colors: active) {
.ca-tools { --ca-accent: Highlight; --ca-focus: Highlight; --ca-border: ButtonText; --ca-line: CanvasText; }
.ca-tools .ca-switch input:checked::before { background: HighlightText; }
.ca-tools button.kiko-selected { outline: 2px solid Highlight; }
}
+138
View File
@@ -0,0 +1,138 @@
export function createScope() {
const controller = new AbortController();
const cleanups = [];
return {
signal: controller.signal,
listen(target, event, callback) {
target.addEventListener(event, callback, { signal: controller.signal });
},
cleanup(callback) { cleanups.push(callback); },
debounce(callback, delay) {
let timer;
cleanups.push(() => clearTimeout(timer));
return (...args) => {
clearTimeout(timer);
if (!controller.signal.aborted) timer = setTimeout(() => callback(...args), delay);
};
},
dispose() {
if (controller.signal.aborted) return;
controller.abort();
cleanups.splice(0).forEach(callback => callback());
},
};
}
export function themeFromColor(color) {
let values;
const hex = color.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i)?.[1];
if (hex) {
const expanded = hex.length === 3 ? [...hex].map(value => value + value).join('') : hex;
values = expanded.match(/../g).map(value => parseInt(value, 16));
} else if (/^rgb\(/.test(color)) {
values = color.match(/[\d.]+/g)?.slice(0, 3).map(Number);
}
if (!values || values.length !== 3) return null;
const linear = values.map(value => value / 255).map(value => value <= .04045 ? value / 12.92 : ((value + .055) / 1.055) ** 2.4);
return linear[0] * .2126 + linear[1] * .7152 + linear[2] * .0722 > .35 ? 'light' : 'dark';
}
export function mountTheme(element, settings, scope) {
const stylesheet = new URL('./controls.css', import.meta.url).href;
if (![...document.querySelectorAll('link[rel="stylesheet"]')].some(link => link.href === stylesheet)) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = stylesheet;
document.head.append(link);
}
const preference = matchMedia('(prefers-color-scheme: dark)');
const update = () => {
const background = getComputedStyle(document.body).getPropertyValue('--bg-color').trim();
element.dataset.caTheme = themeFromColor(background) || (preference.matches ? 'dark' : 'light');
};
const observer = new MutationObserver(update);
for (const target of [document.documentElement, document.body]) observer.observe(target, { attributes: true, attributeFilter: ['style', 'class', 'data-theme'] });
scope.cleanup(() => observer.disconnect());
scope.listen(preference, 'change', update);
if (settings?.addEventListener) scope.listen(settings, 'Comfy.ColorPalette.change', update);
update();
}
export function createButton(text, className = '') {
const element = document.createElement('button');
element.type = 'button';
element.className = className;
element.textContent = text;
return element;
}
export function createSlider(label, { id, min, max, step, value, unit }, scope, onInput) {
const field = document.createElement('div');
field.className = 'ca-range';
const caption = document.createElement('label');
caption.htmlFor = id;
caption.textContent = label;
const input = document.createElement('input');
Object.assign(input, { type: 'range', id, min, max, step, value });
const output = document.createElement('output');
output.setAttribute('for', id);
output.value = `${value} ${unit}`;
scope.listen(input, 'input', () => {
output.value = `${input.value} ${unit}`;
onInput(Number(input.value));
});
field.append(caption, input, output);
return field;
}
export function createField(label, input, id) {
const field = document.createElement('label');
field.className = 'ca-field';
field.htmlFor = id;
const caption = document.createElement('span');
caption.className = 'ca-field-label';
caption.textContent = label;
input.id = id;
field.append(caption, input);
return field;
}
export function createSwitch(label, className, id) {
const input = document.createElement('input');
input.type = 'checkbox';
input.role = 'switch';
input.checked = true;
input.className = className;
const field = createField(label, input, id);
field.className = 'ca-switch';
field.replaceChildren(input, document.createTextNode(label));
return field;
}
export function createSelect(options, className) {
const select = document.createElement('select');
select.className = className;
for (const [value, text] of options) select.add(new Option(text, value));
return select;
}
export function createSegments(label, options, name, scope, onChange) {
const fieldset = document.createElement('fieldset');
fieldset.className = 'ca-segments';
const legend = document.createElement('legend');
legend.className = 'ca-visually-hidden';
legend.textContent = label;
fieldset.append(legend);
options.forEach(([value, text], index) => {
const input = document.createElement('input');
input.type = 'radio';
input.name = name;
input.value = value;
input.checked = index === 0;
const field = createField(text, input, `${name}-${value}`);
field.replaceChildren(input, field.firstChild);
scope.listen(input, 'change', () => onChange(value));
fieldset.append(field);
});
return fieldset;
}
+80
View File
@@ -0,0 +1,80 @@
export function fitWindowRect(rect, viewport) {
const width = Math.min(Math.max(280, rect.width), Math.max(1, viewport.width - 16));
const height = Math.min(Math.max(200, rect.height), Math.max(1, viewport.height - 16));
return {
width, height,
x: Math.max(8, Math.min(rect.x, viewport.width - width - 8)),
y: Math.max(8, Math.min(rect.y, viewport.height - height - 8)),
};
}
export function mountFloatingWindow(element, scope, onClose) {
const stylesheet = new URL('./window.css', import.meta.url).href;
if (![...document.querySelectorAll('link[rel="stylesheet"]')].some(link => link.href === stylesheet)) {
const link = document.createElement('link');
Object.assign(link, { rel: 'stylesheet', href: stylesheet });
document.head.append(link);
}
let gesture;
const viewport = () => ({ width: innerWidth, height: innerHeight });
const move = (x, y) => {
const rect = element.getBoundingClientRect();
const next = fitWindowRect({ x, y, width: rect.width, height: rect.height }, viewport());
element.style.left = `${next.x}px`;
element.style.top = `${next.y}px`;
element.style.right = 'auto';
};
const resize = (width, height) => {
const rect = element.getBoundingClientRect();
const next = fitWindowRect({ x: rect.x, y: rect.y, width, height }, viewport());
element.style.setProperty('--ca-window-width', `${next.width}px`);
element.style.setProperty('--ca-window-height', `${next.height}px`);
move(next.x, next.y);
};
const end = () => {
if (gesture && element.hasPointerCapture(gesture.id)) element.releasePointerCapture(gesture.id);
gesture = null;
element.classList.remove('ca-dragging');
};
scope.listen(element, 'pointerdown', event => {
const handle = event.target.closest('[data-window-move], [data-window-resize]');
if (!handle || event.button !== 0) return;
const rect = element.getBoundingClientRect();
gesture = { id: event.pointerId, kind: handle.hasAttribute('data-window-resize') ? 'resize' : 'move', rect, x: event.clientX, y: event.clientY };
handle.focus();
element.setPointerCapture(event.pointerId);
element.classList.add('ca-dragging');
event.preventDefault();
});
scope.listen(element, 'pointermove', event => {
if (!gesture || event.pointerId !== gesture.id) return;
const dx = event.clientX - gesture.x;
const dy = event.clientY - gesture.y;
if (gesture.kind === 'resize') resize(gesture.rect.width + dx, gesture.rect.height + dy);
else move(gesture.rect.x + dx, gesture.rect.y + dy);
});
for (const event of ['pointerup', 'pointercancel', 'lostpointercapture']) scope.listen(element, event, end);
scope.listen(element, 'keydown', event => {
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); onClose(); return; }
const handle = event.target.closest('[data-window-move], [data-window-resize]');
if (!handle || !['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) return;
event.preventDefault();
event.stopPropagation();
const delta = event.shiftKey ? 40 : 16;
const dx = event.key === 'ArrowLeft' ? -delta : event.key === 'ArrowRight' ? delta : 0;
const dy = event.key === 'ArrowUp' ? -delta : event.key === 'ArrowDown' ? delta : 0;
const rect = element.getBoundingClientRect();
if (handle.hasAttribute('data-window-resize')) resize(rect.width + dx, rect.height + dy);
else move(rect.x + dx, rect.y + dy);
});
const keepReachable = () => {
if (element.hidden) return;
const rect = element.getBoundingClientRect();
move(rect.x, rect.y);
};
scope.listen(window, 'resize', keepReachable);
const observer = new ResizeObserver(keepReachable);
observer.observe(element);
scope.cleanup(() => { observer.disconnect(); end(); });
return { fit: keepReachable };
}
+51
View File
@@ -0,0 +1,51 @@
.ca-floating-window {
box-sizing: border-box;
position: fixed;
inset-block-start: 100px;
right: 20px;
inline-size: min(var(--ca-window-width, 600px), calc(100dvw - 16px));
block-size: min(var(--ca-window-height, 560px), calc(100dvh - 16px));
min-inline-size: min(280px, calc(100dvw - 16px));
min-block-size: min(200px, calc(100dvh - 16px));
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--ca-border);
border-radius: 8px;
box-shadow: 0 8px 28px #00000040;
z-index: 10000;
}
.ca-floating-window[hidden], .ca-floating-window [hidden] { display: none; }
.ca-floating-window.ca-minimized { block-size: auto; min-block-size: 0; }
.ca-window-header { display: flex; align-items: center; gap: 8px; padding: 7px 10px; border-block-end: 1px solid var(--ca-line); flex-shrink: 0; }
.ca-window-header .ca-window-handle { min-inline-size: 0; flex: 1; justify-content: start; border: 0; background: transparent; padding-inline: 0; overflow: hidden; text-overflow: ellipsis; cursor: grab; touch-action: none; -webkit-user-select: none; user-select: none; }
.ca-floating-window.ca-dragging [data-window-move] { cursor: grabbing; }
.ca-window-header .kiko-viewer-controls { display: flex; align-items: center; gap: 6px; }
.ca-window-header button.kiko-control-btn { min-inline-size: 30px; padding-inline: 6px; }
.ca-window-header .ca-window-brand { color: var(--ca-muted); font-size: 10px; }
.ca-floating-window .ca-window-resize { position: absolute; inset-inline-end: 2px; inset-block-end: 2px; min-inline-size: 24px; min-block-size: 24px; border: 0; background: var(--ca-surface); color: var(--ca-muted); cursor: nwse-resize; touch-action: none; padding: 0; }
.ca-floating-window .kiko-viewer-container { padding: 12px 12px 28px; overflow: auto; min-block-size: 0; flex: 1; }
.ca-floating-window .kiko-image-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 224px), 1fr)); gap: 10px; align-items: start; }
.ca-floating-window .kiko-image-item { min-inline-size: 0; border: 1px solid var(--ca-border); border-radius: 5px; background: var(--ca-field); padding: 6px; }
.ca-floating-window .kiko-image-open { padding: 0; border: 0; background: var(--ca-field); inline-size: 100%; display: block; }
.ca-floating-window .kiko-image-open img { display: block; inline-size: 100%; block-size: 230px; object-fit: contain; }
.ca-floating-window.ca-single-image .kiko-viewer-container:not([hidden]) { display: flex; flex-direction: column; }
.ca-floating-window.ca-single-image .kiko-image-grid { display: flex; flex: 1; min-block-size: 0; align-items: stretch; }
.ca-floating-window.ca-single-image .kiko-image-item { display: flex; flex-direction: column; flex: 1; min-block-size: 0; }
.ca-floating-window.ca-single-image .kiko-image-open { position: relative; flex: 1; min-block-size: 100px; overflow: hidden; }
.ca-floating-window.ca-single-image .kiko-image-open img { position: absolute; inset: 0; block-size: 100%; }
.ca-floating-window .kiko-image-actions { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding-block-start: 8px; flex-wrap: wrap; }
.ca-floating-window .kiko-image-selector { display: inline-flex; gap: 6px; align-items: center; cursor: pointer; font-size: 12px; min-block-size: 30px; }
.ca-floating-window .kiko-image-selector input { inline-size: 18px; block-size: 18px; min-block-size: 18px; }
.ca-floating-window .kiko-image-info { padding-block-start: 6px; font-size: 11px; }
.ca-floating-window .kiko-image-filename { overflow-wrap: anywhere; }
.ca-floating-window .kiko-image-stats { color: var(--ca-muted); margin-block-start: 3px; }
.ca-floating-window .kiko-format-badge { display: inline-block; border: 1px solid var(--ca-border); border-radius: 3px; padding: 1px 4px; margin-inline-end: 3px; }
.ca-floating-window .kiko-bulk-actions { border-block-start: 1px solid var(--ca-line); margin-block-start: 12px; padding-block-start: 10px; font-size: 12px; color: var(--ca-muted); }
.ca-floating-window .kiko-bulk-buttons { display: flex; flex-wrap: wrap; gap: 7px; margin-block-start: 7px; }
.ca-floating-window .kiko-bulk-buttons button { font-size: 12px; }
.ca-floating-window .ca-window-status { color: var(--ca-muted); font-size: 11px; padding: 6px 30px 6px 12px; border-block-start: 1px solid var(--ca-line); flex-shrink: 0; }
.ca-viewer-launcher { padding: 3px 0; }
.ca-viewer-launcher button { inline-size: 100%; }
@media (pointer: coarse) { .ca-floating-window .ca-window-header button, .ca-floating-window .ca-window-resize { min-inline-size: 40px; min-block-size: 40px; } }
@media (forced-colors: active) { .ca-floating-window { box-shadow: none; border-color: CanvasText; } }
+75
View File
@@ -0,0 +1,75 @@
import { normalizeHex } from './color.js';
export function formatElapsed(ms) {
ms = Number.isFinite(ms) ? Math.max(0, Math.floor(ms)) : 0;
return `${String(Math.floor(ms / 60000)).padStart(2, '0')}:${String(Math.floor(ms / 1000) % 60).padStart(2, '0')}:${String(ms % 1000).padStart(3, '0')}`;
}
export class WorkflowClock {
constructor(settings, environment = globalThis) {
this.settings = settings;
this.environment = environment;
this.activeNodes = new Set();
this.isRunning = false;
this.startTime = 0;
this.intervalId = null;
}
getTimerColor() {
return normalizeHex(this.settings.getSettingValue('kikotools.workflow_timer.color')) || '#0877bb';
}
renderNode(node) {
node.timerDisplay.textContent = this.isRunning
? formatElapsed(this.environment.Date.now() - this.startTime)
: node.properties.elapsed_time_str || '00:00:000';
node.timerDisplay.style.setProperty('--ca-timer-color', this.getTimerColor());
node.timerDisplay.classList.toggle('running', this.isRunning);
node.timerDisplay.classList.toggle('no-glow', this.settings.getSettingValue('kikotools.workflow_timer.glow') === false);
}
refresh() {
this.activeNodes.forEach(node => this.renderNode(node));
}
ensureInterval() {
if (!this.isRunning || !this.activeNodes.size || this.intervalId !== null) return;
this.intervalId = this.environment.setInterval(() => {
const elapsed = formatElapsed(this.environment.Date.now() - this.startTime);
this.activeNodes.forEach(node => { node.timerDisplay.textContent = elapsed; });
}, 33);
}
clearInterval() {
this.environment.clearInterval(this.intervalId);
this.intervalId = null;
}
start() {
if (this.isRunning) return;
this.isRunning = true;
this.startTime = this.environment.Date.now();
this.refresh();
this.ensureInterval();
}
stop() {
if (!this.isRunning) return;
this.isRunning = false;
this.clearInterval();
const elapsed = formatElapsed(this.environment.Date.now() - this.startTime);
this.activeNodes.forEach(node => { node.properties.elapsed_time_str = elapsed; });
this.refresh();
}
registerNode(node) {
this.activeNodes.add(node);
this.refresh();
this.ensureInterval();
}
unregisterNode(node) {
this.activeNodes.delete(node);
if (!this.activeNodes.size) this.clearInterval();
}
}
+45
View File
@@ -0,0 +1,45 @@
import { createButton, createField, createSelect, createSegments, createSlider, createSwitch, mountTheme } from '../ui/core.js';
export function createImageBrowser(id, settings, scope) {
const element = document.createElement('div');
element.className = 'ca-tools ca-image-browser';
element.innerHTML = `<div class="kiko-container-wrapper">
<div class="ca-browser-header"><strong>Local Image Loader</strong><span>ComfyAssets</span></div>
<div class="kiko-controls kiko-path-controls"></div>
<div class="ca-directory"><label class="ca-field-label" for="${id}-path">Directory</label><div class="kiko-controls ca-path-row"><div class="kiko-path-input-wrapper"><input id="${id}-path" class="kiko-path-input" type="text" spellcheck="false" placeholder="Enter a directory…"><div class="kiko-autocomplete-dropdown"></div></div></div></div>
<div class="kiko-controls ca-options"><div class="ca-sort"></div></div>
<div class="ca-gallery-tools"></div>
<div class="kiko-status-message" role="status">Loading…</div>
<div class="kiko-cardholder" data-view="grid"></div>
<div class="ca-browser-footer"><span data-file-count></span><div class="kiko-pagination"></div></div>
<div class="ca-selection"><span class="ca-field-label">Selected media</span><strong data-selection>No media selected</strong></div>
</div>`;
const saved = createSelect([['', '— Saved paths —']], 'kiko-path-presets');
saved.setAttribute('aria-label', 'Saved paths');
const remove = createButton('×', 'kiko-delete-path ca-danger');
remove.setAttribute('aria-label', 'Remove saved path');
element.querySelector('.kiko-path-controls').append(saved, createButton('☆ Save path', 'kiko-save-path'), remove);
const up = createButton('↑', 'kiko-up-folder ca-icon-button');
up.setAttribute('aria-label', 'Parent folder');
const pathRow = element.querySelector('.ca-path-row');
pathRow.prepend(up);
pathRow.append(createButton('Browse', 'kiko-browse ca-primary'));
element.querySelector('.ca-options').prepend(createSwitch('Hide dot folders', 'kiko-hide-dot-folders', `${id}-hidden`));
const refresh = createButton('↻', 'kiko-refresh ca-icon-button');
refresh.setAttribute('aria-label', 'Refresh folder');
element.querySelector('.ca-sort').append(
createField('Sort', createSelect([['name', 'Name'], ['date', 'Date'], ['size', 'Size']], 'kiko-sort-by'), `${id}-sort`),
createField('Order', createSelect([['asc', '↑ Ascending'], ['desc', '↓ Descending']], 'kiko-sort-order'), `${id}-order`), refresh,
);
const filter = document.createElement('input');
filter.type = 'search';
filter.placeholder = 'Filter this page…';
filter.className = 'ca-file-filter';
filter.setAttribute('aria-label', 'Filter this page');
element.querySelector('.ca-gallery-tools').append(filter, createSegments('Gallery layout', [['grid', 'Grid'], ['list', 'List']], `${id}-view`, scope, value => { element.querySelector('.kiko-cardholder').dataset.view = value; }));
element.querySelector('.ca-browser-footer').after(createSlider('Thumbnail size', { id: `${id}-thumbnail-size`, min: 128, max: 384, step: 16, value: 224, unit: 'px' }, scope, value => {
element.style.setProperty('--ca-thumbnail-size', `${value}px`);
}));
mountTheme(element, settings, scope);
return element;
}
+73
View File
@@ -0,0 +1,73 @@
import { createButton } from '../ui/core.js';
let nextHistoryId = 0;
export function createSeedHistoryView(scope, actions) {
const element = document.createElement('div');
element.className = 'ca-tools ca-seed-history';
const header = document.createElement('header');
header.className = 'ca-seed-header';
const title = document.createElement('h3');
title.textContent = 'Seed History';
const brand = document.createElement('span');
brand.textContent = 'ComfyAssets';
header.append(title, brand);
const toolbar = document.createElement('div');
toolbar.className = 'ca-seed-actions';
const generate = createButton('Generate', 'ca-primary');
const clear = createButton('Clear', 'ca-danger');
const disclosure = createButton('Hide history', 'ca-seed-disclosure');
disclosure.setAttribute('aria-expanded', 'true');
toolbar.append(generate, clear);
const list = document.createElement('ol');
list.className = 'ca-seed-list';
list.id = `ca-seed-list-${++nextHistoryId}`;
list.setAttribute('role', 'list');
list.setAttribute('aria-label', 'Recent seeds');
disclosure.setAttribute('aria-controls', list.id);
const empty = document.createElement('p');
empty.className = 'ca-seed-empty';
empty.textContent = 'No seeds tracked';
const status = document.createElement('div');
status.className = 'ca-seed-status';
status.setAttribute('role', 'status');
element.append(header, toolbar, disclosure, list, empty, status);
scope.listen(generate, 'click', actions.generate);
scope.listen(clear, 'click', actions.clear);
scope.listen(disclosure, 'click', () => actions.expand(list.hidden));
scope.listen(list, 'click', event => {
const button = event.target.closest('button[data-seed-index]');
if (button) actions.select(Number(button.dataset.seedIndex));
});
return {
element, list, disclosure, status,
expand(expanded) {
list.hidden = !expanded;
disclosure.textContent = expanded ? 'Hide history' : 'Show history';
disclosure.setAttribute('aria-expanded', String(expanded));
empty.hidden = !expanded || list.childElementCount > 0;
},
render(history, selectedSeed, formatTime) {
const focused = list.contains(document.activeElement) ? document.activeElement.dataset.seed : null;
list.replaceChildren(...history.map((item, index) => {
const row = document.createElement('li');
const button = createButton('', 'ca-seed-entry');
button.dataset.seedIndex = index;
button.dataset.seed = item.seed;
button.setAttribute('aria-label', `Use seed ${item.seed}`);
button.setAttribute('aria-pressed', String(item.seed === selectedSeed));
const seed = document.createElement('span');
seed.className = 'ca-seed-value';
seed.textContent = item.seed;
const time = document.createElement('span');
time.className = 'ca-seed-time';
time.textContent = formatTime(item.timestamp);
button.append(seed, time);
row.append(button);
return row;
}));
empty.hidden = list.hidden || history.length > 0;
if (focused !== null) list.querySelector(`button[data-seed="${focused}"]`)?.focus();
},
};
}
+8 -8
View File
@@ -123,13 +123,13 @@ app.registerExtension({
widthWidget.value = h;
heightWidget.value = w;
if (presetWidget.callback) {
presetWidget.callback(swappedFormattedPreset, this, presetWidget);
presetWidget.callback(swappedFormattedPreset, app.canvas, this, [0, 0], null);
}
if (widthWidget.callback) {
widthWidget.callback(h, this, widthWidget);
widthWidget.callback(h, app.canvas, this, [0, 0], null);
}
if (heightWidget.callback) {
heightWidget.callback(w, this, heightWidget);
heightWidget.callback(w, app.canvas, this, [0, 0], null);
}
} else {
// Swapped preset doesn't exist, switch to custom and swap manual values
@@ -138,13 +138,13 @@ app.registerExtension({
heightWidget.value = w;
if (presetWidget.callback) {
presetWidget.callback("custom", this, presetWidget);
presetWidget.callback("custom", app.canvas, this, [0, 0], null);
}
if (widthWidget.callback) {
widthWidget.callback(h, this, widthWidget);
widthWidget.callback(h, app.canvas, this, [0, 0], null);
}
if (heightWidget.callback) {
heightWidget.callback(w, this, heightWidget);
heightWidget.callback(w, app.canvas, this, [0, 0], null);
}
}
} else {
@@ -155,10 +155,10 @@ app.registerExtension({
// Trigger widget change events
if (widthWidget.callback) {
widthWidget.callback(widthWidget.value, this, widthWidget);
widthWidget.callback(widthWidget.value, app.canvas, this, [0, 0], null);
}
if (heightWidget.callback) {
heightWidget.callback(heightWidget.value, this, heightWidget);
heightWidget.callback(heightWidget.value, app.canvas, this, [0, 0], null);
}
}