Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c47dc6085 | ||
|
|
ee7d1e9011 | ||
|
|
b7a29a1731 | ||
|
|
3cabbffb40 | ||
|
|
47dc177517 | ||
|
|
ddb356fe17 | ||
|
|
9e2b271824 | ||
|
|
2e7fac4b70 | ||
|
|
740eccec27 | ||
|
|
55bce1004b | ||
|
|
63a8596b81 | ||
|
|
11494c747c | ||
|
|
88a9238ee2 | ||
|
|
3879f18d79 | ||
|
|
64f70b6970 | ||
|
|
ae0fb7f229 | ||
|
|
ddf73a667e | ||
|
|
46999c8d90 | ||
|
|
852864a270 | ||
|
|
c182c775f4 | ||
|
|
02108be2d1 | ||
|
|
2a9fbe78c7 | ||
|
|
5a8a8b62e4 | ||
|
|
335cc53961 | ||
|
|
c5e00c130b | ||
|
|
78f1498ea8 | ||
|
|
c65f3aad03 | ||
|
|
9a55c23282 | ||
|
|
e90bde5849 | ||
|
|
49523e10de | ||
|
|
8e6cf4233a | ||
|
|
6301cf90f8 | ||
|
|
f87eb959b3 | ||
|
|
7783ed8a16 | ||
|
|
726fcee761 | ||
|
|
98f3defe50 | ||
|
|
143a6c47f6 | ||
|
|
76a52765c7 | ||
|
|
f308b23631 |
@@ -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
|
||||
@@ -2,6 +2,7 @@
|
||||
max-line-length = 127
|
||||
max-complexity = 10
|
||||
exclude =
|
||||
node_modules,
|
||||
.git,
|
||||
__pycache__,
|
||||
.mypy_cache,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -8,3 +8,7 @@ updates:
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/tools/testing"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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..."
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
#### 🔁 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.
|
||||
|
||||
@@ -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.
|
||||
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 74 KiB |
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
# Runtime dependencies for ComfyUI-KikoTools
|
||||
|
||||
# Gemini API integration (optional - only needed for Gemini Prompt node)
|
||||
google-generativeai
|
||||
google-genai
|
||||
|
||||
@@ -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';
|
||||
@@ -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()
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 }); }
|
||||
});
|
||||
@@ -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()'>&`), '<img src="x" onerror='run()'>&');
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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
|
||||
@@ -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 == []
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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; });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
@@ -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' } },
|
||||
],
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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">×</button>
|
||||
<button class="lightbox-prev"><</button>
|
||||
<button class="lightbox-next">></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;
|
||||
|
||||
@@ -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());
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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('');
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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; } }
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||