Compare commits

..
Author SHA1 Message Date
Vito Sansevero 367db8d7d6 Merge remote-tracking branch 'origin/main' into feat/326-system-s9-backup-ui 2026-10-25 13:03:08 -07:00
Vito Sansevero 447fd4668b test(coverage): raise the system-backups branch floor
Refs #326
2026-10-25 12:48:06 -07:00
Vito Sansevero af2d8e3792 fix(system): keep a refused or handed-back restore review usable
A Restore refused as busy now opens the same review again with Discard /
Restore, and a restore job whose leases did not drain hands its review back
(scope.review_kept) to the page that started it, which reopens it; every
other path that abandons a token after Restore sends its DELETE, so held
reviews can no longer lock restore out. The kept-review line says where the
review is, and a full review store says what to do (I1). applyPlan resolves
with its outcome and its job tracking now has failing-before proof (N5).

Also: the safety-backup toggle says what the policy does (D104, M2); the
restore picker has no type filter so safety backups can be chosen (M3);
"Export all prompts JSON" names what /export holds (N1); small snapshots
show KB (N3); the duplicated holding and save helpers are shared (N4); a
dismissal test covers Cancel, x, Escape, a replacing dialog and leaving (M1).

Refs #326
2026-10-25 12:38:28 -07:00
Vito Sansevero dde8b99b75 Merge remote-tracking branch 'origin/main' into feat/326-system-s9-backup-ui 2026-10-25 11:53:53 -07:00
Vito Sansevero 61d5518831 test(coverage): raise the frontend floors S9 measured
system-backups.mjs gets its measured floor in place of the provisional one;
admin.js, activity-scan.mjs and maintenance-review.mjs branches go up.

Refs #326
2026-10-25 11:34:02 -07:00
Vito Sansevero 9823a11492 docs(testing): Backup & export contract (D103-D105)
Refs #326
2026-10-25 11:23:43 -07:00
Vito Sansevero 6d4616a182 test(system): compare Backup & export with the concept
system-backups-concept.spec compares the section, the Restore preview and the
Import & inspect dialog with the concept in both themes at 1600 and 390 wide,
mapping only D103, D104, D105 (and D15/D55 for the shared Import dialog), and
proves a renamed label, a removed row, a lost danger mark and swapped tokens
are noticed. A review's grid values now wrap anywhere (D105), so a long backup
file name keeps the concept's two equal columns at phone width.

Refs #326
2026-10-25 11:22:46 -07:00
Vito Sansevero 519f17bf63 feat(system): back up, restore and export natively
System > Backup & export replaces its hand-off with the concept's section on the
S8 backend: Back up downloads the verified copy and the status line shows only
what GET /backup/status reports (D103); Review restore stages a chosen file and
shows the server's review in the shared review dialog, Cancel discards the stage
and Restore runs the Database restore job, after which the open views reload
(D105); Export JSON plus the components and saved-pairs exports (D103); Import /
inspect opens the shared Import & inspect dialog; the safety-backup policy saves
through the Save row and scheduled backups offer Manual only (D104). Overview's
Backup status row reads the same status.

Refs #326
2026-10-25 11:11:42 -07:00
163 changed files with 1176 additions and 21926 deletions
+7 -4
View File
@@ -1,16 +1,19 @@
{
"required_status_checks": {
"strict": false,
"strict": true,
"contexts": [
"py3.10 on ubuntu-latest",
"py3.11 on ubuntu-latest",
"py3.12 on ubuntu-latest",
"py3.13 on ubuntu-latest",
"py3.14 on ubuntu-latest",
"py3.10 on windows-latest",
"py3.11 on windows-latest",
"py3.12 on windows-latest",
"py3.13 on windows-latest",
"py3.14 on windows-latest",
"py3.10 on macos-latest",
"py3.11 on macos-latest",
"py3.12 on macos-latest",
"py3.13 on macos-latest",
"py3.14 on macos-latest",
"coverage gate (85%)",
"black and flake8",
"frontend modules",
+2 -6
View File
@@ -4,7 +4,6 @@ permissions:
contents: read
on:
merge_group:
push:
branches: [main]
pull_request:
@@ -20,17 +19,14 @@ jobs:
security:
name: bandit (high severity)
runs-on: ubuntu-latest
# ceil(1.5 x slowest of three green main runs) + 3, never under 5 (test.yml): runs 40257217665,
# 40254137380, 40240706583 took 23s / 16s / 23s -> 5 min.
timeout-minutes: 5
steps:
- uses: actions/checkout@v7
- name: Set up Python 3.12
- name: Set up Python 3.10
uses: actions/setup-python@v7
with:
python-version: "3.12"
python-version: "3.10"
- name: Install bandit
run: pip install bandit[toml]
+18 -59
View File
@@ -4,7 +4,6 @@ permissions:
contents: read
on:
merge_group:
pull_request:
branches: [main]
push:
@@ -20,28 +19,15 @@ concurrency:
env:
PYTHONFAULTHANDLER: "1"
# Every job has a time limit: ceil(1.5 x the slowest of three recent green runs on main) + 3
# minutes, never under 5. The 3 minutes leave room for the Python runner's watchdog (120 s, see
# tests/run_suite.py) to print its stack dump before GitHub cancels the job. A limit changes only
# with new measurements written here (TESTING.md "Time limits and hangs").
# Measured 2026-10-26 on runs 40257217681 (3be0ada), 40240706568 (fb9134f), 40228167328 (f9835aa):
# py matrix, slowest py3.10 on windows-latest: 12m43s / 15m54s / 14m53s -> 27 min
# (ubuntu 5m27s-6m27s, macos 6m14s-8m30s, windows 11m42s-15m54s)
# 3.10 and 3.11 left the matrix and 3.14 joined it in #356; re-measure after three green main runs.
# real ComfyUI compatibility: 13m32s / 8m55s / 11m46s -> 24 min
# coverage gate (85%): 7m26s / 7m22s / 7m54s -> 15 min
# perf budget 30s / 29s / 27s, black and flake8 39s / 37s / 39s,
# frontend modules 23s / 19s / 29s -> 5 min (the floor)
jobs:
test:
name: py${{ matrix.python-version }} on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
timeout-minutes: 27
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
python-version: ["3.12", "3.13", "3.14"]
python-version: ["3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v7
@@ -59,23 +45,21 @@ jobs:
pip install -r requirements-dev.txt
- name: Run tests
run: python -m tests.run_suite -v
run: python -m unittest discover -s tests -v
comfyui:
name: real ComfyUI compatibility
runs-on: ubuntu-latest
# Chromium and its system libraries come from the pinned Playwright image (tag = @playwright/test in
# tools/vendor/package-lock.json; tests/vendor/browser-image.test.mjs checks it). The runner install
# pulled them from Ubuntu's apt mirror on every run, and three runs (#349, #378, #382) crawled at
# ~26 kB/s for 11-12 minutes until the job hit its limit after every test had passed.
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble
options: --user 1001
timeout-minutes: 24
timeout-minutes: 25
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: "24"
cache: npm
cache-dependency-path: tools/vendor/package-lock.json
- name: Read the reviewed runtime pins
id: runtime
run: |
@@ -100,42 +84,21 @@ jobs:
ref: ${{ steps.runtime.outputs.kikoRevision }}
path: .ci-kikotools
persist-credentials: false
# The image's own Python and Node 24: setup-python/setup-node install the runner's toolcache builds,
# whose entry points name host paths that do not exist in the container. The image has no ensurepip
# and the job has no root, so a checksum-pinned pip wheel bootstraps a user-space venv. The image
# Python must be the reviewed host Python, so a pin or image bump that disagrees fails here.
- name: Python environment on the reviewed host Python
env:
PIP_WHEEL: https://files.pythonhosted.org/packages/f3/6e/1736e5b4ae2b778ef2f81c47d797de9f891d4d8acb047a24ca37a60294dd/pip-26.2.1-py3-none-any.whl
PIP_SHA256: 71138adf1f4ca900cdb7d289c21b7494329f2332b6d85f0e1c42108c0384ed3e
PINNED_PYTHON: ${{ steps.runtime.outputs.pythonVersion }}
run: |
image="$(python3 -c 'import sys; print("%d.%d" % sys.version_info[:2])')"
[ "$image" = "$PINNED_PYTHON" ] || { echo "::error::image Python $image is not the pinned $PINNED_PYTHON"; exit 1; }
venv="$RUNNER_TEMP/venv" wheel="$RUNNER_TEMP/pip-26.2.1-py3-none-any.whl"
python3 -m venv --without-pip "$venv"
curl -fsSL --retry 3 -o "$wheel" "$PIP_WHEEL"
echo "$PIP_SHA256 $wheel" | sha256sum -c -
"$venv/bin/python" "$wheel/pip" install --no-index --no-cache-dir "$wheel"
echo "$venv/bin" >> "$GITHUB_PATH"
- run: python --version && node --version && node --version | grep -q '^v24\.'
# pip's download cache (torch alone is ~200 MB); inside the container ~ is the job's HOME (/github/home).
- name: Cache pip downloads
uses: actions/cache@v6
- uses: actions/setup-python@v7
with:
path: ~/.cache/pip
key: pip-${{ runner.os }}-playwright-v1.64.1-${{ hashFiles('requirements-dev.txt', '.ci-comfyui/requirements.txt', 'tools/vendor/comfyui-runtime.json') }}
python-version: ${{ steps.runtime.outputs.pythonVersion }}
cache: pip
cache-dependency-path: |
requirements-dev.txt
.ci-comfyui/requirements.txt
tools/vendor/comfyui-runtime.json
- name: Install CPU runtime and test dependencies
run: |
python -m pip install --upgrade pip
pip install "torch==${{ steps.runtime.outputs.torchVersion }}" "torchvision==${{ steps.runtime.outputs.torchvisionVersion }}" --index-url https://download.pytorch.org/whl/cpu
pip install -r .ci-comfyui/requirements.txt -r requirements-dev.txt "torch==${{ steps.runtime.outputs.torchVersion }}" "torchvision==${{ steps.runtime.outputs.torchvisionVersion }}"
# npm's download cache, keyed on the lockfile. node_modules itself is still built fresh by npm ci.
- name: Cache npm downloads
uses: actions/cache@v6
with:
path: ~/.npm
key: npm-${{ runner.os }}-playwright-v1.64.1-${{ hashFiles('tools/vendor/package-lock.json') }}
- run: npm ci --prefix tools/vendor --ignore-scripts
- run: npx --prefix tools/vendor playwright install --with-deps chromium
- name: Classic and Nodes 2.0 startup and compatibility
env:
PM_COMFY_ROOT: ${{ github.workspace }}/.ci-comfyui
@@ -164,7 +127,6 @@ jobs:
coverage:
name: coverage gate (85%)
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
@@ -181,7 +143,7 @@ jobs:
- name: Measure coverage
run: |
python -m coverage run -m tests.run_suite
python -m coverage run -m unittest discover -s tests
python -m coverage combine
python -m coverage report --fail-under=85
@@ -192,7 +154,6 @@ jobs:
name: perf budget
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v7
@@ -215,7 +176,6 @@ jobs:
lint:
name: black and flake8
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
@@ -228,7 +188,6 @@ jobs:
js-test:
name: frontend modules
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v7
-1
View File
@@ -1,7 +1,6 @@
name: Viewer.js vendor and compatibility
on:
merge_group:
pull_request:
branches: [main]
push:
+1 -1
View File
@@ -22,7 +22,7 @@ repos:
- id: tests
name: tests
entry: python -m tests.run_suite -q
entry: python -m unittest discover tests/ -v
language: system
pass_filenames: false
stages: [pre-push]
+6 -10
View File
@@ -1,7 +1,7 @@
# ComfyUI Prompt Manager
![License](https://img.shields.io/badge/license-MIT-blue.svg)
![Python](https://img.shields.io/badge/python-3.12%2B-blue.svg)
![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)
![ComfyUI](https://img.shields.io/badge/ComfyUI-compatible-green.svg)
![Tests](https://github.com/ComfyAssets/ComfyUI_PromptManager/workflows/Tests/badge.svg)
![Code Quality](https://github.com/ComfyAssets/ComfyUI_PromptManager/workflows/Code%20Quality/badge.svg)
@@ -483,15 +483,11 @@ If you use [ComfyUI-Lora-Manager](https://github.com/willmiao/ComfyUI-Lora-Manag
Click **Import LoRA Data** in the settings panel to start the import. A progress popup shows real-time status as each LoRA is processed:
- Reads LoRA Manager's own settings the way LoRA Manager does: from the folder in `LORA_MANAGER_SETTINGS_DIR`, from the plugin's `settings.json` in portable mode, or from your user config folder, and only the keys that say where its files are
- Finds each LoRA's metadata in both storage modes: beside the model (the default) or in the central sidecar folder, and imports only LoRAs whose model file still exists in a LoRA folder (the review says how many metadata files it skipped)
- Links LoRA Manager's preview (including `.preview.webp` and video previews) and every downloaded example image and video where they are, without copying them
- Downloads example images from CivitAI as 512px thumbnails only for LoRAs that have no local image
- Scans all LoRA directories (including `extra_model_paths.yaml` paths) for metadata
- Downloads example images from CivitAI as 512px thumbnails
- Creates a prompt entry for each LoRA with trigger words, example prompts, and preview images
- All imported LoRAs are tagged with `lora-manager` category for easy filtering
The System › Integrations review names the settings it read (source, storage mode and LoRA Manager library) and counts metadata files, LoRAs and LoRAs with local images. If LoRA Manager's settings cannot be used, it says why and nothing is changed.
![LoRA Results](images/pm-lora-filtered.png)
After import, filter by the `lora-manager` category to browse your LoRA collection.
@@ -928,12 +924,12 @@ db.model.backup_database("backup_prompts.db")
```bash
cd ComfyUI_PromptManager
pip install -r requirements-dev.txt
python -m tests.run_suite # Python (what CI runs: unittest discovery plus a hang watchdog)
python -m unittest discover -s tests # Python (what CI runs)
node --test tests/js/*.test.js # frontend pure modules
python -m coverage run -m tests.run_suite && python -m coverage combine && python -m coverage report
python -m coverage run -m unittest discover -s tests && python -m coverage combine && python -m coverage report
```
CI runs the suite on Linux, Windows and macOS for Python 3.12 to 3.14 with an 85% coverage gate. `python -m unittest discover -s tests` still works; see `TESTING.md` ("Time limits and hangs") for the watchdog. Tests that delete a database file first check that nothing in the process still holds it open (`tests/open_handles.py`), so a connection leak fails on Linux the way it would on Windows.
CI runs the suite on Linux, Windows and macOS for Python 3.10 to 3.12 with an 85% coverage gate. Tests that delete a database file first check that nothing in the process still holds it open (`tests/open_handles.py`), so a connection leak fails on Linux the way it would on Windows.
### Code Style
+14 -647
View File
@@ -31,7 +31,7 @@ its reset endpoint exists only in the test harness.
Use a Python environment containing `requirements-dev.txt` and Node.js 24:
```sh
python -m tests.run_suite -q # what CI runs; see "Time limits and hangs"
python -m unittest discover -s tests -q
npm ci --prefix tools/vendor --ignore-scripts
npx --prefix tools/vendor playwright install --with-deps chromium firefox webkit
npm run --prefix tools/vendor test
@@ -39,9 +39,6 @@ npm run --prefix tools/vendor test:coverage
npm run --prefix tools/vendor test:workflows -- --project firefox --project webkit
```
`python -m unittest discover -s tests` still works and finds the same tests;
it just has no hang watchdog.
`PM_TEST_PYTHON` selects the Python executable; set it when Python is outside
PATH. Every Playwright worker starts its own `tests/browser/server.py --port 0`,
which binds a port the OS picks and prints it as `PM_TEST_SERVER_PORT=<n>`, with
@@ -86,18 +83,6 @@ timer that "usually" covers a frame is a race on a loaded runner. Library
virtualization specs settle with `libraryObservation.settled()` (two consecutive
frames with no Library frame queued). To look for such races, repeat a spec under
load (`--workers 8 --repeat-each 10`, and in the image with `--cpus=1` or `2`).
CPU limits do not reproduce disk-bound timeouts (backup, restore). For those,
throttle writes in the image: with rootful Docker on a host with cgroup v2, add
`--device-write-iops /dev/<disk>:20` to the `docker run`, where `<disk>` is the
whole disk (not a partition) holding Docker's data root, because the test server
keeps its database in the container's `/tmp`. On a plain partition,
`lsblk -no pkname "$(df --output=source "$(docker info -f '{{.DockerRootDir}}')" | tail -1)"`
prints it; on LVM or LUKS, `lsblk -s` walks down to the disk. A spec that waits
on a timer then fails at its 5 s expect. A spec that waits on the response can
still fail at the 30 s test timeout when several workers share the throttled
disk; that means the disk is too slow for the test budget, not that the spec is
wrong.
On unsupported Linux
distributions, browser system dependencies may need manual installation. For a
quick browser run, use `test:workflows -- --project chromium`.
@@ -219,133 +204,6 @@ not certify every existing feature, arbitrary host versions,
GPU/model changes, cross-pack installation or visual design changes. Those still
need targeted validation.
## Time limits and hangs
CI and the pre-push hook run the Python suite as `python -m tests.run_suite`
(`-v`, `-q`, `-s START` and `-p PATTERN` as in `unittest discover`), and the
coverage job as `python -m coverage run -m tests.run_suite`. The runner
(`tests/run_suite.py`) discovers the same tests with the same `sys.path` and
warning filters as `python -m unittest discover -s tests`, exits 0, 1, or 5 when
no tests ran (Python 3.12+ behaviour, on every version), and adds a watchdog: it writes `start: <test id>` to stderr before every test, at every
verbosity, and arms `faulthandler.dump_traceback_later`. If a test, its teardown,
its event-loop shutdown or interpreter exit stops making progress, the stack of
every thread goes to stderr (`Timeout (0:02:00)!` then `Thread 0x...` blocks) and
the process exits non-zero.
- `PM_TEST_HANG_LIMIT` (default 120 s) bounds each test from its `start:` line to
the next one, including the class and module fixtures (`tearDownClass`,
`setUpModule`, ...) that run in between. `PM_TEST_SHUTDOWN_LIMIT` (default
120 s) bounds the end of the run: the summary, non-daemon threads, atexit
handlers and coverage's save. Both must be
positive integers; anything else stops the runner with exit 2. 120 s stays below
asyncio's 300 s wait for default-executor threads on Python 3.12 and 3.13 (3.10
and 3.11 wait without limit), so a hang in an `IsolatedAsyncioTestCase` loop
shutdown is dumped inside the test that hung.
- Reading a dump: the last `start:` line names the test. An `ok` after it means
the test body passed and the hang came after it: in its teardown or event-loop
shutdown (where `IsolatedAsyncioTestCase` joins executor threads), or in a
class or module fixture before the next test; the stack shows which. A dump
after the summary is a hang at interpreter exit. The dump shows thread stacks, not asyncio task
stacks.
- When the watchdog fires under coverage, that run's coverage data is lost; the
job fails anyway and the dump is what matters.
Every job in `test.yml`, `code-quality.yml` and `viewerjs-check.yml` has
`timeout-minutes` (`tests/vendor/ci-workflow.test.mjs` checks it). The rule is
ceil(1.5 x the slowest of three recent green runs on `main`) + 3 minutes, never
under 5; the three run ids, their times and the resulting limits are written
in a comment above `jobs:`. The 3 minutes leave room for one watchdog firing, so
the dump prints before GitHub cancels the job. A job that hits its limit, or a
watchdog dump, is a failure like any other: read the dump, find the blocking
call, fix it. Never raise a limit or rerun to get green; a limit changes only
with new measurements written next to it.
### After a push
Follow the run for the commit you pushed, never an older one:
```sh
gh run list --commit <pushed sha> --workflow test.yml # the run id for that commit
gh run watch <run id> --exit-status # returns when the run ends
gh run view <run id> --json jobs # shows a job that already failed
gh run view --job <job id> --log-failed # that job's failed log, at once
```
Start fixing as soon as a failed log shows the cause, but push the fix only after
the run for that commit has finished: pull-request runs use `cancel-in-progress`,
so a push cancels the running jobs and can hide a second failure.
## Background threads at exit
PromptManager's own background threads stop through one package teardown,
`_stop_background_work()` in `__init__.py` (#301, #302). It runs once and
works against one 10 s deadline. Each step gets the smaller of its own bound
and the time left. The steps, in order:
1. Refuse new previews (`refuse_auto_thumbnails()`), then detach the
monitor's callbacks (Needs review recorder, preview queue, Images listing
listener).
2. `ImageMonitor.stop_monitoring` works against one 5 s deadline. Closing the
image handler joins the image worker; watchdog's `observer.stop()` (not
bounded here) and the observer join get what is left, which can be 0 s.
3. `close_auto_thumbnails(final=True)` stops the preview worker (30 s) and
kills ffmpeg.
4. `PromptTracker.shutdown` stops the prompt cleanup thread (2 s).
A failing step, or a second Ctrl+C, is logged with its traceback and does not
skip the later steps. A clean run logs one INFO line to PromptManager's log:
`Background work stopped in 0.4 s: monitor=ok thumbnails=ok tracker=ok`. If
any part is still running, one WARNING goes to the console:
`Background work not stopped in time (10.0 s): monitor`. A Ctrl+C that lands
between two steps (each step catches its own) is re-raised after the result is
stored, with the parts not reached False, and the `atexit` callback is
removed: the later steps are skipped, no summary line is logged, and a later
call returns that result and runs nothing.
ComfyUI never runs aiohttp's `on_cleanup` hooks, because it never calls
`runner.cleanup()`. So `atexit` is the live host's path. It runs on Ctrl+C
(`main.py` catches `KeyboardInterrupt` and returns) and on a normal exit.
The teardown is registered after the execution history hook, so at exit
(newest first) it runs before the history writer's 2 s dispose.
It also runs from `on_cleanup` on hosts that do run it (our test servers) and
from the tests' package unload (`cleanup_package`), and it removes its own
`atexit` callback. Nothing runs when the process is killed: SIGTERM, a
ComfyUI-Manager restart through `os.execv`, a closed Windows console, or
ComfyUI Desktop stopping its server. SQLite's WAL journal covers those.
Stop contract: `PromptTracker.shutdown(timeout)`,
`ImageGenerationHandler.shutdown(timeout)` (restartable),
`ImageGenerationHandler.close(timeout)` (final: `schedule()` refuses and a
sealed run's re-check is dropped) and `ImageMonitor.stop_monitoring(timeout)`
each signal their thread and join it within the bound. They return True only
when it ended, and log a warning naming the part when it did not. Called on
their own thread, they only signal.
Test rules:
- Every test that constructs a real `PromptTracker`, `ImageGenerationHandler`
or `ImageMonitor` ends with its final stop and an assertion
(`tests/background_stops.py`: `close_handler`, `stop_monitor`,
`stop_tracker`). The stop comes before the folder and databases are removed.
`tearDown` runs before `addCleanup` callbacks, so a class whose `tearDown`
removes files stops the thread there.
- A test waits for a state through an `Event` set by the code under test, or
by a wrapper around it, with a 30 s ceiling. It never sleeps or polls for
that state.
- A test that asserts "still running" first waits for an `entered` event
inside the held call, then stops with `timeout=0.05`.
- A test that asserts a history writer's `dispose()`/`close()` is True right
after queuing work drains first (`drain_writer`), or holds the write itself
(#370).
- Patch the module attribute of the code under test (for example
`utils.prompt_tracker.time`, or the package's `_clock`), or patch with
`wraps=`. Never replace a `time` or `atexit` function for the whole
process.
- `tests/test_package_exit.py` runs a child process that imports the
package, leaves the image worker alive and returns. Its `atexit` reporter
checks the teardown's result. This is the interpreter path ComfyUI takes
after Ctrl+C, on all three OS.
## Performance budgets
A spec performance budget gets two tests:
@@ -377,122 +235,6 @@ change the budget in the spec with a reason) in a follow-up PR. Never rerun it t
green. It is not a required check, and it cannot be one while it skips pull
requests (`.github/branch-protection.json`).
## Images listing cache (#348)
`/prompt_manager/images/output`, `/prompt_manager/gallery/subfolders` and
`POST /prompt_manager/gallery/rescan` read one shared listing per output root
(`RootListing` in `py/api/gallery_listing.py`, `_listing` in
`py/api/images.py`). The tests are `tests/test_gallery_listing.py` (pure
helpers) and `TestSharedListing` in `tests/test_api_images.py` (routes); they
hold a walk open with a gated scan instead of sleeping.
- **Single flight.** At most one walk per root runs at a time; every reader
that needs that root while it runs waits for it (shielded, so a client that
disconnects never cancels it). The walk, the comparison with the previous
listing and both folder sets run in the executor (`walk_root`). A walk that
finds the same `(path, mtime)` list keeps the previous list object, so the
favorites lookup and the sort orders are not rebuilt.
- **30 s background refresh.** A listing older than `REFRESH_AFTER` (30 s) is
served at once and one background walk replaces it. Files added or removed
by hand therefore show up one open later than before; Rescan shows them at
once.
- **Blocking invalidation after deletes.** Duplicate cleanup and the legacy
duplicate delete still call `invalidate_gallery_cache()`: the listing is
dropped and the next read waits for a walk, so a deleted file is never
listed. A walk running during the drop is discarded (the record's
`generation` outlives every drop); a cold reader gives up with a 500 after 3
discarded walks in a row. Thumbnail writes (jobs, conversion, the legacy
generate and clear routes) no longer drop the listing: thumbnail URLs are
computed per request.
- **Startup warm-up.** The root `__init__.py` registers
`api.warm_gallery_listings` on the host's `on_startup`, which starts one
background walk per root, and `api.cleanup_gallery_listings` on
`on_cleanup`, which cancels running walks. A warm-up failure never stops
startup; the first read retries.
- **Failures and Rescan.** `walk_root` opens the root with `os.scandir` first,
so a deleted or unreadable root fails the walk instead of listing nothing.
A failed walk keeps the previous listing, logs one warning per streak of
failures and is retried at most once per `REFRESH_AFTER`; a reader with no
listing gets a 500. Rescan waits for a walk that started after the click
(by walk number, not by clock), Rescans clicked during one walk share the
next, and a failed walk is a 500 even when a previous listing exists.
- **New outputs (added without a walk, PR 2).** The image monitor reports
every settled output it accepts (by default `.png .jpg .jpeg .webp .gif`;
videos only when their extension is in `monitoring.extensions`), linked or
not, with its `realpath`, through `set_output_listener`. On its worker
thread the API's `notify_output_settled` only does
`loop.call_soon_threadsafe(output_settled, path)`, and does nothing before
the loop is known or after `cleanup_gallery_listings`. `output_settled`
matches the path against each walk's `resolved_root` (`normcase(realpath)`,
computed in the executor; `root_contains` ends the match at a separator
and ignores a Windows `\\?\` or `\\?\UNC\` prefix on either side).
- **The add.** A root with a stored listing and no walk running gets the
file added without a walk. `relative_part` slices the path below the
root in its own case (Windows compares folded, slices the original);
`_run_adds` places the pending files of one root in one executor batch
(`_patch_listing`): `_settled_entry` applies the walk's filters
(not a symlink or folder by `lstat`, `_name_suffix` in `MEDIA_EXTENSIONS`,
no `thumbnails` folder, at most `MAX_SCAN_DEPTH` folders, every folder on
the way listable by `os.access`, which on Windows ignores ACLs: a folder
whose ACL denies listing is added there and corrected by the next walk). Where names fold case the stored
spelling must match first, as the filters compare it: Windows through
`nt._getfinalpathname` (its `realpath` keeps the given case when it cannot
open the file), macOS through `F_GETPATH` on a non-blocking open (a FIFO
never holds the executor); a case-insensitive mount on Linux is not
checked and is corrected by the next walk. `insert_entry` builds a new list with
the `(Path, mtime)` entry where the walk's newest-first sort puts it,
replacing the same path's entry. `add_folder` copies both folder sets. The
batch is stored only when no walk started and no drop happened while it
ran (a walk started after the report lists the file itself); `walked_at`
is untouched, so the 30 s refresh still walks and corrects any drift, and
`walk_root` keeps the patched list object when that walk finds the same.
Tests compare every added listing with a real walk (`_walked`).
- **When it walks instead.** The root is marked dirty (`mark_root_dirty`,
`_mark_dirty`) when the add cannot be exact: a walk is running (it may have
passed the folder: the trailing walk), the path cannot be sliced, the
`lstat` or the spelling read fails, the stored spelling differs, another
file has the same mtime (a walk orders those by its
visit order), the listing is at `MAX_GALLERY_FILES`, or the batch raised.
A file the walk's filters exclude is neither added nor walked. A root
without a listing, or a dropped one, is walked by its next read. A dirty
root gets one background walk, no sooner than `rewalk_interval` =
`min(15, max(5, 5 × last walk's duration))` seconds after the previous walk
started. `_schedule_walk` never arms a timer while a walk runs (the walk's
end re-checks `dirty`: the trailing walk), arms at most one per root and
never sooner than 0.5 s, and keeps the earlier deadline when the stale
refresh and a dirty root both want one; every walk start (read, Rescan,
warm-up or timer) cancels it and forgets the pending adds. A failed walk
clears `dirty`; a cancelled walk starts no trailing walk. When the output
roots change (`reset_gallery_roots_cache`) every record is `retired`: its
timer is cancelled and no add, mark, timer or trailing walk touches it
until a reader or the warm-up asks for that root again (the monitor keeps
watching a removed root until restart). Tests patch
`REWALK_FLOOR`/`REWALK_CAP` and run the pending timer's callback by hand
(`_fire`) instead of waiting for it, and hold an add batch in the executor
(`_hold_adds`) to race it with a walk, a drop or the cleanup.
- **Workspace caches after an add.** A patched listing shares every
unchanged entry object with the one before it, so `listing_delta` finds
the change by identity (a walk builds new tuples and is rebuilt in full,
as is any change of more than `MAX_DELTA` entries). The per-file values
build only the added files (`_workspace_listed_step`), the newest order
places them by bisection (`_newest_step`, only when the per-file cache
still holds exactly the values this call built: a page read and the warm
build run in executor threads at once, and an order labelled with older
values than it holds would add a file twice), the favorites lookup is copied
and extended (`_favorite_listing_step`, rebuilt when a key collides), and
the other orders, wanted paths and locators rebuild lazily. On a synthetic
20,000-file listing a one-file add costs about 3 ms in the executor and
15 ms for the warm build, against about 155 ms + 80 ms for a walk and a
full build (`test_a_warm_build_after_an_add_builds_only_the_added_values`
counts the values built and the sort keys read).
- **Measuring it.** `tools/vendor/perf/images_load_probe.mjs` is a maintainer
tool, never run in CI. After `npm ci --prefix tools/vendor` and
`npx --prefix tools/vendor playwright install chromium`, run
`node tools/vendor/perf/images_load_probe.mjs http://127.0.0.1:8188` against
a running ComfyUI. It prints the time to the first decoded thumbnail, the
time to network idle and the 10 slowest requests, from a fresh browser
context each run.
## Windows portability preflight
Before changing paths, SQLite, streams, restore or cleanup:
@@ -1060,8 +802,8 @@ Recorded shell deviations:
`file_size` equal to the file's size in this walk. Both paths go through
`normalize_image_path` (absolute, normalized, `os.path.normcase`); a row without
`file_path` matches by its `image_path`.
- Read again: files linked without a pair (the monitor's queue and run-key links,
fallback links made before #342, the legacy positive-only import), linked to more than one prompt or pair, not linked, a
- Read again: files linked without a pair (the monitor's fallback and FIFO links, the
legacy positive-only import), linked to more than one prompt or pair, not linked, a
file whose size changed (one replaced at the same path, e.g. a reused ComfyUI
counter name, is read and its conflict lands under Needs review), and a row with no
stored size (unknown is not known). The scan now stores each file's size when it
@@ -1575,8 +1317,8 @@ targets of the longest legal path (1024 code points, even escaped as `\uXXXX`) f
Its fields show the server's defaults (`GET /prompt_manager/autotag/models`, the
D91 Suggestions count included) once that read lands; Start waits for it. A model
the status lists as not downloaded keeps Start `aria-disabled` and
`#tag-generation-status` says "<model> is not downloaded. Download it in System ›
Auto-tagging." (the server's own sentence), so no job starts only to fail.
`#tag-generation-status` says "<model> is not downloaded. Download it from the
legacy Auto-tag page." (the server's own sentence), so no job starts only to fail.
Start validates the fields as Generate does (native validity; nothing is sent).
The concept comparison (`images-concept.spec.mjs`) uses the concept page itself as
the oracle, with Add to collection and Remove links… removed and a copy of its Add
@@ -2658,7 +2400,7 @@ the connection's `total_changes` is 0 at close and the data reads back unchanged
`-shm` bytes are not compared: every WAL reader rewrites its read marks there, so no
reader can keep it byte-identical (`test_health_check_never_writes`).
Recorded deviations (plan D60–D75, D79–D82 renumbered D92–D107 and D111–D114; D121–D134 assigned):
Recorded deviations (plan D60–D75, D79–D82 renumbered D92–D107 and D111–D114; D121–D131 assigned):
- **D92** (shell): no "Sample configuration" chip. The Save status is truthful: "No
unsaved changes.", "Unsaved changes.", "Saved in this browser." / "Saved to the server
@@ -2692,8 +2434,7 @@ Recorded deviations (plan D60–D75, D79–D82 renumbered D92–D107 and D111–
output folder is used." when that is the case.
- **D94** (all sections): a section not delivered yet shows the hand-off panel "Your
current tools are ready" with a link to each legacy tool that still owns it (the
plan's legacy coverage table); retired section by section, gone after S12. Since S11
(#328) only Diagnostics & logs hands off (`SECTION_HANDOFFS`, to the admin page).
plan's legacy coverage table); retired section by section, gone after S12.
- **D95** (health report): the concept's `health-check` dialog titled "Health report",
with no sample note. It lists every check in the server's order: Integrity check,
Foreign keys & pair references, Canonical hashes, Pair uniqueness (added), Missing
@@ -3069,59 +2810,6 @@ Recorded deviations (plan D60–D75, D79–D82 renumbered D92–D107 and D111–
dialog on that scope. Pinned by the thumbnail dialog comparison (mapped) and its
mutation check (system-concept.spec) and `the thumbnail row counts outdated previews`
(workspace-system.spec).
- **D132** (Auto-tagging, S11, #328, OQ1/OQ5/OQ6/OQ10): "Tagging model" (`#tag-model`)
lists the four real engines in the concept's order, each `<label> · <size_label>` from
`GET /prompt_manager/autotag/models` ("WD14 SwinV2 · ~400 MB", "WD14 ViT · ~400 MB",
"GGUF JoyCaption · ~4.5 GB", "HuggingFace JoyCaption · ~16 GB"; GGUF takes the
concept's "Ollama" slot, as D61 does). Its description is the chosen engine's live
status instead of the concept's design note: "Loading model status…" (the select is
disabled until the read answers), "Model status could not be read.", "Not downloaded.
Download size <size>.", "Downloaded. Not loaded.", "Downloaded and loaded." or "Partly
downloaded. Download again to finish." (GGUF with one of its two files). The concept's
two simulated job buttons are "Download" ("Download again" for an engine on disk),
which starts the `model_download` Activity job and opens it, and "Unload", a direct
`POST /autotag/unload` with a toast ("<label> unloaded.") and a fresh status read,
enabled only while a model is loaded; a busy model's refusal reads "The tagging model
is busy: <operation>. Try again when it finishes.". "Preview tagging review" opens the
Tag image dialog for the image selected in Images with the section's current model and
values; it is `aria-disabled` without a selection (the D115 sentence "Select an image
in Images first.") or with an invalid value (the toast names the setting); a saved
engine the section does not know reads "The saved tagging model is not one of these
engines. Choose one and save preferences.". A part-downloaded GGUF offers "Download
again"; an Unload that found nothing loaded only reads the status again. At phone width
the concept's 120px select cap cuts every engine label, so `#tag-model` alone keeps its
natural width there and its row wraps the select under the text (`workspace.css`
`/* D132 */`); Existing tags stays as the concept draws it, cut at 390 as there. The
comparison maps `selectsFit` only for the engine select and measures each row's
`flexWrap` and whether its control sits under the text. Pinned by `auto-tagging matches
the concept` (both themes and sizes), `auto-tagging at 390 wraps only the engine row`,
`auto-tagging engine select fits at 320 wide` and the mutation check (it includes a
wrapped row) (system-tagging-concept.spec), and the model, Download, Unload and Preview
tests of workspace-system-tagging.spec.
- **D133** (Auto-tagging, S11): a "Suggestions per image" row (`#tag-suggestions`,
number 1–100, host key `autotag_suggestion_limit`) follows the two confidence rows:
the persisted count the Tag image dialog's Suggestions field (D91) starts from. Pinned
by the comparison's mapping (it inserts the row) and `defaults save through Save
preferences and the Tag image dialog uses them`.
- **D134** (Auto-tagging, S11, OQ2/OQ3/OQ7/OQ8): an `.operation` "Tag output images"
with "Review" follows `.row-actions`: the legacy prompt-tag batch. Review is
`aria-disabled` (the press explains) while the chosen engine is not downloaded or a
value it uses is invalid; otherwise it posts `/autotag/batch/preview` with the drafted
engine and policy (read-only) and opens "Review tag output images" in the concept's
record-changing review (its `maintenancePlan` shell): `h3` "Tag output images", the
`dl.meta-grid` Model, Existing tags, Images linked, Prompts, Already tagged, Prompts
to tag ("up to N"), Settings ("General <g> · Character <c>" or "Template of N
characters") and Backup, the upper-bound sentence in the `p.small`, the D55 backup box
and Cancel / "Start tagging", which starts the cancellable `autotag` job and opens it.
Replace after review, a model not on disk, a short disk, no linked image and nothing to
tag each show the rows with only Cancel and the reason ("Replacing tags needs a review
of each image. Use Preview tagging review, or choose Skip or Merge for a batch run.",
"Every prompt with an output image is already tagged.", "Not enough free disk space:
the safety backup needs about X MB and Y MB is free. Nothing is written until there is
room.") in the close-only review Integrations shares (`openCloseOnlyReview`). Pinned by
`tag output images review matches the concept`, its mutation check and `the
Cancel-only review matches the concept review shell` (system-tagging-concept.spec) and
the batch tests of workspace-system-tagging.spec.
- **D124** (inspector): with "Load metadata on selection" off, the Images panel and
the viewer's details show "Metadata loads when you ask for it." and a "Load metadata"
button instead of reading the file. The concept has no such state, so no concept
@@ -3496,9 +3184,7 @@ workflow (README changelog).
### LoRA reference import contract (#221, #249 System S10)
The LoRA import is staged (`py/lora_import.py`): discover the LoraManager
`*.metadata.json` files and fingerprint every scanned one, kept or skipped (path, size,
modification time and whether it was kept, #362; a skipped sidecar added, removed or edited
after the review refuses Start with `plan-changed`, `test_start_refused_when_a_skipped_sidecar_changed`); stage each
`*.metadata.json` files and fingerprint them (path, size, modification time); stage each
record (CivitAI example images go into the image cache first, best effort, counted in
`downloaded` / `download_errors`; each image is written to a temp file beside its cache
entry and moved in place, so a half-written image is never seen as cached); the files are
@@ -3549,158 +3235,20 @@ refused while a restore runs; the review is a request lease ("LoRA import review
accepts the optional `expected_revision`. LoraManager detection also searches every
`custom_nodes` folder ComfyUI registers through `folder_paths` (#144), main folder first.
**Where LoraManager keeps its files (#355).** `py/lora_manager_layout.py` repeats
LoraManager's rules without importing it (`tests/test_lora_manager_layout.py`). The
settings file is `<LORA_MANAGER_SETTINGS_DIR>/settings.json`, else the plugin's own
`settings.json` when portable mode is on (`LORA_MANAGER_PORTABLE` `1`/`0` decides, other
values are ignored; otherwise the JSON value `use_portable_settings: true`), else
`platformdirs.user_config_dir("ComfyUI-LoRA-Manager")`. It is read as UTF-8, at most 5 MB,
and only seven keys are kept (`SETTINGS_KEYS`); a missing file means LoraManager's
defaults. Discovery: in `alongside` mode every LoRA root is searched and a sidecar is kept
when `<base>.safetensors` exists beside it; in `centralized` mode only the sidecar root is
searched and a sidecar is kept when its `file_path` exists inside a LoRA root (unparseable
ones are skipped by name). `lora_files` counts the metadata files seen, `with_metadata`
the kept, readable LoRAs and `skipped` the rest. Media per LoRA, without repeats: the
preview (`preview_url`, else LoraManager's `PREVIEW_EXTENSIONS` order), then every file in
`<example-images root>[/<library>]/<sha256>/` in natural order; a leading video yields to the
first still. A file counts only with a servable extension and inside the guarded media
roots. Only an absolute `preview_url` is used; a relative one is skipped with a DEBUG line
naming the sidecar. A sidecar that is not valid UTF-8 JSON is skipped like any unreadable
one (#362). CivitAI downloads run only for a LoRA without local media; a record's
images are linked in reverse order, so its first image (the cover) is the newest link and
the card image. The folder guard keeps a folder from the settings, and each of ComfyUI's
default LoRA folders (`models/loras`, `extra_model_paths.yaml`, `folder_paths`), only when
it exists and is neither a filesystem root (`/`, `C:\`, a UNC share root) nor the home
folder or a folder that contains it (realpath + `normcase`); a refused default folder that
exists is listed in the layout's `refused_defaults` and logged as one warning each time the
refused set changes (`RefusedDefaultsTests`). An unusable layout (no install, unreadable /
non-JSON / non-object / oversized settings, no `platformdirs`, a missing or refused
centralized sidecar root, "the sidecar folder is a filesystem root, the home folder or a
folder that contains it") gives a review without a token ("LoraManager settings: not
usable: <reason>. Nothing was changed."), a legacy scan error with that reason, the default
LoRA roots for serving (no example-images root), and trigger words from the default roots
in alongside mode. A folder discovery cannot list answers the review with 500, code
`folder-unreadable` ("A LoRA folder could not be listed (...). Nothing was changed.").
`TriggerWordCache.load` never raises: an `OSError` is a warning, anything else is logged
with its traceback (#362), both keep the previous map, and a running node retries a failed
load only after 60 s (`TRIGGER_RETRY_SECONDS`). `tests/run_suite.py` and `tests/browser/server.py` point
`LORA_MANAGER_SETTINGS_DIR` at a disposable folder, so no test reads the developer's real
LoraManager settings; tests that call `load_layout` inject their own folder too.
**Linked-only gate and the layout cache (#362).** While the integration is on, the LoRA
allow-list (`ImagesMixin._lora_image_dirs`) is the layout's `media_roots` plus the CivitAI
download cache, and `_media_roots(outputs)` appends it after the output folders for Library
previews, the Library prompt detail, the per-prompt images page, `serve_output_image` for a
root past the outputs, `inspect_image` and `reveal_image`. Under such a root only a file the
database records as a linked image is served, inspected or revealed (`_linked_only_error`,
by its given and its real path); anything else answers 403. Library responses mark that
media `linked_only`: no preview file, no favorite, no Tag button. The allow-list's layout
comes from `py/lora_layout_cache.py` (`tests/test_lora_layout_cache.py`, which injects the
clocks, `stat` and `ntpath` and never sleeps): one entry keyed by the stripped configured
path, reused while the plugin settings file's and the settings file's `(st_mtime_ns,
st_size)` (or "missing") are unchanged, the install and every media root still resolve to
themselves (a folder swapped for a symlink reloads and the guard runs again), and the entry
is under `MAX_AGE_SECONDS` (30 s, monotonic clock) old. A load is stored only when no
watched file was modified within `RACY_SECONDS` (3 s) of the load (git's racy rule: a write
during the load, LoraManager's in-place write, FAT/HFS+ coarse times), every watched file
could be checked (any `stat` error but not-found, and an offline Windows share, is "cannot
validate"), the settings were readable, and no `clear()` ran meanwhile (generation
counter). Hits take no lock; concurrent misses each load. A finished import (System job or
legacy scan, both through `replace_lora_imports`) clears it. The integration-off check runs
before it, and the configured-path rule (a configured path that is not an install under
`custom_nodes` adds no folders) on its result; a hit also needs the same home folder, which
the guard compares against. `serve_image` builds the allow-list in the
executor. The cache writes one INFO line, "LoraManager layout changed: <source> settings,
<mode> storage, <n> media folders", when a stored layout differs from the last one logged.
The review, Start, the legacy scan and the trigger cache read the layout fresh. Image route
tests clear the per-process cache in `ImageRouteCoverageCase.setUp`; `write_settings` in
`TestLoraManagerMediaServing` back-dates its file 10 s so the first load is stored.
`tests/browser/workspace_integrations_fixture.py` (`/__test__/integrations/*`) makes a fake
LoraManager install and LoRA folder under the server's temp root, holds or fails the
import, saves a blocking pair and answers CivitAI downloads with a stub opener, so no
test reaches the network.
```sh
python3 -m unittest tests.test_lora_layout_cache tests.test_lora_manager_layout \
tests.test_lora_import tests.test_api_lora tests.test_lora_database tests.test_lora_utils \
tests.test_api_images -v
python3 -m unittest tests.test_lora_import tests.test_api_lora tests.test_lora_database \
tests.test_lora_utils -v
node --test tests/js/workspace-system-integrations.test.js tests/js/prompt-bridge-adapter.test.js
npm run --prefix tools/vendor test:workflows -- workspace-system-integrations.spec.mjs \
system-integrations-concept.spec.mjs \
--project chromium --project touch --project firefox --project webkit
```
### Auto-tagging contract (#328, #249 System S11)
System › Auto-tagging (`web/js/workspace/system-tagging.mjs`, D132–D134) edits one
server-side tagging configuration, `autotag.defaults` in config.json: `model_type`
(`wd14-swinv2`, `wd14-vit`, `gguf`, `hf`), `existing` (`skip`, `merge`,
`replace_after_review`), `general_threshold` and `character_threshold` (0–1 in
steps of 0.05, #369),
`template` (non-blank, at most 4,000 characters and 16 KiB) and `suggestion_limit`
(1–100). They are host drafts (`autotag_model_type`, `autotag_existing`,
`autotag_general_threshold`, `autotag_character_threshold`, `autotag_template`,
`autotag_suggestion_limit`) saved by the one Save row through `POST
/prompt_manager/settings` with `expected_revision`; a bad value is refused with a 400
naming the setting by its row label ("General-tag confidence must be between 0 and
1."), nothing is written, the Save row shows that sentence (`invalid-setting`, set by
`settingRefusal` in system.mjs only for a save that carried `autotag_*` keys; every
other section's plain 400 keeps the generic status) and the control is highlighted.
The template's limit counts code points, as Python's `len` does. A hand-edited bad value
falls back to its default on load, one key at a time. Every page shows the thresholds in
steps of 0.05 (the System rows, the Tag image dialogs, the legacy sliders), so a saved
threshold off that grid is refused ("General-tag confidence must be a multiple of
0.05.", `validate_saved_threshold`; `draftProblem` holds back Preview tagging review and
the batch Review with the same sentence) and falls back on load; an accepted one is
stored exactly on the grid (#369). A per-request threshold keeps its own check: `/image-annotations/suggest` and
`/jobs/autotag` take 0–1; the legacy `/autotag/single` and `/autotag/start` only require a number. `GET /autotag/models` reports the
saved values (`wd14_general_threshold`, `wd14_character_threshold`, `suggestion_limit`,
`default_prompt`, `model_type`, `existing`), so the Tag image and Auto-tag selected
dialogs start from them; a preset (the bulk review, Preview tagging review) wins.
`GET /prompt_manager/autotag/models` reads the disk in the executor, never loads or
downloads a model and carries no absolute path: per engine `name`, `label`,
`size_label`, `description`, `size`, `downloaded`, `loaded` (GGUF also `model_exists`,
`mmproj_exists`), plus `model_loaded` and `loaded_model_type`. Opening the section
makes exactly one such read per mount (`entering the section makes no model call
besides status`).
One model gate (`py/model_gate.py`) covers download, unload, both batches, the legacy
single-image tag, Generate suggestions and Auto-tag selected. A refusal is `409
model-busy` with `operation`, the holder's label: the download job's title, "Tag output
images", "Tag output images (legacy page)", "Tagging one image (legacy page)",
"Unloading the tagging model", "Auto-tag selected images", "Generate suggestions", else
"Another tagging operation". `ApiError.operation` keeps it (only on `model-busy`).
- **Download**: `POST /prompt_manager/jobs/model-download {model_type}` starts the
`model_download` job (202), title "Tagging model download · <label>", admission
`read`, not cancellable, indeterminate (Activity's generic held view: no Cancel, a
`<progress>` without a value), holding the gate until it ends. A second download is
`409 busy`; the legacy SSE route attaches to the same job and keeps its frames.
- **Unload**: `POST /prompt_manager/autotag/unload` answers `{success, message}` or
`409 model-busy`.
- **Tag output images**: `POST /prompt_manager/autotag/batch/preview {model_type,
existing}` writes nothing and opens no file; it answers `images`, `prompts`,
`already_tagged`, `to_tag` (an upper bound), `model {type, label, size_label,
downloaded}`, `existing`, `review_required`, `backup {policy}` and `disk`. `POST
/prompt_manager/jobs/autotag {model_type, existing, options}` (WD14: both thresholds;
GGUF/HF: `instructions`, the template) starts the cancellable `autotag` job (202),
title "Tag output images", admission `records`, a safety backup by the policy first.
Refusals: `400 review-required` (Replace after review never runs as a batch), `409
model-unavailable` ("<label> is not downloaded. Download it in System ›
Auto-tagging."), `507 insufficient-space`, `409 busy`, `409 model-busy`. The section
toasts `model-busy` with its operation, the other three with the server's sentence,
`busy` with the running job (D57), anything else "<what> could not start. Try again.".
Outcomes: Tagged / Unchanged / Skipped / Errors (the `image-tags` descriptor, D111).
No browser test downloads or runs a model: the injected `TestModel` answers, its
`download_model` raises unless `/__test__/tagging/download {mode: hold | succeed |
fail}` installs a fake that waits on a fixture event, and every tagging reset releases
it. Every workspace-system-tagging test asserts no `/autotag/download/` request.
Changing the routes or `py/config.py` needs a ComfyUI restart; the section itself is
HTML, CSS and JS only.
### Browser cache ownership (#222)
The legacy gallery's Clear cache used to delete every localStorage key starting `pm_` or
@@ -3804,14 +3352,14 @@ generation did not change meanwhile (`PromptModel.get_connection`).
Writes racing the switch: a background write that was waiting on the old file's lock
when the switch closed its connection fails, and run counting (`count_runs`,
`count_pair_runs`) and the image monitor's link (`ImageGenerationHandler.
link_image_to_prompt`: the resolved-pair ingest and the plain prompt link)
link_image_to_prompt`: the resolved-pair ingest and the fallback or recent-prompt link)
run it once more through `PromptDatabase.survive_library_switch` when the model's
generation changed during the attempt; the retry opens the new library. The monitor
links through `PromptDatabase.link_image_to_prompt_or_raise`, which raises database
errors (`link_image_to_prompt` keeps its log-and-return-0 contract for other callers);
each attempt is one transaction, so a failed attempt rolls back and a committed one
does not retry, and the image is linked once. `TestBackgroundWritesSurviveTheSwitch`
races a real UPDATE (run counting) and a real plain image link against the lock and
races a real UPDATE (run counting) and a real fallback image link against the lock and
asserts each lands in the new file only. Any other failure is not retried. Known limit: that
blocked writer, and the mover with it, waits out the writer's busy timeout (30 s)
before its connection closes: SQLite holds the connection's mutex during the busy
@@ -4410,191 +3958,10 @@ Viewer, including nested modal focus, mobile layouts and per-role sending.
real Classic and Nodes 2.0 widgets and verifies workflow serialization without
loading a workflow or queueing generation.
## Image attribution: certain or unlinked (#342)
The image monitor and the output scan link an output only to a prompt that
provably produced it; anything uncertain stays unlinked and the monitor logs it
at info ("No certain prompt for <file>; left unlinked"). There is no recency
fallback (`_get_fallback_prompt` is gone) and no unproven tracker entry.
- **Empty editor.** Prepend and append text are never stored as the prompt
(maintainer's product decision; #343 tracks letting a link drive `text`). A
PromptManager, Text, Focused, Split or Pair node whose `text` is empty saves,
counts and tracks nothing for the positive side, logs at info (no "Could not
save or track" warning), and marks its run key empty, so no earlier entry of
that key can be used and a batch item keeps its queue place
(`PromptTracker.mark_empty`). The queue hook counts nothing for it either. A
save or tracking failure of a positive node marks its run key the same way, so
an older entry of that key never stands in for the failed run.
- **Run keys** (`utils/run_keys.py`): sha256 of a prompt node plus its whole
upstream closure (`class_type` and `inputs`) in the API graph. Nodes register
tracker entries under it; the monitor computes it from the image's embedded
graph. The snapshot taken when the file appears names the key's latest run; a
queued entry is taken only by key and from that run (batch items in order, a
placeholder leaves its image unlinked, same-key entries of earlier runs ahead
of it are stale and dropped, entries of later runs that executed during the
monitor's delay wait for their own images). Without a queued entry the
snapshot's entry itself is used only when its run id is known
(`comfy_execution.utils.get_executing_context`) and that run registered one
entry for the key, which covers a cached re-run (sampler seed changed) without
applying a batch's last item to every image. When the key's latest entry is a
placeholder the image stays unlinked without consulting history (the same
rule as a popped placeholder). More than one positive PromptManager node in
the image graph, or any positive node without a key: never linked from the
tracker. Links are followed exactly as ComfyUI's `is_link` defines them (a
`str` node id and an `int` or `float` slot in a two-item list). Equal keys do
not always mean equal text: the key can't see `IS_CHANGED`, so an upstream
node that forces re-runs gives two runs one key; that is why the run named by
the snapshot is required as well. A file event delivered after the next run
already re-registered the key is the one remaining exposure (see the ledger).
- **Recorded history** (`database/run_attribution.py`): a file is attributed when
exactly one recorded run reported its output location (root key, subfolder,
name), its mtime lies within that run (dequeue to terminal or callback, +-2 s),
and every positive context names the same stored prompt (the live
`positive_prompt_id` hint). The monitor tries it last; the scan tries it when the
#170 browser-parser floor finds nothing, and also for files with no prompt
metadata at all (`--disable-metadata`, savers that embed none); it never
creates a prompt. Lookups are batched (`stored_run_prompts`): one
parameterized `filename IN (...)` query per chunk of at most 500 names
(`LOOKUP_CHUNK`), matched in memory, so a scan batch asks history once per
chunk, never once per file. The history schema is unchanged (no extra index),
so older builds still open the library.
- **After the run ends.** An image with no run keys (no embedded API graph) that
nothing identifies waits for its run: when the run's outputs are recorded
(`write_execution_outputs_for_finish` notifies `subscribe_outputs_sealed`
listeners after commit), each waiting image the run reported gets exactly one
history-only re-check on the monitor's worker. At most 1000 images wait, each
for at most an hour; shutdown forgets them. The image is registered before the
first history check, so a run sealed in between is not missed; a re-check that
can't be scheduled yet (the image's own pass still pending, or the pending set
full) is kept due and scheduled when a pending image finishes. A keyless image
outside ComfyUI's output folder never waits (history can't attribute it).
- **Spellings.** History keeps ComfyUI's own spelling of its output folder (the
root key). A file is matched through that spelling first, then with both sides
resolved (`realpath`), so an output folder reached through a symlink or a
Windows junction still attributes the scan's resolved file paths; waiting
files are keyed by their resolved path for the same reason. The scan makes
its batch's history lookup only when the first file in the batch needs it.
- **Blank positive.** A resolved pair whose stored positive is empty (the #342
image with its Pair execution record) is `unresolved` for the scan and for
`ingest_resolved_pair` (it used to raise in `_save_pair`).
- **Parity floor.** Metadata linking, the scan's resolver and parser floor are
unchanged for every non-blank result; only guesses are gone.
Tests: `tests/test_prompt_attribution.py` (nodes, tracker, monitor, run keys),
`tests/test_run_attribution.py` (history attribution, monitor and scan),
`tests/test_review_image_links_tool.py`, and the real-host
`tests/comfyui/image-attribution.spec.mjs` (both renderers): a typed Pair image
links, a Pair image whose positive is only linked prepend text from an unknown
node stays unlinked with nothing stored, an unknown node feeding `text` links
through its run key, and its cached re-run with a new sampler seed links again
(the host's `execution_cached` event names the PromptManager node, under the
fixture's Classic cache). A second case saves with metadata disabled and holds
the run open behind the fixture gate after SaveImage: the output is waiting and
unlinked while the run is open, and links to the stored prompt once the run's
outputs are recorded. The managed host config disables monitoring, so these
cases start and stop the plugin's monitor through the owned fixture route
`/pm_execution_fixture/monitor` (its GET lists the waiting files).
On origin/main the prepend image was linked to an unrelated prompt.
Repair: `tools/review_image_links.py <db> --output-dir <ComfyUI output> --ids
<ids>` (or `--prompt <id>`) judges links by these rules (`keep`, `relink`,
`unlink`, `conflict`, `missing`) on a backup copy and changes nothing.
`--apply` writes `relink`/`unlink` only for ids named with `--ids`, after saving
`<db>.pre-relink_<stamp>`, and only while each image is still linked to the
prompt it was judged on (otherwise it is counted as skipped). `relink` points the
link record at the other prompt and drops its pair links. `unlink` removes only
the image's link record (its `generated_images` row, the metadata snapshot stored
in it and its pair links): that is how the app represents an unlinked image, the
same as a file the scan could not attribute, so the file stays on disk and in the
gallery as unlinked, and its favorite and tags (kept by file) are untouched. Every
run prints this. Queue-linked batch images can't be proven after the fact and read
`unlink`, so review before applying. Stop ComfyUI first.
## Linked prompt text (#343)
A link drives `text` on every PromptManager node, and `negative_text` on the Pair,
as it drives any core widget. The Focused, Split and Pair editors hide their widgets,
and the frontend only draws a widget's socket while the widget is visible, so
`web/js/text-links.js` detaches those two inputs from their hidden widgets. They become
ordinary sockets; the schema, widget order and saved `widgets_values` do not change. A
link dropped on a prompt box (or its link note) fills that box's input: Nodes 2.0 resolves
it through the box's `.lg-node-widget` and `[data-slot-key]`, Classic through the node's
`getInputOnPos`. An exact STRING link dropped elsewhere on the editor fills the main prompt
first, then the negative, before the frontend's own choice (it used to pick `prepend_text`,
which caused #342). A link being moved off a text input counts as free, as the frontend
counts it, so dropping it back on the editor keeps it on `text`. Wildcard links (a
frontend Primitive before it is typed) are not steered.
The frontend's legacy Primitive is virtual: it writes its value into the target widget,
which the editor text no longer is. For the editors' detached inputs the `graphToPrompt`
wrapper puts the Primitive's value (through virtual reroutes, also inside subgraphs, where
the queued id is `<subgraph node>:<inner node>`) into the queued `text`/`negative_text`,
resolved as the host resolves a value written into the widget: the Primitive's text
replacement when its "Run widget replace on values" option is on, then the hidden widget's
own serializer (dynamic prompts: `{red|blue}` becomes one choice). Each queued output is
filled once (the queue hook only fills an output that did not come through the wrapper),
so a wildcard is drawn once per run. Inputs still bound to their widget (the Classic
nodes) are left as the host resolved them; as on main, the Classic text widget's own
serializer queues its text as written, wildcards included (specs wait on the node's
`_pmTextSerializerReady` before queuing). A Primitive drives the text as before, also in
workflows saved before #343. On every Run the host logs "Invalid widget or widget name"
for a Primitive wired to a detached editor input: its own widget write finds no widget
binding there. That is expected and harmless. A linked
Classic text box gives its canvas row back while linked (the core keeps an empty gap) and
gets its size back on disconnect. The metadata parsers map a known node's positional
widget values through its widget table when its widget-bound inputs do not line up with
it (the editors' text inputs are sockets now).
Not covered, by ruling: linking a subgraph input to an inner editor's `text` gives the
subgraph a plain STRING socket, not a promoted typeable widget as a core widget input
would. The socket was unreachable before #343, so nothing regressed.
While linked, each editor says the value comes from a link, and the typed text is kept
in the workflow for when the link is disconnected. That includes the Classic nodes,
which used to save `null` under a link.
- `tests/js/text-links.test.js`: socket exposure, drop preference (exact STRING, moved
links), box hit-test, Primitive values through reroutes, replacement and the widget
serializer (bound inputs left to the host), stored values, the Classic row.
- `tests/test_generation_params.py` (`workflow-only-detached-*` cases, with JS parity
vectors): editor text inputs without widget bindings keep their positional values, and so
does a core KSampler whose only widget-bound input (`cfg`) is out of schema order.
- `tests/browser/{focused,pair,classic}-node.spec.mjs`: the linked note, typed text
kept through save and configure (also with a workflow's widget binding restored), the
Classic "use prompt" guard while linked.
- `tests/comfyui/text-link.spec.mjs` (both startup projects): a real mouse drag of a
STRING output onto each node's prompt box (and onto the Pair's negative socket); the
link, the note and the typed text survive renderer switches both ways, a reload,
disconnect and node removal. `fixtures/workflow-before-343.json` and
`workflow-before-343-primitive.json` were serialized by the pre-#343 code on the pinned
host and must load unchanged; the second one's legacy Primitives keep driving the text
through a real Run. Also: a Primitive dropped on the Main prompt box and a string on the
Negative prompt box, a moved link released on the editor body, paste, clone and undo, a
legacy Primitive inside a subgraph, and wildcard Primitives on every node type queued
through a real Run as one choice, the same one another `graphToPrompt` wrapper sees.
- `tests/comfyui/execution-text-link.spec.mjs` (execution host): workflows built by the
real frontend through the sockets, with text from `PrimitiveString`, the legacy
Primitive or through `PreviewAny`, for PromptManager, PromptManagerText, Focused, Split
and the Pair (positive, negative, both, neither linked, and a Primitive negative). A
wildcard Primitive on PromptManager (queued as written) and Focused (one choice) saves
and counts what was queued. The linked text is saved, counted once, recorded as
the run's raw positive/negative, and after a scan the run's image links to it. Text
only known at run time (`PreviewAny`) cannot be read from the image; the case only
requires that it is never linked to another prompt (its attribution from the recorded
run belongs to #342).
```sh
node --test tests/js/text-links.test.js
npm run --prefix tools/vendor test:workflows -- classic-node.spec.mjs focused-node.spec.mjs pair-node.spec.mjs \
--project chromium --project touch --project firefox --project webkit
PM_COMFY_ROOT=... PM_TEST_PYTHON=... npm run --prefix tools/vendor test:comfyui -- text-link
PM_COMFY_ROOT=... PM_TEST_PYTHON=... npm run --prefix tools/vendor test:executions -- execution-text-link
```
## Durable execution ledger
`npm run --prefix tools/vendor test:executions` runs 52 real execution cases
(26 each in Classic and Nodes 2.0, retaining all 48 prior execution cases and adding
the two #342 image-attribution cases),
`npm run --prefix tools/vendor test:executions` runs 46 real execution cases
(23 each in Classic and Nodes 2.0, retaining all 42 prior execution cases),
alongside the existing 80 stock host cases (the original 78 plus the native
editor host case) and six pack compatibility cases. Run
these suites serially because they share report cleanup and owned host resources. Use the same pinned disposable `PM_COMFY_ROOT`,
+19 -171
View File
@@ -22,10 +22,7 @@ Features:
- Comprehensive logging and diagnostics
"""
import atexit
import re
import threading
import time
from pathlib import Path
@@ -75,12 +72,6 @@ NODE_DISPLAY_NAME_MAPPINGS = {
# are served by our own routes instead (see py/api).
WEB_DIRECTORY = "web/comfy"
# Parts the package teardown stops (``_stop_background_work``). Each stays None
# when its start-up block below fails, and the teardown skips it.
api = None
_global_prompt_tracker = None
_global_image_monitor = None
# Add API routes (same pattern as ComfyUI_Assets)
try:
# Set extension URI
@@ -98,6 +89,8 @@ try:
api.add_routes(routes)
# Temp files of preview writes a crash interrupted (#317), off the
# import path: the walk may cover a large thumbnails folder.
import threading
threading.Thread(
target=api.sweep_thumbnail_temps,
name="PromptManagerPreviewTempSweep",
@@ -107,11 +100,6 @@ try:
if host_app is not None and hasattr(host_app, "on_cleanup"):
host_app.on_cleanup.append(api.cleanup_history_reads)
host_app.on_cleanup.append(api.cleanup_annotation_captures)
host_app.on_cleanup.append(api.cleanup_gallery_listings)
if host_app is not None and hasattr(host_app, "on_startup"):
# Build each output root's Images listing once the server's loop runs,
# so the first open does not wait for the walk (#348).
host_app.on_startup.append(api.warm_gallery_listings)
if host_app is not None and hasattr(host_app, "on_shutdown"):
# Open Needs review change streams end before the server waits on them.
host_app.on_shutdown.append(api.close_review_events)
@@ -148,16 +136,25 @@ try:
# The Needs review pass for ambiguous new outputs runs through the API's
# capture store (the one the scan and the Tag dialog use): a linked output
# is never queued and its pending items leave the queue. The package
# teardown detaches it before that store is closed at shutdown.
if api is not None:
_global_image_monitor.set_review_recorder(api.record_review_file)
# is never queued and its pending items leave the queue. It stops before
# that store is closed at shutdown.
_review_api = globals().get("api")
if _review_api is not None:
_global_image_monitor.set_review_recorder(_review_api.record_review_file)
# Every settled output's preview is queued for the API's background
# worker (#249): the workspace never asks to generate thumbnails.
_global_image_monitor.set_thumbnail_queue(api.queue_capture_thumbnail)
# Every settled output walks its Images listing again (#348): the
# API hops from the monitor's thread onto the server's loop.
_global_image_monitor.set_output_listener(api.notify_output_settled)
_global_image_monitor.set_thumbnail_queue(_review_api.queue_capture_thumbnail)
_review_host = getattr(config.server_instance, "app", None)
if _review_host is not None and hasattr(_review_host, "on_cleanup"):
async def _stop_review_recording(
app, monitor=_global_image_monitor, api=_review_api
):
monitor.set_review_recorder(None)
monitor.set_thumbnail_queue(None)
api.close_auto_thumbnails(final=True)
_review_host.on_cleanup.insert(0, _stop_review_recording)
# Start monitoring
_global_image_monitor.start_monitoring()
@@ -227,155 +224,6 @@ except Exception:
"Failed to register execution history"
)
# ── Package teardown (#301, #302) ────────────────────────────────────────────
# ComfyUI never runs aiohttp's cleanup hooks (it never calls
# ``runner.cleanup()``), so ``atexit`` is the live host's path: Ctrl+C, and a
# normal exit. It is registered after the history hook, so at exit (newest
# first) it runs before the history dispose. Hosts that do run ``on_cleanup``
# (our test servers) run it there, and the tests' package unload calls it.
TEARDOWN_SECONDS = 10.0
_clock = time.monotonic
_background_work_lock = threading.Lock()
_background_work_result = None
def _teardown_step(name, stop, logger):
"""Run one teardown step; a failure (even Ctrl+C) is logged and is False."""
try:
return bool(stop())
except (Exception, KeyboardInterrupt):
logger.error(f"Background work: stopping {name} failed", exc_info=True)
return False
def _teardown_logger():
"""PromptManager's init logger; the standard one if that cannot load."""
try:
from .utils.logging_config import get_logger
return get_logger("prompt_manager.init")
except Exception:
import logging
return logging.getLogger("prompt_manager.init")
def _stop_background_work():
"""Stop the image monitor, the preview worker and the prompt cleanup
thread against one TEARDOWN_SECONDS deadline, once.
Returns ``{"monitor", "thumbnails", "tracker"}`` booleans (True: it
stopped), leaving out parts that never started. Later calls return a
copy of the first result. A Ctrl+C inside a step is logged and the
step is False; one between steps is re-raised after the result (parts
not reached are False) is stored and the ``atexit`` entry removed.
"""
global _background_work_result
with _background_work_lock:
if _background_work_result is not None:
return dict(_background_work_result)
monitor, thumbnails, tracker = (
_global_image_monitor,
api,
_global_prompt_tracker,
)
# A part whose global is set has started; False until its step says
# otherwise, so an interrupt between steps leaves an honest result.
started = (
("monitor", monitor),
("thumbnails", thumbnails),
("tracker", tracker),
)
result = {name: False for name, part in started if part is not None}
try:
logger = _teardown_logger()
began = _clock()
deadline = began + TEARDOWN_SECONDS
def left(own_bound):
return max(0.0, min(own_bound, deadline - _clock()))
if monitor is not None:
def stop_monitor():
from .utils.image_monitor import OBSERVER_JOIN_SECONDS
# Callbacks first: an image in hand queues no preview into a
# closing preview worker. The worker may have read the queue
# callback already, so the API refuses new previews too (at
# exit, Python 3.12+ refuses to start their worker thread).
if thumbnails is not None:
thumbnails.refuse_auto_thumbnails()
monitor.set_review_recorder(None)
monitor.set_thumbnail_queue(None)
monitor.set_output_listener(None)
return monitor.stop_monitoring(left(OBSERVER_JOIN_SECONDS))
result["monitor"] = _teardown_step(
"the image monitor", stop_monitor, logger
)
if thumbnails is not None:
def close_previews():
from .py.api.thumbnail_auto import CLOSE_WAIT_SECONDS
return thumbnails.close_auto_thumbnails(
final=True, timeout=left(CLOSE_WAIT_SECONDS)
)
result["thumbnails"] = _teardown_step(
"the preview worker", close_previews, logger
)
if tracker is not None:
def stop_tracker():
from .utils.prompt_tracker import TRACKER_JOIN_SECONDS
return tracker.shutdown(left(TRACKER_JOIN_SECONDS))
result["tracker"] = _teardown_step(
"the prompt cleanup thread", stop_tracker, logger
)
elapsed = _clock() - began
failed = [name for name, ok in result.items() if not ok]
if failed:
logger.warning(
f"Background work not stopped in time ({elapsed:.1f} s): "
+ ", ".join(failed)
)
else:
parts = " ".join(f"{name}=ok" for name in result)
logger.info(f"Background work stopped in {elapsed:.1f} s: {parts}")
finally:
_background_work_result = result
atexit.unregister(_stop_background_work)
return dict(result)
async def _stop_background_work_on_cleanup(app):
"""aiohttp ``on_cleanup``: no request is served then, so it runs inline."""
_stop_background_work()
try:
atexit.register(_stop_background_work)
from .py.config import server_instance as _teardown_server
_teardown_host = getattr(_teardown_server, "app", None)
if _teardown_host is not None and hasattr(_teardown_host, "on_cleanup"):
_teardown_host.on_cleanup.insert(0, _stop_background_work_on_cleanup)
except Exception as e:
try:
from .utils.logging_config import get_logger
get_logger("prompt_manager.init").error(
f"Failed to register the background work teardown: {e}"
)
except Exception:
pass
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
# Print startup message with loaded tools
+1 -4
View File
@@ -192,10 +192,7 @@ class CategoryImportOperations:
outcome = "imported"
created[prompt_hash] = prompt_id
result[_COUNTERS[outcome]] += 1
# Linked last to first: every workspace list shows a prompt's media
# newest link first, so the record's first image (the LoRA cover,
# #355) is the one a Library card shows.
for image in reversed(record.get("images") or []):
for image in record.get("images") or []:
if self._insert_image_link(conn, prompt_id, image):
result["images"] += 1
result["rows"].append({"name": name, "outcome": outcome})
-6
View File
@@ -4,8 +4,6 @@ import sqlite3
from contextlib import ExitStack
from typing import Callable, ContextManager
from .run_attribution import notify_outputs_sealed
try:
from ..utils.execution_history_events import RunFinish, encode_command
from ..utils.execution_outputs import OutputBundle, validate_output_bundle
@@ -115,10 +113,6 @@ class ExecutionOutputOperations:
"VALUES(?,?,?,?,?,?,?,?)",
[(run_id, *reference) for reference in prepared],
)
# Committed: images waiting for this run may now be attributed (#342)
notify_outputs_sealed(
run_id, [dict(zip(_REFERENCE_FIELDS, row)) for row in prepared]
)
return True
def get_execution_outputs(self, run_id: str) -> dict:
-31
View File
@@ -1808,37 +1808,6 @@ class PromptDatabase(
reader = DocumentReader(conn)
return [self._image_row_to_dict(row, reader) for row in cursor]
def autotag_batch_candidates(self) -> List[Dict[str, Any]]:
"""The images a Tag output images batch looks at (#328).
``{id, image_path, prompt_id, prompt_tags}`` for every image with a
stored path that is linked to a prompt, newest first (the
``get_all_images`` order, ties newest row first). Never reads
``workflow_data`` or the other documents: a batch needs none of them.
"""
sql = (
"SELECT gi.id, gi.image_path, gi.prompt_id, "
"(SELECT GROUP_CONCAT(t.name, '|||') FROM prompt_tags pt "
"JOIN tags t ON pt.tag_id = t.id WHERE pt.prompt_id = gi.prompt_id) "
"AS prompt_tags "
"FROM generated_images gi "
"INNER JOIN prompts p ON gi.prompt_id = p.id "
"WHERE gi.image_path IS NOT NULL AND gi.image_path != '' "
"ORDER BY gi.generation_time DESC, gi.id DESC"
)
with self.model.get_connection() as conn:
return [
{
"id": row["id"],
"image_path": row["image_path"],
"prompt_id": row["prompt_id"],
"prompt_tags": (
row["prompt_tags"].split("|||") if row["prompt_tags"] else []
),
}
for row in conn.execute(sql)
]
def get_all_images(
self,
limit: int = 0,
-16
View File
@@ -23,26 +23,10 @@ except ImportError:
from utils.prompt_pair_resolver import RESOLVER_VERSION
def blank_positive(resolution: Dict[str, Any]) -> bool:
"""A resolved pair whose stored positive text is empty (#342).
A PromptManager node whose editor is empty encodes only its prepend and
append text, which is never stored as a prompt: such a result names no
prompt to link.
"""
if resolution.get("state") != "resolved":
return False
positive = resolution.get("positive") or {}
text = positive.get("storage_text", positive.get("text"))
return not (isinstance(text, str) and text.strip())
def ingest_resolved_pair(
db: Any, image_path: str, metadata: Dict[str, Any], resolution: Dict[str, Any]
) -> Dict[str, Any]:
state = resolution.get("state", "unresolved")
if state == "resolved" and blank_positive(resolution):
state = "unresolved"
if state != "resolved":
return {"status": state, "pairs_added": 0, "links_added": 0}
with db._pair_transaction() as conn:
-244
View File
@@ -1,244 +0,0 @@
"""Attribute an output file to a stored prompt through recorded history (#342).
Execution history records, per run, the files ComfyUI reported
(``execution_run_outputs``) and the PromptManager texts the run used
(``execution_run_contexts``, whose ``positive_prompt_id`` hint is set only
while the raw text matches exactly one stored prompt). A file is attributed
only when all of this holds:
- exactly one recorded run reported the file's output location (same output
root, subfolder and name);
- the file was last written while that run was executing, from its dequeue
to its terminal event (else the finish callback): a later run that history
did not record may have reused the name;
- every positive context of that run names the same stored prompt.
Anything else returns None: the caller leaves the file unlinked. Read-only.
Lookups are batched (``stored_run_prompts``): one parameterized query per
chunk of at most ``LOOKUP_CHUNK`` distinct file names, matched in memory. The
history schema is left exactly as it is (no extra index), so older builds can
still open the library.
"""
import logging
import os
import sqlite3
import threading
import weakref
from typing import Any, Callable, Dict, Iterable, List, Optional
try:
from ..utils.execution_outputs import snapshot_output_roots
from ..utils.library_location import import_folder_paths
except ImportError:
from utils.execution_outputs import snapshot_output_roots
from utils.library_location import import_folder_paths
# File times may trail or lead the recorded run times a little (coarse file
# system clocks, the write finishing just after the host reported it).
MTIME_SLACK_MS = 2000
# Distinct file names per lookup query: well under SQLite's bound-parameter
# limit (999 on old builds), so a scan batch costs one query per chunk.
LOOKUP_CHUNK = 500
_RUNS_FOR_NAMES = (
"SELECT DISTINCT o.filename, o.subfolder, o.root_key, o.path_style, o.run_id, "
"r.observed_dequeue_at, COALESCE(r.terminal_event_at, r.observed_callback_at) "
"FROM execution_run_outputs o JOIN execution_runs r ON r.run_id = o.run_id "
"WHERE o.folder_type = 'output' AND o.filename IN ({marks})"
)
def host_output_directory() -> Optional[str]:
"""ComfyUI's output directory, or None outside ComfyUI."""
folder_paths = import_folder_paths()
if folder_paths is None:
return None
try:
value = folder_paths.get_output_directory()
except Exception:
return None
return value if isinstance(value, str) and value else None
def _inside(image_path: str, output_root: str) -> Optional[str]:
"""``image_path`` relative to ``output_root``, or None when outside it."""
try:
relative = os.path.relpath(image_path, output_root)
except ValueError: # another drive on Windows
return None
if os.path.isabs(relative) or relative == os.pardir:
return None
if relative.startswith(os.pardir + os.sep):
return None
return relative
def _resolved(path: str) -> str:
try:
return os.path.realpath(path)
except (OSError, ValueError):
return os.path.abspath(path)
def _location(image_path: str, output_root: str, path_style: str):
"""(root key, subfolder, filename) as history records them, or None.
The root key is always the host's own spelling of its output folder,
because history recorded that one. The file may be spelled through the
same folder or through its resolved path (a symlinked or junction output
folder, as scan roots are): the lexical spelling is tried first, then
both sides resolved.
"""
root_key = snapshot_output_roots(path_style, output_root, output_root).output_key
if root_key is None:
return None
relative = _inside(os.path.abspath(image_path), os.path.abspath(output_root))
if relative is None:
relative = _inside(_resolved(image_path), _resolved(output_root))
if relative is None:
return None
subfolder, filename = os.path.split(relative)
if not filename:
return None
return root_key, subfolder, filename
def in_output_folder(image_path: str, output_root: Optional[str]) -> bool:
"""Whether history could ever attribute ``image_path`` (inside the host's
output folder, in either spelling)."""
if not image_path or not output_root:
return False
style = "windows" if os.name == "nt" else "posix"
return _location(image_path, output_root, style) is not None
def _written_during(image_path: str, started: Any, finished: Any) -> bool:
if not isinstance(started, int) or not isinstance(finished, int):
return False
try:
written = os.stat(image_path).st_mtime_ns // 1_000_000
except OSError:
return False
return started - MTIME_SLACK_MS <= written <= finished + MTIME_SLACK_MS
def stored_run_prompt(
db: Any,
image_path: str,
output_root: Optional[str],
path_style: Optional[str] = None,
) -> Optional[int]:
"""The stored prompt id history attributes to ``image_path``, else None."""
if not image_path:
return None
return stored_run_prompts(db, [image_path], output_root, path_style).get(image_path)
def _runs_by_location(db: Any, names: List[str]) -> Optional[Dict[tuple, dict]]:
"""``{(filename, subfolder, root_key, style): {run_id: (start, end)}}``.
One query per ``LOOKUP_CHUNK`` names; None without history tables.
"""
found: Dict[tuple, dict] = {}
conn = db.model.get_connection()
for start in range(0, len(names), LOOKUP_CHUNK):
chunk = names[start : start + LOOKUP_CHUNK]
query = _RUNS_FOR_NAMES.format(marks=",".join("?" for _ in chunk))
try:
rows = conn.execute(query, chunk).fetchall() # nosec B608
except sqlite3.Error:
return None # no history tables (a library without history)
for name, subfolder, root_key, style, run_id, began, ended in rows:
runs = found.setdefault((name, subfolder, root_key, style), {})
runs[run_id] = (began, ended)
return found
def _named_prompt(db: Any, run_id: str) -> Optional[int]:
contexts = db.get_execution_contexts(run_id).get("contexts") or []
named = {
context.get("positive_prompt_id")
for context in contexts
if context.get("positive") is not None
}
if len(named) != 1:
return None
(prompt_id,) = named
return prompt_id if isinstance(prompt_id, int) else None
def stored_run_prompts(
db: Any,
image_paths: Iterable[str],
output_root: Optional[str],
path_style: Optional[str] = None,
) -> Dict[str, Optional[int]]:
"""``{path: stored prompt id or None}`` for many files in few queries.
The output lookup is one query per ``LOOKUP_CHUNK`` distinct names; each
run that alone reported a file is then read once for its contexts.
"""
paths = list(dict.fromkeys(path for path in image_paths if path))
result: Dict[str, Optional[int]] = dict.fromkeys(paths)
if not paths or not output_root:
return result
style = path_style or ("windows" if os.name == "nt" else "posix")
located = {}
for path in paths:
location = _location(path, output_root, style)
if location is not None:
located[path] = location
if not located:
return result
names = sorted({filename for _key, _sub, filename in located.values()})
runs = _runs_by_location(db, names)
if not runs:
return result
prompts: Dict[str, Optional[int]] = {}
for path, (root_key, subfolder, filename) in located.items():
reported = runs.get((filename, subfolder, root_key, style), {})
if len(reported) != 1:
continue
((run_id, (began, ended)),) = reported.items()
if not _written_during(path, began, ended):
continue
if run_id not in prompts:
prompts[run_id] = _named_prompt(db, run_id)
result[path] = prompts[run_id]
return result
_LOGGER = logging.getLogger("prompt_manager.run_attribution")
_SEALED_LISTENERS: List[weakref.WeakMethod] = []
_SEALED_LOCK = threading.Lock()
def subscribe_outputs_sealed(method: Callable[[str, List[dict]], None]) -> None:
"""Call bound ``method(run_id, references)`` after a run's outputs are recorded.
Held weakly: a listener that is garbage collected is dropped.
"""
with _SEALED_LOCK:
_SEALED_LISTENERS.append(weakref.WeakMethod(method))
def notify_outputs_sealed(run_id: str, references: Iterable[dict]) -> None:
"""Tell listeners a run's output references were just committed.
A listener failure is logged and never reaches the history writer.
"""
references = list(references)
with _SEALED_LOCK:
alive = [ref for ref in _SEALED_LISTENERS if ref() is not None]
_SEALED_LISTENERS[:] = alive
listeners = [ref() for ref in alive]
for listener in listeners:
if listener is None:
continue
try:
listener(run_id, references)
except Exception:
_LOGGER.warning("Output seal listener failed", exc_info=True)
-3
View File
@@ -238,9 +238,6 @@ class PromptManager(PromptManagerBase, ComfyNodeABC):
except Exception as e:
# Log error but don't fail the encoding
self.logger.warning(f"Failed to save prompt to database: {e}")
self._mark_untracked(prompt, unique_id)
else:
self._skip_empty_prompt(prompt, unique_id)
# Perform standard CLIP text encoding using the combined text
self.logger.debug(
+3 -60
View File
@@ -24,7 +24,6 @@ try:
from .utils.prompt_tracker import get_prompt_tracker
from .utils.hashing import generate_prompt_hash
from .utils.prompt_graph import resolve_text, run_prompt_nodes
from .utils.run_keys import executing_run_id, prompt_node_key
from .utils.usage_tracking import PENDING_FIRST_USE
except ImportError:
import sys
@@ -36,7 +35,6 @@ except ImportError:
from utils.prompt_tracker import get_prompt_tracker
from utils.hashing import generate_prompt_hash
from utils.prompt_graph import resolve_text, run_prompt_nodes
from utils.run_keys import executing_run_id, prompt_node_key
from utils.usage_tracking import PENDING_FIRST_USE
@@ -170,67 +168,14 @@ class PromptManagerBase:
prompt_id = self._save_prompt_to_database(
text=text, category=category, tags=tags, count_run=batch_item
)
if role["is_run_prompt"] and not prompt_id:
# The save failed: an older entry of this key must not stand in (#342)
self._mark_untracked(prompt_graph, unique_id)
elif role["is_run_prompt"]:
if prompt_id and role["is_run_prompt"]:
self.prompt_tracker.set_current_prompt(
prompt_text=encoding_text,
additional_data={**additional_data, "prompt_id": prompt_id},
push_to_queue=batch_item or not role["known"],
**self._run_identity(prompt_graph, unique_id),
)
return prompt_id
@staticmethod
def _run_identity(prompt_graph: Any, unique_id: Any) -> Dict[str, Any]:
"""The tracker's proof that an image belongs to this execution (#342)."""
return {
"run_key": prompt_node_key(prompt_graph, unique_id),
"run_id": executing_run_id(),
}
def _skip_empty_prompt(self, prompt_graph: Any, unique_id: Any) -> None:
"""The ``text`` editor is empty: save and track nothing (#342).
Prepend and append text are deliberately never stored as the prompt, so
an empty editor has no prompt to save, count or link. For the run's
positive node this is logged at info and the node's run key is marked
empty (``_mark_untracked``).
"""
self._mark_untracked(
prompt_graph,
unique_id,
"Prompt text is empty (prepend and append text are not stored): "
"nothing saved or tracked, and this run's images stay unlinked",
)
def _mark_untracked(
self, prompt_graph: Any, unique_id: Any, message: Optional[str] = None
) -> None:
"""This execution of a positive node has no prompt to link (#342).
An empty editor, or a save or tracking failure. The node's run key is
marked with a placeholder, so no earlier prompt of that key can be
linked to this run's images, and a batch item keeps its queue place.
``message`` is logged at info for a positive node.
"""
try:
role = self._prompt_role(prompt_graph, unique_id, "")
if not role["is_run_prompt"]:
if message:
self.logger.debug("Empty text on a non-positive node; not saved")
return
if message:
self.logger.info(message)
self.prompt_tracker.mark_empty(
push_to_queue=role["known"],
**self._run_identity(prompt_graph, unique_id),
)
except Exception as error:
# Tracking must never block generation
self.logger.warning(f"Could not mark the untracked prompt: {error}")
def _record_use(self, prompt_id: int, times: int = 1) -> None:
"""Count runs; a tracking failure must never block generation."""
try:
@@ -268,10 +213,8 @@ class PromptManagerBase:
cache = get_trigger_cache()
# Lazy-load cache on first use; a failed load waits before the next try
# (#355: never a rescan of LoraManager's folders on every run).
retry_due = getattr(cache, "retry_due", lambda: True)
if not cache.is_loaded and IntegrationConfig.LORA_MANAGER_PATH and retry_due():
# Lazy-load cache on first use
if not cache.is_loaded and IntegrationConfig.LORA_MANAGER_PATH:
cache.load(IntegrationConfig.LORA_MANAGER_PATH)
modified, injected = inject_trigger_words(text, cache)
+77 -117
View File
@@ -117,29 +117,85 @@ class PromptManagerPair(PromptManagerFocused):
)
is_positive = "text" in used_fields
pair_used = node_id in intrinsic_pair_nodes(prompt) if known else True
if not text.strip():
# Nothing to store: prepend/append are never the stored prompt (#342)
self._skip_empty_prompt(prompt, unique_id)
else:
try:
pair_id, prompt_id = self._save_and_track_pair(
text,
try:
pair_id = self.db.save_prompt_pair(
text,
negative_text,
category=category.strip() or None,
tags=self._parse_tags(tags),
)
pair = self.db.get_prompt_pair(pair_id)
prompt_id = pair["positive_prompt_id"]
resolved_positive = (
resolve_text_input(prompt, node_id, "text") if known else None
)
resolved_negative = (
resolve_text_input(prompt, node_id, "negative_text") if known else None
)
positive_seen = resolved_positive is not None and generate_prompt_hash(
resolved_positive
) == generate_prompt_hash(text)
pair_seen = (
positive_seen
and resolved_negative is not None
and generate_prompt_hash(resolved_negative)
== generate_prompt_hash(negative_text)
)
positive_pending = getattr(self, "pending_first_use", PENDING_FIRST_USE)
pair_pending = getattr(
self, "pending_pair_first_use", PENDING_PAIR_FIRST_USE
)
counted = set()
for field, component_text, resolved_text, component_id in (
("text", text, resolved_positive, prompt_id),
(
"negative_text",
negative_text,
positive,
category=category,
tags=tags,
prepend_text=prepend_text,
append_text=append_text,
prompt=prompt,
unique_id=unique_id,
used_fields=used_fields,
is_positive=is_positive,
pair_used=pair_used,
resolved_negative,
pair["negative_prompt_id"],
),
):
component_hash = generate_prompt_hash(component_text)
if (
field not in used_fields
or not component_id
or component_hash in counted
):
continue
counted.add(component_hash)
seen_at_queue = (
resolved_text is not None
and generate_prompt_hash(resolved_text) == component_hash
)
except Exception as error:
self.logger.warning(f"Could not save or track paired prompt: {error}")
if is_positive:
self._mark_untracked(prompt, unique_id)
runs = positive_pending.consume(component_hash) + int(not seen_at_queue)
if runs:
self.db.record_prompt_use(component_id, times=runs)
if pair_used:
runs = pair_pending.consume(pair_run_key(text, negative_text)) + int(
not pair_seen
)
if runs:
self.db.record_prompt_pair_use(pair_id, times=runs)
if is_positive:
self.prompt_tracker.set_current_prompt(
prompt_text=positive,
additional_data={
"prompt_id": prompt_id,
"pair_id": pair_id if pair_used else None,
"positive_text": positive,
"negative_text": negative_text,
"positive_storage_text": text.strip(),
"negative_storage_text": negative_text.strip(),
"prepend_text": prepend_text.strip(),
"append_text": append_text.strip(),
"final_text": positive,
"category": category.strip() or None,
"tags": self._parse_tags(tags) or [],
},
push_to_queue=not positive_seen or (pair_used and not pair_seen),
)
except Exception as error:
self.logger.warning(f"Could not save or track paired prompt: {error}")
try:
self.comfyui_integration.register_prompt(
f"promptmanager_pair_{time.time_ns()}",
@@ -167,102 +223,6 @@ class PromptManagerPair(PromptManagerFocused):
)
return result
def _save_and_track_pair(
self,
text: str,
negative_text: str,
positive: str,
*,
category: str,
tags: str,
prepend_text: str,
append_text: str,
prompt: Optional[Dict[str, Any]],
unique_id: Optional[str],
used_fields: set,
is_positive: bool,
pair_used: bool,
) -> Tuple[Optional[int], Optional[int]]:
"""Save the recipe, count its runs and register the positive for linking."""
known = isinstance(prompt, dict) and unique_id is not None
node_id = str(unique_id)
pair_id = self.db.save_prompt_pair(
text,
negative_text,
category=category.strip() or None,
tags=self._parse_tags(tags),
)
pair = self.db.get_prompt_pair(pair_id)
prompt_id = pair["positive_prompt_id"]
resolved_positive = (
resolve_text_input(prompt, node_id, "text") if known else None
)
resolved_negative = (
resolve_text_input(prompt, node_id, "negative_text") if known else None
)
positive_seen = resolved_positive is not None and generate_prompt_hash(
resolved_positive
) == generate_prompt_hash(text)
pair_seen = (
positive_seen
and resolved_negative is not None
and generate_prompt_hash(resolved_negative)
== generate_prompt_hash(negative_text)
)
positive_pending = getattr(self, "pending_first_use", PENDING_FIRST_USE)
pair_pending = getattr(self, "pending_pair_first_use", PENDING_PAIR_FIRST_USE)
counted = set()
for field, component_text, resolved_text, component_id in (
("text", text, resolved_positive, prompt_id),
(
"negative_text",
negative_text,
resolved_negative,
pair["negative_prompt_id"],
),
):
component_hash = generate_prompt_hash(component_text)
if (
field not in used_fields
or not component_id
or component_hash in counted
):
continue
counted.add(component_hash)
seen_at_queue = (
resolved_text is not None
and generate_prompt_hash(resolved_text) == component_hash
)
runs = positive_pending.consume(component_hash) + int(not seen_at_queue)
if runs:
self.db.record_prompt_use(component_id, times=runs)
if pair_used:
runs = pair_pending.consume(pair_run_key(text, negative_text)) + int(
not pair_seen
)
if runs:
self.db.record_prompt_pair_use(pair_id, times=runs)
if is_positive:
self.prompt_tracker.set_current_prompt(
prompt_text=positive,
additional_data={
"prompt_id": prompt_id,
"pair_id": pair_id if pair_used else None,
"positive_text": positive,
"negative_text": negative_text,
"positive_storage_text": text.strip(),
"negative_storage_text": negative_text.strip(),
"prepend_text": prepend_text.strip(),
"append_text": append_text.strip(),
"final_text": positive,
"category": category.strip() or None,
"tags": self._parse_tags(tags) or [],
},
push_to_queue=not positive_seen or (pair_used and not pair_seen),
**self._run_identity(prompt, unique_id),
)
return pair_id, prompt_id
@classmethod
def IS_CHANGED(
cls,
-3
View File
@@ -216,9 +216,6 @@ class PromptManagerText(PromptManagerBase, ComfyNodeABC):
except Exception as e:
# Log error but don't fail the processing
self.logger.warning(f"Failed to save prompt to database: {e}")
self._mark_untracked(prompt, unique_id)
else:
self._skip_empty_prompt(prompt, unique_id)
# Register with ComfyUI integration for standard metadata compatibility
node_id = f"promptmanagertext_{int(time.time() * 1000)}"
+13 -148
View File
@@ -31,7 +31,6 @@ import math
import ntpath
import os
import re
import threading
from pathlib import Path
from aiohttp import web
@@ -48,7 +47,6 @@ from .review_events import ReviewEventsMixin
from .saved_views import SavedViewRoutesMixin
from .history import HistoryRoutesMixin
from .images import ImageRoutesMixin
from .gallery_listing import WALK_STOPPED, RootListing, WalkOutcome
from .admin import AdminRoutesMixin
from .settings_routes import SettingsRoutesMixin
from .job_routes import JobRoutesMixin
@@ -89,18 +87,6 @@ def _get_project_root():
return os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# How long an executor thread waits for the loop to drop the listings.
_OFF_LOOP_DROP_WAIT = 30
def _running_loop():
"""The event loop running in this thread, or None."""
try:
return asyncio.get_running_loop()
except RuntimeError:
return None
def _public_path(path) -> str:
"""Render a server path for an API response without revealing the layout.
@@ -358,20 +344,8 @@ class PromptManagerAPI(
self._captures_closed = False
self._cached_output_dir = None # Lazy-cached by _find_comfyui_output_dir()
self._html_cache = {} # Cached HTML file contents keyed by path
# One shared listing per output root, keyed by str(root), and the
# background walks refreshing them (py/api/gallery_listing.py, #348).
self._root_listings: dict[str, RootListing] = {}
self._listing_tasks: set = set()
# The loop the walks run on, set by the startup warm-up or the first
# walk; the monitor's thread hops onto it (notify_output_settled).
self._listing_loop = None
# Set by cleanup_gallery_listings: no background walk starts after it.
self._listings_closed = False
# The workspace caches rebuilt behind a stored walk that changed the
# listing, once the host's startup warm-up has run (#348).
self._workspace_warm_enabled = False
self._workspace_warm_task = None
self._workspace_warm_again = False
self._gallery_cache = {} # dict: path_str -> (files, timestamp)
self._gallery_cache_ttl = 30 # Cache TTL in seconds
# Workspace listing values and sort orders derived from a cached scan
# (py/api/images.py _workspace_listed/_workspace_orders).
self._workspace_listed_cache = {}
@@ -491,122 +465,23 @@ class PromptManagerAPI(
return await loop.run_in_executor(None, call)
return await loop.run_in_executor(None, func, *args)
def _root_listing(self, root) -> RootListing:
"""The shared listing record of one output root (created empty)."""
return self._root_listings.setdefault(str(root), RootListing())
def invalidate_gallery_cache(self):
"""Invalidate the cached gallery file listing.
def invalidate_gallery_cache(self, retire=False):
"""Drop every root's listing, so the next read waits for a new walk.
Called after files are deleted through the app (duplicate cleanup and
the legacy duplicate delete), never for thumbnail writes. A walk
running now is not stored (its generation moved on). The workspace
keeps its per-file values and card facts, so the next request
rebuilds only new or changed files (see ImageRoutesMixin.
_workspace_listed and _card_facts); its sort orders are dropped, as
the walk replaces them anyway, and so is the favorites lookup, which
holds the old list.
``retire`` (the roots changed): no background walk of a root starts
again until a reader or the warm-up asks for it.
Called by the image monitor when new files are detected. The workspace
keeps its per-file values and card facts, so the next request rebuilds
only new or changed files (see ImageRoutesMixin._workspace_listed and
_card_facts); its sort orders
are dropped, as the rescan replaces them anyway, and so is the
favorites lookup, which holds the old scan list.
"""
loop = self._listing_loop
if loop is not None and loop.is_running() and _running_loop() is not loop:
# Called from an executor thread (a settings save, a test seed):
# listing records are touched only on the loop, and the caller
# expects the drop done when this returns.
dropped = threading.Event()
def drop():
try:
self._drop_listings(retire)
finally:
dropped.set()
loop.call_soon_threadsafe(drop)
if not dropped.wait(_OFF_LOOP_DROP_WAIT):
self.logger.warning("The Images listing drop is still queued")
return
self._drop_listings(retire)
def _drop_listings(self, retire=False):
for record in list(self._root_listings.values()):
record.drop()
if retire:
# The roots changed: a removed root must not come back through
# a pending timer, a trailing walk or a monitor mark.
record.retired = True
record.dirty = False
handle, record.rewalk_handle = record.rewalk_handle, None
if handle is not None:
handle.cancel()
self._gallery_cache = {}
self._workspace_order_cache = {}
self._favorite_listing_cache = {}
async def warm_gallery_listings(self, app=None):
"""Start one background walk per output root (aiohttp on_startup).
Registered by the root ``__init__.py``, so the first Images open after
ComfyUI starts finds the listing built. It never fails startup: a
root that cannot be walked is retried by the first read.
"""
# Each stored walk that changes a listing then rebuilds the workspace's
# per-file values, orders and favorites behind it (_warm_workspace).
self._workspace_warm_enabled = True
self._listing_loop = asyncio.get_running_loop()
self._listings_closed = False
try:
for root in self._get_all_output_dirs():
self._root_listing(root).retired = False
self._schedule_walk(root)
except Exception as e:
self.logger.warning(f"Could not warm the Images listing: {e}")
def notify_output_settled(self, path):
"""The image monitor settled an output at *path* (any thread).
Called on the monitor's worker thread with the resolved path. It only
hands the path to the event loop (``output_settled``) and never
touches a listing from this thread; before the loop is known, or once
the listings are cleaned up, it does nothing.
"""
loop = self._listing_loop
if loop is None or self._listings_closed or not loop.is_running():
return
try:
loop.call_soon_threadsafe(self.output_settled, path)
except RuntimeError:
pass # the loop closed meanwhile (shutdown)
async def cleanup_gallery_listings(self, app=None):
"""Stop the background listing walks (aiohttp on_cleanup)."""
self._workspace_warm_enabled = False
# No mark, deferred walk or trailing walk starts a walk from now on.
self._listings_closed = True
# Before any await, so no delayed refresh can start a walk meanwhile.
for record in self._root_listings.values():
handle, record.rewalk_handle = record.rewalk_handle, None
if handle is not None:
handle.cancel()
warm, self._workspace_warm_task = self._workspace_warm_task, None
if warm is not None:
warm.cancel()
await asyncio.gather(warm, return_exceptions=True)
tasks = list(self._listing_tasks)
for task in tasks:
task.cancel()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
self._listing_tasks.clear()
# A walk cancelled before its first step never ran its own cleanup.
for record in self._root_listings.values():
flight, record.flight = record.flight, None
if flight is not None and not flight.done():
flight.set_result(WalkOutcome.failed(RuntimeError(WALK_STOPPED)))
def reset_gallery_roots_cache(self):
"""Drop every cached listing and derived workspace value (roots changed)."""
self.invalidate_gallery_cache(retire=True)
self.invalidate_gallery_cache()
self._workspace_listed_cache = {}
self._workspace_card_cache = {}
@@ -750,12 +625,7 @@ class PromptManagerAPI(
# ── Shared utilities used by multiple mixins ──────────────────────
def _enrich_images(self, images):
"""Add url and thumbnail_url to a flat list of image dicts.
Each dict without ``is_video`` also gets ``is_video`` and
``media_type`` from its extension (#355: a linked LoraManager video
outside the output folders is shown as a video, not a picture).
"""
"""Add url and thumbnail_url to a flat list of image dicts."""
from urllib.parse import quote as url_quote
output_dirs = self._get_all_output_dirs()
@@ -765,11 +635,6 @@ class PromptManagerAPI(
if not image_path_str:
continue
if "is_video" not in image:
video = is_video_path(image_path_str)
image["is_video"] = video
image["media_type"] = "video" if video else "image"
img_path = Path(image_path_str)
# Set fallback url via image ID
+4 -102
View File
@@ -35,12 +35,7 @@ except ImportError:
from utils.parallel import map_parallel
try:
from ...database.pair_ingestion import blank_positive, ingest_resolved_pair
from ...database.run_attribution import (
host_output_directory,
stored_run_prompt,
stored_run_prompts,
)
from ...database.pair_ingestion import ingest_resolved_pair
from ...utils.generation_params import (
PLACEHOLDERS,
GenerationParamsError,
@@ -50,12 +45,7 @@ try:
from ...utils.prompt_pair_resolver import resolve_prompt_pair
from ...utils.review_capture import reviewable
except ImportError:
from database.pair_ingestion import blank_positive, ingest_resolved_pair
from database.run_attribution import (
host_output_directory,
stored_run_prompt,
stored_run_prompts,
)
from database.pair_ingestion import ingest_resolved_pair
from utils.generation_params import (
PLACEHOLDERS,
GenerationParamsError,
@@ -558,24 +548,6 @@ def _current_image_monitor():
return getattr(_image_monitor_module(), "_monitor_instance", None)
class _BatchHistory:
"""A scan batch's recorded-history lookup, made on the first file needing it.
Most files link through their metadata and never ask; the first one that
does triggers one lookup for the whole batch (``_recorded_runs_for``).
"""
def __init__(self, api, media_files):
self._api = api
self._files = media_files
self._found = None
def get(self, path):
if self._found is None:
self._found = self._api._recorded_runs_for(self._files)
return self._found.get(path)
class AdminRoutesMixin:
"""Mixin providing admin, diagnostics, and maintenance API endpoints."""
@@ -1592,8 +1564,6 @@ class AdminRoutesMixin:
if outcomes is not None:
outcomes.append(_scan_outcome(media_file, roots, status, title))
batch_results = list(batch_results)
history = _BatchHistory(self, [media for media, _meta in batch_results])
for media_file, metadata in batch_results:
if self._scan_job and self._scan_job.cancelled:
break
@@ -1607,7 +1577,6 @@ class AdminRoutesMixin:
ambiguous=ambiguous,
untraced=untraced,
records=records,
history=history,
)
except Exception as e:
self.logger.error(f"Error processing {media_file.name}: {e}")
@@ -1666,7 +1635,6 @@ class AdminRoutesMixin:
ambiguous=None,
untraced=None,
records=None,
history=None,
):
"""Enrich exact pairs; retain legacy positive-only import for absent pairs.
@@ -1684,9 +1652,7 @@ class AdminRoutesMixin:
a dict.
"""
if not metadata:
# No prompt metadata (--disable-metadata, bare savers): only the
# recorded run can say which prompt made it (#342)
return self._link_recorded_run(media_file, {}, detailed, history)
return None
canonical = dict(metadata)
for key in ("prompt", "workflow"):
if isinstance(canonical.get(key), str):
@@ -1696,12 +1662,9 @@ class AdminRoutesMixin:
# Preserve malformed metadata for conservative resolver rejection.
pass
if not canonical.get("prompt") and not canonical.get("parameters"):
return self._link_recorded_run(media_file, canonical, detailed, history)
return None
_with_file_size(canonical, media_file)
resolution = resolve_prompt_pair(canonical)
if blank_positive(resolution):
# Only prepend/append text: no stored prompt can own it (#342)
resolution = {**resolution, "state": "unresolved"}
# Ambiguous without a choice: unresolved unless something links it.
unchoosable = resolution["state"] == "ambiguous" and not reviewable(resolution)
if unchoosable:
@@ -1730,11 +1693,6 @@ class AdminRoutesMixin:
# Graphs without a recognized sampler keep the legacy extractor below.
prompt_text = self._graph_prompt_text(metadata)
if not prompt_text:
linked = self._link_recorded_run(
media_file, canonical, detailed, history
)
if linked is not None:
return linked
return "unresolved" if unchoosable else resolution["state"]
elif resolution["state"] == "ambiguous":
return "unresolved" if unchoosable else "ambiguous"
@@ -1758,62 +1716,6 @@ class AdminRoutesMixin:
return status
return {"status": status, "title": self._scan_title(media_file, prompt_text)}
def _recorded_runs_for(self, media_files):
"""``{path: prompt id or None}`` from recorded history for a scan batch.
One output lookup per chunk of names (``stored_run_prompts``), so a
batch never queries history once per file. An empty dict on failure.
"""
try:
return stored_run_prompts(
self.db, [str(path) for path in media_files], host_output_directory()
)
except Exception:
self.logger.warning(
"History attribution failed for a scan batch", exc_info=True
)
return {}
def _link_recorded_run(self, media_file, canonical, detailed=False, history=None):
"""Link a file recorded history attributes to one stored prompt (#342).
Returns the scan status (with ``detailed``, a stored or linked file's
``{"status", "title"}``), or None when history names no single stored
prompt for the file (``database.run_attribution``). Never creates a
prompt. Works without any prompt metadata in the file. ``history`` is
the batch's precomputed lookup; without it the file is looked up alone.
"""
if history is not None:
prompt_id = history.get(str(media_file))
else:
try:
prompt_id = stored_run_prompt(
self.db, str(media_file), host_output_directory()
)
except Exception:
self.logger.warning(
f"History attribution failed for {media_file.name}",
exc_info=True,
)
return None
if prompt_id is None:
return None
try:
image_id = self.db.link_image_to_prompt(
prompt_id, str(media_file), metadata=canonical
)
except Exception as e:
self.logger.error(f"Failed to link {media_file.name} from history: {e}")
return "found"
status = "linked" if image_id else "unchanged"
if not detailed or status not in SCAN_TITLED_DETAILS:
return status
prompt = self.db.get_prompt_by_id(prompt_id) or {}
return {
"status": status,
"title": self._scan_title(media_file, prompt.get("text") or ""),
}
def _scan_title(self, media_file, text):
"""The linked prompt's Library title (its stored title, else ``text``'s
first line), as the Library and the inspector name it."""
+24 -17
View File
@@ -10,15 +10,12 @@ filesystem or the database.
import math
from ..tagging_defaults import ( # noqa: F401 - re-exported for the route
LLM_MODEL_TYPES as LLM_MODELS,
MAX_INSTRUCTION_BYTES,
MAX_INSTRUCTION_CHARS,
MODEL_TYPES,
WD14_MODEL_TYPES as WD14_MODELS,
validate_template,
validate_threshold,
)
WD14_MODELS = frozenset({"wd14-swinv2", "wd14-vit"})
LLM_MODELS = frozenset({"gguf", "hf"})
MODEL_TYPES = WD14_MODELS | LLM_MODELS
MAX_INSTRUCTION_CHARS = 4000
MAX_INSTRUCTION_BYTES = 16 * 1024
SUGGEST_FIELDS = frozenset(
{"target", "capture_token", "expected_epoch", "model_type", "options"}
@@ -27,7 +24,7 @@ _COMMON = frozenset({"suggestion_limit", "use_gpu"})
_WD14_REQUIRED = frozenset({"general_threshold", "character_threshold"})
_LLM_REQUIRED = frozenset({"instructions"})
UNAVAILABLE_HINT = "Download it in System › Auto-tagging."
UNAVAILABLE_HINT = "Download it from the legacy Auto-tag page."
class OptionError(ValueError):
@@ -35,17 +32,27 @@ class OptionError(ValueError):
def _threshold(name: str, value) -> float:
try:
return validate_threshold(name, value)
except ValueError as error:
raise OptionError(str(error)) from None
if type(value) not in (int, float) or not math.isfinite(value):
raise OptionError(f"{name} must be a finite number")
if not 0 <= value <= 1:
raise OptionError(f"{name} must be between 0 and 1")
return float(value)
def _instructions(value) -> str:
if not isinstance(value, str) or not value.strip():
raise OptionError("instructions must be non-empty text")
if len(value) > MAX_INSTRUCTION_CHARS:
raise OptionError(
f"instructions must be at most {MAX_INSTRUCTION_CHARS} characters"
)
try:
return validate_template(value)
except ValueError as error:
raise OptionError(str(error)) from None
encoded = value.encode("utf-8")
except UnicodeEncodeError:
raise OptionError("instructions must be valid Unicode text") from None
if len(encoded) > MAX_INSTRUCTION_BYTES:
raise OptionError("instructions are too long")
return value
def validate_options(model_type, options, *, default_limit: int, max_limit: int):
+85 -628
View File
@@ -1,26 +1,14 @@
"""AutoTag API routes for PromptManager."""
import asyncio
import contextlib
import functools
import json
import os
import re
import threading
import time
from pathlib import Path
from typing import Optional
from urllib.parse import quote
from .job_routes import busy_response
from .jobs import (
AUTOTAG_BATCH_JOB_KIND,
AUTOTAG_GROUP_NAMES,
MODEL_DOWNLOAD_JOB_KIND,
JobBusy,
)
from .admin import _plural, _scan_location, _scan_roots
from .maintenance import disk_plan, short_space_message
from .jobs import AUTOTAG_BATCH_JOB_KIND
from .prompts import thumbnail_url_for
from . import thumbnail_settings
from .. import model_gate
@@ -28,104 +16,20 @@ from .. import model_gate
from aiohttp import web
_INTERNAL_ERROR = "An internal error occurred. Check server logs for details."
# Tag output images (#328): the native prompt-tag batch.
AUTOTAG_JOB_TITLE = "Tag output images"
AUTOTAG_PREVIEW_LEASE = "Tag output images review"
AUTOTAG_OWNER_KIND = "autotag-job"
BATCH_PREVIEW_BODY_MAX = 1024
BATCH_JOB_BODY_MAX = 32 * 1024
REVIEW_REQUIRED = (
"Replacing tags needs a review of each image. Use Preview tagging review, "
"or choose Skip or Merge for a batch run."
)
BATCH_CANCELLED = "Cancelled. Tags already added were kept."
_BUSY_ERROR = "Auto-tagging is already running"
MODEL_BUSY_ERROR = (
"A tagging model is busy with another operation. Try again when it finishes."
)
# Who holds the model gate, as a 409 model-busy names it (``operation``). An
# owner is ``"<kind>:<uuid>"`` (``model_gate.new_owner``); a download names
# its own job, whose title says which model.
MODEL_DOWNLOAD_OWNER_KIND = "model-download"
OWNER_LABELS = {
"autotag-job": "Tag output images",
"legacy-batch": "Tag output images (legacy page)",
"legacy-single": "Tagging one image (legacy page)",
"legacy-unload": "Unloading the tagging model",
"image-tag-job": "Auto-tag selected images",
"image-annotations": "Generate suggestions",
}
ANOTHER_OPERATION = "Another tagging operation"
_DOWNLOAD_FAILED = "The download failed. Check server logs for details."
# POST /jobs/model-download carries only {model_type}.
MODEL_DOWNLOAD_BODY_MAX = 1024
# The running download's gate owner and job title. Only one download runs at a
# time and each needs the gate, so a new start replaces the entry.
_download_titles = {}
def _holder_label(holder: Optional[str]) -> str:
if not holder:
return ANOTHER_OPERATION
kind = holder.split(":", 1)[0]
if kind == MODEL_DOWNLOAD_OWNER_KIND:
return _download_titles.get(holder, ANOTHER_OPERATION)
return OWNER_LABELS.get(kind, ANOTHER_OPERATION)
def model_busy_response() -> web.Response:
"""``409 model-busy``: another operation holds the shared model gate.
``operation`` names the holder (``OWNER_LABELS``), or "Another tagging
operation" when the gate was freed meanwhile or the kind is unknown.
"""
"""``409 model-busy``: another operation holds the shared model gate."""
return web.json_response(
{
"success": False,
"code": "model-busy",
"error": MODEL_BUSY_ERROR,
"operation": _holder_label(model_gate.holder()),
},
{"success": False, "code": "model-busy", "error": MODEL_BUSY_ERROR},
status=409,
)
_SIZE = re.compile(r"^(~?[0-9.]+)\s*([KMGT]?B)$")
def size_label(size: str) -> str:
"""The engine table's size with a space before the unit (``~400 MB``)."""
match = _SIZE.match(str(size or "").strip())
return f"{match.group(1)} {match.group(2)}" if match else str(size or "")
def public_models(status: dict, loaded_type: Optional[str]) -> dict:
"""Each engine model's public entry: names and sizes from ``MODELS``, the
flags from ``status``; never a path (``model_path`` is dropped)."""
from ..autotag import MODELS
models = {}
for model_type, config in MODELS.items():
flags = status.get(model_type) or {}
entry = {
"name": config["name"],
"label": config["label"],
"description": config["description"],
"size": config["size"],
"size_label": size_label(config["size"]),
"downloaded": bool(flags.get("downloaded")),
"loaded": loaded_type == model_type,
}
for key in ("model_exists", "mmproj_exists"):
if key in flags:
entry[key] = bool(flags[key])
models[model_type] = entry
return models
# Only these may be handed to the tagging engine or listed by the scanner.
_MEDIA_EXTENSIONS = frozenset(
{".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tiff", ".tif"}
@@ -161,39 +65,6 @@ def path_is_within(candidate, root) -> bool:
return child != parent and child.is_relative_to(parent)
def _thresholds_or_saved(general, character):
"""The request's WD14 thresholds as floats; one it omits is the saved
default (``autotag.defaults``, OQ4). Raises ValueError/TypeError."""
from ..config import AutotagConfig
return (
AutotagConfig.GENERAL_THRESHOLD if general is None else float(general),
AutotagConfig.CHARACTER_THRESHOLD if character is None else float(character),
)
async def _single(event):
"""A stream of one event."""
yield event
_monotonic = time.monotonic # the legacy frames' throttle clock (patched in tests)
# What one image came to in a batch (``on_item``): fixed strings, never an
# exception's text.
SKIP_DETAILS = {
"unlinked": "File not found",
"prompt already processed": "Prompt handled by another image",
"outside allowed directories": "Outside the output folders",
"file missing": "File not found",
"already tagged": "Prompt already tagged",
}
def _added(count):
return f"Added {count} tag" if count == 1 else f"Added {count} tags"
async def _refusal(title):
"""The single error event of an auto-tag batch refused by another job."""
message = f"{title} already running"
@@ -212,10 +83,6 @@ class AutotagRoutesMixin:
async def download_autotag_model_route(request):
return await self.download_autotag_model(request)
@routes.post("/prompt_manager/autotag/batch/preview")
async def preview_autotag_batch_route(request):
return await self.preview_autotag_batch(request)
@routes.post("/prompt_manager/autotag/start")
async def start_autotag_route(request):
return await self.start_autotag(request)
@@ -243,24 +110,18 @@ class AutotagRoutesMixin:
from ..config import AutotagConfig
service = get_autotag_service()
# Off the loop: the HuggingFace check walks the whole HF cache.
status = await self._run_in_executor(service.get_models_status)
loaded = service.is_model_loaded()
loaded_type = service.get_loaded_model_type() if loaded else None
models_status = service.get_models_status()
return web.json_response(
{
"success": True,
"models": public_models(status, loaded_type),
# The saved tagging defaults (#328, System › Auto-tagging).
"default_prompt": AutotagConfig.TEMPLATE,
"model_loaded": loaded,
"loaded_model_type": loaded_type,
"wd14_general_threshold": AutotagConfig.GENERAL_THRESHOLD,
"wd14_character_threshold": AutotagConfig.CHARACTER_THRESHOLD,
"models": models_status,
"default_prompt": service.default_prompt,
"model_loaded": service.is_model_loaded(),
"loaded_model_type": service.get_loaded_model_type(),
"wd14_general_threshold": service.wd14_general_threshold,
"wd14_character_threshold": service.wd14_character_threshold,
"suggestion_limit": AutotagConfig.suggestion_limit(),
"model_type": AutotagConfig.MODEL_TYPE,
"existing": AutotagConfig.EXISTING,
}
)
@@ -298,156 +159,55 @@ class AutotagRoutesMixin:
return response
async def download_autotag_model(self, request):
"""Download a tagging model, streaming the legacy events (POST only).
The download is the model_download Activity job: this request
attaches to a running download of the same model or starts one, and
streams the legacy frames until the job ends. A client disconnect
ends only the stream; the download goes on. A running download of
another model answers 409 model-busy (it holds the gate).
"""
from ..autotag import MODELS, get_autotag_service
"""Download an AutoTag model with streaming progress (POST only)."""
model_type = request.match_info.get("model_type")
if model_type not in MODELS:
invalid = {"type": "error", "message": f"Invalid model type: {model_type}"}
return await self._stream_sse(request, _single(invalid))
try:
job = self._jobs.active(MODEL_DOWNLOAD_JOB_KIND)
if job is not None and job.scope.get("model_type") != model_type:
return model_busy_response()
if job is None:
job, refusal = self._start_model_download(
model_type, get_autotag_service()
owner = model_gate.new_owner("legacy-download")
async def events():
try:
from ..autotag import get_autotag_service
service = get_autotag_service()
if model_type not in service.models_config:
yield {
"type": "error",
"message": f"Invalid model type: {model_type}",
}
return
yield {
"type": "progress",
"progress": 0,
"status": "Starting download...",
}
def progress_callback(status: str, progress: float):
pass
success = await model_gate.run(
owner, service.download_model, model_type, progress_callback
)
if refusal is not None:
return refusal
except Exception:
self.logger.exception("Download model error")
failed = {"type": "error", "message": _INTERNAL_ERROR}
return await self._stream_sse(request, _single(failed))
return await self._stream_sse(request, self._download_frames(job))
if success:
yield {
"type": "complete",
"progress": 100,
"status": "Download complete",
}
else:
yield {"type": "error", "message": "Download failed"}
except Exception:
self.logger.exception("Download model error")
yield {
"type": "error",
"message": _INTERNAL_ERROR,
}
@staticmethod
async def _download_frames(job):
"""The legacy download frames for job: start, then its end."""
yield {"type": "progress", "progress": 0, "status": "Starting download..."}
async with contextlib.aclosing(job.events()) as events:
async for _payload in events:
pass
if job.state == "finished":
yield {"type": "complete", "progress": 100, "status": "Download complete"}
else:
yield {"type": "error", "message": "Download failed"}
# ── model download job (#328) ───────────────────────────────────────
async def start_model_download_job(self, request):
"""``POST /jobs/model-download {model_type}``: 202 with the job.
400 for a bad body, 409 ``busy`` while a download (or an exclusive
job) runs, 409 ``model-busy`` while another operation holds the gate.
"""
data, error = await self._read_json_body(
request, max_bytes=MODEL_DOWNLOAD_BODY_MAX
)
if error is not None:
return error
if set(data) != {"model_type"}:
return self._bad_request("The body must be exactly {model_type}")
from ..autotag import MODELS, get_autotag_service
model_type = data["model_type"]
if not isinstance(model_type, str) or model_type not in MODELS:
return self._bad_request(
"model_type must be one of " + ", ".join(sorted(MODELS))
)
try:
job, refusal = self._start_model_download(model_type, get_autotag_service())
except Exception as e:
self.logger.exception("Model download start error")
return web.json_response(
{"success": False, "error": self._public_error(e)}, status=500
)
if refusal is not None:
return refusal
return web.json_response({"success": True, "job": job.summary()}, status=202)
def _start_model_download(self, model_type, service):
"""Register the download job: ``(job, None)`` or ``(None, refusal)``.
Synchronous (no await between the checks and the start). The gate is
taken before the job registers and released when the runner ends,
also when the task is cancelled before the runner ever starts.
"""
from ..autotag import MODELS
busy = self._jobs.blocker(MODEL_DOWNLOAD_JOB_KIND, admission="read")
if busy is not None:
return None, busy_response(JobBusy(busy))
owner = model_gate.new_owner(MODEL_DOWNLOAD_OWNER_KIND)
if not model_gate.try_acquire(owner):
return None, model_busy_response()
config = MODELS[model_type]
label = config["label"]
title = f"Tagging model download · {label}"
_download_titles.clear()
_download_titles[owner] = title
return model_busy_response()
try:
job = self._jobs.start(
MODEL_DOWNLOAD_JOB_KIND,
title,
admission="read",
cancellable=False,
scope={
"model_type": model_type,
"label": label,
"size_label": size_label(config["size"]),
},
runner=functools.partial(
self._run_model_download, service, model_type, owner
),
)
except BaseException as refused:
# JobBusy, or anything else the registry raised: give the gate back.
model_gate.release(owner)
_download_titles.pop(owner, None)
if isinstance(refused, JobBusy):
return None, busy_response(refused)
raise
def ended(_task):
model_gate.release(owner)
if model_gate.holder() != owner: # not while a thread still runs
_download_titles.pop(owner, None)
job.task.add_done_callback(ended)
return job, None
async def _run_model_download(self, service, model_type, owner, job):
label = job.scope["label"]
job.update(status_line=f"Downloading {label} ({job.scope['size_label']})")
job.log_line(f"Download started: {label}")
job.publish({"type": "status", "status": job.status_line})
try:
ok = await model_gate.run(owner, service.download_model, model_type, None)
except Exception:
self.logger.exception(f"Tagging model download failed: {model_type}")
ok = False
return await self._stream_sse(request, events())
finally:
# Before the job ends, so a reader that sees the end finds the
# gate free (a running download thread defers the release).
model_gate.release(owner)
if ok:
job.update(status_line=f"Downloaded {label}")
job.log_line(f"Download finished: {label}")
job.publish({"type": "status", "status": job.status_line})
job.finish("finished")
return
job.update(status_line=_DOWNLOAD_FAILED)
job.log_line(f"Download failed: {label}")
job.publish({"type": "status", "status": job.status_line})
job.finish("failed")
async def _read_start_params(self, request) -> Optional[dict]:
"""Merge query-string and optional JSON-body parameters for autotag/start.
@@ -478,48 +238,35 @@ class AutotagRoutesMixin:
and not t.startswith("append:")
]
async def _apply_tags_to_prompt(self, prompt_id, tags) -> int:
"""Merge ``tags`` into the prompt; how many new tags were added."""
async def _apply_tags_to_prompt(self, prompt_id, tags) -> bool:
"""Merge ``tags`` into the prompt; True when something new was added."""
existing_prompt = await self._run_in_executor(
self.db.get_prompt_by_id, prompt_id
)
if not existing_prompt:
return 0
return False
existing_tags = existing_prompt.get("tags", [])
if isinstance(existing_tags, str):
existing_tags = [t.strip() for t in existing_tags.split(",") if t.strip()]
new_tags = list(dict.fromkeys(t for t in tags if t not in existing_tags))
new_tags = [t for t in tags if t not in existing_tags]
if not new_tags:
return 0
return False
await self._run_in_executor(
self.db.update_prompt_metadata, prompt_id, tags=existing_tags + new_tags
)
return len(new_tags)
return True
async def _autotag_batch_events(self, service, opts: dict):
"""Yield SSE progress dicts while tagging every image linked to a prompt.
"""Yield SSE progress dicts while tagging every image linked to a prompt."""
import time as _time
Both batches run here: the legacy page's (its frames unchanged) and
the ``autotag`` job's. ``opts``: ``model_type``, ``keep_in_memory``,
``existing`` (``skip`` leaves prompts with real tags alone, ``merge``
adds to them), the two thresholds, and optionally ``gate_owner``,
``prompt`` (the template, passed to each call; the legacy page sets
``custom_prompt`` instead), ``cancelled`` (checked before each image:
a cancelled run returns without a ``complete`` frame) and
``on_item(index, total, group, image_path, detail)`` (one outcome per
image, ``SKIP_DETAILS`` for the skips).
"""
model_type = opts["model_type"]
keep_in_memory = opts["keep_in_memory"]
cancelled = opts.get("cancelled") or (lambda: False)
on_item = opts.get("on_item") or (lambda *_args: None)
status = await self._run_in_executor(service.get_models_status)
status = service.get_models_status()
if not status.get(model_type, {}).get("downloaded"):
yield {"type": "error", "message": f"Model {model_type} not downloaded"}
return
if cancelled():
return
yield {"type": "progress", "progress": 0, "status": "Loading model..."}
run = self._gated_runner(opts.get("gate_owner"))
try:
@@ -532,21 +279,15 @@ class AutotagRoutesMixin:
}
return
if opts.get("custom_prompt"):
if opts["custom_prompt"]:
service.custom_prompt = opts["custom_prompt"]
tag_kwargs = {
"general_threshold": opts["general_threshold"],
"character_threshold": opts["character_threshold"],
}
if opts.get("prompt"):
tag_kwargs["prompt"] = opts["prompt"]
yield {
"type": "progress",
"progress": 5,
"status": "Model loaded. Fetching all images from database...",
}
images = await self._run_in_executor(self.db.autotag_batch_candidates)
images = await self._run_in_executor(self.db.get_all_images)
total = len(images)
if total == 0:
yield {
@@ -558,8 +299,7 @@ class AutotagRoutesMixin:
"skipped_outside_roots": 0,
"status": "No images with linked prompts found",
}
if not keep_in_memory:
service.unload_model()
service.unload_model()
return
yield {
@@ -576,7 +316,7 @@ class AutotagRoutesMixin:
"skipped_outside_roots": 0,
}
tagged_prompt_ids = set()
last_update = _monotonic()
last_update = _time.monotonic()
finished = False
def progress_event(i, message):
@@ -592,8 +332,6 @@ class AutotagRoutesMixin:
try:
for i, image_data in enumerate(images):
if cancelled():
return
image_path = image_data.get("image_path")
prompt_id = image_data.get("prompt_id")
skip_reason = None
@@ -612,7 +350,7 @@ class AutotagRoutesMixin:
counts["skipped_outside_roots"] += 1
else:
skip_reason = "file missing"
elif opts["existing"] == "skip" and self._real_tags(
elif opts["skip_tagged"] and self._real_tags(
image_data.get("prompt_tags", [])
):
tagged_prompt_ids.add(prompt_id)
@@ -620,8 +358,7 @@ class AutotagRoutesMixin:
if skip_reason is not None:
counts["skipped"] += 1
on_item(i, total, "skipped", image_path, SKIP_DETAILS[skip_reason])
now = _monotonic()
now = _time.monotonic()
if (now - last_update) >= 0.5 or i == total - 1:
yield progress_event(
i, f"Skipping {i + 1}/{total} ({skip_reason})..."
@@ -631,17 +368,17 @@ class AutotagRoutesMixin:
continue
try:
tags = await run(service.generate_tags, allowed_path, **tag_kwargs)
tags = await run(
service.generate_tags,
allowed_path,
general_threshold=opts["general_threshold"],
character_threshold=opts["character_threshold"],
)
counts["processed"] += 1
added = 0
if tags:
added = await self._apply_tags_to_prompt(prompt_id, tags)
if added:
if tags and await self._apply_tags_to_prompt(prompt_id, tags):
counts["tagged"] += 1
on_item(i, total, "tagged", image_path, _added(added))
else:
counts["skipped"] += 1
on_item(i, total, "unchanged", image_path, "No new tags")
tagged_prompt_ids.add(prompt_id)
except Exception as img_err:
self.logger.error(
@@ -650,11 +387,10 @@ class AutotagRoutesMixin:
)
counts["errors"] += 1
counts["processed"] += 1
on_item(i, total, "errors", image_path, "Tagging failed")
yield progress_event(i, f"Processing {i + 1}/{total}...")
await asyncio.sleep(0.01)
last_update = _monotonic()
last_update = _time.monotonic()
finished = True
if not keep_in_memory:
@@ -682,22 +418,16 @@ class AutotagRoutesMixin:
opts = {
"model_type": params.get("model_type", "gguf"),
"custom_prompt": params.get("prompt", ""),
"existing": (
"skip"
if str(params.get("skip_tagged", "true")).lower() == "true"
else "merge"
),
"skip_tagged": str(params.get("skip_tagged", "true")).lower() == "true",
"keep_in_memory": str(params.get("keep_in_memory", "true")).lower()
== "true",
"general_threshold": params.get("general_threshold"),
"character_threshold": params.get("character_threshold"),
}
try:
opts["general_threshold"], opts["character_threshold"] = (
_thresholds_or_saved(
opts["general_threshold"], opts["character_threshold"]
)
)
for key in ("general_threshold", "character_threshold"):
if opts[key] is not None:
opts[key] = float(opts[key])
except (ValueError, TypeError):
return self._bad_request(
"general_threshold and character_threshold must be numeric"
@@ -727,9 +457,7 @@ class AutotagRoutesMixin:
# never runs beside a scan, restore or the workspace tagging job.
busy = self._jobs.blocker(AUTOTAG_BATCH_JOB_KIND, admission="records")
if busy is not None:
# A second legacy batch keeps its JSON 409; the workspace's Tag
# output images job is named in a stream frame.
if busy.kind == AUTOTAG_BATCH_JOB_KIND and busy.title != AUTOTAG_JOB_TITLE:
if busy.kind == AUTOTAG_BATCH_JOB_KIND: # unchanged payload
return web.json_response(
{"success": False, "error": _BUSY_ERROR}, status=409
)
@@ -739,7 +467,7 @@ class AutotagRoutesMixin:
return await self._stream_sse(request, _refusal(busy.title))
async with self._jobs.hold(
AUTOTAG_BATCH_JOB_KIND,
OWNER_LABELS["legacy-batch"],
"Auto-tag images (legacy page)",
admission="records",
) as job:
response = await self._start_autotag_batch(request, opts, events)
@@ -763,278 +491,6 @@ class AutotagRoutesMixin:
finally:
_autotag_batch_lock.release()
# ── Tag output images (#328) ────────────────────────────────────────
async def _batch_body(self, request, keys, max_bytes):
"""``(data, None)`` for a JSON object with exactly ``keys``."""
data, error = await self._read_json_body(request, max_bytes=max_bytes)
if error is not None:
return None, error
if set(data) != set(keys):
return None, self._bad_request(
"The body must be exactly {" + ", ".join(keys) + "}"
)
return data, None
@staticmethod
def _batch_choice(data):
"""The model's engine entry and the policy, or a 400 message."""
from ..autotag import MODELS
from ..config import EXISTING_POLICIES
model_type, existing = data.get("model_type"), data.get("existing")
if not isinstance(model_type, str) or model_type not in MODELS:
return None, "model_type must be one of " + ", ".join(sorted(MODELS))
if not isinstance(existing, str) or existing not in EXISTING_POLICIES:
return None, "existing must be one of " + ", ".join(EXISTING_POLICIES)
return MODELS[model_type], None
async def preview_autotag_batch(self, request):
"""Review Tag output images: counts, model, backup and disk; no write.
Opens no image file. Refused (409 ``busy``) while an exclusive job runs.
"""
try:
async with self._jobs.lease(AUTOTAG_PREVIEW_LEASE):
return await self._preview_autotag_batch(request)
except JobBusy as busy:
return busy_response(busy)
async def _preview_autotag_batch(self, request):
from ..autotag import get_autotag_service
data, error = await self._batch_body(
request, ("model_type", "existing"), BATCH_PREVIEW_BODY_MAX
)
if error is not None:
return error
config, message = self._batch_choice(data)
if message:
return self._bad_request(message)
model_type, existing = data["model_type"], data["existing"]
policy = self._safety_backup_policy()
try:
candidates = await self._run_in_executor(self.db.autotag_batch_candidates)
status = await self._run_in_executor(
get_autotag_service().get_models_status
)
disk, _short, _size = await self._run_in_executor(
functools.partial(
disk_plan,
self.db.model.db_path,
backup=policy != "off",
vacuum=False,
)
)
except Exception as e:
self.logger.exception("Tag output images could not be reviewed")
return web.json_response(
{"success": False, "error": self._public_error(e)}, status=500
)
prompts = {row["prompt_id"]: row["prompt_tags"] for row in candidates}
tagged = sum(1 for tags in prompts.values() if self._real_tags(tags))
to_tag = len(prompts) - tagged if existing == "skip" else len(prompts)
return web.json_response(
{
"success": True,
"images": len(candidates),
"prompts": len(prompts),
"already_tagged": tagged,
"to_tag": to_tag,
"model": {
"type": model_type,
"label": config["label"],
"size_label": size_label(config["size"]),
"downloaded": bool(
(status.get(model_type) or {}).get("downloaded")
),
},
"existing": existing,
"review_required": existing == "replace_after_review",
"backup": {"policy": policy},
"disk": disk,
}
)
async def start_autotag_job(self, request):
"""``POST /jobs/autotag``: start Tag output images (202), or refuse.
In order: 400 body or options, 400 ``review-required``; 409
``model-unavailable``; 507 ``insufficient-space``; 409 ``busy``
(another records job, the legacy batch's hold included); 409
``model-busy``. No await follows the busy check.
"""
from ..autotag import get_autotag_service
from ..config import MAX_SUGGESTION_LIMIT, AutotagConfig
from .annotation_suggest import OptionError, validate_options
data, error = await self._batch_body(
request, ("model_type", "existing", "options"), BATCH_JOB_BODY_MAX
)
if error is not None:
return error
config, message = self._batch_choice(data)
if message:
return self._bad_request(message)
model_type, existing, options = (
data["model_type"],
data["existing"],
data["options"],
)
if isinstance(options, dict) and {"suggestion_limit", "use_gpu"} & set(options):
return self._bad_request("options take no suggestion_limit or use_gpu")
try:
_model, options = validate_options(
model_type,
options,
default_limit=AutotagConfig.suggestion_limit(),
max_limit=MAX_SUGGESTION_LIMIT,
)
except OptionError as error:
return self._bad_request(str(error))
if existing == "replace_after_review":
return web.json_response(
{"success": False, "code": "review-required", "error": REVIEW_REQUIRED},
status=400,
)
service = get_autotag_service()
backup_on = self._safety_backup_policy() == "on"
try:
status = await self._run_in_executor(service.get_models_status)
candidates = await self._run_in_executor(self.db.autotag_batch_candidates)
_disk, short, _size = await self._run_in_executor(
functools.partial(
disk_plan, self.db.model.db_path, backup=backup_on, vacuum=False
)
)
except Exception as e:
self.logger.exception("Tag output images could not start")
return web.json_response(
{"success": False, "error": self._public_error(e)}, status=500
)
if not (status.get(model_type) or {}).get("downloaded"):
return web.json_response(
{
"success": False,
"code": "model-unavailable",
"error": f"{config['label']} is not downloaded. "
"Download it in System › Auto-tagging.",
},
status=409,
)
if backup_on and short is not None:
return web.json_response(
{
"success": False,
"code": "insufficient-space",
"error": short_space_message(short),
},
status=507,
)
busy = self._jobs.blocker(AUTOTAG_BATCH_JOB_KIND, admission="records")
if busy is not None:
return busy_response(JobBusy(busy))
owner = model_gate.new_owner(AUTOTAG_OWNER_KIND)
if not model_gate.try_acquire(owner):
return model_busy_response()
opts = {
"model_type": model_type,
"existing": existing,
"keep_in_memory": True,
"gate_owner": owner,
"general_threshold": options.get("general_threshold"),
"character_threshold": options.get("character_threshold"),
"prompt": options.get("instructions"),
}
try:
job = self._jobs.start(
AUTOTAG_BATCH_JOB_KIND,
AUTOTAG_JOB_TITLE,
admission="records",
cancellable=True,
scope={
"model_type": model_type,
"existing": existing,
"images": len(candidates),
"label": config["label"],
},
groups=AUTOTAG_GROUP_NAMES,
runner=functools.partial(
self._run_autotag_job, service, opts, backup_on
),
)
except BaseException as refused:
model_gate.release(owner)
if isinstance(refused, JobBusy):
return busy_response(refused)
raise
job.task.add_done_callback(lambda _task: model_gate.release(owner))
return web.json_response({"success": True, "job": job.summary()}, status=202)
async def _run_autotag_job(self, service, opts, backup_on, job):
ok, _backup = await self._safety_backup_step(job, self.db, backup_on)
if not ok:
return
try:
await self._tag_output_images(service, opts, job)
except Exception:
self.logger.exception("Tag output images failed")
job.update(status_line=_INTERNAL_ERROR)
job.log_line(f"Tagging failed · {_INTERNAL_ERROR}")
job.finish("failed")
finally:
if backup_on:
await self._prune_backup_pool(job, self.db)
async def _tag_output_images(self, service, opts, job):
roots = _scan_roots(self._get_all_output_dirs())
job.log_line(
f"Tagging started · {job.scope['label']} · "
+ ("skip prompts already tagged" if opts["existing"] == "skip" else "merge")
)
job.update(status_line="Loading the tagging model")
def on_item(index, total, group, image_path, detail):
path, root, root_index = _scan_location(image_path or "", roots)
job.update(
processed=index + 1,
total=total,
progress=100 * (index + 1) // total,
status_line=f"Tagging {index + 1} of {total}",
)
job.record(
group,
{
"path": path,
"root": root,
"root_index": root_index,
"detail": detail,
},
f"{path}: {detail}",
)
job.publish({"type": "progress", "progress": job.progress})
final = None
async for event in self._autotag_batch_events(
service,
{**opts, "cancelled": lambda: job.cancelled, "on_item": on_item},
):
if event.get("type") in ("complete", "error"):
final = event
if final is not None and final["type"] == "error":
job.update(status_line=final["message"])
job.log_line(f"Tagging failed · {final['message']}")
job.finish("failed")
elif job.cancelled: # a cancel wins over a run that then completed
job.update(status_line=BATCH_CANCELLED)
job.log_line(BATCH_CANCELLED)
job.finish("cancelled")
else:
done = f"Tagged {_plural(job.counters.get('tagged', 0), 'prompt')}"
job.update(status_line=done, progress=100)
job.log_line(done)
job.finish("finished")
def _gated_runner(self, owner):
"""Executor calls under the model gate *owner* holds (plain when None)."""
if owner is None:
@@ -1151,9 +607,10 @@ class AutotagRoutesMixin:
general_threshold = data.get("general_threshold")
character_threshold = data.get("character_threshold")
try:
general_threshold, character_threshold = _thresholds_or_saved(
general_threshold, character_threshold
)
if general_threshold is not None:
general_threshold = float(general_threshold)
if character_threshold is not None:
character_threshold = float(character_threshold)
except (ValueError, TypeError):
return self._bad_request(
"general_threshold and character_threshold must be numeric"
-337
View File
@@ -1,337 +0,0 @@
"""One shared listing per output root, and the pure helpers behind it (#348).
The API keeps one ``RootListing`` per output root (``_root_listings`` in
``py/api/__init__.py``). The record is read and written only on the event
loop; the walk itself, the comparison with the previous listing and both
folder sets run in the executor through ``walk_root``. Routes live in
``py/api/images.py`` (``_listing``); this module has no aiohttp import so
tests can exercise it without a server.
A settled output the image monitor reports is added to its root's stored
listing without a walk (``output_settled``, ``insert_entry``), in the place
a walk would list it. When that cannot be exact, the root is marked dirty
(``mark_root_dirty``) instead; a dirty root gets one background walk, no
sooner than ``rewalk_interval`` after its previous walk started (PR 2).
"""
from __future__ import annotations
import asyncio
import os
from bisect import bisect_left
from dataclasses import dataclass, field
from pathlib import PurePath
from typing import Callable, NamedTuple
# A listing older than this (seconds) is served as it is, and one background
# walk replaces it. Read as ``gallery_listing.REFRESH_AFTER`` so tests can
# patch it.
REFRESH_AFTER = 30
# The stale read is served at once; its refresh walk starts this many seconds
# later, so the walk does not share the GIL with the page's own requests
# (#348 live test: reads during the walk took 2.2 s instead of 0.07 s).
REFRESH_DELAY = 1.0
# No background walk of a root starts sooner than rewalk_interval(last walk's
# duration) after its previous walk started: FACTOR times that duration, at
# least FLOOR and at most CAP seconds. Walks up to CAP / FACTOR (3 s) then
# hold the GIL at most ~20% of the time; the cap keeps a new output listed
# within ~20 s, at the cost of a higher share (back to back from 15 s) on
# slower walks. Read through this module so tests can patch them.
REWALK_FLOOR = 5
REWALK_FACTOR = 5
REWALK_CAP = 15
# A deferred walk is never armed sooner than this: no zero-delay loop.
MIN_REWALK_DELAY = 0.5
# A reader with no listing gives up (500) after this many walks in a row were
# discarded by invalidations made while they ran.
MAX_DISCARDED_WALKS = 3
WALK_STOPPED = "The listing walk was stopped"
STORED = "stored"
DISCARDED = "discarded"
FAILED = "failed"
class WalkOutcome(NamedTuple):
"""What one walk ended with; a walk's flight future resolves to this."""
kind: str
error: BaseException | None = None
@classmethod
def stored(cls) -> "WalkOutcome":
return cls(STORED)
@classmethod
def discarded(cls) -> "WalkOutcome":
return cls(DISCARDED)
@classmethod
def failed(cls, error: BaseException) -> "WalkOutcome":
return cls(FAILED, error)
@dataclass
class RootListing:
"""The shared listing of one output root.
A record is never deleted: ``drop()`` clears the listing and bumps
``generation``, so a walk that started before a drop can never be stored
after it.
"""
# (path, mtime) newest first, as _scan_gallery_files_sync returns it.
files: list | None = None
folders: frozenset | None = None
folders_with_ancestors: frozenset | None = None
# Monotonic time the last walk finished, stored or failed.
walked_at: float | None = None
# Number of the last walk started; a running walk is always that one.
walk_seq: int = 0
# Number of the walk whose files are stored (Rescan waits on it).
stored_seq: int = 0
generation: int = 0
# The running walk's future (resolves to a WalkOutcome), or None.
flight: asyncio.Future | None = None
# True while walks keep failing: one warning per streak.
failing: bool = False
# The one deferred background walk not started yet (loop.call_later), or
# None: a stale refresh or a dirty root's rewalk, whichever is due first.
rewalk_handle: asyncio.TimerHandle | None = None
# normcase(realpath(root)), computed by the walk in the executor: monitor
# paths are matched against it without file I/O on the loop.
resolved_root: str | None = None
# Monotonic start and duration of the last walk (rewalk_interval).
last_started_at: float | None = None
last_duration: float | None = None
# A settled output was reported under this root; cleared when a walk starts.
dirty: bool = False
# Dropped because the output roots changed: no background walk until a
# reader (or the warm-up) asks for this root again. The monitor keeps
# watching a removed root until ComfyUI restarts.
retired: bool = False
# Relative paths of settled outputs waiting to be added to ``files``
# without a walk, and the one task adding them (``output_settled``).
pending_adds: list = field(default_factory=list)
add_task: asyncio.Task | None = None
# Settled outputs added to the stored listing since the last walk stored.
patched: int = 0
def drop(self) -> None:
self.files = None
self.folders = None
self.folders_with_ancestors = None
self.walked_at = None
self.pending_adds = []
self.patched = 0
self.generation += 1
def folder_sets(root, files) -> tuple[frozenset, frozenset]:
"""``(folders, folders_with_ancestors)`` of the files listed under *root*.
Folders keep the spelling ``/gallery/subfolders`` has always answered:
``str(relative.parent)`` (native separators) for the folders holding
files, plus every ancestor joined by ``/``.
"""
# From the path strings: relative_to per file was half of a large walk.
sep, prefix = _root_prefix(root)
folders = set()
for path, _mtime in files:
rel_dir = _folder_of(root, path, sep, prefix)
if rel_dir:
folders.add(rel_dir)
with_ancestors = set(folders)
for folder in folders:
with_ancestors.update(_ancestors(folder))
return frozenset(folders), frozenset(with_ancestors)
def add_folder(root, path, folders, with_ancestors) -> tuple[frozenset, frozenset]:
"""Both folder sets once *path* is listed too (the same sets when it adds none)."""
sep, prefix = _root_prefix(root)
rel_dir = _folder_of(root, path, sep, prefix)
if not rel_dir or rel_dir in folders:
return folders, with_ancestors
return folders | {rel_dir}, with_ancestors | {rel_dir, *_ancestors(rel_dir)}
def _root_prefix(root) -> tuple[str, str]:
# The separator is the path flavour's own (tests use PureWindowsPath).
sep = str(type(root)("a", "b"))[1]
prefix = str(root)
if not prefix.endswith(sep):
prefix += sep
return sep, prefix
def _folder_of(root, path, sep, prefix) -> str:
"""The folder ``/gallery/subfolders`` names for *path*; "" for the root."""
text = str(path)
if text.startswith(prefix):
rel_dir = text[len(prefix) :].rpartition(sep)[0]
else:
rel_dir = str(path.relative_to(root).parent)
return "" if rel_dir == "." else rel_dir
def _ancestors(folder: str) -> list[str]:
parts = folder.replace("\\", "/").split("/")
return ["/".join(parts[:i]) for i in range(1, len(parts))]
def relative_part(resolved_root: str, path, pathmod=os.path) -> str | None:
"""The part of the resolved *path* below *resolved_root*, as a walk spells it.
Kept in *path*'s own case (``realpath`` gives a file's on-disk case on
Windows) with the native separator. None when *path* is not below the
root, or when the case folding changed the length so the slice would
not be exact.
"""
sep = pathmod.sep
text = str(path)
if sep == "\\":
text = _plain(text.replace("/", sep), pathmod)
folded = pathmod.normcase(text)
prefix = _plain(resolved_root, pathmod)
if not prefix.endswith(sep):
prefix += sep
if len(folded) != len(text) or not folded.startswith(prefix):
return None
relative = text[len(prefix) :]
if any(part in ("", ".", "..") for part in relative.split(sep)):
return None
return relative
def insert_entry(files: list, entry: tuple, max_files: int) -> list | None:
"""A new listing with *entry* ``(path, mtime)`` where a walk would list it.
*files* is newest first; an entry for the same path is replaced. Returns
*files* itself when it already holds *entry*, and None when the place is
not exact: another file has the same mtime (a walk orders those by its
own visit order), or the listing is at the walk's *max_files* cap.
Blocking (one pass over *files*): run it in the executor.
"""
path, mtime = entry
text = str(path)
kept = files
for index, (listed, listed_mtime) in enumerate(files):
if str(listed) == text:
if listed_mtime == mtime:
return files
kept = files[:index] + files[index + 1 :]
break
else:
if len(files) >= max_files:
return None
index = bisect_left(kept, -mtime, key=lambda item: -item[1])
if index < len(kept) and kept[index][1] == mtime:
return None
return [*kept[:index], entry, *kept[index:]]
# A listing that differs from the cached one by more entries than this is
# rebuilt in full by the workspace caches (listing_delta).
MAX_DELTA = 256
def listing_delta(old: list, new: list, limit: int = MAX_DELTA):
"""``(added, removed)`` entries from *old* to *new*, or None past *limit*.
A listing patched by ``insert_entry`` shares every unchanged entry object
with the one it came from, so entries are told apart by identity: a walk
builds new tuples, shares none and is rebuilt in full. *old* must still
be alive (the
caller's cache holds it), so no identity was reused.
"""
if abs(len(new) - len(old)) > limit:
return None
old_ids = set(map(id, old))
added = []
for entry in new:
if id(entry) not in old_ids:
added.append(entry)
if len(added) > limit:
return None
if new and len(added) == len(new):
return None # nothing shared: a walk
if len(old) + len(added) == len(new):
return added, []
new_ids = set(map(id, new))
return added, [entry for entry in old if id(entry) not in new_ids]
def rewalk_interval(last_duration) -> float:
"""Seconds from a walk's start to the next background walk of its root."""
scaled = REWALK_FACTOR * (last_duration or 0.0)
return min(REWALK_CAP, max(REWALK_FLOOR, scaled))
def root_contains(resolved_root: str, path, pathmod=os.path) -> bool:
"""True when the resolved *path* lies under *resolved_root* (no file I/O).
*resolved_root* is already ``normcase``d; *path* is folded the same way,
so Windows matches case-insensitively and with either separator. The
match ends at a separator: ``/out2/x.png`` is not under ``/out``.
"""
target = _plain(pathmod.normcase(str(path)), pathmod)
prefix = _plain(resolved_root, pathmod)
if not prefix.endswith(pathmod.sep):
prefix += pathmod.sep
return target.startswith(prefix)
def _plain(path: str, pathmod) -> str:
"""*path* (normcased or not) without a Windows extended-length prefix.
``realpath`` may keep ``\\\\?\\`` on a file past MAX_PATH while its shorter
root resolves without it.
"""
if pathmod.sep != "\\":
return path
if path[:8].lower() == "\\\\?\\unc\\":
return "\\\\" + path[8:]
if path.startswith("\\\\?\\"):
return path[4:]
return path
def resolve_root(root) -> str:
"""``normcase(realpath(root))``, the spelling monitor paths are matched in.
File I/O (realpath follows links): call it in the executor.
"""
return os.path.normcase(os.path.realpath(str(root)))
def same_listing(old, new) -> bool:
"""True when two listings hold the same ``(path, mtime)`` pairs in order."""
if old is None or new is None:
return False
return old is new or old == new
def walk_root(
scan_fn: Callable[[PurePath], list], root, previous_files
) -> tuple[list, frozenset, frozenset, str]:
"""The single executor job of a walk.
Returns ``(files, folders, with_ancestors, resolved_root)``.
Opens the root first, so a missing or unreadable root raises ``OSError``
instead of listing nothing. Returns *previous_files* itself when nothing
changed, which keeps the identity-keyed caches built on it warm.
"""
with os.scandir(root):
pass
files = scan_fn(root)
if same_listing(previous_files, files):
files = previous_files
folders, with_ancestors = folder_sets(root, files)
return files, folders, with_ancestors, resolve_root(root)
+76 -752
View File
File diff suppressed because it is too large Load Diff
-8
View File
@@ -130,14 +130,6 @@ class JobRoutesMixin:
async def start_image_tag_job_route(request):
return await self.start_image_tag_job(request)
@routes.post("/prompt_manager/jobs/model-download")
async def start_model_download_job_route(request):
return await self.start_model_download_job(request)
@routes.post("/prompt_manager/jobs/autotag")
async def start_autotag_job_route(request):
return await self.start_autotag_job(request)
@routes.post("/prompt_manager/jobs/thumbnails")
async def start_thumbnail_job_route(request):
return await self.start_thumbnail_job(request)
-4
View File
@@ -87,8 +87,6 @@ SCAN_GROUPS = {
# Record-changing: it writes image tag sets and new vocabulary names.
IMAGE_TAG_JOB_KIND = "image-tags"
IMAGE_TAG_GROUP_NAMES = ("tagged", "unchanged", "skipped", "errors")
# Tag output images (#328) counts its prompts the same way.
AUTOTAG_GROUP_NAMES = IMAGE_TAG_GROUP_NAMES
# Thumbnail generation (#249 System S5): previews written, already fresh, failed.
THUMBNAIL_GROUP_NAMES = ("generated", "skipped", "errors")
@@ -111,8 +109,6 @@ DUPLICATE_CLEANUP_JOB_KIND = "duplicate_cleanup"
THUMBNAILS_JOB_KIND = "thumbnails"
LORA_IMPORT_JOB_KIND = "lora_import"
AUTOTAG_BATCH_JOB_KIND = "autotag"
# A tagging model download (#328): read admission, not cancellable.
MODEL_DOWNLOAD_JOB_KIND = "model_download"
# Duplicate image files (#249 System S6, py/api/duplicate_files.py): the
# find is a cancellable ``read`` job; the reviewed removal is ``records``.
DUPLICATES_JOB_KIND = "duplicates"
+8 -36
View File
@@ -234,22 +234,9 @@ class LibraryRoutesMixin:
)
def _library_media(
self,
records: list[dict],
roots: list[Path],
*,
linked: bool = True,
outputs: int | None = None,
self, records: list[dict], roots: list[Path], *, linked: bool = True
) -> list[dict]:
"""Workspace descriptors for a prompt's linked media under ``roots``.
Roots from index ``outputs`` on are linked-only (LoRA media, #355):
their media keeps its rooted identity, but gets no preview file (the
card loads the original; a video shows its label), no favorite star
and is marked ``linked_only`` (no Tag action).
"""
page = []
linked_only = set()
for record in records:
try:
raw_path = Path(record["image_path"])
@@ -263,25 +250,10 @@ class LibraryRoutesMixin:
canonical_root = _canonical(root)
if path.is_relative_to(canonical_root):
page.append((path, 0, canonical_root, index))
if outputs is not None and index >= outputs:
relative = path.relative_to(canonical_root).as_posix()
linked_only.add((index, relative))
break
except (OSError, ValueError, RuntimeError):
continue
images = self._format_workspace_page(page, linked=linked)
for image in images:
if (image["root_index"], image["path"]) in linked_only:
image["thumbnail_url"] = None
image["thumbnail_ready"] = False
image["linked_only"] = True
image.pop("favorite", None)
return images
def _library_roots(self) -> tuple[list[Path], int]:
"""``(roots, number of output roots)`` for a prompt's linked media."""
outputs = list(self._get_all_output_dirs())
return self._media_roots(outputs), len(outputs)
return self._format_workspace_page(page, linked=linked)
@pair_response
async def get_library_options(self, request: web.Request) -> web.Response:
@@ -322,13 +294,13 @@ class LibraryRoutesMixin:
return result, records
result, records = await self._run_in_executor(read)
roots = list(self._get_all_output_dirs())
def previews() -> None:
roots, outputs = self._library_roots()
for parent in result["items"]:
# Previews belong to their parent; a per-parent link read is N+1.
parent["previews"] = self._library_media(
records[parent["id"]], roots, linked=False, outputs=outputs
records[parent["id"]], roots, linked=False
)
await self._run_in_executor(previews)
@@ -370,9 +342,9 @@ class LibraryRoutesMixin:
result, records = await self._run_in_executor(read)
if result is None:
return _missing()
roots, outputs = await self._run_in_executor(self._library_roots)
roots = list(self._get_all_output_dirs())
result["positive"]["previews"] = await self._run_in_executor(
lambda: self._library_media(records, roots, outputs=outputs)
self._library_media, records, roots
)
return web.json_response({"success": True, **result})
@@ -386,7 +358,7 @@ class LibraryRoutesMixin:
archive = _archive_scope(request)
limit = _page_limit(request)
cursor = request.query.get("cursor")
roots, outputs = await self._run_in_executor(self._library_roots)
roots = list(self._get_all_output_dirs())
canonical_roots = [str(_canonical(root)) for root in roots]
def read() -> dict | None:
@@ -419,7 +391,7 @@ class LibraryRoutesMixin:
if result is None:
return _missing()
result["images"] = await self._run_in_executor(
lambda: self._library_media(result["images"], roots, outputs=outputs)
self._library_media, result["images"], roots
)
return web.json_response({"success": True, **result})
+19 -56
View File
@@ -5,12 +5,9 @@ it lists the LoraManager metadata files and parses them, and counts the
current imports, those a saved pair uses and the example images an import
would download. It writes nothing and requests nothing. It answers a
``Plan`` (the maintenance plan's shape, ``py/api/maintenance.py``) plus
``{lora_files, with_metadata, skipped, with_local_media, current_imports,
blocked_by_pairs, planned_downloads, network:{planned_downloads, api_key},
layout}`` (#355: ``layout`` says which LoraManager settings file, storage mode
and library the import read, and ``error`` when they cannot be used). A plan
that saved pairs block, or whose layout is unusable, carries no token
(nothing can be applied).
``{lora_files, with_metadata, current_imports, blocked_by_pairs,
planned_downloads, network:{planned_downloads, api_key}}``. A plan that saved
pairs block carries no token (nothing can be applied).
``POST /prompt_manager/lora/import/apply {token}`` starts the job (202
``{job}``): kind ``lora_import``, titled "LoRA reference import", admission
@@ -34,7 +31,6 @@ import sqlite3
from aiohttp import web
from .. import lora_import, lora_utils
from .inspection_service import SERVABLE_EXTENSIONS
from .job_routes import busy_response
from .jobs import LORA_IMPORT_JOB_KIND, JobBusy
from .maintenance import (
@@ -156,9 +152,8 @@ class LoraImportRoutesMixin:
return None, ("lora-path", _LORA_PATH_ERROR)
return allowed, None
async def stage_lora_import(self, discovery, *, on_record=None, cancelled=None):
"""Stage ``discovery``'s sidecars off the loop: records, skips and
download counts."""
async def stage_lora_import(self, files, *, on_record=None, cancelled=None):
"""Stage ``files`` off the loop: records, skips and download counts."""
from ..config import IntegrationConfig
cache_dir = await self._run_in_executor(lora_utils.get_lora_image_cache_dir)
@@ -171,10 +166,9 @@ class LoraImportRoutesMixin:
return await self._run_in_executor(
lambda: lora_import.stage(
discovery,
files,
cache_dir,
IntegrationConfig.CIVITAI_API_KEY,
media_extensions=SERVABLE_EXTENSIONS,
on_record=report,
cancelled=cancelled or (lambda: False),
logger=self.logger,
@@ -182,8 +176,8 @@ class LoraImportRoutesMixin:
)
async def lora_files_unchanged(self, lm_path, expected):
"""Whether LoraManager's sidecars for ``lm_path`` still fingerprint as
``expected`` (found again, so an added file counts too)."""
"""Whether the metadata files under ``lm_path`` still fingerprint as
``expected`` (listed again, so an added file counts too)."""
def current():
return lora_import.fingerprint(lora_import.discover(lm_path))
@@ -194,13 +188,8 @@ class LoraImportRoutesMixin:
return False
async def replace_lora_imports(self, db, staged, *, expected_ids, allow_empty):
"""Swap the collection for ``staged.records`` in one transaction.
Then the image routes' layout cache is dropped (#362): the import may
have linked files under a folder LoraManager created after the cache
stored its layout (its first example-image download creates one).
"""
result = await self._run_in_executor(
"""Swap the collection for ``staged.records`` in one transaction."""
return await self._run_in_executor(
lambda: db.replace_category_prompts(
CATEGORY,
staged.records,
@@ -208,8 +197,6 @@ class LoraImportRoutesMixin:
allow_empty=allow_empty,
)
)
lora_utils.clear_layout_cache()
return result
# ── preview ───────────────────────────────────────────────────────
@@ -233,22 +220,8 @@ class LoraImportRoutesMixin:
cache_dir = await self._run_in_executor(
lambda: lora_utils.get_lora_image_cache_dir(create=False)
)
found = await self._run_in_executor(
lora_import.preview, lm_path, cache_dir, SERVABLE_EXTENSIONS
)
found = await self._run_in_executor(lora_import.preview, lm_path, cache_dir)
state = await self._run_in_executor(db.category_import_state, CATEGORY)
except OSError as error:
# A folder discovery cannot list refuses the import (it must not
# replace the collection with part of it): say which one.
self.logger.warning(
f"LoRA import review: a folder could not be listed: {error}"
)
return _failure(
f"A LoRA folder could not be listed ({self._public_error(error)}). "
"Nothing was changed.",
500,
"folder-unreadable",
)
except Exception:
self.logger.exception("LoRA import preview failed")
return _failure("The LoRA import could not be reviewed.", 500)
@@ -275,9 +248,7 @@ class LoraImportRoutesMixin:
return _failure("The LoRA import could not be reviewed.", 500)
token = None
expires_at = None
layout = found["layout"]
# An unusable LoraManager layout found nothing it may replace with.
if not blocked and layout["error"] is None:
if not blocked:
payload = {
"path": lm_path,
"fingerprint": found["fingerprint"],
@@ -309,9 +280,6 @@ class LoraImportRoutesMixin:
"expires_at": expires_at,
"lora_files": found["lora_files"],
"with_metadata": found["with_metadata"],
"skipped": found["skipped"],
"with_local_media": found["with_local_media"],
"layout": layout,
"current_imports": current,
"blocked_by_pairs": blocked,
"planned_downloads": downloads,
@@ -374,8 +342,8 @@ class LoraImportRoutesMixin:
store.discard(token)
return _failure(INTEGRATION_CHANGED, 409, "plan-changed")
try:
discovery = await self._run_in_executor(lora_import.discover, lm_path)
now = await self._run_in_executor(lora_import.fingerprint, discovery)
files = await self._run_in_executor(lora_import.discover, lm_path)
now = await self._run_in_executor(lora_import.fingerprint, files)
backup = self._backup_promised(plan)
_disk, short, _size = await self._run_in_executor(
disk_plan, db.model.db_path, backup=backup, vacuum=False
@@ -399,14 +367,9 @@ class LoraImportRoutesMixin:
lora_import.JOB_TITLE,
admission="records",
cancellable=True,
scope={
"operation": LORA_OPERATION,
"lora_files": len(discovery.sidecars),
},
scope={"operation": LORA_OPERATION, "lora_files": len(files)},
groups=lora_import.GROUPS,
runner=lambda job: self._run_lora_import(
job, db, plan, discovery, backup
),
runner=lambda job: self._run_lora_import(job, db, plan, files, backup),
)
except JobBusy as busy:
return busy_response(busy)
@@ -415,8 +378,8 @@ class LoraImportRoutesMixin:
# ── the job ───────────────────────────────────────────────────────
async def _run_lora_import(self, job, db, plan, discovery, backup_on):
total = len(discovery.sidecars)
async def _run_lora_import(self, job, db, plan, files, backup_on):
total = len(files)
job.update(total=total, status_line="Preparing LoRA records")
job.log_line(f"Found {_count(total, 'LoRA metadata file')}")
@@ -430,7 +393,7 @@ class LoraImportRoutesMixin:
try:
staged = await self.stage_lora_import(
discovery, on_record=on_record, cancelled=lambda: job.cancelled
files, on_record=on_record, cancelled=lambda: job.cancelled
)
except lora_import.ImportCancelled:
job.log_line(f"{CANCELLED} {CACHE_NOTE}")
+7 -25
View File
@@ -50,15 +50,6 @@ _IMPORTS_CHANGED = (
)
class LoraSettingsUnusable(Exception):
"""LoraManager's settings cannot be used; nothing was imported (#355)."""
def __init__(self, reason):
super().__init__(
f"LoraManager settings: not usable: {reason}. Nothing was changed."
)
class LoraFilesChanged(Exception):
"""A metadata file changed, appeared or went while the legacy scan staged."""
@@ -407,19 +398,13 @@ class LoraIntegrationMixin:
return
if isinstance(error, PlanChanged):
message = _IMPORTS_CHANGED
elif isinstance(error, (LoraFilesChanged, LoraSettingsUnusable)):
elif isinstance(error, LoraFilesChanged):
message = str(error)
else:
message = self._public_error(error)
if isinstance(
error,
(
PlanChanged,
LoraFilesChanged,
LoraSettingsUnusable,
EmptyReplacement,
PairDependencyError,
),
(PlanChanged, LoraFilesChanged, EmptyReplacement, PairDependencyError),
):
self.logger.warning(f"LoRA scan refused: {message}")
else:
@@ -447,11 +432,8 @@ class LoraIntegrationMixin:
"progress": 2,
}
)
discovery = await self._run_in_executor(lora_import.discover, lm_path)
if discovery.layout.error is not None:
raise LoraSettingsUnusable(discovery.layout.error)
sidecars = discovery.sidecars
total = len(sidecars)
files = await self._run_in_executor(lora_import.discover, lm_path)
total = len(files)
await send_progress(
{
"type": "progress",
@@ -461,7 +443,7 @@ class LoraIntegrationMixin:
}
)
reviewed = await self._run_in_executor(lora_import.fingerprint, discovery)
reviewed = await self._run_in_executor(lora_import.fingerprint, files)
events = asyncio.Queue()
state = await self._run_in_executor(
self.db.category_import_state, lora_import.LORA_CATEGORY
@@ -485,7 +467,7 @@ class LoraIntegrationMixin:
)
if not existing:
counts["imported"] += 1
status = record["name"] if record else sidecars[index].metadata_path.name
status = record["name"] if record else files[index].name
if record and record["images"]:
status += f" ({len(record['images'])} images)"
await send_progress(
@@ -508,7 +490,7 @@ class LoraIntegrationMixin:
stop = threading.Event()
staging = asyncio.ensure_future(
self.stage_lora_import(
discovery,
files,
on_record=on_record,
cancelled=lambda: stop.is_set() or disconnected(),
)
+1 -84
View File
@@ -47,23 +47,11 @@ _SETTINGS_FILE_KEYS = {
"THUMBNAIL_FORMAT": ("thumbnails", "format"),
"THUMBNAIL_QUALITY": ("thumbnails", "quality"),
"THUMBNAIL_EDGE": ("thumbnails", "edge"),
# The one tagging configuration (#328), published to AutotagConfig.
"AUTOTAG_MODEL_TYPE": ("autotag", "defaults", "model_type"),
"AUTOTAG_EXISTING": ("autotag", "defaults", "existing"),
"AUTOTAG_GENERAL_THRESHOLD": ("autotag", "defaults", "general_threshold"),
"AUTOTAG_CHARACTER_THRESHOLD": ("autotag", "defaults", "character_threshold"),
"AUTOTAG_TEMPLATE": ("autotag", "defaults", "template"),
"AUTOTAG_SUGGESTION_LIMIT": ("autotag", "defaults", "suggestion_limit"),
}
# The thumbnail settings a running Convert outdated job fixes (#317).
_THUMBNAIL_SETTINGS = ("THUMBNAIL_FORMAT", "THUMBNAIL_QUALITY", "THUMBNAIL_EDGE")
# Attributes that live on GalleryConfig; the others on PromptManagerConfig.
_GALLERY_SETTINGS = frozenset({"MONITORING_ENABLED"})
# Attributes that live on AutotagConfig, named without the AUTOTAG_ prefix.
_AUTOTAG_SETTINGS = frozenset(
name for name in _SETTINGS_FILE_KEYS if name.startswith("AUTOTAG_")
)
_AUTOTAG_PREFIX = "AUTOTAG_"
SOURCE_CHANGE_JOB_KIND = "source_change"
SOURCE_CHANGE_JOB_TITLE = "Source change"
@@ -163,63 +151,6 @@ def _set_nested(config, sections, key, value):
config[sections[0]] = merged
def _validate_autotag_settings(data):
"""The ``autotag_*`` settings as ``{AUTOTAG_ATTR: value}``.
The first bad value raises ValueError with a message that names the
setting by its label in System › Auto-tagging.
"""
from ..config import (
EXISTING_POLICIES,
MAX_SUGGESTION_LIMIT,
MODEL_TYPES,
validate_saved_threshold,
validate_template,
)
updates = {}
if "autotag_model_type" in data:
value = data["autotag_model_type"]
if not isinstance(value, str) or value not in MODEL_TYPES:
raise ValueError(
"Tagging model must be one of " + ", ".join(sorted(MODEL_TYPES)) + "."
)
updates["AUTOTAG_MODEL_TYPE"] = value
if "autotag_existing" in data:
value = data["autotag_existing"]
if not isinstance(value, str) or value not in EXISTING_POLICIES:
raise ValueError(
"Existing tags must be skip, merge or replace_after_review."
)
updates["AUTOTAG_EXISTING"] = value
for key, label in (
("autotag_general_threshold", "General-tag confidence"),
("autotag_character_threshold", "Character-tag confidence"),
):
if key in data:
try:
value = validate_saved_threshold(label, data[key])
except ValueError as error:
raise ValueError(f"{error}.") from None
updates[key.upper()] = value
if "autotag_template" in data:
try:
updates["AUTOTAG_TEMPLATE"] = validate_template(
data["autotag_template"], "LLM tag template"
)
except ValueError as error:
raise ValueError(f"{error}.") from None
if "autotag_suggestion_limit" in data:
value = data["autotag_suggestion_limit"]
if type(value) is not int or not 1 <= value <= MAX_SUGGESTION_LIMIT:
raise ValueError(
"Suggestions per image must be a whole number from 1 to "
f"{MAX_SUGGESTION_LIMIT}."
)
updates["AUTOTAG_SUGGESTION_LIMIT"] = value
return updates
async def _read_json(request):
"""Parse a JSON object body through the package's capped reader."""
from . import _read_json_body
@@ -246,7 +177,7 @@ class SettingsRoutesMixin:
async def get_settings(self, request):
"""Get current settings."""
try:
from ..config import AutotagConfig, PromptManagerConfig, GalleryConfig
from ..config import PromptManagerConfig, GalleryConfig
root_paths = [
self._public_path(d) for d in GalleryConfig.MONITORING_DIRECTORIES
@@ -272,14 +203,6 @@ class SettingsRoutesMixin:
"thumbnail_format": PromptManagerConfig.THUMBNAIL_FORMAT,
"thumbnail_quality": PromptManagerConfig.THUMBNAIL_QUALITY,
"thumbnail_edge": PromptManagerConfig.THUMBNAIL_EDGE,
"autotag_model_type": AutotagConfig.MODEL_TYPE,
"autotag_existing": AutotagConfig.EXISTING,
"autotag_general_threshold": AutotagConfig.GENERAL_THRESHOLD,
"autotag_character_threshold": (
AutotagConfig.CHARACTER_THRESHOLD
),
"autotag_template": AutotagConfig.TEMPLATE,
"autotag_suggestion_limit": AutotagConfig.SUGGESTION_LIMIT,
"cpu_count": PromptManagerConfig.max_worker_threads(),
"gallery_root_paths": root_paths,
"gallery_root_path": root_paths[0] if root_paths else "",
@@ -502,12 +425,7 @@ class SettingsRoutesMixin:
"""
from ..config import GalleryConfig, PromptManagerConfig
from ..config import AutotagConfig
for name, value in updates.items():
if name in _AUTOTAG_SETTINGS:
setattr(AutotagConfig, name[len(_AUTOTAG_PREFIX) :], value)
continue
owner = GalleryConfig if name in _GALLERY_SETTINGS else PromptManagerConfig
setattr(owner, name, value)
restart_fields = []
@@ -646,7 +564,6 @@ class SettingsRoutesMixin:
updates["THUMBNAIL_EDGE"] = thumbnail_settings.validate_edge(
data["thumbnail_edge"]
)
updates.update(_validate_autotag_settings(data))
return updates
def _parse_gallery_roots(self, data):
+12 -31
View File
@@ -488,16 +488,10 @@ class AutoThumbnailer:
f"Automatic preview failed for {src.name}", exc_info=True
)
def close(self, timeout=None):
def close(self):
"""Stop the worker at its next file boundary (queued files are
dropped), kill a running ffmpeg and wait up to ``timeout`` seconds
(None: CLOSE_WAIT_SECONDS, read now); a worker still running after
that is logged.
Returns True when there was no worker or it ended; False when it is
still running, or when called on the worker itself (it only signals).
"""
wait = CLOSE_WAIT_SECONDS if timeout is None else timeout
dropped), kill a running ffmpeg and wait up to CLOSE_WAIT_SECONDS;
a worker still running after that is logged."""
with self._changed:
self._closed = True
self._queue.clear()
@@ -510,18 +504,14 @@ class AutoThumbnailer:
process.kill()
except OSError:
pass
if worker is None:
return True
if worker is threading.current_thread():
return False
worker.join(wait)
if worker is None or worker is threading.current_thread():
return
worker.join(CLOSE_WAIT_SECONDS)
if worker.is_alive():
self._logger.warning(
"The automatic preview worker is still running after "
f"{wait} s; it stops at its next file"
f"{CLOSE_WAIT_SECONDS} s; it stops at its next file"
)
return False
return True
# ── waits (tests and shutdown; never timers) ──────────────────────
@@ -619,19 +609,12 @@ class AutoThumbnailMixin:
)
return self._auto_thumbnailer
def refuse_auto_thumbnails(self):
"""Refuse every later capture without stopping the worker yet (the
package teardown, before ``close_auto_thumbnails(final=True)``)."""
with AutoThumbnailMixin._auto_thumbnailer_lock:
self._auto_thumbnails_shut = True
def close_auto_thumbnails(self, final=False, timeout=None):
def close_auto_thumbnails(self, final=False):
"""Stop the capture worker and the view pool, and forget them.
Bounded by ``timeout`` (see AutoThumbnailer.close). ``final`` (the
package teardown) also keeps a late monitor event from starting a
worker again; a test reset passes False and the next use starts
fresh ones. Returns True when no capture worker is left running.
Bounded (see AutoThumbnailer.close). ``final`` (the app's cleanup)
also keeps a late monitor event from starting a worker again; a
test reset passes False and the next use starts fresh ones.
"""
with AutoThumbnailMixin._auto_thumbnailer_lock:
if final:
@@ -639,14 +622,12 @@ class AutoThumbnailMixin:
thumbnailer, self._auto_thumbnailer = self._auto_thumbnailer, None
pool, self._preview_pool_obj = self._preview_pool_obj, None
self._preview_gate_obj = None
closed = True
if thumbnailer is not None:
closed = thumbnailer.close(timeout)
thumbnailer.close()
if pool is not None:
pool.shutdown(wait=False, cancel_futures=True)
if final:
shutdown_encoders() # kill JPEG XL / ffmpeg encoders still running
return closed
def _auto_thumbnail_failures(self):
thumbnailer = self._auto_thumbnailer
+1
View File
@@ -175,6 +175,7 @@ class ThumbnailConvertMixin:
job.log_line(f"Conversion finished · {NOTHING_OUTDATED.lower()}")
return "finished"
totals, left = await self._convert_in_batches(job, options, plan, output_dirs)
self.invalidate_gallery_cache()
if totals["stopped"]:
job.update(status_line=FORMAT_CHANGED.format(converted=totals["converted"]))
job.log_line(
+1
View File
@@ -261,6 +261,7 @@ class ThumbnailJobsMixin:
stats, start, left, attempted = await self._generate_in_batches(
job, options, plan, roots, output_dirs
)
self.invalidate_gallery_cache()
generated = _plural(stats["generated"], "preview")
if job.cancelled:
return self._end_cancelled(
+28 -30
View File
@@ -25,23 +25,6 @@ except ImportError:
from database.annotations import MAX_IMAGE_TAGS
from database.vocabulary import validate_vocabulary_tag_name
# The tag template and thresholds live with the saved tagging defaults
# (#328), so config.py never imports PIL.
try:
from .tagging_defaults import DEFAULT_TAG_TEMPLATE
except ImportError:
from py.tagging_defaults import DEFAULT_TAG_TEMPLATE
def _autotag_config():
"""The saved defaults; imported late, as config needs ComfyUI's server."""
try:
from .config import AutotagConfig
except ImportError:
from py.config import AutotagConfig
return AutotagConfig
# Try to import logging from utils, fallback to standard logging
try:
from ..utils.logging_config import get_logger
@@ -62,12 +45,10 @@ except ImportError:
return logger
# Model configurations - same as standalone_tagger.py. ``label`` is the short
# name System, Activity and a 409 model-busy show (#328).
# Model configurations - same as standalone_tagger.py
MODELS = {
"gguf": {
"name": "GGUF (Recommended)",
"label": "GGUF JoyCaption",
"description": "Quantized model, ~4.5GB, faster loading",
"size": "~4.5GB",
"repo": "mradermacher/llama-joycaption-beta-one-hf-llava-GGUF",
@@ -78,7 +59,6 @@ MODELS = {
},
"hf": {
"name": "HuggingFace",
"label": "HuggingFace JoyCaption",
"description": "Full precision model, ~16GB, higher quality",
"size": "~16GB",
"repo": "fancyfeast/llama-joycaption-beta-one-hf-llava",
@@ -86,7 +66,6 @@ MODELS = {
},
"wd14-swinv2": {
"name": "WD14 SwinV2",
"label": "WD14 SwinV2",
"description": "Fast classifier tagger, ~400MB, no prompt needed",
"size": "~400MB",
"repo": "SmilingWolf/wd-swinv2-tagger-v3",
@@ -96,7 +75,6 @@ MODELS = {
},
"wd14-vit": {
"name": "WD14 ViT",
"label": "WD14 ViT",
"description": "Fast classifier tagger, ~400MB, ViT architecture",
"size": "~400MB",
"repo": "SmilingWolf/wd-vit-tagger-v3",
@@ -107,7 +85,17 @@ MODELS = {
}
# Default prompts
DEFAULT_PROMPT = DEFAULT_TAG_TEMPLATE
DEFAULT_PROMPT = (
"Generate only comma-separated Danbooru tags (lowercase_underscores, no spaces).\n"
"Include: subject_count (1girl/1boy/etc), gender, ethnicity (asian, caucasian, "
"etc), age_approx (teen, young_woman), body_attributes, face_features, "
"hairstyle, appearance, clothing, accessories, pose, expression, action, "
"camera_angle, camera_shot, scene_composition (wide_shot, long_shot, "
"dutch_angle), environment, background_elements, lighting, mood.\n"
"Prioritize visual accuracy.\n"
"No sentences, no commentary, no captions. Only tags. Keep length short but "
"descriptive."
)
# Upper bound of one suggestion request: the image tag set cap.
MAX_SUGGESTION_LIMIT = MAX_IMAGE_TAGS
@@ -191,6 +179,10 @@ class AutoTagService:
self._current_model_type: Optional[str] = None
self._custom_prompt: str = DEFAULT_PROMPT
# WD14 threshold defaults
self._wd14_general_threshold: float = 0.35
self._wd14_character_threshold: float = 0.85
@property
def models_config(self) -> Dict[str, Dict[str, Any]]:
"""Get the models configuration dictionary."""
@@ -213,13 +205,19 @@ class AutoTagService:
@property
def wd14_general_threshold(self) -> float:
"""The saved General-tag confidence (``autotag.defaults``)."""
return _autotag_config().GENERAL_THRESHOLD
return self._wd14_general_threshold
@wd14_general_threshold.setter
def wd14_general_threshold(self, value: float):
self._wd14_general_threshold = max(0.0, min(1.0, value))
@property
def wd14_character_threshold(self) -> float:
"""The saved Character-tag confidence (``autotag.defaults``)."""
return _autotag_config().CHARACTER_THRESHOLD
return self._wd14_character_threshold
@wd14_character_threshold.setter
def wd14_character_threshold(self, value: float):
self._wd14_character_threshold = max(0.0, min(1.0, value))
def get_models_status(self) -> Dict[str, Dict[str, Any]]:
"""Get availability status for all model types.
@@ -862,12 +860,12 @@ class AutoTagService:
gen_thresh = (
general_threshold
if general_threshold is not None
else _autotag_config().GENERAL_THRESHOLD
else self._wd14_general_threshold
)
char_thresh = (
character_threshold
if character_threshold is not None
else _autotag_config().CHARACTER_THRESHOLD
else self._wd14_character_threshold
)
return self._generate_wd14(
session, tags_list, image, gen_thresh, char_thresh
+16 -98
View File
@@ -563,92 +563,18 @@ class IntegrationConfig:
DEFAULT_SUGGESTION_LIMIT = 10
MAX_SUGGESTION_LIMIT = 100 # pinned to MAX_IMAGE_TAGS by tests/test_config.py
# The tagging engines, options, template and validators (server-free, #328).
try:
from .tagging_defaults import ( # noqa: F401 - re-exported
DEFAULT_TAG_TEMPLATE,
EXISTING_POLICIES,
LLM_MODEL_TYPES,
MAX_INSTRUCTION_BYTES,
MAX_INSTRUCTION_CHARS,
MODEL_TYPES,
WD14_MODEL_TYPES,
validate_saved_threshold,
validate_template,
validate_threshold,
)
except ImportError:
from py.tagging_defaults import ( # noqa: F401 - re-exported
DEFAULT_TAG_TEMPLATE,
EXISTING_POLICIES,
LLM_MODEL_TYPES,
MAX_INSTRUCTION_BYTES,
MAX_INSTRUCTION_CHARS,
MODEL_TYPES,
WD14_MODEL_TYPES,
validate_saved_threshold,
validate_template,
validate_threshold,
)
def _valid_model_type(value: Any) -> str:
if not isinstance(value, str) or value not in MODEL_TYPES:
raise ValueError("must be one of " + ", ".join(sorted(MODEL_TYPES)))
return value
def _valid_existing(value: Any) -> str:
if not isinstance(value, str) or value not in EXISTING_POLICIES:
raise ValueError("must be one of " + ", ".join(EXISTING_POLICIES))
return value
def _valid_suggestion_limit(value: Any) -> int:
if type(value) is not int or not 1 <= value <= MAX_SUGGESTION_LIMIT:
raise ValueError(f"must be an integer from 1 to {MAX_SUGGESTION_LIMIT}")
return value
class AutotagConfig:
"""Persisted tagging defaults under ``autotag.defaults`` in config.json.
One tagging configuration (#328): the model, what a batch does with
prompts already tagged, the two WD14 thresholds, the language-model
template and how many suggestions Generate returns. Each key is checked
alone on load; a hand-edited value that fails its check falls back to
its default with one warning rather than failing the load.
``suggestion_limit`` is how many suggestions Generate returns (the top N
by score), an integer in ``1..MAX_SUGGESTION_LIMIT``. A hand-edited value
outside that range, or of another type, falls back to the default with a
warning rather than failing the load.
"""
MODEL_TYPE = "wd14-swinv2"
EXISTING = "skip"
GENERAL_THRESHOLD = 0.35
CHARACTER_THRESHOLD = 0.85
TEMPLATE = DEFAULT_TAG_TEMPLATE
SUGGESTION_LIMIT = DEFAULT_SUGGESTION_LIMIT
# config.json key -> (attribute, validator, default)
_KEYS = {
"model_type": ("MODEL_TYPE", _valid_model_type, "wd14-swinv2"),
"existing": ("EXISTING", _valid_existing, "skip"),
"general_threshold": (
"GENERAL_THRESHOLD",
lambda v: validate_saved_threshold("value", v),
0.35,
),
"character_threshold": (
"CHARACTER_THRESHOLD",
lambda v: validate_saved_threshold("value", v),
0.85,
),
"template": ("TEMPLATE", validate_template, DEFAULT_TAG_TEMPLATE),
"suggestion_limit": (
"SUGGESTION_LIMIT",
_valid_suggestion_limit,
DEFAULT_SUGGESTION_LIMIT,
),
}
@classmethod
def suggestion_limit(cls) -> int:
return cls.SUGGESTION_LIMIT
@@ -661,14 +587,7 @@ class AutotagConfig:
@classmethod
def get_config(cls) -> Dict[str, Any]:
"""The saved defaults; the template only when it is not the built-in
one, so a later built-in template still reaches who never set one."""
defaults = {
key: getattr(cls, attr) for key, (attr, _v, _d) in cls._KEYS.items()
}
if defaults["template"] == DEFAULT_TAG_TEMPLATE:
del defaults["template"]
return {"defaults": defaults}
return {"defaults": {"suggestion_limit": cls.SUGGESTION_LIMIT}}
@classmethod
def update_config(cls, new_config: Any) -> None:
@@ -679,18 +598,17 @@ class AutotagConfig:
if not isinstance(defaults, dict):
config_logger.warning("Ignoring autotag.defaults that is not an object")
return
for key, (attr, validate, default) in cls._KEYS.items():
if key not in defaults:
continue
try:
setattr(cls, attr, validate(defaults[key]))
except ValueError as error:
config_logger.warning(
f"autotag.defaults.{key} {error}; using {default!r}"
if key != "template"
else f"autotag.defaults.template {error}; using the built-in one"
)
setattr(cls, attr, default)
if "suggestion_limit" not in defaults:
return
value = defaults["suggestion_limit"]
if cls.valid_suggestion_limit(value):
cls.SUGGESTION_LIMIT = value
return
config_logger.warning(
"autotag.defaults.suggestion_limit must be an integer from 1 to "
f"{MAX_SUGGESTION_LIMIT}; using {DEFAULT_SUGGESTION_LIMIT}"
)
cls.SUGGESTION_LIMIT = DEFAULT_SUGGESTION_LIMIT
class PromptManagerConfig:
+55 -108
View File
@@ -1,27 +1,18 @@
"""The staged LoRA reference import (#221, #249 System S10, #355).
"""The staged LoRA reference import (#221, #249 System S10).
The import reads LoraManager's ``*.metadata.json`` sidecars and turns each
LoRA into one prompt of the ``lora-manager`` category, tagged with its name and
trigger words and linked to its images. It runs in three stages, and only the
last writes to the database:
The import reads LoraManager's ``*.metadata.json`` files and turns each LoRA
into one prompt of the ``lora-manager`` category, tagged with its name and
trigger words and linked to its preview images. It runs in three stages, and
only the last writes to the database:
1. **discover**: read LoraManager's settings the way LoraManager does
(``py/lora_manager_layout.py``: the settings file from the environment
folder, the portable plugin file or the user config folder; the LoRA
roots, the storage mode, the sidecar and example-images roots) and find
the sidecars (``discover``). In ``alongside`` mode a sidecar beside its
model is kept when ``<base>.safetensors`` exists; in ``centralized`` mode
every sidecar under the sidecar root is kept when its ``file_path`` names
a model inside a LoRA root. ``fingerprint`` covers every scanned sidecar,
kept or skipped (path, size, modification time, kept or not, #362) and the
layout; ``preview`` adds the counts a review shows. Nothing is written
and nothing is requested.
2. **stage**: read each sidecar and build its record. Its LoraManager preview
and example images are linked where they are (``local_media``); only a
LoRA without any local image downloads its CivitAI example images into the
image cache first (best effort, counted). A cancelled or failed stage
leaves the database untouched; downloaded images stay in the cache.
3. **replace**: the sidecars are found and fingerprinted once more (a file
1. **discover**: list the metadata files (``discover``) and fingerprint them
(path, size and modification time; ``fingerprint``). ``preview`` adds the
counts a review shows. Nothing is written and nothing is requested.
2. **stage**: read each file and build its record; CivitAI example images are
downloaded into the image cache first (best effort, counted). A cancelled
or failed stage leaves the database untouched; downloaded images stay in
the cache.
3. **replace**: the files are listed and fingerprinted once more (a file
changed, removed or added while staging aborts the import), then
``PromptDatabase.replace_category_prompts`` swaps the old collection for
the staged records in one transaction (or changes nothing).
@@ -33,15 +24,9 @@ Both the System job (``POST /lora/import/apply``) and the legacy ``POST
import hashlib
from dataclasses import dataclass, field
from pathlib import Path
from typing import AbstractSet, Callable, Dict, List, Optional
from typing import Callable, Dict, List, Optional
from . import lora_utils
from .lora_manager_layout import (
Discovery,
discover_sidecars,
layout_summary,
local_media,
)
try:
from ..utils.hashing import generate_prompt_hash
@@ -52,97 +37,69 @@ LORA_CATEGORY = "lora-manager"
JOB_TITLE = "LoRA reference import"
# The job's outcome groups, in its counter-tile order.
GROUPS = ("imported", "linked", "skipped", "downloaded", "download_errors", "errors")
METADATA_SUFFIX = "*.metadata.json"
class ImportCancelled(Exception):
"""The import was cancelled while staging; nothing was written."""
def discover(lora_manager_path: Optional[str]) -> Discovery:
"""The sidecars of every LoRA whose model exists inside a LoRA root.
def discover(lora_manager_path: str) -> List[Path]:
"""Every LoRA metadata file, sorted and without repeats.
``lora_manager_path`` is resolved as the integration resolves it (an
install under ``custom_nodes``; empty means auto-detection). Raises
whatever listing a folder raised: a discovery that cannot see every
folder must not replace the collection with part of it.
Raises whatever listing a folder raised: a discovery that cannot see
every folder must not replace the collection with part of it.
"""
return discover_sidecars(lora_utils.lora_manager_layout(lora_manager_path))
seen = set()
files = []
for folder in lora_utils.find_lora_directories(lora_manager_path):
for path in Path(folder).rglob(METADATA_SUFFIX):
key = str(path)
if key not in seen:
seen.add(key)
files.append(path)
files.sort(key=str)
return files
def fingerprint(discovery: Discovery) -> str:
"""What a review saw: the layout, then every scanned sidecar, kept or
skipped, with its path, size, modification time and whether it was kept
(#362). A LoraManager save that changes unrelated settings leaves it as
it was."""
layout = discovery.layout
def fingerprint(files: List[Path]) -> str:
"""What a review saw of ``files``: each path, size and modification time."""
digest = hashlib.sha256()
for value in (
layout.settings_path,
layout.source,
layout.error,
layout.mode,
"\0".join(layout.lora_roots),
layout.sidecar_root,
layout.example_images_root,
layout.library,
):
digest.update(f"{value}\n".encode("utf-8", "surrogatepass"))
for name in discovery.unreadable:
digest.update(f"unreadable\0{name}\n".encode("utf-8", "surrogatepass"))
kept = {str(sidecar.metadata_path) for sidecar in discovery.sidecars}
for path in discovery.scanned_files:
flag = 1 if str(path) in kept else 0
for path in files:
try:
stat = path.stat()
entry = f"{path}\0{stat.st_size}\0{stat.st_mtime_ns}\0{flag}\n"
entry = f"{path}\0{stat.st_size}\0{stat.st_mtime_ns}\n"
except OSError:
entry = f"{path}\0missing\0{flag}\n"
entry = f"{path}\0missing\n"
digest.update(entry.encode("utf-8", "surrogatepass"))
return "sha256:" + digest.hexdigest()
def preview(
lora_manager_path: Optional[str],
cache_dir: Path,
media_extensions: AbstractSet[str],
) -> Dict:
def preview(lora_manager_path: str, cache_dir: Path) -> Dict:
"""The review's counts (blocking; reads only, requests nothing).
``{lora_files, with_metadata, skipped, with_local_media,
planned_downloads, fingerprint, names, layout}``: the metadata files
seen, the kept LoRAs whose sidecar parses, the rest, the LoRAs with a
local image, the CivitAI images an import would download for the LoRAs
without one (not yet in the cache), the first LoRA names and what the
layout says (``layout_summary``).
``{lora_files, with_metadata, planned_downloads, fingerprint, names}``:
the metadata files found, those that parse, the example images an import
would download (not yet in the cache) and the first LoRA names.
"""
discovery = discover(lora_manager_path)
layout = discovery.layout
files = discover(lora_manager_path)
with_metadata = 0
with_local_media = 0
planned = 0
names = []
for sidecar in discovery.sidecars:
metadata = lora_utils.read_lora_metadata(sidecar.metadata_path)
for path in files:
metadata = lora_utils.read_lora_metadata(path)
if not metadata:
continue
with_metadata += 1
if local_media(sidecar, metadata, layout, media_extensions):
with_local_media += 1
else:
planned += lora_utils.planned_civitai_downloads(
metadata, sidecar.metadata_path, cache_dir
)
planned += lora_utils.planned_civitai_downloads(metadata, path, cache_dir)
if len(names) < 50:
names.append(lora_utils.get_model_name_from_metadata(metadata))
return {
"lora_files": discovery.scanned,
"lora_files": len(files),
"with_metadata": with_metadata,
"skipped": discovery.scanned - with_metadata,
"with_local_media": with_local_media,
"planned_downloads": planned,
"fingerprint": fingerprint(discovery),
"fingerprint": fingerprint(files),
"names": names,
"layout": layout_summary(layout, Path.home()),
}
@@ -174,45 +131,35 @@ class StagedImport:
def stage(
discovery: Discovery,
files: List[Path],
cache_dir: Path,
api_key: str = "",
*,
media_extensions: AbstractSet[str],
on_record: Optional[Callable[[int, Dict, "StagedImport"], None]] = None,
cancelled: Callable[[], bool] = lambda: False,
logger=None,
) -> StagedImport:
"""Read each sidecar and build its record with its images.
"""Read each file and build its record, downloading its example images.
Blocking. Local images (``local_media``) come first; a LoRA without any
downloads its CivitAI example images. A sidecar that cannot be read, or
whose record fails to build, is skipped by its name, as are the
centralized sidecars discovery could not parse. ``cancelled()`` is asked
before each sidecar (raises ImportCancelled); ``on_record(index,
record_or_None, staged)`` follows each one.
Blocking. A file that cannot be read, or whose record fails to build, is
skipped by its name. ``cancelled()`` is asked before each file (raises
ImportCancelled); ``on_record(index, record_or_None, staged)`` follows
each one.
"""
sidecars = discovery.sidecars
staged = StagedImport(total=len(sidecars))
staged.skipped.extend(discovery.unreadable)
for index, sidecar in enumerate(sidecars):
staged = StagedImport(total=len(files))
for index, path in enumerate(files):
if cancelled():
raise ImportCancelled()
record = None
path = sidecar.metadata_path
metadata = lora_utils.read_lora_metadata(path)
if not metadata:
staged.skipped.append(path.name)
else:
try:
local = local_media(
sidecar, metadata, discovery.layout, media_extensions
local = lora_utils.get_preview_images_from_metadata(metadata, path)
remote = lora_utils.download_civitai_images(
metadata, path, cache_dir, api_key, stats=staged.stats
)
remote = []
if not local:
remote = lora_utils.download_civitai_images(
metadata, path, cache_dir, api_key, stats=staged.stats
)
images = list(dict.fromkeys([*local, *remote]))
record = build_record(metadata, path, images)
staged.records.append(record)
-217
View File
@@ -1,217 +0,0 @@
"""A per-process cache of LoraManager's layout for the image hot paths (#362).
Every Library list, prompt detail, inspect, reveal and serve request needs the
LoRA allow-list (``ImagesMixin._lora_image_dirs``). Building it means finding
the LoraManager install, reading its plugin settings file and its settings
file, and resolving every folder. ``LayoutCache`` keeps the last result and
reuses it while all of these hold:
- the key (the configured install path, stripped) and the home folder the
folder guard compares against are the same;
- the entry is younger than ``MAX_AGE_SECONDS`` (monotonic clock);
- the plugin settings file and the settings file have the same signature
(``st_mtime_ns``, ``st_size``, or ``MISSING``) as when it was stored;
- the install and every stored media root still resolve to themselves (a
folder swapped for a symlink reloads, so the folder guard runs again).
A result is stored only when no watched file is "racy" (modified within
``RACY_SECONDS`` of the load, git's rule for racily clean index entries),
every watched file could be checked, the settings file was readable, and no
``clear()`` ran during the load. What each rule covers: signatures catch a
settings change on the next request; the racy rule catches a write during the
load, a half-written file and coarse timestamps; the maximum age bounds the
inputs no signature sees (``extra_model_paths.yaml``, environment, temporary
failures); ``clear()`` makes a finished import's new folders servable at once.
Hits and loads take no lock: a slow ``os.stat`` on a network share holds up
only its own request, and two threads that miss together both load (both
loads only read). A short lock guards the compare-and-store and the logging
state; no file I/O runs under it. Standard library only, so it is tested
without ComfyUI or LoraManager.
"""
import logging
import os
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Optional, Tuple
from .lora_manager_layout import UNREADABLE, LoraManagerLayout, _canonical
# A watched file modified this recently is not trusted: LoraManager writes its
# settings in place, and FAT/exFAT store modification times in 2 s steps.
RACY_SECONDS = 3
# Bounds what no signature sees (unwatched inputs, temporary failures).
MAX_AGE_SECONDS = 30
# The signature of a watched file that does not exist (a valid state).
MISSING = object()
# ERROR_BAD_NETPATH and ERROR_BAD_NET_NAME, which Windows raises as
# FileNotFoundError.
_OFFLINE_SHARE_ERRORS = frozenset({53, 67})
logger = logging.getLogger("prompt_manager.lora_layout_cache")
@dataclass(frozen=True)
class _Entry:
key: str
home: str
install: Optional[str]
layout: LoraManagerLayout
signatures: Tuple[Tuple[str, object], ...]
stored_at: float
class _CannotValidate(Exception):
"""A watched file could not be checked (not merely missing)."""
class LayoutCache:
"""At most one ``(install, layout)`` for the active LoraManager config."""
def __init__(
self,
resolve: Callable[[str], Optional[str]],
load: Callable[[Optional[str]], LoraManagerLayout],
*,
wall_ns: Callable[[], int] = time.time_ns,
monotonic: Callable[[], float] = time.monotonic,
stat=os.stat,
pathmod=os.path,
home: Callable[[], str] = lambda: str(Path.home()),
):
self._resolve = resolve
self._load = load
self._wall_ns = wall_ns
self._monotonic = monotonic
self._stat = stat
self._pathmod = pathmod
self._home = home
self._entry: Optional[_Entry] = None
self._generation = 0
self._lock = threading.Lock()
# What was last logged; survives clear() so a reload of the same
# layout stays quiet.
self._logged_layout: Optional[LoraManagerLayout] = None
self._logged_refused: Tuple[str, ...] = ()
def get(self, configured: str) -> Tuple[Optional[str], LoraManagerLayout]:
"""``(install, layout)`` for the configured path ("" auto-detects)."""
key = (configured or "").strip()
home = self._home()
entry = self._entry
if entry is not None and self._valid(entry, key, home):
return entry.install, entry.layout
with self._lock:
if entry is not None and self._entry is entry:
self._entry = None
generation = self._generation
install = self._resolve(key)
layout = self._load(install)
self._log_refused(layout)
try:
signatures = tuple(
(path, self._signature(path)) for path in self._watched(install, layout)
)
except _CannotValidate:
return install, layout
if layout.error == UNREADABLE or self._racy(signatures, self._wall_ns()):
return install, layout
stored = _Entry(key, home, install, layout, signatures, self._monotonic())
changed = False
with self._lock:
if self._generation == generation:
self._entry = stored
changed = layout != self._logged_layout
if changed:
self._logged_layout = layout
if changed:
logger.info(
f"LoraManager layout changed: {layout.source or 'no'} settings, "
f"{layout.mode} storage, {len(layout.media_roots)} media folders"
)
return install, layout
def clear(self) -> None:
"""Drop the entry; a load that started before this stores nothing."""
with self._lock:
self._entry = None
self._generation += 1
def _log_refused(self, layout: LoraManagerLayout) -> None:
"""One warning per default LoRA folder newly refused by the guard."""
refused = tuple(layout.refused_defaults)
with self._lock:
if refused == self._logged_refused:
return
new = [folder for folder in refused if folder not in self._logged_refused]
self._logged_refused = refused
for folder in new:
logger.warning(
f"ComfyUI LoRA folder '{folder}' is neither scanned nor served: "
"it is a filesystem root, the home folder or a folder that "
"contains it"
)
# ── validation ──────────────────────────────────────────────────
def _valid(self, entry: _Entry, key: str, home: str) -> bool:
if entry.key != key or entry.home != home:
return False
if self._monotonic() - entry.stored_at >= MAX_AGE_SECONDS:
return False
try:
for path, signature in entry.signatures:
if self._signature(path) != signature:
return False
except _CannotValidate:
return False
folders = entry.layout.media_roots
if entry.install is not None:
folders = (entry.install,) + tuple(folders)
for folder in folders:
try:
if _canonical(folder, self._pathmod) != folder:
return False
except (OSError, ValueError):
return False
return True
def _watched(self, install, layout) -> Tuple[str, ...]:
"""The plugin settings file, then the settings file, each once."""
if install is None:
return ()
paths = [self._pathmod.join(install, "settings.json")]
if layout.settings_path is not None:
paths.append(str(layout.settings_path))
watched, seen = [], set()
for path in paths:
identity = self._pathmod.normcase(self._pathmod.abspath(path))
if identity not in seen:
seen.add(identity)
watched.append(path)
return tuple(watched)
def _signature(self, path: str):
try:
result = self._stat(path)
except (FileNotFoundError, NotADirectoryError) as error:
# Windows reports an offline share as "not found": that is a
# temporary failure, not a missing file.
if getattr(error, "winerror", None) in _OFFLINE_SHARE_ERRORS:
raise _CannotValidate(path) from error
return MISSING
except (OSError, ValueError) as error:
raise _CannotValidate(path) from error
return (result.st_mtime_ns, result.st_size)
@staticmethod
def _racy(signatures, now_ns: int) -> bool:
threshold = now_ns - RACY_SECONDS * 10**9
return any(
signature is not MISSING and signature[0] > threshold
for _path, signature in signatures
)
-653
View File
@@ -1,653 +0,0 @@
"""Where LoraManager keeps its files (#355).
LoraManager (``custom_nodes/comfyui-lora-manager``) stores one
``<name>.metadata.json`` sidecar per model, a preview image or video beside
it, and each model's downloaded CivitAI example images. Where it stores them
is a user setting, and its settings file itself can live in three places.
This module repeats LoraManager's own rules, reading its files only (never
importing it), so the LoRA import, the trigger-word cache and the image
server share one definition:
- ``load_layout`` finds the settings file (environment folder, portable
plugin file, or the user config folder, in LoraManager's order), keeps only
the seven keys the import needs, and resolves the LoRA roots, the sidecar
root (centralized storage), the example-images root and the active
LoraManager library. Every folder from the settings, and each of
ComfyUI's default LoRA folders, passes the folder guard: it must exist and
must not be a filesystem root, the home folder or a folder that contains
it. Default folders the guard refuses are listed in ``refused_defaults``.
- ``discover_sidecars`` finds every sidecar whose model file exists inside a
LoRA root: beside the models in ``alongside`` mode, under the sidecar root
(matched to its model by ``file_path``) in ``centralized`` mode.
- ``local_media`` lists a LoRA's preview and example images that the image
server may serve.
Standard library plus ``platformdirs`` only (LoraManager depends on it), so
it is testable outside ComfyUI.
"""
import json
import logging
import os
import re
from dataclasses import dataclass, replace
from pathlib import Path
from typing import (
AbstractSet,
Callable,
Dict,
Iterator,
List,
Mapping,
Optional,
Sequence,
Tuple,
)
# py/utils/settings_paths.py: SETTINGS_DIR_ENV, _LM_PORTABLE_ENV, APP_NAME.
SETTINGS_DIR_ENV = "LORA_MANAGER_SETTINGS_DIR"
PORTABLE_ENV = "LORA_MANAGER_PORTABLE"
APP_NAME = "ComfyUI-LoRA-Manager"
# py/utils/constants.py PREVIEW_EXTENSIONS, in LoraManager's order.
PREVIEW_EXTENSIONS = (
".webp",
".preview.webp",
".preview.png",
".preview.jpeg",
".preview.jpg",
".preview.mp4",
".png",
".jpeg",
".jpg",
".mp4",
".gif",
".webm",
".avif",
".jxl",
)
# LoraManager's LoRA scanner accepts .safetensors only (py/services/lora_scanner.py).
MODEL_SUFFIX = ".safetensors"
# py/utils/sidecar_paths.py METADATA_SUFFIX.
METADATA_SUFFIX = ".metadata.json"
MAX_SETTINGS_BYTES = 5 * 1024 * 1024
# The only settings the import reads; every other key (the file can hold API
# keys) is dropped right after parsing.
SETTINGS_KEYS = frozenset(
{
"sidecar_storage_mode",
"sidecar_storage_path",
"example_images_path",
"folder_paths",
"extra_folder_paths",
"libraries",
"active_library",
}
)
# py/utils/sidecar_paths.py STORAGE_MODE_ALONGSIDE / STORAGE_MODE_CENTRALIZED.
MODE_ALONGSIDE = "alongside"
MODE_CENTRALIZED = "centralized"
# utils/video_metadata.py VIDEO_EXTENSIONS (not imported: that package needs
# the node's relative imports).
VIDEO_SUFFIXES = frozenset({".mp4", ".mov", ".m4v", ".webm", ".mkv", ".avi", ".wmv"})
SOURCE_ENVIRONMENT = "environment"
SOURCE_PORTABLE = "portable"
SOURCE_USER_CONFIG = "user config"
NO_INSTALL = "no LoraManager install found"
UNREADABLE = "the settings file could not be read"
NOT_JSON = "the settings file is not valid JSON"
NOT_OBJECT = "the settings file is not a JSON object"
TOO_LARGE = "the settings file is larger than 5 MB"
NO_PLATFORMDIRS = "platformdirs is not available"
NO_SIDECAR_ROOT = "the sidecar folder does not exist"
BAD_SIDECAR_ROOT = (
"the sidecar folder is a filesystem root, the home folder or a folder "
"that contains it"
)
logger = logging.getLogger("prompt_manager.lora_manager_layout")
@dataclass(frozen=True)
class LoraManagerLayout:
"""What LoraManager's settings say about where its files are."""
settings_path: Optional[Path]
source: Optional[str]
error: Optional[str]
mode: str
lora_roots: Tuple[str, ...]
sidecar_root: Optional[str]
example_images_root: Optional[str]
library: str
media_roots: Tuple[str, ...]
# ComfyUI's default LoRA folders that exist but that the folder guard
# refused (canonical): the layout cache logs them (#362).
refused_defaults: Tuple[str, ...] = ()
@dataclass(frozen=True)
class Sidecar:
"""One LoRA's metadata file and the model file it describes."""
metadata_path: Path
model_path: Path
@dataclass(frozen=True)
class Discovery:
"""The sidecars kept, every metadata file seen, and those unread."""
layout: LoraManagerLayout
sidecars: List[Sidecar]
# Every ``*.metadata.json`` seen, kept or skipped: resolved, once each,
# sorted by ``str(path)``. The import's fingerprint covers all of them.
scanned_files: Tuple[Path, ...]
unreadable: List[str]
@property
def scanned(self) -> int:
"""How many metadata files were seen."""
return len(self.scanned_files)
class _SettingsError(Exception):
"""The settings file cannot be used, for the reason it carries."""
# ── paths ────────────────────────────────────────────────────────────
def _canonical(path: str, pathmod=os.path) -> str:
# py/config.py _canonical_path (not imported: py.config imports ComfyUI's
# ``server`` at import time).
return pathmod.normcase(pathmod.realpath(path))
def _is_filesystem_root(path: str, pathmod=os.path) -> bool:
# py/config.py _is_filesystem_root: '/', 'C:\\' and UNC share roots.
return pathmod.dirname(path) == path
def _guard_folder(
path: str,
home: str,
*,
pathmod=os.path,
isdir: Optional[Callable[[str], bool]] = None,
) -> Optional[str]:
"""The canonical form of ``path`` when it may be read and served, else None.
Kept only when it is a folder, not a filesystem root, and neither the
home folder nor one of its ancestors (``/home``, ``C:\\Users``: a
security review ruled that a folder holding the home folder must never
be read or served).
"""
isdir = isdir or os.path.isdir
try:
canonical = _canonical(path, pathmod)
if not isdir(canonical) or _is_filesystem_root(canonical, pathmod):
return None
if _inside(home, (canonical,), pathmod):
return None
except (OSError, ValueError):
return None
return canonical
def _inside(path, roots: Sequence[str], pathmod=os.path) -> bool:
"""True when ``path`` (canonical) is one of ``roots`` or below one."""
try:
candidate = _canonical(str(path), pathmod)
except (OSError, ValueError):
return False
for root in roots:
prefix = root if root.endswith(pathmod.sep) else root + pathmod.sep
if candidate == root or candidate.startswith(prefix):
return True
return False
def _absolute(value) -> Optional[str]:
"""A settings path, stripped, ``~``-expanded and absolute; None when unset."""
if not isinstance(value, str) or not value.strip():
return None
return os.path.abspath(os.path.expanduser(value.strip()))
# ── the settings file ───────────────────────────────────────────────
def _platform_user_config_dir() -> str:
from platformdirs import user_config_dir
return user_config_dir(APP_NAME, appauthor=False)
def _read_bytes(path: Path) -> bytes:
with open(path, "rb") as handle:
return handle.read(MAX_SETTINGS_BYTES + 1)
def _read_settings(path: Path) -> Optional[Dict]:
"""The parsed object in ``path``; None when it does not exist."""
try:
raw = _read_bytes(path)
except FileNotFoundError:
return None
except OSError:
raise _SettingsError(UNREADABLE) from None
if len(raw) > MAX_SETTINGS_BYTES:
raise _SettingsError(TOO_LARGE)
try:
data = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, ValueError):
raise _SettingsError(NOT_JSON) from None
if not isinstance(data, dict):
raise _SettingsError(NOT_OBJECT)
return data
def _settings_subset(data: Mapping) -> Dict:
"""Only the keys the import reads."""
return {key: data[key] for key in SETTINGS_KEYS if key in data}
def _portable_flag(environ: Mapping[str, str], plugin_settings: Path) -> bool:
"""LoraManager's portable mode (settings_paths._should_use_portable_settings)."""
forced = environ.get(PORTABLE_ENV)
if forced == "1":
return True
if forced == "0":
return False
try:
data = _read_settings(plugin_settings)
except _SettingsError:
return False
return bool(data) and data.get("use_portable_settings") is True
def _settings_location(
lora_manager_path: str,
environ: Mapping[str, str],
user_config_dir: Callable[[], str],
) -> Tuple[Path, str]:
"""``(settings file, source)`` in LoraManager's lookup order."""
override = environ.get(SETTINGS_DIR_ENV)
if override:
folder = os.path.abspath(os.path.expanduser(override))
return Path(folder) / "settings.json", SOURCE_ENVIRONMENT
plugin_settings = Path(lora_manager_path) / "settings.json"
if _portable_flag(environ, plugin_settings):
return plugin_settings, SOURCE_PORTABLE
try:
folder = user_config_dir()
except ImportError:
raise _SettingsError(NO_PLATFORMDIRS) from None
return Path(folder) / "settings.json", SOURCE_USER_CONFIG
# ── libraries and folders ───────────────────────────────────────────
def _sanitize_library_name(name: str) -> str:
# py/utils/example_images_paths.py sanitize_library_name.
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", name or "")
return safe or "default"
def _active_library(settings: Mapping) -> Tuple[str, Optional[Dict], int]:
"""``(name, library dict or None, number of libraries)``."""
libraries = settings.get("libraries")
if not isinstance(libraries, dict) or not libraries:
return "default", None, 0
active = settings.get("active_library")
if not isinstance(active, str) or active not in libraries:
active = next(iter(libraries))
library = libraries[active]
return str(active), library if isinstance(library, dict) else None, len(libraries)
def _lora_list(settings: Mapping, library: Optional[Dict], key: str) -> List[str]:
"""The ``loras`` folders of ``key`` (the library's, else the top level)."""
source = library if library is not None and key in library else settings
section = source.get(key)
if not isinstance(section, dict):
return []
value = section.get("loras")
if isinstance(value, str):
value = [value]
if not isinstance(value, list):
return []
return [entry for entry in value if isinstance(entry, str) and entry.strip()]
def _guarded(paths, home: str) -> Tuple[str, ...]:
kept = []
for path in paths:
absolute = _absolute(path)
guarded = _guard_folder(absolute, home) if absolute else None
if guarded and guarded not in kept:
kept.append(guarded)
return tuple(kept)
def _refused(paths, home: str) -> Tuple[str, ...]:
"""The folders in ``paths`` that exist but that the guard refuses."""
refused = []
for path in paths:
absolute = _absolute(path)
if not absolute or not os.path.isdir(absolute):
continue
if _guard_folder(absolute, home) is None:
try:
canonical = _canonical(absolute)
except (OSError, ValueError):
continue
if canonical not in refused:
refused.append(canonical)
return tuple(refused)
def _example_images_root(
settings: Mapping, library: str, libraries: int, home: str
) -> Optional[str]:
"""py/utils/example_images_paths.py get_library_root, when the folder exists."""
configured = _absolute(settings.get("example_images_path"))
root = _guard_folder(configured, home) if configured else None
if root is None or libraries <= 1:
return root
# Guarded (canonical) like its root: a library name keeps its case, and
# the media check compares normcased paths.
return _guard_folder(os.path.join(root, _sanitize_library_name(library)), home)
def _media_roots(*groups) -> Tuple[str, ...]:
roots = []
for group in groups:
for root in group:
if root and root not in roots:
roots.append(root)
return tuple(roots)
def load_layout(
lora_manager_path: Optional[str],
*,
default_roots: Callable[[], List[str]],
environ: Mapping[str, str] = os.environ,
user_config_dir: Callable[[], str] = _platform_user_config_dir,
) -> LoraManagerLayout:
"""Read LoraManager's settings the way LoraManager does (read only).
``lora_manager_path`` is the resolved install (``None`` when none was
found). An unusable layout carries ``error``, alongside mode and the
default LoRA roots, so serving and trigger words keep working.
"""
home = str(Path.home())
default_list = list(default_roots())
defaults = _guarded(default_list, home)
refused_defaults = _refused(default_list, home)
fallback = LoraManagerLayout(
settings_path=None,
source=None,
error=NO_INSTALL,
mode=MODE_ALONGSIDE,
lora_roots=defaults,
sidecar_root=None,
example_images_root=None,
library="default",
media_roots=defaults,
refused_defaults=refused_defaults,
)
if not lora_manager_path:
return fallback
try:
settings_path, source = _settings_location(
lora_manager_path, environ, user_config_dir
)
except _SettingsError as error:
return replace(fallback, error=str(error))
fallback = replace(fallback, settings_path=settings_path, source=source)
try:
raw = _read_settings(settings_path)
except _SettingsError as error:
return replace(fallback, error=str(error))
settings = _settings_subset(raw or {})
return _layout_from_settings(
settings, settings_path, source, defaults, home, refused_defaults
)
def _layout_from_settings(
settings, settings_path, source, defaults, home, refused_defaults=()
):
library, entry, libraries = _active_library(settings)
lora_roots = _media_roots(
defaults,
_guarded(_lora_list(settings, entry, "folder_paths"), home),
_guarded(_lora_list(settings, entry, "extra_folder_paths"), home),
)
mode = settings.get("sidecar_storage_mode")
mode = MODE_CENTRALIZED if mode == MODE_CENTRALIZED else MODE_ALONGSIDE
example_root = _example_images_root(settings, library, libraries, home)
layout = LoraManagerLayout(
settings_path=settings_path,
source=source,
error=None,
mode=mode,
lora_roots=lora_roots,
sidecar_root=None,
example_images_root=example_root,
library=library,
media_roots=_media_roots(lora_roots, [example_root]),
refused_defaults=refused_defaults,
)
if mode != MODE_CENTRALIZED:
return layout
configured = _absolute(settings.get("sidecar_storage_path"))
configured = configured or os.path.join(str(settings_path.parent), "sidecars")
sidecar_root = _guard_folder(configured, home)
if sidecar_root is None:
reason = NO_SIDECAR_ROOT if not os.path.isdir(configured) else BAD_SIDECAR_ROOT
return replace(
layout,
error=reason,
lora_roots=defaults,
example_images_root=None,
media_roots=defaults,
)
return replace(
layout,
sidecar_root=sidecar_root,
media_roots=_media_roots(lora_roots, [sidecar_root], [example_root]),
)
# ── sidecars ────────────────────────────────────────────────────────
def _metadata_files(folder: str) -> Iterator[Path]:
"""Every ``*.metadata.json`` under ``folder``; an unlistable folder raises.
Symlinked folders are followed, each real folder once (no loops).
"""
def fail(error):
raise error
visited = set()
for current, dirs, files in os.walk(folder, onerror=fail, followlinks=True):
real = _canonical(current)
if real in visited:
dirs[:] = []
continue
visited.add(real)
dirs.sort()
for name in sorted(files):
if name.endswith(METADATA_SUFFIX):
yield Path(current) / name
def _read_sidecar(path: Path) -> Optional[Dict]:
try:
with open(path, "r", encoding="utf-8") as handle:
data = json.load(handle)
except (OSError, ValueError):
return None
return data if isinstance(data, dict) else None
def _model_for(path: Path, layout: LoraManagerLayout, unreadable: List[str]):
"""The model file a sidecar describes, when it exists inside a LoRA root."""
if layout.mode == MODE_ALONGSIDE:
base = path.name[: -len(METADATA_SUFFIX)]
model = path.parent / f"{base}{MODEL_SUFFIX}"
return model if model.is_file() else None
metadata = _read_sidecar(path)
if metadata is None:
unreadable.append(path.name)
return None
file_path = metadata.get("file_path")
if not isinstance(file_path, str) or not file_path.strip():
return None
model = Path(file_path)
if not model.is_file() or not _inside(model, layout.lora_roots):
return None
return model
def discover_sidecars(layout: LoraManagerLayout) -> Discovery:
"""Every sidecar whose model exists inside a LoRA root, sorted, once each.
Raises ``OSError`` when a folder cannot be listed: a discovery that
cannot see every folder must not replace the collection with part of it.
"""
if layout.error is not None:
return Discovery(layout, [], (), [])
if layout.mode == MODE_CENTRALIZED:
folders = [layout.sidecar_root]
else:
folders = list(layout.lora_roots)
seen = set()
scanned: List[Path] = []
unreadable: List[str] = []
sidecars = []
for folder in folders:
for path in _metadata_files(folder):
resolved = Path(os.path.realpath(path))
key = os.path.normcase(str(resolved))
if key in seen:
continue
seen.add(key)
scanned.append(resolved)
model = _model_for(resolved, layout, unreadable)
if model is not None:
sidecars.append(Sidecar(resolved, Path(os.path.realpath(model))))
sidecars.sort(key=lambda sidecar: str(sidecar.metadata_path))
scanned.sort(key=str)
return Discovery(layout, sidecars, tuple(scanned), unreadable)
# ── images ──────────────────────────────────────────────────────────
def _natural_key(name: str):
parts = re.split(r"(\d+)", name)
return [int(part) if part.isdigit() else part.lower() for part in parts], name
def _example_files(folder: str) -> List[str]:
try:
with os.scandir(folder) as entries:
names = [entry.name for entry in entries if entry.is_file()]
except OSError:
return []
return [os.path.join(folder, name) for name in sorted(names, key=_natural_key)]
def _preview_candidates(sidecar: Sidecar, metadata: Dict) -> Iterator[str]:
"""The paths a preview is looked for at, in LoraManager's order."""
url = metadata.get("preview_url")
if isinstance(url, str) and url.strip():
if os.path.isabs(url.strip()):
yield url.strip()
else:
# LoraManager writes absolute paths; it would resolve a relative
# one against its working directory, not the sidecar folder.
logger.debug(
"Relative preview_url skipped in %s", sidecar.metadata_path.name
)
base = sidecar.metadata_path.name[: -len(METADATA_SUFFIX)]
for suffix in PREVIEW_EXTENSIONS:
yield str(sidecar.metadata_path.parent / f"{base}{suffix}")
def local_media(
sidecar: Sidecar,
metadata: Dict,
layout: LoraManagerLayout,
media_extensions: AbstractSet[str],
) -> List[str]:
"""The LoRA's preview, then its example images: absolute, resolved paths.
A file counts only when its extension is in ``media_extensions`` (what the
image server serves) and it lies inside ``layout.media_roots``.
"""
def usable(path: str) -> bool:
return (
os.path.splitext(path)[1].lower() in media_extensions
and os.path.isfile(path)
and _inside(path, layout.media_roots)
)
found: List[str] = []
for path in _preview_candidates(sidecar, metadata):
if usable(path):
found.append(path)
break
sha256 = metadata.get("sha256")
if layout.example_images_root and isinstance(sha256, str) and sha256.strip():
folder = os.path.join(layout.example_images_root, sha256.strip().lower())
found.extend(path for path in _example_files(folder) if usable(path))
media: List[str] = []
for path in found:
resolved = os.path.realpath(path)
if resolved not in media:
media.append(resolved)
return _cover_first(media)
def _cover_first(media: List[str]) -> List[str]:
"""A still image in front of a leading video (no thumbnails for these yet)."""
if not media or os.path.splitext(media[0])[1].lower() not in VIDEO_SUFFIXES:
return media
for index, path in enumerate(media):
if os.path.splitext(path)[1].lower() not in VIDEO_SUFFIXES:
return [path] + media[:index] + media[index + 1 :]
return media
# ── the review ──────────────────────────────────────────────────────
def layout_summary(layout: LoraManagerLayout, home: Path) -> Dict:
"""What the review shows about the layout (no settings values)."""
settings_file = None
if layout.settings_path is not None:
settings_file = str(layout.settings_path)
try:
relative = Path(settings_file).relative_to(home)
settings_file = os.path.join("~", str(relative))
except ValueError:
pass
return {
"source": layout.source,
"settings_file": settings_file,
"error": layout.error,
"mode": layout.mode,
"library": layout.library,
"example_images": layout.example_images_root is not None,
}
+97 -132
View File
@@ -13,7 +13,6 @@ import json
import os
import re
import threading
import time
import urllib.error
import urllib.request
from pathlib import Path
@@ -28,17 +27,6 @@ except ImportError:
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from utils.logging_config import get_logger
from dataclasses import replace
from .lora_layout_cache import LayoutCache
from .lora_manager_layout import (
MODE_ALONGSIDE,
METADATA_SUFFIX,
LoraManagerLayout,
discover_sidecars,
load_layout,
)
logger = get_logger("prompt_manager.lora_utils")
# ── LoraManager detection ────────────────────────────────────────────
@@ -246,22 +234,23 @@ def resolve_lora_manager_path(path) -> Optional[str]:
# ── Metadata reading ─────────────────────────────────────────────────
def default_lora_roots() -> List[str]:
"""The LoRA folders ComfyUI knows, without LoraManager's settings.
def find_lora_directories(lora_manager_path: str) -> List[str]:
"""Find directories that contain LoRA models (with .metadata.json files).
ComfyUI's ``models/loras``, the ``loras`` entries of
``extra_model_paths.yaml`` and, inside ComfyUI, every folder
``folder_paths`` registers for ``loras``. ``lora_manager_layout`` adds
the folders LoraManager's own settings name.
Searches: ComfyUI models/loras, extra_model_paths.yaml lora dirs,
and the LoraManager extension dir itself.
"""
dirs = set()
lm_path = Path(lora_manager_path)
root = find_comfyui_root()
if root:
# Default models/loras
models_loras = root / "models" / "loras"
if models_loras.is_dir():
dirs.add(str(models_loras.resolve()))
# Extra model paths from ComfyUI config
for extra_dir in _get_extra_lora_paths(root):
if extra_dir.is_dir():
dirs.add(str(extra_dir.resolve()))
@@ -278,53 +267,13 @@ def default_lora_roots() -> List[str]:
# folder_paths unavailable — not running inside ComfyUI runtime
pass
# Check for any .metadata.json in the LoraManager dir tree
for meta in lm_path.rglob("*.metadata.json"):
dirs.add(str(meta.parent.resolve()))
return sorted(dirs)
def lora_manager_install(lora_manager_path: Optional[str]) -> Optional[str]:
"""The LoraManager install to read: the configured path when it is an
allowed install under ``custom_nodes``, auto-detection when it is empty."""
if isinstance(lora_manager_path, str) and lora_manager_path.strip():
return resolve_lora_manager_path(lora_manager_path)
detected = detect_lora_manager()
return resolve_lora_manager_path(detected) if detected else None
def layout_for_install(install: Optional[str]) -> LoraManagerLayout:
"""The layout of an already resolved install (None: none was found)."""
return load_layout(install, default_roots=default_lora_roots)
def lora_manager_layout(lora_manager_path: Optional[str]) -> LoraManagerLayout:
"""Where LoraManager keeps its files (``py/lora_manager_layout.py``).
A fresh read: the import review, Start, the legacy scan and the trigger
word cache use it. The image hot paths use ``cached_lora_manager_layout``.
"""
return layout_for_install(lora_manager_install(lora_manager_path))
# The image hot paths' layout cache (#362). The lambdas look the functions up
# when called, so a test that patches them here is seen by the cache.
_layout_cache = LayoutCache(
lambda path: lora_manager_install(path),
lambda install: layout_for_install(install),
)
def cached_lora_manager_layout(
configured: Optional[str],
) -> Tuple[Optional[str], LoraManagerLayout]:
"""``(install, layout)`` for the configured path, from the layout cache
while LoraManager's settings files are unchanged (``py/lora_layout_cache.py``)."""
return _layout_cache.get(configured or "")
def clear_layout_cache() -> None:
"""Drop the cached layout (after an import linked files in new folders)."""
_layout_cache.clear()
def _get_extra_lora_paths(comfyui_root: Path) -> List[Path]:
"""Parse extra_model_paths.yaml for additional LoRA directories."""
results = []
@@ -368,7 +317,7 @@ def read_lora_metadata(metadata_path: Path) -> Optional[Dict]:
try:
with open(metadata_path, "r", encoding="utf-8") as f:
return json.load(f)
except (ValueError, OSError) as e: # JSONDecodeError, UnicodeDecodeError
except (json.JSONDecodeError, OSError) as e:
logger.debug(f"Failed to read {metadata_path}: {e}")
return None
@@ -430,6 +379,46 @@ def get_model_name_from_metadata(metadata: Dict) -> str:
return name
def get_preview_images_from_metadata(metadata: Dict, metadata_path: Path) -> List[str]:
"""Find all local preview/example image paths for a LoRA.
Returns:
List of absolute path strings to image files.
"""
results = []
lora_dir = metadata_path.parent
file_name = metadata.get("file_name", "")
if not file_name:
stem = metadata_path.name.replace(".metadata.json", "")
file_name = stem
base_name = Path(file_name).stem
# Check standard preview naming conventions
for ext in (
".png",
".jpg",
".jpeg",
".webp",
".preview.png",
".preview.jpg",
".preview.jpeg",
):
candidate = lora_dir / f"{base_name}{ext}"
if candidate.exists():
results.append(str(candidate.resolve()))
return results
def get_preview_image_from_metadata(
metadata: Dict, metadata_path: Path
) -> Optional[str]:
"""Find the first preview image path for a LoRA (backward compat)."""
images = get_preview_images_from_metadata(metadata, metadata_path)
return images[0] if images else None
_THUMB_MAX_SIZE = 512
# CivitAI's own domains (#340: civitai.red and civitai.green serve the same
@@ -848,16 +837,30 @@ def get_lora_image_cache_dir(create: bool = True) -> Path:
return cache
def get_example_images_dir(lora_manager_path: str) -> Optional[str]:
"""Find the LoraManager example_images directory."""
lm_path = Path(lora_manager_path)
# Direct subdirectory
candidate = lm_path / "example_images"
if candidate.is_dir():
return str(candidate.resolve())
# Search one level in user data dirs
for child in lm_path.iterdir():
if child.is_dir():
sub = child / "example_images"
if sub.is_dir():
return str(sub.resolve())
return None
# ── Trigger word cache & injection ───────────────────────────────────
_LORA_PATTERN = re.compile(r"<lora:([^:>]+):[^>]+>", re.IGNORECASE)
# A failed load is retried by a running node only after this long (an explicit
# reload, from the settings routes, always runs).
TRIGGER_RETRY_SECONDS = 60
class TriggerWordCache:
"""Thread-safe cache mapping LoRA names to their trigger words.
@@ -868,89 +871,52 @@ class TriggerWordCache:
self._cache: Dict[str, List[str]] = {}
self._lock = threading.Lock()
self._loaded = False
self._failed_at: Optional[float] = None
def load(self, lora_manager_path: str) -> int:
"""Build the trigger word map from the sidecars LoraManager uses.
Never raises (a node calls this while it runs): a folder that cannot
be listed, or any other failure, is logged and the previous map is
kept. When LoraManager's settings cannot be used, the reason is
logged and the default LoRA folders are read in alongside mode.
"""Scan LoRA metadata files and build the trigger word mapping.
Returns:
Number of LoRAs with trigger words in the map.
Number of LoRAs with trigger words found.
"""
try:
new_cache = self._build(lora_manager_path)
except OSError as error:
logger.warning(f"Trigger word cache not refreshed: {error}")
return self._failed()
except Exception:
# Not a disk error: keep the traceback (#362).
logger.exception("Trigger word cache not refreshed")
return self._failed()
new_cache: Dict[str, List[str]] = {}
lora_dirs = find_lora_directories(lora_manager_path)
for lora_dir in lora_dirs:
dir_path = Path(lora_dir)
for meta_file in dir_path.rglob("*.metadata.json"):
metadata = read_lora_metadata(meta_file)
if not metadata:
continue
words = get_trigger_words_from_metadata(metadata)
if not words:
continue
# Key by filename stem (what appears in <lora:NAME:weight>)
file_name = metadata.get("file_name", "")
if file_name:
stem = Path(file_name).stem
new_cache[stem.lower()] = words
# Also key by the metadata file stem
meta_stem = meta_file.name.replace(".metadata.json", "")
if meta_stem.lower() not in new_cache:
new_cache[meta_stem.lower()] = words
with self._lock:
self._cache = new_cache
self._loaded = True
self._failed_at = None
logger.info(
f"Trigger word cache loaded: {len(new_cache)} LoRAs with trigger words"
)
return len(new_cache)
def _failed(self) -> int:
"""Keep the previous map and note when this load failed."""
with self._lock:
self._failed_at = time.monotonic()
return len(self._cache)
@staticmethod
def _build(lora_manager_path: str) -> Dict[str, List[str]]:
layout = lora_manager_layout(lora_manager_path)
if layout.error is not None:
logger.warning(
f"LoraManager settings not usable ({layout.error}); trigger words "
"come from the default LoRA folders"
)
layout = replace(
layout,
error=None,
mode=MODE_ALONGSIDE,
sidecar_root=None,
example_images_root=None,
)
new_cache: Dict[str, List[str]] = {}
for sidecar in discover_sidecars(layout).sidecars:
metadata = read_lora_metadata(sidecar.metadata_path)
if not metadata:
continue
words = get_trigger_words_from_metadata(metadata)
if not words:
continue
# Key by the model file name (what appears in <lora:NAME:weight>)
# and by the sidecar's name.
new_cache[sidecar.model_path.stem.lower()] = words
sidecar_stem = sidecar.metadata_path.name[: -len(METADATA_SUFFIX)]
new_cache.setdefault(sidecar_stem.lower(), words)
return new_cache
def get_trigger_words(self, lora_name: str) -> List[str]:
"""Look up trigger words for a LoRA by name (case-insensitive)."""
with self._lock:
return self._cache.get(lora_name.lower(), [])
def retry_due(self) -> bool:
"""Whether a node may try loading again: never failed, or the last
failure is TRIGGER_RETRY_SECONDS old (no rescan on every prompt run)."""
with self._lock:
failed_at = self._failed_at
return failed_at is None or time.monotonic() - failed_at >= (
TRIGGER_RETRY_SECONDS
)
@property
def is_loaded(self) -> bool:
with self._lock:
@@ -960,7 +926,6 @@ class TriggerWordCache:
with self._lock:
self._cache.clear()
self._loaded = False
self._failed_at = None
# Module-level singleton
-74
View File
@@ -1,74 +0,0 @@
"""The tagging configuration's constants and validators (#328).
Server-free on purpose: ``py.api`` must import without ComfyUI's ``server``
module, which ``py/config.py`` needs; config re-exports all of this.
"""
import math
from typing import Any
# The tagging engines (``py/autotag.py`` MODELS) and the options they take.
WD14_MODEL_TYPES = frozenset({"wd14-swinv2", "wd14-vit"})
LLM_MODEL_TYPES = frozenset({"gguf", "hf"})
MODEL_TYPES = WD14_MODEL_TYPES | LLM_MODEL_TYPES
MAX_INSTRUCTION_CHARS = 4000
MAX_INSTRUCTION_BYTES = 16 * 1024
# What a batch does with a prompt that already has tags (#328).
EXISTING_POLICIES = ("skip", "merge", "replace_after_review")
# The language-model tag template every page starts from until one is saved.
DEFAULT_TAG_TEMPLATE = (
"Generate only comma-separated Danbooru tags (lowercase_underscores, no spaces).\n"
"Include: subject_count (1girl/1boy/etc), gender, ethnicity (asian, caucasian, "
"etc), age_approx (teen, young_woman), body_attributes, face_features, "
"hairstyle, appearance, clothing, accessories, pose, expression, action, "
"camera_angle, camera_shot, scene_composition (wide_shot, long_shot, "
"dutch_angle), environment, background_elements, lighting, mood.\n"
"Prioritize visual accuracy.\n"
"No sentences, no commentary, no captions. Only tags. Keep length short but "
"descriptive."
)
def validate_threshold(name: str, value: Any) -> float:
"""A WD14 confidence threshold in [0, 1], or ValueError naming ``name``."""
if type(value) not in (int, float) or not math.isfinite(value):
raise ValueError(f"{name} must be a finite number")
if not 0 <= value <= 1:
raise ValueError(f"{name} must be between 0 and 1")
return float(value)
# Every page shows the thresholds in steps of 0.05 (the concept's System rows,
# the Tag image dialogs and the legacy sliders), so a saved one must be on that
# grid (#369). Per-request thresholds keep their own checks.
THRESHOLD_STEP = 0.05
_STEPS_PER_UNIT = round(1 / THRESHOLD_STEP)
# A computed float can miss the grid by a hair: (0.1 + 0.2) * 20 is 6.000000000000001.
_STEP_TOLERANCE = 1e-12 # in steps; refuses 0.35000000001
def validate_saved_threshold(name: str, value: Any) -> float:
"""A saved WD14 threshold: in [0, 1] and a multiple of ``THRESHOLD_STEP``,
returned exactly on the grid; else ValueError naming ``name``."""
value = validate_threshold(name, value)
steps = value * _STEPS_PER_UNIT
if abs(steps - round(steps)) > _STEP_TOLERANCE:
raise ValueError(f"{name} must be a multiple of {THRESHOLD_STEP}")
return round(steps) / _STEPS_PER_UNIT
def validate_template(value: Any, name: str = "instructions") -> str:
"""Non-empty language-model instructions within the character and byte
caps, or ValueError naming ``name``."""
if not isinstance(value, str) or not value.strip():
raise ValueError(f"{name} must be non-empty text")
if len(value) > MAX_INSTRUCTION_CHARS:
raise ValueError(f"{name} must be at most {MAX_INSTRUCTION_CHARS} characters")
try:
encoded = value.encode("utf-8")
except UnicodeEncodeError:
raise ValueError(f"{name} must be valid Unicode text") from None
if len(encoded) > MAX_INSTRUCTION_BYTES:
raise ValueError(f"{name} {'are' if name == 'instructions' else 'is'} too long")
return value
+1 -1
View File
@@ -3,7 +3,7 @@ name = "promptmanager"
description = "A powerful ComfyUI custom node that extends the standard text encoder with persistent prompt storage, advanced search capabilities, and an automatic image gallery system using SQLite."
version = "3.3.1"
license = {file = "LICENSE"}
requires-python = ">=3.12"
requires-python = ">=3.10"
dependencies = ["watchdog>=2.1.0", "Pillow>=8.0.0"]
[build-system]
-27
View File
@@ -1,27 +0,0 @@
"""Final stops for the background threads a test starts (#301, #302).
Every real ``ImageGenerationHandler``, ``ImageMonitor`` or ``PromptTracker``
a test constructs ends with its final stop and an assertion, before the
test's folder and databases go: ``close()`` for a handler,
``stop_monitoring()`` for a monitor, ``shutdown()`` for a tracker.
"""
from utils.prompt_tracker import PromptTracker
def stop_tracker(testcase, tracker):
"""Shut a real tracker's cleanup thread down; mocks are left alone."""
if isinstance(tracker, PromptTracker):
testcase.assertTrue(tracker.shutdown(), "prompt cleanup thread still running")
def close_handler(testcase, handler):
"""Close ``handler`` for good, then stop the tracker it was built with."""
testcase.assertTrue(handler.close(), "image worker still running")
stop_tracker(testcase, getattr(handler, "prompt_tracker", None))
def stop_monitor(testcase, monitor):
"""Stop ``monitor`` (its observer and worker), then its tracker."""
testcase.assertTrue(monitor.stop_monitoring(), "image monitor still running")
stop_tracker(testcase, getattr(monitor, "prompt_tracker", None))
-46
View File
@@ -149,49 +149,3 @@ test('Classic test button reports success and failure and releases expired notif
await page.clock.fastForward(3001);
await expect(page.locator('.pm-classic-notification')).toHaveCount(0);
});
test('Classic shows linked text, keeps the typed text through save and refuses to overwrite it (#343)',async({page})=>{
await page.route('**/prompt_manager/recent?**',route=>route.fulfill({json:{success:true,results:[{text:'Saved prompt',tags:[]}]}}));
const note=page.locator('.pm-classic').getByText('Text comes from a link. Your typed text is kept for when you disconnect it.',{exact:true});
await expect(note).toBeHidden();
await page.evaluate(()=>{
const widget=node.widgets.find(item=>item.name==='text');widget.value=widget.inputEl.value='Typed classic';
node.inputs=[{name:'text',type:'STRING',link:3,widget:{name:'text'}}];node.onConnectionsChange();
});
await expect(note).toBeVisible();
// The link drives the run; the saved workflow keeps what the user typed, not a stale or empty value.
expect(await page.evaluate(()=>node.serialize().widgets_values[0])).toBe('Typed classic');
await page.getByRole('button',{name:'📋 Recent',exact:true}).click();
await page.getByRole('button',{name:/Saved prompt/}).click();
expect(await page.evaluate(()=>node.widgets.find(item=>item.name==='text').value)).toBe('Typed classic');
await expect(page.getByRole('alert').filter({hasText:'Text comes from a link. Disconnect it to use a saved prompt.'})).toBeVisible();
await page.evaluate(()=>{node.inputs[0].link=null;node.onConnectionsChange();});
await expect(note).toBeHidden();
await page.getByRole('button',{name:/Saved prompt/}).click();
expect(await page.evaluate(()=>node.widgets.find(item=>item.name==='text').value)).toBe('Saved prompt');
// A workflow load re-evaluates the link state.
await page.evaluate(()=>{node.inputs[0].link=8;node.configure({});});
await expect(note).toBeVisible();
});
test('PromptManagerText shows linked text in its legacy controls and keeps the typed text (#343)',async({page})=>{
await page.evaluate(async()=>{
class TextNode {
constructor(){this.widgets=['text','category','tags','search_text'].map(name=>({name,value:'',inputEl:document.createElement('textarea')}));this.widgets_values=[];this.inputs=[{name:'text',type:'STRING',link:null,widget:{name:'text'}}];}
addDOMWidget(name,type,element){element.id='legacy-text-node';document.body.append(element);return {element};}
setSize(){} setDirtyCanvas(){} serialize(){return {properties:{...this.properties}};}
}
await fixtureApp.extensions[0].beforeRegisterNodeDef(TextNode,{name:'PromptManagerText'},fixtureApp);
window.textNode=new TextNode();textNode.onNodeCreated();
const widget=textNode.widgets[0];widget.value=widget.inputEl.value='Typed text node';
});
const note=page.locator('#legacy-text-node').getByText('Text comes from a link. Your typed text is kept for when you disconnect it.',{exact:true});
await expect(note).toBeHidden();
await page.evaluate(()=>{textNode.inputs[0].link=5;textNode.onConnectionsChange();});
await expect(note).toBeVisible();
expect(await page.evaluate(()=>textNode.serialize().widgets_values[0])).toBe('Typed text node');
await page.evaluate(()=>{textNode.usePrompt({text:'Replacement'},0);});
expect(await page.evaluate(()=>textNode.widgets[0].value)).toBe('Typed text node');
await page.evaluate(()=>{textNode.inputs[0].link=null;textNode.onConnectionsChange();textNode.onRemoved();});
await expect(note).toBeHidden();
});
+3 -30
View File
@@ -26,8 +26,7 @@ test.beforeEach(async({page,browserName})=>{
window.fixtureApp={registerExtension(extension){this.extension=extension;},ui:{settings:new EventTarget()},graph:{setDirtyCanvas(){}},canvas:{}};
await import('/extensions/promptmanager/prompt_manager.js');
class Node {
constructor(){this.widgets=['text','category','tags','search_text'].map(name=>({name,type:'text',value:'',options:{},inputEl:document.createElement('textarea')}));this.inputs=[...['clip','prepend_text','append_text'].map(name=>({name,type:name==='clip'?'CLIP':'STRING',link:null})),...['text','category','tags','search_text'].map(name=>({name,type:'STRING',link:null,widget:{name}}))];this.properties={};this.size=[550,700];}
findInputByType(type){const index=this.inputs.findIndex(input=>input.type===type&&input.link==null);return index<0?undefined:{index,slot:this.inputs[index]};}
constructor(){this.widgets=['text','category','tags','search_text'].map(name=>({name,type:'text',value:'',options:{},inputEl:document.createElement('textarea')}));this.inputs=['clip','prepend_text','append_text'].map(name=>({name,link:null}));this.properties={};this.size=[550,700];}
addWidget(type,name,value,callback,options){const widget={type,name,value,callback,options};this.widgets.push(widget);return widget;}
addDOMWidget(name,type,element){const host=document.createElement('div');host.style.height='650px';host.className='fixture-dom-widget';host.append(element);document.body.append(host);return {element};}
setSize(size){this.size=size;}setDirtyCanvas(){}
@@ -247,39 +246,13 @@ test(`${variant} restores workflow values and disclosures and protects connected
await page.evaluate(()=>{const saved=node.serialize();node.configure(saved);});
await expect(page.getByLabel('Category',{exact:true})).toBeVisible();
await expect(page.getByLabel('Category',{exact:true})).toHaveValue('Saved category');
await page.evaluate(()=>{node.inputs.find(input=>input.name==='text').link=9;node.onConnectionsChange();});
await page.evaluate(()=>{node.inputs.push({name:'text',link:9});node.onConnectionsChange();});
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toBeHidden();
await expect(page.getByText('Main prompt comes from a link. Your typed text is kept for when you disconnect it.',{exact:true})).toBeVisible();
await expect(page.getByText('Text is supplied by an upstream connection.',{exact:true})).toBeVisible();
await page.evaluate(()=>{node.inputs.find(input=>input.name==='text').link=null;node.onConnectionsChange();});
await expect(page.getByRole('textbox',{name:'Main prompt',exact:true})).toHaveValue('Saved draft');
});
test(`${variant} offers text as a link socket, keeps typed text while linked and through a reload (#343)`,async({page})=>{
const sockets=()=>page.evaluate(()=>Object.fromEntries(node.inputs.map(input=>[input.name,input.widget?.name??null])));
// The hidden text widget no longer owns the input, so the host draws it as a socket; the other widget inputs are unchanged.
expect(await sockets()).toEqual({clip:null,prepend_text:null,append_text:null,text:null,category:'category',tags:'tags',search_text:'search_text'});
expect(await page.evaluate(()=>node.findInputByType('STRING').slot.name)).toBe('text');
expect(await page.evaluate(()=>node.findInputByType('CLIP').slot.name)).toBe('clip');
const main=page.getByRole('textbox',{name:'Main prompt',exact:true});
await main.fill('Typed main');
await page.evaluate(()=>{node.inputs.find(input=>input.name==='text').link=21;node.onConnectionsChange();});
await expect(main).toBeHidden();
const note=page.getByText('Main prompt comes from a link. Your typed text is kept for when you disconnect it.',{exact:true});
await expect(note).toBeVisible();
await expect(page.locator('[data-composed-preview]')).toHaveText('[text supplied by connection]');
// A second STRING link dropped on the editor falls back to the frontend's own choice.
expect(await page.evaluate(()=>node.findInputByType('STRING').slot.name)).toBe('prepend_text');
// An older saved workflow brings the widget binding back with its inputs: configure exposes the socket again.
const saved=await page.evaluate(()=>{const saved=node.serialize();node.inputs.find(input=>input.name==='text').widget={name:'text'};node.configure(saved);return saved;});
expect(saved.widgets_values[0]).toBe('Typed main');
expect((await sockets()).text).toBeNull();
await expect(note).toBeVisible();
await page.evaluate(()=>{node.inputs.find(input=>input.name==='text').link=null;node.onConnectionsChange();});
await expect(main).toBeVisible();
await expect(main).toHaveValue('Typed main');
await expect(note).toBeHidden();
});
test(`${variant} searches quoted tags with Enter and explicitly loads metadata`, async ({page}) => {
let requested;
await page.route('**/prompt_manager/search?**', async route => {
-152
View File
@@ -1,152 +0,0 @@
// #328: the legacy admin and gallery auto-tag tools keep working beside System › Auto-tagging. The model download is
// the `model_download` Activity job; the pages' stream attaches to it. No model is ever downloaded: the fixture's
// fake download waits until the test releases it (`/__test__/tagging/download`).
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
const TITLE = 'Tagging model download · WD14 ViT';
async function post(request, url, data) {
await expect(await request.post(url, data === undefined ? {} : { data })).toBeOK();
}
async function downloads(request) {
const response = await request.get('/prompt_manager/jobs');
await expect(response).toBeOK();
return (await response.json()).jobs.filter(job => job.kind === 'model_download');
}
test.beforeEach(async ({ page, request, browserName }) => {
await startCoverage(page, browserName);
// The 3.2.0 update notice would cover the admin page's toolbar.
await page.addInitScript(() => { localStorage.setItem('pm_update_notice_dismissed', '3.2.0'); });
await post(request, '/__test__/jobs/restart');
await post(request, '/__test__/tagging/reset');
await post(request, '/__test__/tagging/models', { unavailable: ['wd14-vit'] });
await post(request, '/__test__/tagging/download', { mode: 'hold' });
});
test.afterEach(async ({ page, request, browserName }) => {
await saveCoverage(page, browserName);
// Releases a download a failed test left held, and puts the raising default back.
await post(request, '/__test__/tagging/reset');
await post(request, '/__test__/jobs/restart');
});
async function startDownload(page, request) {
await page.locator('#autoTagBtn').click();
const status = page.locator('#wd14VitModelStatus');
await status.getByRole('button', { name: 'Download', exact: true }).click();
const modal = page.locator('#autoTagDownloadModal');
await expect(modal).toBeVisible();
await expect(page.locator('#downloadStatus')).toHaveText('Downloading…');
await expect(page.locator('#downloadProgressBar')).toHaveClass(/animate-pulse/);
await expect(modal).toContainText('The download continues in Activity.');
await expect(page.locator('#cancelDownloadBtn')).toHaveText('Close');
const running = (await downloads(request)).filter(job => job.state === 'running');
expect(running.map(job => [job.title, job.cancellable])).toEqual([[TITLE, false]]);
return { modal, status };
}
test('admin download modal closes on complete and Activity lists the job without Cancel', async ({ page, request }) => {
await page.goto('/prompt_manager/admin');
const { modal, status } = await startDownload(page, request);
await page.goto('/prompt_manager/workspace#/activity');
const item = page.locator('#workspace-main section.surface > .activity-item')
.filter({ has: page.getByRole('link', { name: new RegExp(`${TITLE}$`) }) });
await expect(item.locator('.chip')).toHaveText('Running');
await expect(item.locator('progress')).not.toHaveAttribute('value');
await expect(item.getByRole('button', { name: 'Cancel job', exact: true })).toHaveCount(0);
// Back on the admin page a second download of the same model attaches to the running job.
await page.goto('/prompt_manager/admin');
await startDownload(page, request);
await post(request, '/__test__/tagging/download', { mode: 'succeed' });
await expect(modal).toBeHidden();
await expect(status).toContainText('Downloaded');
expect((await downloads(request)).map(job => [job.title, job.state])).toEqual([[TITLE, 'finished']]);
});
test('gallery download modal closes on complete', async ({ page, request }) => {
await page.goto('/prompt_manager/gallery');
const { modal, status } = await startDownload(page, request);
await post(request, '/__test__/tagging/download', { mode: 'succeed' });
await expect(modal).toBeHidden();
await expect(page.locator('#autoTagModal')).toBeVisible();
await expect(status).toContainText('Downloaded');
expect((await downloads(request)).map(job => job.state)).toEqual(['finished']);
});
test('a failed download closes the modal and says so', async ({ page, request }) => {
await page.goto('/prompt_manager/gallery');
const { modal, status } = await startDownload(page, request);
await post(request, '/__test__/tagging/download', { mode: 'fail' });
await expect(modal).toBeHidden();
await expect(page.getByText('Download failed: Download failed')).toBeVisible();
await expect(status.getByRole('button', { name: 'Download', exact: true })).toBeVisible();
expect((await downloads(request)).map(job => job.state)).toEqual(['failed']);
});
// (wd14-vit is not downloaded in these tests: beforeEach marks it so the download tests can offer it.)
// The gallery's batch reader keyed on `data.status` values the server never sends, so its modal never moved or
// closed (#328 Problem 5). It now reads `type` frames as the admin page does.
async function watchProgress(page) {
await page.evaluate(() => {
window.progressSeen = [];
const node = document.getElementById('autoTagProgressPercent');
new MutationObserver(() => window.progressSeen.push(node.textContent))
.observe(node, { childList: true, characterData: true, subtree: true });
});
}
async function runGalleryBatch(page) {
await page.locator('#autoTagBtn').click();
await page.locator('input[name="autoTagModel"][value="wd14-swinv2"]').check();
await watchProgress(page);
const done = page.waitForResponse(response => response.url().includes('/prompt_manager/autotag/start'));
await page.locator('#startAutoTagBtn').click();
await done;
await expect(page.locator('#autoTagProgressModal')).toBeHidden();
await expect(page.getByText(/^Auto tagging complete! Applied tags to \d+ prompts\.$/)).toBeVisible();
}
test('gallery batch modal counts progress and closes on complete', async ({ page, request }) => {
await page.goto('/prompt_manager/gallery');
await runGalleryBatch(page);
const seen = await page.evaluate(() => window.progressSeen);
// The fixture's prompts are already tagged, so the batch skips them; its progress frames still move the bar.
expect(seen).toContain('95%');
expect((await downloads(request)).length).toBe(0);
});
test('admin review opens the first image', async ({ page }) => {
await page.goto('/prompt_manager/admin');
await page.locator('#autoTagBtn').click();
await page.locator('input[name="autoTagModel"][value="wd14-swinv2"]').check();
await page.locator('#startReviewBtn').click();
// The first image's prompt is already tagged: confirm a new pass, then the review opens on that image.
await page.locator('#retagConfirmBtn').click();
await expect(page.locator('#autoTagReviewModal')).toBeVisible();
await expect(page.locator('#reviewImageName')).not.toHaveText(/^(image_name\.png|Unknown|)$/);
await expect(page.getByText('Failed to start review mode')).toHaveCount(0);
});
test('gallery unload works and is refused while the gate is held', async ({ page, request }) => {
await page.goto('/prompt_manager/gallery');
await runGalleryBatch(page); // keeps the model in memory
await page.locator('#autoTagBtn').click();
const unload = page.locator('#unloadModelBtn');
await expect(unload).toBeVisible();
// A held download owns the model gate: Unload is refused with the server's error.
const held = await request.post('/prompt_manager/jobs/model-download', { data: { model_type: 'gguf' } });
expect(held.status()).toBe(202);
await unload.click();
await expect(page.getByText(/^Failed to unload model: A tagging model is busy/)).toBeVisible();
await post(request, '/__test__/tagging/download', { mode: 'succeed' });
await expect.poll(async () => (await downloads(request)).map(job => job.state)).toEqual(['finished']);
await unload.click();
await expect(page.getByText('WD14-SWINV2 model unloaded successfully')).toBeVisible();
});
+1 -42
View File
@@ -17,8 +17,7 @@ test.beforeEach(async({page,browserName})=>{
window.fixtureApp={registerExtension(extension){this.extension=extension;},ui:{settings},graph:{_nodes:[],setDirtyCanvas(){}},canvas:{},graphToPrompt:async()=>({})};
await import('/extensions/promptmanager/prompt_manager.js');
class Node {
constructor(){this.id=1;this.type='PromptManagerPair';this.widgets=['text','negative_text','category','tags','search_text'].map(name=>({name,type:'text',value:'',options:{},inputEl:document.createElement('textarea')}));this.inputs=[...['clip','prepend_text','append_text'].map(name=>({name,type:name==='clip'?'CLIP':'STRING',link:null})),...['text','negative_text','category','tags','search_text'].map(name=>({name,type:'STRING',link:null,widget:{name}}))];this.properties={};this.size=[1100,700];}
findInputByType(type){const index=this.inputs.findIndex(input=>input.type===type&&input.link==null);return index<0?undefined:{index,slot:this.inputs[index]};}
constructor(){this.id=1;this.type='PromptManagerPair';this.widgets=['text','negative_text','category','tags','search_text'].map(name=>({name,type:'text',value:'',options:{},inputEl:document.createElement('textarea')}));this.inputs=['clip','prepend_text','append_text'].map(name=>({name,link:null}));this.properties={};this.size=[1100,700];}
addWidget(type,name,value,callback,options){const widget={type,name,value,callback,options};this.widgets.push(widget);return widget;}
addDOMWidget(name,type,element){const host=document.createElement('div');host.style.cssText='height:700px;width:1100px;max-width:100%';host.className='fixture-dom-widget';host.append(element);document.body.append(host);return {element};}
setSize(size){this.size=size;}setDirtyCanvas(){}
@@ -124,46 +123,6 @@ test('a superseded library response never replaces the latest pair selection',as
await expect(page.locator('.pm-editor-result')).not.toContainText('Stale positive');
});
test('paired editor offers positive and negative link sockets and keeps both typed texts (#343)',async({page})=>{
await draft(page,'Typed positive','Typed negative');
expect(await page.evaluate(()=>node.inputs.filter(input=>!input.widget).map(input=>input.name))).toEqual(['clip','prepend_text','append_text','text','negative_text']);
const target=()=>page.evaluate(()=>node.findInputByType('STRING').slot.name);
expect(await target()).toBe('text');
const positive=page.getByRole('textbox',{name:'Positive prompt',exact:true}),negative=page.getByRole('textbox',{name:'Negative prompt',exact:true});
const positiveNote=page.getByText('Positive prompt comes from a link. Your typed text is kept for when you disconnect it.',{exact:true});
const negativeNote=page.getByText('Negative prompt comes from a link. Your typed text is kept for when you disconnect it.',{exact:true});
const link=(name,value)=>page.evaluate(({name,value})=>{node.inputs.find(input=>input.name===name).link=value;node.onConnectionsChange();},{name,value});
for(const [linked,next] of [[['text'],'negative_text'],[['negative_text'],'text'],[['text','negative_text'],'prepend_text'],[[],'text']]){
for(const name of ['text','negative_text'])await link(name,linked.includes(name)?30:null);
expect(await target()).toBe(next);
await expect(positive).toBeVisible({visible:!linked.includes('text')});
await expect(negative).toBeVisible({visible:!linked.includes('negative_text')});
await expect(positiveNote).toBeVisible({visible:linked.includes('text')});
await expect(negativeNote).toBeVisible({visible:linked.includes('negative_text')});
const saved=await page.evaluate(()=>{const saved=node.serialize();for(const input of node.inputs)if(['text','negative_text'].includes(input.name))input.widget={name:input.name};node.configure(saved);return saved.widgets_values.slice(0,2);});
expect(saved).toEqual(['Typed positive','Typed negative']);
expect(await page.evaluate(()=>node.inputs.filter(input=>input.widget&&['text','negative_text'].includes(input.name)).length)).toBe(0);
}
await expect(positive).toHaveValue('Typed positive');await expect(negative).toHaveValue('Typed negative');
});
test('a link dropped on either prompt box, or its note, fills that box (#343 review M1)',async({page})=>{
const keys=await page.evaluate(()=>[...document.querySelectorAll('.pm-pair .lg-node-widget [data-slot-key]')].map(key=>key.dataset.slotKey));
// Nodes 2.0 resolves a drop through the nearest .lg-node-widget and its slot key: positive box and note, negative box and note.
expect(keys).toEqual(['1-in-3','1-in-3','1-in-4','1-in-4']);
const at=(name,connecting=true)=>page.evaluate(({name,connecting})=>{
fixtureApp.canvas={linkConnector:{state:{connectingTo:connecting?'input':undefined}},ds:{convertOffsetToCanvas:point=>point},canvas:{getBoundingClientRect:()=>({left:0,top:0})}};
const box=[...document.querySelectorAll('.pm-pair .lg-node-widget')].find(element=>element.getClientRects().length&&element.textContent.includes(name)).getBoundingClientRect();
return node.getInputOnPos([box.left+box.width/2,box.top+box.height/2])?.name??null;
},{name,connecting});
expect(await at('Negative prompt')).toBe('negative_text');
expect(await at('Positive prompt')).toBe('text');
expect(await at('Negative prompt',false)).toBeNull();
await page.evaluate(()=>{node.inputs.find(input=>input.name==='negative_text').link=4;node.onConnectionsChange();});
expect(await at('Negative prompt comes from a link')).toBe('negative_text');
expect(await page.evaluate(()=>{fixtureApp.canvas={};return node.getInputOnPos([1,1])??null;})).toBeNull();
});
test('queue hook synchronizes both editors, preserves links and forwards branch execution arguments',async({page})=>{
await draft(page);
const args=await page.evaluate(async()=>{
+1 -17
View File
@@ -72,8 +72,6 @@ class TestModel:
# Scored names the concept comparisons answer instead of the fixed list
# (``/__test__/tagging/suggestions``); None keeps the fixed list.
suggestions: list | None = None
# The engine's model table (``py/autotag.py`` MODELS), set at startup.
models_config: dict = {}
def get_models_status(self) -> dict:
return {
@@ -81,7 +79,7 @@ class TestModel:
"downloaded": name not in self.unavailable,
"loaded": self.loaded == name,
}
for name in self.models_config
for name in ("gguf", "hf", "wd14-vit", "wd14-convnext", "wd14-swinv2")
}
def is_model_loaded(self) -> bool:
@@ -168,20 +166,7 @@ def register_reveal_stub(routes, api, root: Path):
return types.SimpleNamespace(reset=reset)
def isolate_lora_manager_settings(root: Path) -> Path:
"""LoraManager's settings lookup reads an empty folder under ``root`` (#355).
The server's temporary root is removed when it exits, and with it this
folder: no page test reads the developer's real LoraManager settings.
"""
folder = root / "lora-manager-settings"
folder.mkdir(exist_ok=True)
os.environ["LORA_MANAGER_SETTINGS_DIR"] = str(folder)
return folder
def create_app(root: Path) -> web.Application:
isolate_lora_manager_settings(root)
os.environ["PROMPT_MANAGER_CONFIG_PATH"] = str(root / "config.json")
os.chdir(root)
app = web.Application()
@@ -209,7 +194,6 @@ def create_app(root: Path) -> web.Application:
from py import autotag
from utils.hashing import generate_prompt_hash
TestModel.models_config = autotag.MODELS
model = TestModel()
autotag.get_autotag_service = lambda: model
api = PromptManagerAPI()
@@ -270,8 +270,7 @@ function mapImportConcept(reference, plan) {
title: 'Review LoRA import',
tree: ['h3', 'dl.meta-grid', 'details', 'p.small', 'div.notice', 'div.dialog-actions'],
heading: { ...reference.heading, text: 'LoRA reference data' },
grid: { ...reference.grid, rows: [['LoraManager', 'environment settings · alongside storage · library default'],
['Scope', plan.scope.label], ['Found', '2 metadata files · 2 LoRAs · 0 with local images'],
grid: { ...reference.grid, rows: [['Scope', plan.scope.label], ['Found', '2 metadata files · 2 readable'],
['Protected', 'Nothing is protected'], ['Network', 'Downloads up to 2 example images from CivitAI'],
['Backup', `Create and verify before changes (${plan.backup.destination_label})`],
['Media on disk', 'No files are removed; image links are replaced']] },
@@ -313,7 +312,7 @@ async function productionReview(page, request, theme) {
for (const theme of THEMES) for (const [width, height] of SIZES) {
test(`LoRA import review matches the concept ${theme} ${width}x${height}`, async ({ page, request }, testInfo) => {
await page.setViewportSize({ width, height });
await fakeLoraManager(request, { loras: 2, civitai: true, preview: false });
await fakeLoraManager(request, { loras: 2, civitai: true });
const concept = await conceptReview(page, theme);
expect(concept.tree).toEqual(['p.demo-note', 'h3', 'dl.meta-grid', 'p.small', 'div.dialog-actions']);
await page.screenshot({ path: testInfo.outputPath(`concept-plan-review-${theme}-${width}.png`) });
@@ -325,15 +324,15 @@ for (const theme of THEMES) for (const [width, height] of SIZES) {
test('LoRA import review comparison detects mutations', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
await fakeLoraManager(request, { loras: 2, civitai: true, preview: false });
await fakeLoraManager(request, { loras: 2, civitai: true });
const concept = await conceptReview(page, 'dark');
const { plan } = await productionReview(page, request, 'dark');
const expected = mapImportConcept(concept, plan);
const paths = async () => diffInventory(expected, await page.evaluate(measureReview)).map(item => item.path);
expect(await paths()).toEqual([]);
await page.evaluate(() => { document.querySelectorAll('dialog#modal[open] dl.meta-grid dt')[4].textContent = 'Downloads'; });
expect(await paths()).toEqual(['grid.rows.4.0']);
await page.evaluate(() => { document.querySelectorAll('dialog#modal[open] dl.meta-grid dt')[4].textContent = 'Network'; });
await page.evaluate(() => { document.querySelectorAll('dialog#modal[open] dl.meta-grid dt')[3].textContent = 'Downloads'; });
expect(await paths()).toEqual(['grid.rows.3.0']);
await page.evaluate(() => { document.querySelectorAll('dialog#modal[open] dl.meta-grid dt')[3].textContent = 'Network'; });
const undoGrid = await mutate(page, { css: 'dialog#modal dl.meta-grid { column-gap: 3px; }' });
expect(await paths()).toEqual(expect.arrayContaining(['grid.columnGap']));
await undoGrid();
@@ -1,385 +0,0 @@
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
import { SIZES, THEMES, seedConcept, openConcept, openProduction, diffInventory, mutate } from './concept-inventory.mjs';
// #328 System S11 Task 8: System › Auto-tagging (app.js renderSystem 685–686) and the Tag output images review against
// the immutable concept, on the concept-shaped fixture with the fake tagging model (every engine downloaded, none
// loaded). Each concept measurement is mapped only by the recorded deviations (D132, D133, D134), each commented where
// it applies; after that the difference must be empty, and a mutated label, option, order, class, select width or
// token must be noticed. The concept has no batch review, so the Tag output images review is compared with the
// concept's own record-changing review, `maintenancePlan` (953–958), whose shell, grid, note and actions it shares.
test.use({ locale: 'en-US', timezoneId: 'UTC' });
test.beforeEach(async ({ page, request, browserName }) => {
await startCoverage(page, browserName);
// The tagging reset restores the fake model (nothing loaded, every engine on disk) and the saved tagging defaults;
// the concept fixture then replaces its rows and files.
await expect(await request.post('/__test__/tagging/reset', { data: {} })).toBeOK();
await seedConcept(request);
await expect(await request.post('/__test__/system/reset')).toBeOK();
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
page.conceptErrors = [];
page.on('pageerror', error => page.conceptErrors.push(error.message));
});
test.afterEach(async ({ page, request, browserName }) => {
await saveCoverage(page, browserName);
await request.post('/__test__/jobs/restart');
await request.post('/__test__/system/reset');
await request.post('/__test__/tagging/reset', { data: {} });
expect(page.conceptErrors).toEqual([]);
});
const PREVIEW_URL = '/prompt_manager/autotag/batch/preview';
const MODEL_OPTIONS = ['WD14 SwinV2 · ~400 MB', 'WD14 ViT · ~400 MB', 'GGUF JoyCaption · ~4.5 GB', 'HuggingFace JoyCaption · ~16 GB'];
const SUGGESTIONS = 'From 1 to 100: the most suggestions Generate shows for one image.';
const BATCH = 'Add tags to each prompt from its linked output images with the model above. Review the scope first.';
/** Runs in the page: Auto-tagging's surface under `mainSelector`: rows, field, actions, operation, geometry and tokens. */
function measureTagging(mainSelector) {
const layout = document.querySelector(`${mainSelector} .settings-layout`);
const css = (node, ...keys) => node ? Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]])) : null;
const name = node => [node.localName, ...[...node.classList].sort()].join('.');
const text = node => node?.textContent.trim() ?? null;
const control = row => {
const select = row.querySelector(':scope > select');
if (select) return { kind: 'select', id: select.id, options: [...select.options].map(option => [option.textContent, option.disabled]),
selected: select.selectedOptions[0]?.textContent ?? null, disabled: select.disabled };
const input = row.querySelector(':scope > input');
return input ? { kind: `input.${input.type}`, id: input.id, value: input.value, disabled: input.disabled } : null;
};
const surface = layout.querySelector(':scope > section.surface');
const firstRow = surface.querySelector('.setting-row');
const field = surface.querySelector(':scope > .field');
const actions = surface.querySelector(':scope > .row-actions');
return {
layout: [...layout.children].map(name),
tree: [...surface.children].map(name),
heading: text(surface.querySelector(':scope > h2')),
intro: text(surface.querySelector(':scope > p')),
// Per row: whether it may wrap, and whether its control sits under the text (a wrapped row) or beside it.
rows: [...surface.querySelectorAll(':scope > .setting-row')].map(row => ({ tree: [...row.children].map(name),
text: [...row.firstElementChild.children].map(node => [name(node), text(node), node.localName === 'label' ? node.htmlFor : null]),
control: control(row), flexWrap: getComputedStyle(row).flexWrap,
controlBelow: row.lastElementChild.getBoundingClientRect().top >= row.firstElementChild.getBoundingClientRect().bottom - 0.5 })),
field: { tree: [...field.children].map(name), label: [text(field.querySelector('label')), field.querySelector('label').htmlFor],
textarea: { id: field.querySelector('textarea').id, value: field.querySelector('textarea').value,
...css(field.querySelector('textarea'), 'fontSize', 'minHeight', 'backgroundColor', 'color', 'borderTopColor') },
...css(field, 'display', 'rowGap', 'fontSize', 'marginTop', 'marginBottom') },
actions: { buttons: [...actions.children].map(item => [text(item), item.className]),
...css(actions, 'display', 'flexWrap', 'columnGap', 'justifyContent'),
primary: css(actions.querySelector('.ca-primary'), 'backgroundColor', 'color', 'fontSize', 'paddingTop', 'paddingLeft'),
plain: css(actions.querySelector('button:not(.ca-primary)'), 'backgroundColor', 'color', 'fontSize', 'borderTopColor') },
operations: [...surface.querySelectorAll(':scope > .operation')].map(row => ({ tree: [...row.children].map(name),
title: text(row.querySelector('h3')), description: text(row.querySelector('p')),
actions: [...row.querySelectorAll('.row-actions > button')].map(item => [text(item), item.className]) })),
surface: css(surface, 'paddingTop', 'paddingLeft', 'backgroundColor', 'borderTopWidth', 'borderTopColor', 'borderTopLeftRadius'),
row: css(firstRow, 'display', 'alignItems', 'justifyContent', 'columnGap', 'paddingTop', 'paddingBottom', 'borderTopWidth',
'borderTopColor', 'marginTop'),
label: css(firstRow.querySelector('label'), 'fontSize', 'fontWeight', 'color'),
description: css(firstRow.querySelector('p'), 'fontSize', 'color', 'marginTop', 'maxWidth'),
select: css(firstRow.querySelector('select'), 'fontSize', 'minWidth', 'backgroundColor', 'color', 'borderTopColor'),
number: css(surface.querySelector('.setting-row input[type="number"]'), 'fontSize', 'minWidth', 'maxWidth', 'backgroundColor',
'color', 'borderTopColor'),
// Whether each select is as wide as the browser lays out a copy holding only its chosen option, i.e. it shows
// that option whole (the browser's own select sizing, arrow included).
selectsFit: [...surface.querySelectorAll('.setting-row > select')].map(select => {
const copy = select.cloneNode(false);
copy.removeAttribute('id');
copy.append(new Option(select.selectedOptions[0]?.textContent ?? ''));
// Important inline values: the phone cap is an !important rule (concept.css), which a plain inline style loses to.
for (const [key, value] of [['position', 'absolute'], ['visibility', 'hidden'], ['width', 'max-content'], ['min-width', '0'],
['max-width', 'none'], ['flex', 'none']]) copy.style.setProperty(key, value, 'important');
select.parentElement.append(copy);
const needed = copy.getBoundingClientRect().width;
copy.remove();
return select.getBoundingClientRect().width + 0.5 >= needed;
}),
pageScroll: document.documentElement.scrollWidth > document.documentElement.clientWidth,
};
}
/**
* The concept's Auto-tagging with the recorded deviations applied, for the fixture (all engines on disk, none loaded).
* `phone` is a viewport at the concept's 700px breakpoint or below.
*/
function mapTaggingConcept(reference, { template, phone }) {
const [model, existing, general, character] = reference.rows;
const [preview] = reference.actions.buttons;
return {
...reference,
// D133 adds a row after the thresholds; D134 adds the operation after the actions.
tree: [...reference.tree.slice(0, 6), 'div.setting-row', ...reference.tree.slice(6), 'div.operation'],
rows: [
// D132 (OQ5, OQ6): the four real engines with their download size, and the chosen one's live status.
{ ...model, text: [model.text[0], ['p', 'Downloaded. Not loaded.', null]],
control: { ...model.control, options: MODEL_OPTIONS.map(label => [label, false]), selected: MODEL_OPTIONS[0] },
// D132: at phone width the row wraps, so the engine select sits whole under its text.
...(phone ? { flexWrap: 'wrap', controlBelow: true } : {}) },
existing, general, character,
// D133: the persisted suggestion count, on the concept's number row.
{ ...general, text: [['label', 'Suggestions per image', 'tag-suggestions'], ['p', SUGGESTIONS, null]],
control: { ...general.control, id: 'tag-suggestions', value: '10' } },
],
// The saved template (the server's default until one is saved) replaces the concept's sample text.
field: { ...reference.field, textarea: { ...reference.field.textarea, value: template } },
// D132: Download (for an engine already on disk, "Download again") and Unload replace the two simulated jobs.
actions: { ...reference.actions, buttons: [preview, ['Download again', ''], ['Unload', '']] },
// D134: the prompt-tag batch, reviewed first.
operations: [{ tree: ['div', 'div.row-actions'], title: 'Tag output images', description: BATCH, actions: [['Review', '']] }],
// D132: the engine select shows its whole chosen option at every width (at 390 the concept's 120px cap cuts it;
// `#tag-model` alone may grow there). Existing tags keeps the concept's own fit, cut or not.
selectsFit: reference.selectsFit.map((fit, index) => (index === 0 ? true : fit)),
};
}
async function conceptTagging(page, theme) {
await openConcept(page, 'system', theme);
await page.locator('#main .section-nav').getByRole('button', { name: 'Auto-tagging', exact: true }).click();
await expect(page.locator('#main #tag-template')).toBeVisible();
await page.mouse.move(0, 0);
return page.evaluate(measureTagging, '#main');
}
async function productionTagging(page, theme) {
await openProduction(page, 'system/tagging', theme);
// Compare once the settings and the model status answered.
await expect(page.locator('#workspace-main #tag-model')).toBeEnabled();
await expect(page.locator('#workspace-main .setting-row').first().locator('p')).toHaveText('Downloaded. Not loaded.');
await page.mouse.move(0, 0);
return page.evaluate(measureTagging, '#workspace-main');
}
async function savedTemplate(request, page) {
const response = await request.get('/prompt_manager/settings');
await expect(response).toBeOK();
return { template: (await response.json()).settings.autotag_template, phone: page.viewportSize().width <= 700 };
}
for (const theme of THEMES) for (const [width, height] of SIZES) {
test(`auto-tagging matches the concept ${theme} ${width}x${height}`, async ({ page, request }, testInfo) => {
await page.setViewportSize({ width, height });
const concept = await conceptTagging(page, theme);
await page.screenshot({ path: testInfo.outputPath(`concept-tagging-${theme}-${width}.png`), fullPage: true });
const production = await productionTagging(page, theme);
await page.screenshot({ path: testInfo.outputPath(`production-tagging-${theme}-${width}.png`), fullPage: true });
expect(diffInventory(mapTaggingConcept(concept, await savedTemplate(request, page)), production)).toEqual([]);
expect(production.pageScroll).toBe(false);
});
}
// D132 below the concept's smallest size: on a 320px phone the engine select still shows its whole option.
for (const theme of THEMES) test(`auto-tagging engine select fits at 320 wide ${theme}`, async ({ page }) => {
await page.setViewportSize({ width: 320, height: 700 });
const production = await productionTagging(page, theme);
for (const model of ['hf', 'gguf', 'wd14-swinv2']) {
await page.locator('#workspace-main #tag-model').selectOption(model);
expect((await page.evaluate(measureTagging, '#workspace-main')).selectsFit[0], model).toBe(true);
}
expect(production.pageScroll).toBe(false);
});
test('auto-tagging comparison detects mutations', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const concept = await conceptTagging(page, 'dark');
const saved = await savedTemplate(request, page);
const expected = mapTaggingConcept(concept, saved);
const production = await productionTagging(page, 'dark');
expect(diffInventory(expected, production)).toEqual([]);
// Unmapped, the section differs only where D132, D133 and D134 are recorded (and the saved template's text).
expect(diffInventory(concept, production).map(item => item.path.split('.').slice(0, 3).join('.'))
.filter((path, index, all) => all.indexOf(path) === index).sort()).toEqual([
'actions.buttons.1', 'actions.buttons.2', 'field.textarea.value', 'operations.0.actions', 'operations.0.description',
'operations.0.title', 'operations.0.tree', 'operations.length', 'rows.0.control', 'rows.0.text', 'rows.4.control', 'rows.4.controlBelow',
'rows.4.flexWrap', 'rows.4.text', 'rows.4.tree', 'rows.length', 'tree.6', 'tree.7', 'tree.8', 'tree.9', 'tree.length'].sort());
const measure = () => page.evaluate(measureTagging, '#workspace-main');
const paths = async () => diffInventory(expected, await measure()).map(item => item.path);
// A removed row.
const undoRow = await mutate(page, { remove: ['#workspace-main section.surface > .setting-row:nth-of-type(2)'] });
expect(await paths()).toEqual(expect.arrayContaining(['rows.length', 'tree.length']));
await undoRow();
// A renamed label.
await page.evaluate(() => { document.querySelector('#workspace-main label[for="tag-existing"]').textContent = 'Existing'; });
expect(await paths()).toEqual(['rows.1.text.0.1']);
await page.evaluate(() => { document.querySelector('#workspace-main label[for="tag-existing"]').textContent = 'Existing tags'; });
// A reordered option.
await page.evaluate(() => { const select = document.querySelector('#workspace-main #tag-existing'); select.append(select.options[0]); });
expect(await paths()).toEqual(expect.arrayContaining(['rows.1.control.options.0.0']));
await page.evaluate(() => { const select = document.querySelector('#workspace-main #tag-existing'); select.prepend(select.options[2]); });
// ca-primary moved to another action.
await page.evaluate(() => {
const [first, second] = document.querySelectorAll('#workspace-main section.surface > .row-actions > button');
first.classList.remove('ca-primary');
second.classList.add('ca-primary');
});
expect(await paths()).toEqual(expect.arrayContaining(['actions.buttons.0.1', 'actions.buttons.1.1']));
await page.evaluate(() => {
const [first, second] = document.querySelectorAll('#workspace-main section.surface > .row-actions > button');
second.classList.remove('ca-primary');
first.classList.add('ca-primary');
});
// A select squeezed until its chosen option is cut.
const undoSqueeze = await mutate(page, { css: '#workspace-main #tag-model { max-width: 60px !important; min-width: 0 !important; }' });
expect(await paths()).toEqual(expect.arrayContaining(['selectsFit.0']));
await undoSqueeze();
// A row that wraps where the concept's does not.
const undoWrap = await mutate(page, { css: '#workspace-main .setting-row:has(> #tag-existing) { flex-wrap: wrap; }' });
expect(await paths()).toEqual(['rows.1.flexWrap']);
await undoWrap();
// A swapped token.
const undoToken = await mutate(page, { css: '#workspace-main .settings-layout { --line: rgb(255, 0, 0); }' });
expect(await paths()).toEqual(expect.arrayContaining(['row.borderTopColor']));
await undoToken();
expect(diffInventory(expected, await measure())).toEqual([]);
});
// D132 at phone width, unmapped: only the engine row wraps; Existing tags keeps the concept's cut select.
test('auto-tagging at 390 wraps only the engine row', async ({ page, request }) => {
await page.setViewportSize({ width: 390, height: 844 });
const concept = await conceptTagging(page, 'dark');
const production = await productionTagging(page, 'dark');
expect(diffInventory(mapTaggingConcept(concept, await savedTemplate(request, page)), production)).toEqual([]);
expect(production.rows.map(row => row.flexWrap)).toEqual(['wrap', 'nowrap', 'nowrap', 'nowrap', 'nowrap']);
expect(production.selectsFit[1]).toBe(concept.selectsFit[1]);
});
/** Runs in the page: the open review dialog: its title, body and the concept's tokens. */
function measureReview() {
const dialog = document.querySelector('dialog#modal[open]');
const css = (node, ...keys) => node ? Object.fromEntries(keys.map(key => [key, getComputedStyle(node)[key]])) : null;
const name = node => [node.localName, ...[...node.classList].sort()].join('.');
const text = node => node?.textContent.trim() ?? null;
const body = dialog.querySelector('#modal-body');
const grid = body.querySelector(':scope > dl.meta-grid');
const note = [...body.querySelectorAll(':scope > p.small')].at(-1);
const actions = body.querySelector(':scope > .dialog-actions');
return {
title: text(dialog.querySelector('#modal-title')),
tree: [...body.children].map(name),
heading: { text: text(body.querySelector(':scope > h3')),
...css(body.querySelector(':scope > h3'), 'fontSize', 'fontWeight', 'color', 'marginBottom') },
grid: { rows: [...grid.children].map(row => [text(row.querySelector('dt')), text(row.querySelector('dd'))]),
...css(grid, 'display', 'gridTemplateColumns', 'columnGap', 'rowGap'),
dt: css(grid.querySelector('dt'), 'fontSize', 'color', 'textTransform'), dd: css(grid.querySelector('dd'), 'fontSize', 'color') },
backup: body.querySelector(':scope > .notice') && { tree: [...body.querySelector(':scope > .notice').children].map(name),
text: [...body.querySelectorAll(':scope > .notice > div:first-child > *')].map(node => [node.localName, text(node)]),
buttons: [...body.querySelectorAll(':scope > .notice button')].map(text) },
note: { text: text(note), ...css(note, 'fontSize', 'color', 'marginTop') },
actions: { tree: [...actions.children].map(name), ...css(actions, 'display', 'justifyContent', 'columnGap', 'marginTop'),
buttons: [...actions.children].map(item => ({ text: text(item), className: item.className,
...css(item, 'backgroundColor', 'color', 'fontSize', 'paddingTop', 'paddingLeft', 'borderTopColor') })) },
};
}
const number = value => value.toLocaleString('en-US');
/**
* D134: the server's batch preview in the concept's review: "Review tag output images" with "Tag output images" as its
* `h3`, the model, policy, counts and settings in the concept's `dl.meta-grid` (Backup in the concept's wording), the
* upper-bound sentence in the concept's `p.small`, the D55 backup box and Cancel / "Start tagging".
*/
function mapBatchConcept(reference, plan) {
const [cancel, start] = reference.actions.buttons;
return {
...reference,
title: 'Review tag output images',
tree: ['h3', 'dl.meta-grid', 'p.small', 'div.notice', 'div.dialog-actions'],
heading: { ...reference.heading, text: 'Tag output images' },
grid: { ...reference.grid, rows: [['Model', 'WD14 SwinV2 · ~400 MB'], ['Existing tags', 'Merge with existing'],
['Images linked', number(plan.images)], ['Prompts', number(plan.prompts)], ['Already tagged', number(plan.already_tagged)],
['Prompts to tag', `up to ${number(plan.to_tag)}`], ['Settings', 'General 0.35 · Character 0.85'],
['Backup', 'Create and verify before changes']] },
backup: { tree: ['div', 'div.row-actions'], text: [['h3', 'Back up the database first (recommended)'],
['p', 'Downloads a copy of the prompts database before this operation changes any records.']], buttons: ['Back up now'] },
note: { ...reference.note, text: 'Nothing is written until you start. Missing files and files outside the output folders '
+ 'are counted, so the prompts to tag are an upper bound; Activity reports what was tagged.' },
actions: { ...reference.actions, buttons: [cancel, { ...start, text: 'Start tagging' }] },
};
}
async function conceptReview(page, theme) {
await openConcept(page, 'system', theme);
await page.locator('#main .section-nav').getByRole('button', { name: 'Maintenance', exact: true }).click();
await page.locator('#main [data-plan="duplicate prompts"]').click();
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Review maintenance plan');
await page.mouse.move(0, 0);
return page.evaluate(measureReview);
}
async function productionReview(page, theme) {
await productionTagging(page, theme);
// Every concept-fixture prompt with an image is already tagged, so under the saved Skip there is nothing to start
// (the Cancel-only review). Merge reviews a batch with work: the full review the concept's shell is compared with.
await page.locator('#workspace-main #tag-existing').selectOption('merge');
const answer = page.waitForResponse(response => response.url().endsWith(PREVIEW_URL));
await page.locator('#workspace-main .operation').getByRole('button', { name: 'Review', exact: true }).click();
const plan = await (await answer).json();
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Review tag output images');
await page.mouse.move(0, 0);
return { plan, production: await page.evaluate(measureReview) };
}
for (const theme of THEMES) for (const [width, height] of SIZES) {
test(`tag output images review matches the concept ${theme} ${width}x${height}`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height });
const concept = await conceptReview(page, theme);
expect(concept.tree).toEqual(['p.demo-note', 'h3', 'dl.meta-grid', 'p.small', 'div.dialog-actions']);
await page.screenshot({ path: testInfo.outputPath(`concept-plan-review-${theme}-${width}.png`) });
const { plan, production } = await productionReview(page, theme);
// Under Merge every prompt with a linked image is tagged again: the batch has work to review.
expect(plan.to_tag).toBeGreaterThan(0);
await page.screenshot({ path: testInfo.outputPath(`production-tagging-review-${theme}-${width}.png`) });
expect(diffInventory(mapBatchConcept(concept, plan), production)).toEqual([]);
});
}
/**
* D134 (CUJ 5): Replace after review never runs as a batch: the same review shell with the counts, the reason as its
* `p.small`, no backup box and only Cancel (the shared close-only review Integrations also uses).
*/
function mapCancelOnlyConcept(reference, plan) {
const [cancel] = reference.actions.buttons;
const mapped = mapBatchConcept(reference, plan);
return {
...mapped,
tree: ['h3', 'dl.meta-grid', 'p.small', 'div.dialog-actions'],
grid: { ...mapped.grid, rows: mapped.grid.rows.map(([term, value]) => [term, term === 'Existing tags' ? 'Replace after review' : value]) },
backup: null,
note: { ...reference.note, text: 'Replacing tags needs a review of each image. Use Preview tagging review, or choose Skip or '
+ 'Merge for a batch run.' },
actions: { ...reference.actions, tree: [reference.actions.tree[0]], buttons: [cancel] },
};
}
test('the Cancel-only review matches the concept review shell', async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const concept = await conceptReview(page, 'dark');
await productionTagging(page, 'dark');
await page.locator('#workspace-main #tag-existing').selectOption('replace_after_review');
const answer = page.waitForResponse(response => response.url().endsWith(PREVIEW_URL));
await page.locator('#workspace-main .operation').getByRole('button', { name: 'Review', exact: true }).click();
const plan = await (await answer).json();
expect(plan.review_required).toBe(true);
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Review tag output images');
await page.mouse.move(0, 0);
expect(diffInventory(mapCancelOnlyConcept(concept, plan), await page.evaluate(measureReview))).toEqual([]);
});
test('tag output images review comparison detects mutations', async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const concept = await conceptReview(page, 'dark');
const { plan } = await productionReview(page, 'dark');
const expected = mapBatchConcept(concept, plan);
const paths = async () => diffInventory(expected, await page.evaluate(measureReview)).map(item => item.path);
expect(await paths()).toEqual([]);
await page.evaluate(() => { document.querySelectorAll('dialog#modal[open] dl.meta-grid dt')[5].textContent = 'To tag'; });
expect(await paths()).toEqual(['grid.rows.5.0']);
await page.evaluate(() => { document.querySelectorAll('dialog#modal[open] dl.meta-grid dt')[5].textContent = 'Prompts to tag'; });
const undoGrid = await mutate(page, { css: 'dialog#modal dl.meta-grid { column-gap: 3px; }' });
expect(await paths()).toEqual(expect.arrayContaining(['grid.columnGap']));
await undoGrid();
const undoBox = await mutate(page, { remove: ['dialog#modal[open] #modal-body > .notice'] });
expect(await paths()).toEqual(expect.arrayContaining(['backup', 'tree.length']));
await undoBox();
expect(await paths()).toEqual([]);
});
+1 -1
View File
@@ -236,7 +236,7 @@ test('paired Tag dialog captures', async ({ page, request }, testInfo) => {
await expect(await request.post('/__test__/tagging/models', { data: { unavailable: ['wd14-swinv2'] } })).toBeOK();
await openTagDialog(page, 'production', 'dark');
await generateWith(page, 'production').catch(() => {});
await expect(status()).toHaveText('WD14 SwinV2 is not downloaded. Download it in System › Auto-tagging.');
await expect(status()).toHaveText('WD14 SwinV2 is not downloaded. Download it from the legacy Auto-tag page.');
await shot('production-unavailable');
await expect(await request.post('/__test__/tagging/models', { data: { unavailable: [] } })).toBeOK();
+10 -15
View File
@@ -257,18 +257,6 @@ async function chooseRestore(page, file) {
await page.locator('#restoreFileInput').setInputFiles(file);
}
// Confirms the chosen restore and waits for the server's answer, not for a timer: a restore is disk-bound and can take
// longer than an expect timeout on a slow runner (#359). The modal closes only after that answer.
async function confirmRestoreAndWait(page) {
const restored = page.waitForResponse(response => response.url().endsWith('/prompt_manager/restore'));
await page.locator('#confirmRestore').click();
const response = await restored;
const body = await response.text();
expect(response.status(), body).toBe(200);
expect(JSON.parse(body)).toMatchObject({ success: true, prompt_count: 2, backup_created: expect.any(String) });
await expect(page.locator('#restoreModal')).toBeHidden();
}
function confirmNextDialog(page, accept, message) {
page.once('dialog', async dialog => {
expect(dialog.type()).toBe('confirm');
@@ -299,7 +287,12 @@ test('downloaded backup restores deleted prompts, tags, and image links through
expect(await promptSnapshot(request)).not.toEqual(before);
await chooseRestore(page, backup);
confirmNextDialog(page, true, backup.name);
await confirmRestoreAndWait(page);
const restored = page.waitForResponse(response => response.url().endsWith('/prompt_manager/restore'));
await page.locator('#confirmRestore').click();
const response = await restored;
expect(response.ok()).toBe(true);
expect(await response.json()).toMatchObject({ success: true, prompt_count: 2, backup_created: expect.any(String) });
await expect(page.locator('#restoreModal')).toBeHidden();
await expect(page.locator('.prompt-text')).toHaveCount(2);
await page.reload();
await expect(page.locator('.prompt-text').filter({ hasText: 'Cat portrait' })).toBeVisible();
@@ -342,7 +335,8 @@ test('restore validation and cancellation preserve live data and allow a valid r
expect(await promptSnapshot(request)).toEqual(before);
await page.locator('#restoreFileInput').setInputFiles(backup);
confirmNextDialog(page, true, backup.name);
await confirmRestoreAndWait(page);
await page.locator('#confirmRestore').click();
await expect(page.locator('#restoreModal')).toBeHidden();
expect(await promptSnapshot(request)).toEqual(before);
});
@@ -362,7 +356,8 @@ test('backup and restore recover from interrupted network requests without losin
await expect(page.locator('#restoreModal')).toBeVisible();
expect(await promptSnapshot(request)).toEqual(before);
confirmNextDialog(page, true, backup.name);
await confirmRestoreAndWait(page);
await page.locator('#confirmRestore').click();
await expect(page.locator('#restoreModal')).toBeHidden();
expect(await promptSnapshot(request)).toEqual(before);
});
-30
View File
@@ -1088,36 +1088,6 @@ test('a held legacy job and the health check show no tiles, no Results and no ma
await expect(toast(page)).toHaveText('Health check finished.');
});
// #328: a tagging model download is a job of its own kind with no byte progress and no Cancel; Activity shows it
// through the generic held view (its title, status line and an indeterminate bar), then its end toast.
test('a held model download shows its title, an indeterminate bar and no Cancel; its end toast follows', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const title = 'Tagging model download · WD14 ViT';
await expect(await request.post('/__test__/tagging/reset')).toBeOK();
await expect(await request.post('/__test__/tagging/download', { data: { mode: 'hold' } })).toBeOK();
try {
const response = await request.post('/prompt_manager/jobs/model-download', { data: { model_type: 'wd14-vit' } });
expect(response.status()).toBe(202);
const job = (await response.json()).job;
expect([job.kind, job.title, job.cancellable]).toEqual(['model_download', title, false]);
await page.goto(`${workspace}#/activity`);
const item = items(page).filter({ has: page.getByRole('link', { name: new RegExp(`${title}$`) }) });
await expect(item.locator('progress')).not.toHaveAttribute('value');
await expect(item.getByRole('button', { name: 'Cancel job', exact: true })).toHaveCount(0);
await item.getByRole('link').click();
await expect(heading(page).locator('h1')).toHaveText(title);
await expect(progressSection(page).locator('.panel-heading > span.small')).toHaveText('Downloading WD14 ViT (~400 MB)');
await expect(progressSection(page).locator('progress')).not.toHaveAttribute('value');
await expect(progressSection(page).locator('dl.stat-row')).toHaveCount(0);
await expect(heading(page).getByRole('button', { name: /^Cancel/ })).toHaveCount(0);
await expect(await request.post('/__test__/tagging/download', { data: { mode: 'succeed' } })).toBeOK();
await expect(toast(page)).toHaveText(`${title} finished.`);
await expect(heading(page).locator('p.muted')).toHaveText(/^Finished \d\d:\d\d$/);
} finally {
await request.post('/__test__/tagging/reset');
}
});
// S5 review I3: a thumbnail job with nothing to generate ends on the server's final line ("No previews to generate · N
// already up to date"), never on a stale "Processing file 0 of 0" under the Completed chip.
test('a finished thumbnail job with nothing to do says so', async ({ page, request }) => {
@@ -485,6 +485,5 @@ test('a safety backup file (no .db extension) can be picked and reviewed (M3)',
const answer = await chooseBackup(page, bytes, 'prompts.db.backup_20261025_093000_000000');
expect((await answer).status()).toBe(200);
await expect(dialog(page).locator('#modal-title')).toHaveText('Restore preview');
// The copy's size depends on what earlier tests on this server wrote: KB or MB.
expect((await gridOf(page))[0][1]).toMatch(/^prompts\.db\.backup_20261025_093000_000000 · [\d,.]+ (KB|MB) · .+$/);
expect((await gridOf(page))[0][1]).toMatch(/^prompts\.db\.backup_20261025_093000_000000 · \d+ KB · .+$/);
});
@@ -322,7 +322,7 @@ test('a partial save made from Integrations keeps its outcome when Sources opens
test('Review import shows replacement, blocked items and network effects with the backup box; Start opens the job (D107)',
async ({ page, request }) => {
await lora(request, { loras: 3, civitai: true, preview: false, previous: 2 });
await lora(request, { loras: 3, civitai: true, previous: 2 });
const previews = [];
const applies = [];
page.on('request', item => {
@@ -339,17 +339,13 @@ test('Review import shows replacement, blocked items and network effects with th
await expect(dialog(page).locator('#modal-body > h3')).toHaveText('LoRA reference data');
expect(previews).toEqual([{}]);
expect(await gridOf(page)).toEqual([
['LoraManager', 'environment settings · alongside storage · library default'],
['Scope', 'Replace 2 imported prompts with 3 LoRAs'],
['Found', '3 metadata files · 3 LoRAs · 0 with local images'],
['Found', '3 metadata files · 3 readable'],
['Protected', 'Nothing is protected'],
['Network', 'Downloads up to 3 example images from CivitAI'],
['Backup', 'Create and verify before changes (browser.db.backup_<date and time>)'],
['Media on disk', 'No files are removed; image links are replaced'],
]);
// #355: the settings file the server read is the LoraManager row's hover title.
await expect(dialog(page).locator('dl.meta-grid > div').first().locator('dd'))
.toHaveAttribute('title', /lora-manager-settings[\/\\]settings\.json$/);
await expect(dialog(page).locator('details > summary')).toHaveText('LoRAs found (3)');
await expect(dialog(page).locator('.notice h3')).toHaveText('Back up the database first (recommended)');
await expect(dialog(page).locator('p.small').last()).toContainText('stay in the image cache');
@@ -385,8 +381,7 @@ test('saved pairs block the import: the review explains it and offers only Close
await openIntegrations(page);
await enable(page);
await layoutOf(page).getByRole('button', { name: 'Review import', exact: true }).click();
// gridOf reads once; poll until the review has rendered its rows.
await expect.poll(async () => (await gridOf(page))[3]).toEqual(['Protected', '1 imported prompt used by saved pairs']);
expect((await gridOf(page))[2]).toEqual(['Protected', '1 imported prompt used by saved pairs']);
await expect(dialog(page).locator('p.small')).toHaveText(
'Delete or change the saved pairs that use these prompts, then review the import again. Nothing was changed.');
expect(await dialog(page).locator('.dialog-actions button').allTextContents()).toEqual(['Close']);
@@ -394,51 +389,6 @@ test('saved pairs block the import: the review explains it and offers only Close
await expect(dialog(page)).toHaveCount(0);
});
test('unusable LoraManager settings: the review says why and offers only Close (#355)', async ({ page, request }) => {
await lora(request, { previous: 1, settings: 'invalid' });
await openIntegrations(page);
await enable(page);
const before = await serverJson(request, '/__test__/integrations/state');
await layoutOf(page).getByRole('button', { name: 'Review import', exact: true }).click();
await expect.poll(async () => (await gridOf(page))[0])
.toEqual(['LoraManager', 'Not usable: the settings file is not valid JSON']);
await expect(dialog(page).locator('p.small')).toHaveText(
'LoraManager settings: not usable: the settings file is not valid JSON. Nothing was changed.');
expect(await dialog(page).locator('.dialog-actions button').allTextContents()).toEqual(['Close']);
await dialog(page).getByRole('button', { name: 'Close', exact: true }).click();
await expect(dialog(page)).toHaveCount(0);
expect(await serverJson(request, '/__test__/integrations/state')).toEqual(before);
});
test('imported LoRA previews show on the Library card and open in the inspector and viewer (#355 G6)', async ({ page, request }) => {
await lora(request, { loras: 1 });
await openIntegrations(page);
await enable(page);
await layoutOf(page).getByRole('button', { name: 'Review import', exact: true }).click();
await dialog(page).getByRole('button', { name: 'Start import', exact: true }).click();
await expect(page).toHaveURL(/#\/activity\/[0-9a-f]{32}$/);
expect((await ended(request, page.url().split('/').pop())).state).toBe('finished');
await page.goto(`${workspace}#/library`);
const card = page.locator('#workspace-main article.prompt-card').filter({ hasText: 'Style 01' });
const cover = card.locator('.card-art img');
await expect(cover).toHaveAttribute('src', /\/prompt_manager\/images\/serve\/style_01\.png\?root=\d+&root_scope=[0-9a-f]{64}$/);
await expect.poll(() => cover.evaluate(node => node.complete && node.naturalWidth)).toBeGreaterThan(0);
await expect(card.locator('.card-art')).not.toContainText('No available image previews');
// The prompt's gallery: Info inspects the file and the viewer opens it; no Tag action or star for LoRA media.
await card.getByRole('button', { name: 'Explore history', exact: true }).click();
const item = page.getByRole('region', { name: 'Shared gallery', exact: true }).locator('article[data-image-key]').first();
await expect(item.getByRole('button', { name: 'Tag or auto-tag style_01.png', exact: true })).toHaveCount(0);
await expect(item.locator('.star-button')).toHaveCount(0);
await item.getByRole('button', { name: 'Inspect style_01.png', exact: true }).click();
const inspector = page.getByRole('complementary', { name: 'Selected image details', exact: true });
await expect(inspector).toContainText('style_01.png');
await item.getByRole('button', { name: 'Open style_01.png', exact: true }).click();
await expect(page.locator('dialog[open] .viewer-canvas img')).toBeVisible();
await expect.poll(() => page.locator('dialog[open] .viewer-canvas img').evaluate(node => node.complete && node.naturalWidth))
.toBeGreaterThan(0);
await page.keyboard.press('Escape');
});
test('a failed import keeps old imports and its job says so', async ({ page, request }) => {
await lora(request, { loras: 2, previous: 2 });
await openIntegrations(page);
@@ -457,7 +407,7 @@ test('a failed import keeps old imports and its job says so', async ({ page, req
});
test('the import can be cancelled while it downloads; then nothing is written', async ({ page, request }) => {
await lora(request, { loras: 2, civitai: true, preview: false, previous: 1 });
await lora(request, { loras: 2, civitai: true, previous: 1 });
await openIntegrations(page);
await enable(page);
const before = await serverJson(request, '/__test__/integrations/state');
@@ -429,9 +429,7 @@ test('Rebuild all is explicit and the plan line says every preview is regenerate
await expect(page).toHaveURL(/#\/activity\/[0-9a-f]{32}$/);
expect(seen.starts).toEqual([{ mode: 'rebuild' }]);
const id = page.url().split('/').pop();
// Rebuild regenerates all 24 previews with real image work: on a busy CI
// runner that took over 5 s (12 of 24 done at the default poll budget).
await expect.poll(async () => (await serverJson(request, `/prompt_manager/jobs/${id}`)).job.state, { timeout: 30000 }).toBe('finished');
await expect.poll(async () => (await serverJson(request, `/prompt_manager/jobs/${id}`)).job.state).toBe('finished');
});
test('a plan that cannot be read says so; Start still starts', async ({ page }) => {
@@ -1,385 +0,0 @@
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
// System › Auto-tagging (#328, System S11 Tasks 6–7) on the tagging fixture (workspace_tagging_fixture.py) with the
// injected fake model: no model file exists and nothing is downloaded or run for real. The fake download waits on the
// fixture (`/__test__/tagging/download {mode}`), and every reset releases it. Every test asserts that no legacy
// download stream (`/autotag/download/`) was requested: the section downloads only through the Activity job.
const workspace = '/prompt_manager/workspace';
const MODELS_URL = '/prompt_manager/autotag/models';
const PREVIEW_URL = '/prompt_manager/autotag/batch/preview';
const SETTINGS_URL = '/prompt_manager/settings';
const MODEL_LABELS = ['WD14 SwinV2 · ~400 MB', 'WD14 ViT · ~400 MB', 'GGUF JoyCaption · ~4.5 GB', 'HuggingFace JoyCaption · ~16 GB'];
const FIRST = 'tagging/dunes_00001.png';
const SECOND = 'tagging/dunes_00002.png';
test.beforeEach(async ({ page, request, browserName }) => {
await startCoverage(page, browserName);
await expect(await request.post('/__test__/tagging/reset', { data: {} })).toBeOK();
await expect(await request.post('/__test__/system/reset')).toBeOK();
await expect(await request.post('/__test__/jobs/restart')).toBeOK();
page.systemErrors = [];
page.on('pageerror', error => page.systemErrors.push(error.message));
page.downloads = [];
page.modelCalls = [];
page.on('request', item => {
const { pathname } = new URL(item.url());
if (pathname.includes('/autotag/download/')) page.downloads.push(pathname);
if (pathname.startsWith('/prompt_manager/autotag/') || pathname.startsWith('/prompt_manager/image-annotations/suggest')
|| pathname.startsWith('/prompt_manager/jobs/model-download')) page.modelCalls.push(`${item.method()} ${pathname}`);
});
});
test.afterEach(async ({ page, request, browserName }) => {
await saveCoverage(page, browserName);
// The reset releases a held fake download, so no runner outlives the test.
await request.post('/__test__/tagging/reset', { data: {} });
await request.post('/__test__/jobs/restart');
await request.post('/__test__/system/reset');
expect(page.systemErrors).toEqual([]);
expect(page.downloads).toEqual([]);
});
const toast = page => page.locator('#workspace-toast');
const layoutOf = page => page.locator('#workspace-main .settings-layout');
const dialog = page => page.locator('dialog#modal[open]');
const status = page => page.locator('#settings-status');
const save = page => page.getByRole('button', { name: 'Save preferences', exact: true });
const surface = page => layoutOf(page).locator(':scope > section.surface');
const settingOf = (page, label) => layoutOf(page).locator('.setting-row').filter({ has: page.locator(`label:text-is("${label}")`) });
const modelRow = page => settingOf(page, 'Tagging model');
const actionOf = (page, name) => surface(page).locator(':scope > .row-actions').getByRole('button', { name, exact: true });
const reviewButton = page => surface(page).locator('.operation').getByRole('button', { name: 'Review', exact: true });
const card = (page, path) => page.locator(`article[data-image-key="0:${path}"]`);
const tagDialog = page => page.getByRole('dialog', { name: 'Tag image', exact: true });
const gridOf = page => dialog(page).locator('dl.meta-grid > div').evaluateAll(items => items.map(item =>
[item.querySelector('dt').textContent, item.querySelector('dd').textContent]));
async function serverJson(request, url) {
const response = await request.get(url);
await expect(response).toBeOK();
return response.json();
}
async function fakeDownload(request, mode) {
await expect(await request.post('/__test__/tagging/download', { data: { mode } })).toBeOK();
}
async function unavailable(request, models) {
await expect(await request.post('/__test__/tagging/models', { data: { unavailable: models } })).toBeOK();
}
async function ended(request, id) {
await expect.poll(async () => (await serverJson(request, `/prompt_manager/jobs/${id}`)).job.state).not.toBe('running');
return (await serverJson(request, `/prompt_manager/jobs/${id}`)).job;
}
/** System › Auto-tagging on a fresh page (the drafts live for the page session, Q10), settled on the model status. */
async function openTagging(page) {
if (page.url().endsWith('#/system/tagging')) await page.reload();
else await page.goto(`${workspace}#/system/tagging`);
await expect(surface(page)).toHaveCount(1);
await expect(page.locator('#tag-model')).toBeEnabled();
await expect(modelRow(page).locator('p').first()).not.toHaveText('Loading model status…');
}
/** Select `path` in Images (the concept's `state.selectedImage`), then open System › Auto-tagging from the sidebar. */
async function selectThenOpen(page, path) {
await page.goto(`${workspace}#/images`);
await card(page, path).getByRole('button', { name: `Inspect ${path.split('/').pop()}`, exact: true }).click();
await expect(card(page, path)).toHaveClass(/\bselected\b/);
await page.locator('aside.sidebar a[href="#/system"]').click();
await page.locator('#workspace-main .section-nav').getByRole('button', { name: 'Auto-tagging', exact: true }).click();
await expect(page.locator('#tag-model')).toBeEnabled();
await expect(modelRow(page).locator('p').first()).not.toHaveText('Loading model status…');
}
test('model options are the four real engines with size and the description shows live status', async ({ page, request }) => {
await unavailable(request, ['wd14-vit', 'hf']);
await openTagging(page);
expect(await surface(page).locator(':scope > h2').textContent()).toBe('Auto-tagging');
expect(await page.locator('#tag-model option').allTextContents()).toEqual(MODEL_LABELS);
await expect(page.locator('#tag-model')).toHaveValue('wd14-swinv2');
const line = modelRow(page).locator('p').first();
await expect(line).toHaveText('Downloaded. Not loaded.');
await expect(actionOf(page, 'Download again')).toBeVisible();
// Choosing another engine drafts it and tells its own status; nothing is downloaded or loaded.
await page.locator('#tag-model').selectOption('wd14-vit');
await expect(line).toHaveText('Not downloaded. Download size ~400 MB.');
await expect(actionOf(page, 'Download')).toBeVisible();
await page.locator('#tag-model').selectOption('hf');
await expect(line).toHaveText('Not downloaded. Download size ~16 GB.');
await expect(status(page)).toHaveText('Unsaved changes.');
// The other rows keep the concept's copy and the saved values (D133 adds Suggestions per image).
expect(await surface(page).locator(':scope > .setting-row label').allTextContents()).toEqual(['Tagging model', 'Existing tags',
'General-tag confidence', 'Character-tag confidence', 'Suggestions per image']);
await expect(page.locator('#tag-existing')).toHaveValue('skip');
await expect(page.locator('#threshold-general')).toHaveValue('0.35');
await expect(page.locator('#threshold-character')).toHaveValue('0.85');
await expect(page.locator('#tag-suggestions')).toHaveValue('10');
const settings = await serverJson(request, SETTINGS_URL);
await expect(page.locator('#tag-template')).toHaveValue(settings.settings.autotag_template);
// Unload is offered only while a model is loaded (none is).
await expect(actionOf(page, 'Unload')).toBeDisabled();
});
test('entering the section makes no model call besides status', async ({ page }) => {
await openTagging(page);
await page.locator('#workspace-main .section-nav').getByRole('button', { name: 'Integrations', exact: true }).click();
await page.locator('#workspace-main .section-nav').getByRole('button', { name: 'Auto-tagging', exact: true }).click();
await expect(page.locator('#tag-model')).toBeEnabled();
await expect(modelRow(page).locator('p').first()).toHaveText('Downloaded. Not loaded.');
// One status read per mount (two mounts), and nothing else that could load, run or download a model.
expect(page.modelCalls).toEqual([`GET ${MODELS_URL}`, `GET ${MODELS_URL}`]);
const models = await (await page.request.get(MODELS_URL)).json();
expect(models.model_loaded).toBe(false);
});
test('defaults save through Save preferences and the Tag image dialog uses them', async ({ page, request }) => {
await openTagging(page);
await page.locator('#tag-existing').selectOption('merge');
await page.locator('#threshold-general').fill('0.4');
await page.locator('#threshold-general').press('Tab');
await page.locator('#tag-suggestions').fill('15');
await page.locator('#tag-suggestions').press('Tab');
const posted = page.waitForRequest(item => item.method() === 'POST' && new URL(item.url()).pathname === SETTINGS_URL);
await save(page).click();
const body = (await posted).postDataJSON();
expect(body).toMatchObject({ autotag_existing: 'merge', autotag_general_threshold: 0.4, autotag_suggestion_limit: 15 });
expect(typeof body.expected_revision).toBe('string');
await expect(status(page)).toHaveText('Saved to the server configuration.');
const saved = (await serverJson(request, SETTINGS_URL)).settings;
expect([saved.autotag_existing, saved.autotag_general_threshold, saved.autotag_character_threshold, saved.autotag_suggestion_limit])
.toEqual(['merge', 0.4, 0.85, 15]);
const models = await serverJson(request, MODELS_URL);
expect([models.existing, models.wd14_general_threshold, models.suggestion_limit]).toEqual(['merge', 0.4, 15]);
// The Tag image dialog in a fresh page starts from them.
await page.goto(`${workspace}#/images`);
await card(page, FIRST).getByRole('button', { name: `Tag or auto-tag ${FIRST.split('/').pop()}`, exact: true }).click();
await expect(tagDialog(page)).toBeVisible();
await expect(tagDialog(page).locator('#image-general-threshold')).toHaveValue('0.4');
await expect(tagDialog(page).locator('#image-character-threshold')).toHaveValue('0.85');
await expect(tagDialog(page).locator('#image-suggestion-limit')).toHaveValue('15');
});
test('a refused value shows the server\'s message', async ({ page, request }) => {
const before = (await serverJson(request, SETTINGS_URL)).settings;
// An image is selected, so Preview tagging review is held back only by the invalid value below.
await selectThenOpen(page, FIRST);
await page.locator('#threshold-general').fill('1.5');
await page.locator('#threshold-general').press('Tab');
await save(page).click();
await expect(status(page)).toHaveText('General-tag confidence must be between 0 and 1.');
await expect(page.locator('#threshold-general')).toHaveAttribute('aria-invalid', 'true');
// Nothing was written; the draft is kept for the next Save.
expect((await serverJson(request, SETTINGS_URL)).settings.autotag_general_threshold).toBe(before.autotag_general_threshold);
await expect(page.locator('#threshold-general')).toHaveValue('1.5');
// While the value is invalid, Preview tagging review is aria-disabled and explains instead of opening (Playwright
// waits on an aria-disabled control, so it is pressed from the keyboard, as a user can).
await expect(actionOf(page, 'Preview tagging review')).toHaveAttribute('aria-disabled', 'true');
await actionOf(page, 'Preview tagging review').focus();
await page.keyboard.press('Enter');
await expect(toast(page)).toHaveText('General-tag confidence must be between 0 and 1.');
await expect(tagDialog(page)).toHaveCount(0);
await page.locator('#threshold-general').fill('0.5');
await page.locator('#threshold-general').press('Tab');
await expect(page.locator('#threshold-general')).not.toHaveAttribute('aria-invalid', 'true');
await expect(status(page)).toHaveText('Unsaved changes.');
await expect(actionOf(page, 'Preview tagging review')).not.toHaveAttribute('aria-disabled', 'true');
});
test('a value off the 0.05 step is refused and never reaches the Tag image dialog (#369)', async ({ page, request }) => {
const before = (await serverJson(request, SETTINGS_URL)).settings;
const STEP = 'General-tag confidence must be a multiple of 0.05.';
await selectThenOpen(page, FIRST);
await page.locator('#threshold-general').fill('0.37');
await page.locator('#threshold-general').press('Tab');
// Preview tagging review explains instead of opening a dialog whose Generate the browser would block.
await expect(actionOf(page, 'Preview tagging review')).toHaveAttribute('aria-disabled', 'true');
await actionOf(page, 'Preview tagging review').focus();
await page.keyboard.press('Enter');
await expect(toast(page)).toHaveText(STEP);
await expect(tagDialog(page)).toHaveCount(0);
// The server refuses the save with the same sentence and writes nothing.
await save(page).click();
await expect(status(page)).toHaveText(STEP);
expect((await serverJson(request, SETTINGS_URL)).settings.autotag_general_threshold).toBe(before.autotag_general_threshold);
// A value on the step opens the dialog, and its Generate passes the browser's own check and sends.
await page.locator('#threshold-general').fill('0.4');
await page.locator('#threshold-general').press('Tab');
await expect(actionOf(page, 'Preview tagging review')).not.toHaveAttribute('aria-disabled', 'true');
await actionOf(page, 'Preview tagging review').click();
await expect(tagDialog(page).locator('#image-general-threshold')).toHaveValue('0.4');
expect(await tagDialog(page).locator('#image-general-threshold').evaluate(input => input.checkValidity())).toBe(true);
const suggested = page.waitForResponse(item => item.url().endsWith('/image-annotations/suggest'));
await tagDialog(page).getByRole('button', { name: 'Generate suggestions', exact: true }).click();
const answer = await suggested;
expect(answer.request().postDataJSON().options.general_threshold).toBe(0.4);
});
test('Download starts the job and Activity shows it without Cancel', async ({ page, request }) => {
await unavailable(request, ['wd14-vit']);
await fakeDownload(request, 'hold');
await openTagging(page);
await page.locator('#tag-model').selectOption('wd14-vit');
const started = page.waitForResponse(item => new URL(item.url()).pathname === '/prompt_manager/jobs/model-download');
await actionOf(page, 'Download').click();
const answer = await started;
expect(answer.status()).toBe(202);
expect(answer.request().postDataJSON()).toEqual({ model_type: 'wd14-vit' });
const { job } = await answer.json();
expect(job.title).toBe('Tagging model download · WD14 ViT');
await expect(toast(page)).toHaveText('Tagging model download · WD14 ViT started.');
await expect(page).toHaveURL(`${workspace}#/activity/${job.id}`);
const main = page.locator('#workspace-main');
await expect(main.locator('h1')).toHaveText('Tagging model download · WD14 ViT');
await expect(main.getByRole('button', { name: /^Cancel/ })).toHaveCount(0);
// Indeterminate: the server knows no byte progress.
await expect(main.locator('progress').first()).toBeVisible();
expect(await main.locator('progress').first().evaluate(node => node.hasAttribute('value'))).toBe(false);
await fakeDownload(request, 'succeed');
expect((await ended(request, job.id)).state).toBe('finished');
await expect(toast(page)).toHaveText('Tagging model download · WD14 ViT finished.');
// Back in the section, the engine reads as on disk (the fake marks it downloaded on success).
await page.goto(`${workspace}#/system/tagging`);
await expect(page.locator('#tag-model')).toBeEnabled();
await page.locator('#tag-model').selectOption('wd14-vit');
await expect(modelRow(page).locator('p').first()).toHaveText('Downloaded. Not loaded.');
expect(page.modelCalls.filter(call => call.startsWith('POST'))).toEqual(['POST /prompt_manager/jobs/model-download']);
});
test('Unload is enabled only while a model is loaded, and a held gate\'s refusal names the operation', async ({ page, request }) => {
await openTagging(page);
await expect(actionOf(page, 'Unload')).toBeDisabled();
// Load WD14 SwinV2 the way a user does: Generate suggestions in the Tag image dialog.
await page.goto(`${workspace}#/images`);
await card(page, FIRST).getByRole('button', { name: `Tag or auto-tag ${FIRST.split('/').pop()}`, exact: true }).click();
await expect(tagDialog(page)).toBeVisible();
const suggested = page.waitForResponse(item => item.url().endsWith('/image-annotations/suggest'));
await tagDialog(page).getByRole('button', { name: 'Generate suggestions', exact: true }).click();
expect((await suggested).status()).toBe(200);
await tagDialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
await openTagging(page);
await expect(modelRow(page).locator('p').first()).toHaveText('Downloaded and loaded.');
await expect(actionOf(page, 'Unload')).toBeEnabled();
// A download holds the model gate: Unload is refused and the toast names it; nothing is unloaded.
await unavailable(request, ['wd14-vit']);
await fakeDownload(request, 'hold');
const held = await request.post('/prompt_manager/jobs/model-download', { data: { model_type: 'wd14-vit' } });
expect(held.status()).toBe(202);
const { job } = await held.json();
const refused = page.waitForResponse(item => item.url().endsWith('/autotag/unload'));
await actionOf(page, 'Unload').click();
const refusal = await refused;
expect(refusal.status()).toBe(409);
expect(await refusal.json()).toMatchObject({ code: 'model-busy', operation: 'Tagging model download · WD14 ViT' });
await expect(toast(page)).toHaveText('The tagging model is busy: Tagging model download · WD14 ViT. Try again when it finishes.');
expect((await serverJson(request, MODELS_URL)).loaded_model_type).toBe('wd14-swinv2');
await expect(actionOf(page, 'Unload')).toBeEnabled();
// Once the download ended, Unload works, says so and the row follows.
await fakeDownload(request, 'succeed');
await ended(request, job.id);
await actionOf(page, 'Unload').click();
await expect(toast(page)).toHaveText('WD14 SwinV2 unloaded.');
await expect(modelRow(page).locator('p').first()).toHaveText('Downloaded. Not loaded.');
await expect(actionOf(page, 'Unload')).toBeDisabled();
});
test('Preview tagging review opens the Tag image dialog for the selected image', async ({ page }) => {
await selectThenOpen(page, SECOND);
// The section's current (unsaved) values are what the dialog starts from.
await page.locator('#threshold-character').fill('0.6');
await page.locator('#threshold-character').press('Tab');
await page.locator('#tag-model').selectOption('wd14-vit');
const preview = actionOf(page, 'Preview tagging review');
await expect(preview).toHaveClass(/ca-primary/);
await expect(preview).not.toHaveAttribute('aria-disabled', 'true');
const observed = page.waitForResponse(item => item.url().endsWith('/image-annotations/observe'));
await preview.click();
expect((await observed).status()).toBe(200);
await expect(tagDialog(page)).toBeVisible();
await expect(tagDialog(page).locator('.tag-image-heading p.small')).toHaveText('dunes_00002.png');
await expect(tagDialog(page).getByRole('combobox', { name: 'Tagging model', exact: true })).toHaveValue('wd14-vit');
await expect(tagDialog(page).locator('#image-character-threshold')).toHaveValue('0.6');
await expect(tagDialog(page).locator('#image-general-threshold')).toHaveValue('0.35');
});
test('Preview tagging review without a selection toasts the D115 sentence', async ({ page }) => {
await openTagging(page);
const preview = actionOf(page, 'Preview tagging review');
await expect(preview).toHaveAttribute('aria-disabled', 'true');
// An aria-disabled control still takes a press; it only explains.
await preview.focus();
await page.keyboard.press('Enter');
await expect(toast(page)).toHaveText('Select an image in Images first.');
await expect(tagDialog(page)).toHaveCount(0);
});
test('Tag output images reviews the scope and starts a batch job', async ({ page, request }) => {
const before = await serverJson(request, '/__test__/tagging/snapshot');
await openTagging(page);
// The fixture's one prompt is already tagged: under Merge it is tagged again with what its images give.
await page.locator('#tag-existing').selectOption('merge');
const previewed = page.waitForResponse(item => new URL(item.url()).pathname === PREVIEW_URL);
await reviewButton(page).click();
const answer = await previewed;
expect(answer.request().postDataJSON()).toEqual({ model_type: 'wd14-swinv2', existing: 'merge' });
const plan = await answer.json();
await expect(dialog(page).locator('#modal-title')).toHaveText('Review tag output images');
await expect(dialog(page).locator('#modal-body > h3')).toHaveText('Tag output images');
expect(await gridOf(page)).toEqual([['Model', 'WD14 SwinV2 · ~400 MB'], ['Existing tags', 'Merge with existing'],
['Images linked', String(plan.images)], ['Prompts', String(plan.prompts)], ['Already tagged', String(plan.already_tagged)],
['Prompts to tag', `up to ${plan.to_tag}`], ['Settings', 'General 0.35 · Character 0.85'],
['Backup', 'Create and verify before changes']]);
await expect(dialog(page).locator('#modal-body > .notice h3')).toHaveText('Back up the database first (recommended)');
// The review wrote nothing.
expect(await serverJson(request, '/__test__/tagging/snapshot')).toEqual(before);
await expect(dialog(page).locator('.dialog-actions button')).toHaveText(['Cancel', 'Start tagging']);
const started = page.waitForResponse(item => new URL(item.url()).pathname === '/prompt_manager/jobs/autotag');
await dialog(page).getByRole('button', { name: 'Start tagging', exact: true }).click();
const start = await started;
expect(start.request().postDataJSON()).toEqual({ model_type: 'wd14-swinv2', existing: 'merge',
options: { general_threshold: 0.35, character_threshold: 0.85 } });
const { job } = await start.json();
await expect(page).toHaveURL(`${workspace}#/activity/${job.id}`);
await expect(page.locator('#workspace-main h1')).toHaveText('Tag output images');
const finished = await ended(request, job.id);
expect(finished.state).toBe('finished');
const after = await serverJson(request, '/__test__/tagging/snapshot');
expect(after.prompt_tags.length).toBeGreaterThan(before.prompt_tags.length);
});
test('Replace after review shows only Cancel with the reason', async ({ page, request }) => {
const before = await serverJson(request, '/__test__/tagging/snapshot');
await openTagging(page);
await page.locator('#tag-existing').selectOption('replace_after_review');
await reviewButton(page).click();
await expect(dialog(page).locator('#modal-title')).toHaveText('Review tag output images');
expect((await gridOf(page)).map(([term]) => term)).toEqual(['Model', 'Existing tags', 'Images linked', 'Prompts', 'Already tagged',
'Prompts to tag', 'Settings', 'Backup']);
await expect(dialog(page).locator('#modal-body > p.small')).toHaveText('Replacing tags needs a review of each image. '
+ 'Use Preview tagging review, or choose Skip or Merge for a batch run.');
await expect(dialog(page).locator('.dialog-actions button')).toHaveText(['Cancel']);
await dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(dialog(page)).toHaveCount(0);
// Under Skip the fixture's only prompt is already tagged: nothing to start either.
await page.locator('#tag-existing').selectOption('skip');
await reviewButton(page).click();
await expect(dialog(page).locator('#modal-body > p.small')).toHaveText('Every prompt with an output image is already tagged.');
await expect(dialog(page).locator('.dialog-actions button')).toHaveText(['Cancel']);
expect(await serverJson(request, '/__test__/tagging/snapshot')).toEqual(before);
expect((await serverJson(request, '/prompt_manager/jobs')).jobs.filter(item => item.kind === 'autotag')).toEqual([]);
});
test('a model that is not downloaded cannot be reviewed for a batch; the button says why', async ({ page, request }) => {
await unavailable(request, ['gguf']);
await openTagging(page);
await page.locator('#tag-model').selectOption('gguf');
await expect(reviewButton(page)).toHaveAttribute('aria-disabled', 'true');
// Playwright waits on an aria-disabled control: press it from the keyboard, as a user can.
await reviewButton(page).focus();
await page.keyboard.press('Enter');
await expect(toast(page)).toHaveText('GGUF JoyCaption is not downloaded. Download it first.');
await expect(dialog(page)).toHaveCount(0);
expect(page.modelCalls.filter(call => call.includes('/batch/preview'))).toEqual([]);
});
+3 -3
View File
@@ -412,11 +412,11 @@ test('out-of-order overview responses never repaint a newer section', async ({ p
await nav.getByRole('button', { name: 'Appearance', exact: true }).click();
await nav.getByRole('button', { name: 'Overview', exact: true }).click();
await expect.poll(held).toBe(2);
// Diagnostics still hands off (S12), so its body is one known panel.
await nav.getByRole('button', { name: 'Diagnostics & logs', exact: true }).click();
// Auto-tagging still hands off (S11), so its body is one known panel.
await nav.getByRole('button', { name: 'Auto-tagging', exact: true }).click();
await expect(await request.post('/__test__/release')).toBeOK();
await expect.poll(held).toBe(0);
// Both late answers reached a section that had left: Diagnostics keeps its own body.
// Both late answers reached a section that had left: Auto-tagging keeps its own body.
await expect(layout.locator(':scope > section.handoff')).toBeVisible();
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
await expect(layout.locator('.summary-metrics')).toHaveCount(0);
@@ -17,7 +17,7 @@ const chips = page => modal(page).locator('#tag-suggestions .chip.tag-suggestion
const actionButtons = page => modal(page).locator('.dialog-actions button');
const panel = page => page.getByRole('complementary', { name: 'Selected image details', exact: true });
const tagRow = page => panel(page).locator('.detail-inner > .tags').last();
const NOT_DOWNLOADED = 'WD14 SwinV2 is not downloaded. Download it in System › Auto-tagging.';
const NOT_DOWNLOADED = 'WD14 SwinV2 is not downloaded. Download it from the legacy Auto-tag page.';
test.beforeEach(async ({ page, request, browserName }) => {
await startCoverage(page, browserName);
+1 -1
View File
@@ -246,7 +246,7 @@ test('unavailable model shows inline status and never downloads', async ({ page,
await expect(await request.post('/__test__/tagging/models', { data: { unavailable: ['wd14-swinv2'] } })).toBeOK();
await openTag(page);
expect((await generated(page)).status()).toBe(409);
await expect(status(page)).toHaveText('WD14 SwinV2 is not downloaded. Download it in System › Auto-tagging.');
await expect(status(page)).toHaveText('WD14 SwinV2 is not downloaded. Download it from the legacy Auto-tag page.');
await expect(chips(page)).toHaveCount(0);
await expect(dialog(page).locator('#suggestion-count')).toHaveText('Not generated');
// D116: Retry leads the actions while the controller allows it, and generates again.
+5 -7
View File
@@ -413,7 +413,7 @@ for (const width of [1600, 390]) test(`overview and handoff render with concept
await sidebarLink(page, 'System').click();
// System's Overview is native (S2 Task 6); a section not delivered yet still hands off (D94).
await main.locator('.section-nav').getByRole('button', { name: 'Diagnostics & logs', exact: true }).click();
await main.locator('.section-nav').getByRole('button', { name: 'Auto-tagging', exact: true }).click();
const handoff = main.locator('section.handoff');
await expect(handoff).toHaveClass(/\bsurface\b/);
expect(await handoff.evaluate(surfaceStyle)).toMatchObject(SURFACE);
@@ -465,9 +465,8 @@ test('System is native: the concept shell, section navigation, sections, hand-of
// S2 Task 6: Overview is native (workspace-system.spec.mjs), and so are Appearance (S3 Task 8) and Sources & scans
// (S4 Task 11, workspace-system-sources.spec.mjs, its update notice shown in a fresh browser), Integrations (S10
// Task 27, workspace-system-integrations.spec.mjs), Maintenance (S7 Task 19,
// workspace-system-maintenance.spec.mjs), Backup & export (S9 Task 24, workspace-system-backups.spec.mjs) and
// Auto-tagging (S11 Task 6, workspace-system-tagging.spec.mjs); every other section hands off.
if (hash === 'tagging') await expect(layout.locator('#tag-model')).toBeVisible();
// workspace-system-maintenance.spec.mjs) and Backup & export (S9 Task 24, workspace-system-backups.spec.mjs);
// every other section hands off.
if (hash === 'overview') await expect(layout.locator(':scope > .summary-metrics')).toBeVisible();
if (hash === 'sources') await expect(layout.locator('#source-list .operation')).toHaveCount(2);
expect(await layout.evaluate(node => [...node.children].map(child => `${child.localName}.${child.className}`)))
@@ -475,10 +474,9 @@ test('System is native: the concept shell, section navigation, sections, hand-of
sources: ['div.notice', 'section.surface', 'div.save-row'],
integrations: ['section.surface', 'section.surface', 'div.save-row'],
maintenance: ['div.notice', 'section.surface', 'section.surface', 'div.save-row'],
backups: ['div.two-col', 'section.surface', 'div.save-row'],
tagging: ['section.surface', 'div.save-row'] }[hash]
backups: ['div.two-col', 'section.surface', 'div.save-row'] }[hash]
?? ['section.surface handoff', 'div.save-row']);
if (['overview', 'appearance', 'sources', 'integrations', 'tagging', 'maintenance', 'backups'].includes(hash)) continue;
if (['overview', 'appearance', 'sources', 'integrations', 'maintenance', 'backups'].includes(hash)) continue;
const handoff = layout.locator('section.handoff');
await expect(handoff.locator('h2')).toHaveText('Your current tools are ready');
expect(await handoff.locator('a.workspace-link').evaluateAll(links => links.map(link => link.getAttribute('href'))))
@@ -7,18 +7,13 @@ reaches a real LoraManager, ComfyUI install or the network:
no key, no trigger words), removes the fake install, its LoRA folder and
the image cache, forgets every LoRA import and injected failure, and opens
the download gate;
- ``POST /__test__/integrations/lora-manager``
``{loras, civitai, preview, previous, settings}`` writes
``custom_nodes/ComfyUI-Lora-Manager`` (detected through the
``folder_paths`` stub's base path) and ``loras`` LoRAs under
``models/loras``: each a ``<stem>.safetensors`` model beside its
``<stem>.metadata.json`` sidecar (LoraManager's alongside layout, #355)
and, unless ``preview`` is false, a local preview PNG; ``civitai`` adds one
CivitAI example image per LoRA (served by the stub opener below; an import
downloads it only for a LoRA without a local preview); ``previous`` saves
that many earlier ``lora-manager`` imports with tags and image links;
``settings: "invalid"`` makes LoraManager's settings file unreadable JSON
(the server's own isolated settings folder, ``LORA_MANAGER_SETTINGS_DIR``);
- ``POST /__test__/integrations/lora-manager`` ``{loras, civitai, previous}``
writes ``custom_nodes/ComfyUI-Lora-Manager`` (detected through the
``folder_paths`` stub's base path) and ``loras`` metadata files under
``models/loras``, each with a local preview PNG; ``civitai`` adds one
CivitAI example image per LoRA (served by the stub opener below);
``previous`` saves that many earlier ``lora-manager`` imports with tags and
image links;
- ``POST /__test__/integrations/gate`` ``{hold}`` holds every CivitAI
download until it is released (``{"hold": false}``), so a test sees the job
while it downloads;
@@ -35,7 +30,6 @@ test ever opens a network connection.
import io
import json
import os
import shutil
import threading
from pathlib import Path
@@ -97,11 +91,7 @@ def register_integrations_commands(app, routes, api, root: Path):
type(api.db).replace_category_prompts = replace
def settings_file():
return Path(os.environ["LORA_MANAGER_SETTINGS_DIR"]) / "settings.json"
def reset():
settings_file().unlink(missing_ok=True)
IntegrationConfig.LORA_MANAGER_ENABLED = False
IntegrationConfig.LORA_MANAGER_PATH = ""
IntegrationConfig.LORA_TRIGGER_WORDS_ENABLED = False
@@ -128,17 +118,11 @@ def register_integrations_commands(app, routes, api, root: Path):
data = await request.json() if request.can_read_body else {}
count = data.get("loras", 2)
previous = data.get("previous", 0)
preview = data.get("preview", True)
settings = data.get("settings")
for value in (count, previous):
if isinstance(value, bool) or not isinstance(value, int):
raise web.HTTPBadRequest()
if not 0 <= count <= MAX_LORAS or not 0 <= previous <= MAX_LORAS:
raise web.HTTPBadRequest()
if not isinstance(preview, bool) or settings not in (None, "invalid"):
raise web.HTTPBadRequest()
if settings == "invalid":
settings_file().write_text("{not json", encoding="utf-8")
(lora_manager / "py").mkdir(parents=True, exist_ok=True)
(lora_manager / "__init__.py").write_text("")
loras.mkdir(parents=True, exist_ok=True)
@@ -157,10 +141,8 @@ def register_integrations_commands(app, routes, api, root: Path):
"model_name": f"Style {number + 1:02}",
"civitai": civitai,
}
(loras / f"{stem}.safetensors").write_bytes(b"model")
(loras / f"{stem}.metadata.json").write_text(json.dumps(meta))
if preview:
(loras / f"{stem}.png").write_bytes(_png())
(loras / f"{stem}.png").write_bytes(_png())
for number in range(previous):
text = f"previous lora import {number + 1}"
prompt_id = api.db.save_prompt(
+17 -20
View File
@@ -97,25 +97,15 @@ def register_system_commands(app, routes, api, root: Path):
real_move = library_move.move_library
keep = PromptManagerConfig.KEEP_LIBRARY_IN_NODE_FOLDER
config_bytes = config_path.read_bytes() if config_path.exists() else None
from py.api.settings_routes import (
_AUTOTAG_SETTINGS,
_GALLERY_SETTINGS,
_SETTINGS_FILE_KEYS,
host_values = (
PromptManagerConfig.RESULT_TIMEOUT,
PromptManagerConfig.WEBUI_DISPLAY_MODE,
PromptManagerConfig.WORKER_THREADS,
GalleryConfig.MONITORING_ENABLED,
PromptManagerConfig.THUMBNAIL_FORMAT,
PromptManagerConfig.THUMBNAIL_QUALITY,
PromptManagerConfig.THUMBNAIL_EDGE,
)
# Every value POST /settings can change, so a reset restores each one
# (a saved safety_backup=false once leaked into a later spec's review).
# The tagging settings are the tagging fixture's to reset.
host_values = {
(GalleryConfig if name in _GALLERY_SETTINGS else PromptManagerConfig, name): (
getattr(
GalleryConfig if name in _GALLERY_SETTINGS else PromptManagerConfig,
name,
)
)
for name in _SETTINGS_FILE_KEYS
if name not in _AUTOTAG_SETTINGS
}
from py.api import thumbnail_encode
monitor = getattr(image_monitor, "_monitor_instance", None)
@@ -145,8 +135,15 @@ def register_system_commands(app, routes, api, root: Path):
unplugged.rmdir()
GalleryConfig.MONITORING_DIRECTORIES = list(configured)
api._health_report = None
for (owner, name), value in host_values.items():
setattr(owner, name, value)
(
PromptManagerConfig.RESULT_TIMEOUT,
PromptManagerConfig.WEBUI_DISPLAY_MODE,
PromptManagerConfig.WORKER_THREADS,
GalleryConfig.MONITORING_ENABLED,
PromptManagerConfig.THUMBNAIL_FORMAT,
PromptManagerConfig.THUMBNAIL_QUALITY,
PromptManagerConfig.THUMBNAIL_EDGE,
) = host_values
thumbnail_encode.reset_encoders()
image_monitor._monitor_instance = monitor
api.__dict__.pop("_library_resolver", None)
+2 -63
View File
@@ -12,7 +12,6 @@ import json
import os
from pathlib import Path
import shutil
import threading
import time
from typing import TYPE_CHECKING
@@ -182,28 +181,6 @@ def register_tagging_commands(
routes: web.RouteTableDef, api: PromptManagerAPI, root: Path, model
) -> None:
"""Register fixed seed, snapshot and fake-model commands; no paths or SQL input."""
# The fake download (``/__test__/tagging/download``): waits on ``event``,
# then answers ``result``. Every reset sets the event and puts back the
# model's raising default, so no test can leave a worker hanging.
# ``generation`` grows on every reset: a download a reset released (or one
# that timed out) fails without touching the next test's model state.
download = {"event": threading.Event(), "result": True, "generation": 0}
def restore_download() -> None:
download["generation"] += 1
download["event"].set()
model.__dict__.pop("download_model", None)
def fake_download(model_type: str, progress_callback=None) -> bool:
event, generation = download["event"], download["generation"]
released = event.wait(DOWNLOAD_HOLD_LIMIT_S)
if not released or download["generation"] != generation:
return False
if download["result"]:
model.unavailable = model.unavailable - {model_type}
return download["result"]
_set_defaults(model, DEFAULTS) # the server starts on the standard ones
@routes.post("/__test__/tagging/reset")
async def reset(request: web.Request) -> web.Response:
@@ -218,7 +195,6 @@ def register_tagging_commands(
model.unavailable = set()
model.suggestions = None
model.loaded = None
restore_download()
_set_defaults(model, DEFAULTS)
# A test that ends with the dialog open leaves its capture behind; drop
# every capture so the next test starts below the store's limits.
@@ -247,33 +223,6 @@ def register_tagging_commands(
model.unavailable = set(data["unavailable"])
return web.json_response({"success": True})
@routes.post("/__test__/tagging/download")
async def download_mode(request: web.Request) -> web.Response:
"""Switch the model's download to a fake (never a real download).
``{"mode": "hold"}``: the next download waits until a later
``succeed`` or ``fail`` (or a reset) releases it. ``succeed`` and
``fail`` release a held download with that result and answer every
later one at once. A successful fake marks the model downloaded.
"""
data = await request.json() if request.can_read_body else None
if (
request.query
or not isinstance(data, dict)
or set(data) != {"mode"}
or data["mode"] not in ("hold", "succeed", "fail")
):
raise web.HTTPBadRequest()
if data["mode"] == "hold":
download["event"].set()
download["event"] = threading.Event()
download["result"] = True
else:
download["result"] = data["mode"] == "succeed"
download["event"].set()
model.download_model = fake_download
return web.json_response({"success": True})
@routes.post("/__test__/tagging/suggestions")
async def suggestions(request: web.Request) -> web.Response:
"""Answer Generate with fixed scored names (the concept's own sample).
@@ -324,28 +273,18 @@ def register_tagging_commands(
return web.json_response({"captures": count})
# A held fake download gives up after this long, whatever the test does.
DOWNLOAD_HOLD_LIMIT_S = 60
# The standard tagging defaults: WD14 general and character thresholds, and
# the suggestion count (``AutotagConfig``).
DEFAULTS = (0.35, 0.85, 10)
def _set_defaults(model, values) -> None:
"""Set the saved tagging defaults (``AutotagConfig``, #328); a reset also
puts back the saved model, batch policy and template (the fake model's
short instructions, which the concept comparisons show)."""
from py.config import AutotagConfig
general, character, limit = values
AutotagConfig.GENERAL_THRESHOLD = general
AutotagConfig.CHARACTER_THRESHOLD = character
model.wd14_general_threshold = general
model.wd14_character_threshold = character
AutotagConfig.SUGGESTION_LIMIT = limit
if values == DEFAULTS:
AutotagConfig.MODEL_TYPE = "wd14-swinv2"
AutotagConfig.EXISTING = "skip"
AutotagConfig.TEMPLATE = model.default_prompt
def _valid_defaults(data: object) -> bool:
-195
View File
@@ -1,195 +0,0 @@
// #343: a link drives a PromptManager node's text (and the Pair's negative) on the real pinned host.
// The workflow is built by the real frontend through the nodes' sockets and queued as the frontend serializes it;
// only inference is replaced (fixtures/ledger_nodes.py). The linked text must be saved, counted, recorded in the
// execution history, and the run's image must link to it.
import { execFile } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { promisify } from 'node:util';
import { expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import { test, readExecutionRuns } from './execution-fixture.mjs';
import { installHostHooks } from './host-fixture.mjs';
// The worker's owned host must be up before the shared host hooks open the page.
test.beforeEach(async ({ execution }) => { expect(execution.databasePath).toBeTruthy(); });
installHostHooks(test, ['PromptManager', 'PromptManagerText', 'PromptManagerFocused', 'PromptManagerPair', 'PMHistoryInputs']);
// `primitive`: a core string node the queue-time resolver follows. `legacy`: the frontend's virtual Primitive node,
// whose value is queued as literal text. `preview`: PrimitiveString -> PreviewAny, the shape of the maintainer's
// TextGenerate -> PreviewAny chain, whose text is only known when the node runs.
function build(page, { kind, positive, negative, prefix }) {
return page.evaluate(async ({ kind, positive, negative, prefix }) => {
const { app } = await import('/scripts/app.js');
app.graph.clear();
const add = (type, x, y) => { const node = LiteGraph.createNode(type); app.graph.add(node); node.pos = [x, y]; return node; };
const slot = (node, name) => { const index = node.inputs.findIndex(input => input.name === name); if (index < 0) throw new Error(`${node.type} has no ${name} socket`); return index; };
const connect = (from, output, to, name) => { if (!from.connect(output, to, slot(to, name))) throw new Error(`${from.type} could not link to ${to.type}.${name}`); };
const inputs = add('PMHistoryInputs', 0, 0);
const prompt = add(kind, 700, 0);
const set = (name, value) => { const widget = prompt.widgets.find(item => item.name === name); widget.value = value; if (widget.inputEl) widget.inputEl.value = value; };
set('text', positive.typed);
if (negative) set('negative_text', negative.typed);
const drive = (name, side, y) => {
if (!side.via) return;
if (side.via === 'legacy') {
// The frontend's own Primitive: a virtual node whose value is applied when the workflow is queued.
const primitive = add('PrimitiveNode', 0, y);
connect(primitive, 0, prompt, name);
primitive.widgets[0].value = side.text;
return;
}
const text = add('PrimitiveString', 0, y);
text.widgets.find(widget => widget.name === 'value').value = side.text;
let source = text;
if (side.via === 'preview') { source = add('PreviewAny', 350, y); connect(text, 0, source, 'source'); }
connect(source, 0, prompt, name);
};
drive('text', positive, 300);
if (negative) drive('negative_text', negative, 500);
if (kind !== 'PromptManagerText') connect(inputs, 0, prompt, 'clip');
const sampler = add('KSampler', 1400, 0);
connect(inputs, 1, sampler, 'model');
connect(inputs, 2, sampler, 'latent_image');
if (kind === 'PromptManagerText') {
const encode = add('CLIPTextEncode', 1100, 0);
encode.convertWidgetToInput?.(encode.widgets.find(widget => widget.name === 'text'));
connect(inputs, 0, encode, 'clip');
connect(prompt, 0, encode, 'text');
connect(encode, 0, sampler, 'positive');
} else connect(prompt, 0, sampler, 'positive');
if (kind === 'PromptManagerPair') connect(prompt, 1, sampler, 'negative');
else {
const empty = add('CLIPTextEncode', 1100, 300);
connect(inputs, 0, empty, 'clip');
connect(empty, 0, sampler, 'negative');
}
const images = add('PMHistoryImages', 1800, 0);
images.widgets.find(widget => widget.name === 'value').value = 99;
connect(sampler, 0, images, 'latent');
const save = add('SaveImage', 2100, 0);
save.widgets.find(widget => widget.name === 'filename_prefix').value = prefix;
connect(images, 0, save, 'images');
// The Classic nodes install their own text serializer shortly after creation; queue after it, as a user does.
await prompt._pmTextSerializerReady;
const { output, workflow } = await app.graphToPrompt();
return { output, workflow, promptNode: String(prompt.id) };
}, { kind, positive, negative, prefix });
}
async function run(execution, page, testCase) {
const prefix = `textlink/${randomUUID()}`;
const { output, workflow, promptNode } = await build(page, { ...testCase, prefix });
const id = randomUUID();
const response = await execution.request.post('/prompt', { data: { prompt: output, prompt_id: id, extra_data: { extra_pnginfo: { workflow } } } });
expect(response.ok(), await response.text()).toBe(true);
let item;
await expect.poll(async () => {
item = (await (await execution.request.get(`/history/${id}`)).json())[id];
return item?.status?.status_str ?? null;
}, { timeout: 20000 }).toBe('success');
let result;
await expect.poll(async () => {
result = await readExecutionRuns(execution.databasePath, id);
const run = result.rows[0];
return run ? result.captures[run.run_id].state : null;
}, { timeout: 10000, message: 'the run and its prompt context reach SQLite' }).toBe('complete');
const run = result.rows[0];
const files = Object.values(item.outputs).flatMap(value => value.images || []).map(image => image.filename);
return { output, promptNode, context: result.captures[run.run_id].contexts[0], usage: result.usage[run.run_id], files };
}
async function scan(execution) {
const response = await execution.request.post('/prompt_manager/scan', { timeout: 60000 });
expect(response.ok()).toBe(true);
const events = (await response.text()).split('\n').filter(line => line.startsWith('data: ')).map(line => JSON.parse(line.slice(6)));
expect(events.at(-1)?.type, JSON.stringify(events.at(-1))).toBe('complete');
}
// filename -> linked prompt id (null when the image is not linked), read-only from the owned host's library.
const LINKS = `import json, sqlite3, sys, pathlib
db = sqlite3.connect(pathlib.Path(sys.argv[1]).resolve().as_uri() + "?mode=ro", uri=True)
links = {name: None for name in sys.argv[2:]}
for name, prompt in db.execute("SELECT filename, prompt_id FROM generated_images WHERE filename IN (%s)" % ",".join("?" * (len(sys.argv) - 2)), sys.argv[2:]):
links[name] = prompt
print(json.dumps(links))`;
async function linkedPrompts(execution, files) {
const { stdout } = await promisify(execFile)(process.env.PM_TEST_PYTHON || 'python', ['-c', LINKS, execution.databasePath, ...files], { timeout: 5000 });
return JSON.parse(stdout);
}
// Text a link resolves from the queued graph (a primitive) links its images through their embedded metadata.
// Text only known at run time (PreviewAny) cannot be read from the image: its attribution from the recorded run is
// #342's. Here it must never be linked to another prompt.
async function expectImagesLinked(execution, evidence, viaRuntime) {
await scan(execution);
const links = await linkedPrompts(execution, evidence.files);
expect(evidence.files.length).toBeGreaterThan(0);
for (const [file, prompt] of Object.entries(links)) {
if (viaRuntime) expect([null, evidence.context.positive_prompt_id], file).toContain(prompt);
else expect(prompt, file).toBe(evidence.context.positive_prompt_id);
}
}
const unique = label => `${label} ${randomUUID()}`;
const sources = { primitive: 'PrimitiveString', legacy: 'a legacy Primitive', preview: 'PreviewAny' };
for (const kind of ['PromptManager', 'PromptManagerText', 'PromptManagerFocused', 'PromptManagerSplit']) {
for (const via of Object.keys(sources)) {
test(`${kind} saves, counts, records and links images to text linked from ${sources[via]}`, async ({ execution, page }) => {
test.setTimeout(90000);
const text = unique(`Linked ${kind} ${via}`);
const evidence = await run(execution, page, { kind, positive: { typed: 'Typed under the link', text, via } });
const source = evidence.output[evidence.promptNode].inputs.text;
// A real node is queued as a link; the virtual Primitive as its own value, never the hidden typed text.
if (via === 'legacy') expect(source).toBe(text);
else expect(Array.isArray(source), 'the frontend queued the link, not the typed text').toBe(true);
expect(evidence.context.positive.raw).toBe(text);
expect(evidence.context.positive.effective).toBe(text);
expect(evidence.usage).toEqual([{ role: 'positive', run_count: 1 }]);
const saved = await (await execution.request.get(`/prompt_manager/library/${evidence.context.positive_prompt_id}`)).json();
expect(saved.positive.text).toBe(text);
await expectImagesLinked(execution, evidence, via === 'preview');
});
}
}
// A legacy Primitive's wildcard is drawn once when the workflow is queued: the run saves and counts what was queued.
for (const kind of ['PromptManager', 'PromptManagerFocused']) {
test(`${kind} saves and counts the text a wildcard legacy Primitive queued (#343 re-review R1)`, async ({ execution, page }) => {
test.setTimeout(90000);
const stem = unique(`Wildcard ${kind}`);
const evidence = await run(execution, page, { kind, positive: { typed: 'Typed under the link', text: `${stem} {alpha|beta}`, via: 'legacy' } });
const queuedText = evidence.output[evidence.promptNode].inputs.text;
// The Classic node keeps its widget binding: the host applies the Primitive, and its own text serializer
// queues the text as written, as on main. The Focused editor queues one choice.
if (kind === 'PromptManager') expect(queuedText).toBe(`${stem} {alpha|beta}`);
else expect([`${stem} alpha`, `${stem} beta`]).toContain(queuedText);
expect(evidence.context.positive.raw).toBe(queuedText);
expect(evidence.usage).toEqual([{ role: 'positive', run_count: 1 }]);
const saved = await (await execution.request.get(`/prompt_manager/library/${evidence.context.positive_prompt_id}`)).json();
expect(saved.positive.text).toBe(queuedText);
});
}
for (const [label, linked, negativeVia] of [['positive', ['text'], 'primitive'], ['negative', ['negative_text'], 'primitive'],
['both', ['text', 'negative_text'], 'primitive'], ['neither', [], 'primitive'], ['negative from a legacy Primitive', ['negative_text'], 'legacy']]) {
test(`PromptManagerPair with ${label} linked saves, counts and records the pair and links its images`, async ({ execution, page }) => {
test.setTimeout(90000);
const sides = {
text: { typed: unique('Typed positive'), text: unique('Linked positive'), via: linked.includes('text') ? 'preview' : null },
negative_text: { typed: unique('Typed negative'), text: unique('Linked negative'), via: linked.includes('negative_text') ? negativeVia : null },
};
const evidence = await run(execution, page, { kind: 'PromptManagerPair', positive: sides.text, negative: sides.negative_text });
const expected = name => sides[name].via ? sides[name].text : sides[name].typed;
for (const name of ['text', 'negative_text']) {
const queuedValue = evidence.output[evidence.promptNode].inputs[name];
if (sides[name].via === 'legacy') expect(queuedValue, name).toBe(sides[name].text);
else expect(Array.isArray(queuedValue), name).toBe(linked.includes(name));
}
expect(evidence.context.positive.raw).toBe(expected('text'));
expect(evidence.context.negative.raw).toBe(expected('negative_text'));
expect(evidence.usage).toEqual([{ role: 'positive', run_count: 1 }, { role: 'negative', run_count: 0 }, { role: 'pair', run_count: 1 }]);
const pair = await (await execution.request.get(`/prompt_manager/pairs/${evidence.context.pair_id}`)).json();
expect(pair.pair).toMatchObject({ positive_text: expected('text'), negative_text: expected('negative_text') });
await expectImagesLinked(execution, evidence, sides.text.via === 'preview');
});
}
-51
View File
@@ -1,7 +1,5 @@
"""Deterministic nodes and queue controls installed only in an owned test host."""
import asyncio
import os
import threading
import time
@@ -257,52 +255,3 @@ async def cache_mode(request: web.Request) -> web.Response:
raise web.HTTPBadRequest()
_CACHE_MODE = mode
return web.json_response({"configured": True})
def _plugin_module(suffix: str):
"""A loaded PromptManager module (ComfyUI names the package by its path)."""
import sys
for name, module in list(sys.modules.items()):
if name.endswith("promptmanager." + suffix):
return module
raise web.HTTPServiceUnavailable(text=f"PromptManager {suffix} not loaded")
@PromptServer.instance.routes.post("/pm_execution_fixture/monitor")
async def image_monitor(request: web.Request) -> web.Response:
"""Start or stop PromptManager's image monitor on the host output folder.
The managed host's config disables monitoring; only the attribution cases
(#342) turn it on, and they turn it off again.
"""
enabled = (await request.json())["enabled"]
if type(enabled) is not bool:
raise web.HTTPBadRequest()
import folder_paths
monitor_module = _plugin_module("utils.image_monitor")
config = _plugin_module("py.config").GalleryConfig
monitor = monitor_module._monitor_instance
if monitor is None:
database = _plugin_module("database.operations").PromptDatabase()
tracker = _plugin_module("utils.prompt_tracker").get_prompt_tracker(database)
monitor = monitor_module.get_image_monitor(database, tracker)
if enabled:
config.MONITORING_ENABLED = True
monitor.start_monitoring([folder_paths.get_output_directory()])
else:
# Joins the image worker and the observer (one shared 5 s deadline),
# so it runs on a worker thread, not on the event loop.
await asyncio.to_thread(monitor.stop_monitoring)
config.MONITORING_ENABLED = False
return web.json_response({"monitoring": monitor.is_monitoring})
@PromptServer.instance.routes.get("/pm_execution_fixture/monitor")
async def image_monitor_state(request: web.Request) -> web.Response:
"""File names of keyless images waiting for their run's recorded outputs."""
monitor = _plugin_module("utils.image_monitor")._monitor_instance
handler = getattr(monitor, "handler", None)
waiting = handler.awaiting_history() if handler is not None else []
return web.json_response({"awaiting": [os.path.basename(p) for p in waiting]})
@@ -1,379 +0,0 @@
{
"id": "37b6baef-94f5-49d2-9b42-1ffc70e7da92",
"revision": 0,
"last_node_id": 4,
"last_link_id": 2,
"nodes": [
{
"id": 1,
"type": "PromptManagerFocused",
"pos": [
600,
0
],
"size": [
550,
650
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": null
},
{
"localized_name": "prepend_text",
"name": "prepend_text",
"shape": 7,
"type": "STRING",
"link": null
},
{
"localized_name": "append_text",
"name": "append_text",
"shape": 7,
"type": "STRING",
"link": null
},
{
"localized_name": "text",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": 1
},
{
"localized_name": "category",
"name": "category",
"shape": 7,
"type": "STRING",
"widget": {
"name": "category"
},
"link": null
},
{
"localized_name": "tags",
"name": "tags",
"shape": 7,
"type": "STRING",
"widget": {
"name": "tags"
},
"link": null
},
{
"localized_name": "search_text",
"name": "search_text",
"shape": 7,
"type": "STRING",
"widget": {
"name": "search_text"
},
"link": null
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": null
},
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": null
}
],
"title": "Focused fed by a Primitive",
"properties": {
"Node name for S&R": "PromptManagerFocused",
"focusedUI": {
"libraryOpen": false,
"libraryFilters": {
"category": "",
"tags": ""
},
"composition": false,
"metadata": false,
"preview": false
}
},
"widgets_values": [
"Primitive focused text",
"",
"",
"",
"",
"",
""
],
"widgets_values_named": {
"text": "Primitive focused text",
"category": "",
"tags": "",
"search_text": "",
"prepend_text": "",
"append_text": "",
"prompt_manager_focused_ui": ""
}
},
{
"id": 2,
"type": "PromptManagerPair",
"pos": [
600,
1200
],
"size": [
1100,
700
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": null
},
{
"localized_name": "prepend_text",
"name": "prepend_text",
"shape": 7,
"type": "STRING",
"link": null
},
{
"localized_name": "append_text",
"name": "append_text",
"shape": 7,
"type": "STRING",
"link": null
},
{
"localized_name": "text",
"name": "text",
"type": "STRING",
"widget": {
"name": "text"
},
"link": null
},
{
"localized_name": "negative_text",
"name": "negative_text",
"type": "STRING",
"widget": {
"name": "negative_text"
},
"link": 2
},
{
"localized_name": "category",
"name": "category",
"shape": 7,
"type": "STRING",
"widget": {
"name": "category"
},
"link": null
},
{
"localized_name": "tags",
"name": "tags",
"shape": 7,
"type": "STRING",
"widget": {
"name": "tags"
},
"link": null
},
{
"localized_name": "search_text",
"name": "search_text",
"shape": 7,
"type": "STRING",
"widget": {
"name": "search_text"
},
"link": null
}
],
"outputs": [
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"links": null
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"links": null
},
{
"localized_name": "positive_text",
"name": "positive_text",
"type": "STRING",
"links": null
},
{
"localized_name": "negative_text",
"name": "negative_text",
"type": "STRING",
"links": null
}
],
"title": "Pair negative fed by a Primitive",
"properties": {
"Node name for S&R": "PromptManagerPair",
"pairUI": {
"libraryOpen": true,
"libraryFilters": {
"category": "",
"tags": ""
},
"composition": false,
"metadata": false,
"preview": false
}
},
"widgets_values": [
"Typed pair positive",
"Primitive pair negative",
"",
"",
"",
"",
"",
""
],
"widgets_values_named": {
"text": "Typed pair positive",
"negative_text": "Primitive pair negative",
"category": "",
"tags": "",
"search_text": "",
"prepend_text": "",
"append_text": "",
"prompt_manager_focused_ui": ""
}
},
{
"id": 3,
"type": "PrimitiveNode",
"pos": [
0,
0
],
"size": [
210,
88
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"widget": {
"name": "text"
},
"links": [
1
]
}
],
"properties": {
"Run widget replace on values": false
},
"widgets_values": [
"Primitive focused text"
],
"widgets_values_named": {
"text": "Primitive focused text"
}
},
{
"id": 4,
"type": "PrimitiveNode",
"pos": [
0,
1200
],
"size": [
210,
88
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"widget": {
"name": "negative_text"
},
"links": [
2
]
}
],
"properties": {
"Run widget replace on values": false
},
"widgets_values": [
"Primitive pair negative"
],
"widgets_values_named": {
"negative_text": "Primitive pair negative"
}
}
],
"links": [
[
1,
3,
0,
1,
3,
"STRING"
],
[
2,
4,
0,
2,
4,
"STRING"
]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 0.9,
"offset": [
416,
110
]
}
},
"version": 0.4
}
File diff suppressed because it is too large Load Diff
-142
View File
@@ -1,142 +0,0 @@
// #342 on the real pinned host: the image monitor links an output only to a prompt
// that provably produced it. An unknown node (PMHistoryCached) stands in for the
// maintainer's LLM; KSampler is the fixture's deterministic stand-in.
import { randomUUID } from 'node:crypto';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { fileURLToPath } from 'node:url';
import { expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import { test } from './execution-fixture.mjs';
const run = promisify(execFile);
const reader = fileURLToPath(new URL('./read_execution_history.py', import.meta.url));
async function readLinks(databasePath, filenames, texts = []) {
const { stdout } = await run(process.env.PM_TEST_PYTHON || 'python',
[reader, databasePath, '--links', ...filenames, '--texts', ...texts], { timeout: 5000, maxBuffer: 1024 * 1024 });
return JSON.parse(stdout);
}
// kind: the positive node; text: a typed editor or 'llm' (linked from the unknown
// node); prepend: 'llm' links the unknown node into prepend_text.
function graph({ kind = 'PromptManagerPair', text = '', prepend = '', llm = '', seed = 1, prefix }) {
const linked = value => value === 'llm' ? ['11', 0] : value;
const nodes = {
'10': { class_type: 'PMHistoryInputs', inputs: {} },
'20': { class_type: kind, inputs: { text: linked(text), clip: ['10', 0], append_text: '',
...(prepend ? { prepend_text: linked(prepend) } : {}),
...(kind === 'PromptManagerPair' ? { negative_text: '' } : {}) } },
'30': { class_type: 'CLIPTextEncode', inputs: { text: '', clip: ['10', 0] } },
'40': { class_type: 'KSampler', inputs: { model: ['10', 1], latent_image: ['10', 2],
positive: ['20', 0], negative: kind === 'PromptManagerPair' ? ['20', 1] : ['30', 0],
seed, steps: 1, cfg: 1, sampler_name: 'euler', scheduler: 'normal', denoise: 1 } },
'60': { class_type: 'PMHistoryImages', inputs: { value: 64, batch: 1, latent: ['40', 0] } },
'70': { class_type: 'SaveImage', inputs: { images: ['60', 0], filename_prefix: prefix } },
};
if (llm) nodes['11'] = { class_type: 'PMHistoryCached', inputs: { value: llm } };
return nodes;
}
async function saved(request, prompt, history = {}) {
const id = randomUUID();
const response = await request.post('/prompt', { data: { prompt, prompt_id: id } });
expect(response.ok()).toBe(true);
let item;
await expect.poll(async () => {
item = (await (await request.get(`/history/${id}`)).json())[id];
return item?.status?.messages?.some(([kind]) => kind === 'execution_success') || false;
}, { timeout: 15000 }).toBe(true);
const [image] = item.outputs['70'].images;
expect(image.type).toBe('output');
history.item = item;
return image.filename;
}
async function linkedTo(execution, filename, expected) {
await expect.poll(async () => (await readLinks(execution.databasePath, [filename])).links[filename],
{ timeout: 20000, message: `${filename} is linked by the image monitor` }).toBe(expected);
}
async function monitoring(execution, enabled) {
const response = await execution.request.post('/pm_execution_fixture/monitor', { data: { enabled } });
expect(response.ok()).toBe(true);
expect((await response.json()).monitoring).toBe(enabled);
}
async function cacheMode(execution, mode) {
expect((await execution.request.post('/pm_execution_fixture/cache', { data: { mode } })).ok()).toBe(true);
}
test.beforeEach(async ({ execution }) => {
expect((await execution.request.post('/pm_execution_fixture/metadata', { data: { disabled: false } })).ok()).toBe(true);
await cacheMode(execution, 'CLASSIC'); // keeps the re-run's PromptManager node cached
await monitoring(execution, true);
});
test.afterEach(async ({ execution }) => {
await monitoring(execution, false);
await cacheMode(execution, 'RAM_PRESSURE');
});
test('an output whose positive is only linked prepend text stays unlinked, never given an earlier prompt', async ({ execution }, testInfo) => {
const tag = `${testInfo.project.name} ${randomUUID()}`;
const earlier = `a woman holding a coffee cup ${tag}`;
const prepended = `llm prepend landscape ${tag}`, llmText = `llm text landscape ${tag}`;
// A typed prompt runs first: before #342 the next unidentifiable image took it.
const first = await saved(execution.request, graph({ text: earlier, prefix: 'misslink/earlier' }));
await linkedTo(execution, first, earlier);
// The maintainer's workflow: empty editor, the generated text arrives in prepend.
const orphan = await saved(execution.request,
graph({ prepend: 'llm', llm: prepended, prefix: 'misslink/prepend' }));
// An LLM feeding the editor itself is stored and links through its run key; the
// monitor works in creation order, so the prepend image was processed first.
const llmGraph = graph({ kind: 'PromptManager', text: 'llm', llm: llmText, prefix: 'misslink/text' });
const proven = await saved(execution.request, llmGraph);
await linkedTo(execution, proven, llmText);
const state = await readLinks(execution.databasePath, [orphan], [prepended]);
expect(state).toEqual({ links: { [orphan]: null }, stored: { [prepended]: false } });
// Only the sampler seed changes: the PromptManager node is served from the cache
// without running (the host says so), so no queue entry exists for this run and
// only the snapshot's run match can link it.
const rerunHistory = {};
const rerun = await saved(execution.request, { ...llmGraph, '40': { ...llmGraph['40'],
inputs: { ...llmGraph['40'].inputs, seed: 2 } } }, rerunHistory);
const cached = rerunHistory.item.status.messages.find(([kind]) => kind === 'execution_cached');
expect(cached?.[1].nodes).toEqual(expect.arrayContaining(['11', '20']));
await linkedTo(execution, rerun, llmText);
});
test('an output saved without metadata links once its recorded run names a stored prompt', async ({ execution }, testInfo) => {
const llmText = `bare output landscape ${testInfo.project.name} ${randomUUID()}`;
const request = execution.request;
expect((await request.post('/pm_execution_fixture/metadata', { data: { disabled: true } })).ok()).toBe(true);
try {
// No embedded graph: no run key can prove the tracker entry, so only the
// run's recorded history can, and only once the run's outputs are sealed.
// The gate after SaveImage holds the run open while the monitor looks.
const prompt = graph({ kind: 'PromptManager', text: 'llm', llm: llmText, prefix: 'misslink/bare' });
prompt['80'] = { class_type: 'PMHistoryImageOutput', inputs: { images: ['70', 0], mode: 'gate' } };
expect((await request.post('/pm_execution_fixture/gate/hold')).ok()).toBe(true);
const id = randomUUID();
try {
expect((await request.post('/prompt', { data: { prompt, prompt_id: id } })).ok()).toBe(true);
let waiting = [];
await expect.poll(async () => {
waiting = (await (await request.get('/pm_execution_fixture/monitor')).json()).awaiting;
return waiting.filter(name => name.startsWith('bare_')).length;
}, { timeout: 20000, message: 'the monitor processed the bare output while its run was open' }).toBe(1);
const [bare] = waiting.filter(name => name.startsWith('bare_'));
expect((await readLinks(execution.databasePath, [bare])).links[bare]).toBeNull();
expect((await request.post('/pm_execution_fixture/gate/release')).ok()).toBe(true);
await linkedTo(execution, bare, llmText);
expect((await (await request.get('/pm_execution_fixture/monitor')).json()).awaiting).not.toContain(bare);
} finally {
expect((await request.post('/pm_execution_fixture/gate/release')).ok()).toBe(true);
}
} finally {
expect((await request.post('/pm_execution_fixture/metadata', { data: { disabled: false } })).ok()).toBe(true);
}
});
+5 -40
View File
@@ -1,7 +1,6 @@
"""Bounded read-only SQLite inspection for the owned execution fixture."""
import argparse
from contextlib import closing
import json
from pathlib import Path
import sqlite3
@@ -90,48 +89,14 @@ def read_runs(database: str, job_id: str, session_id: str | None = None) -> dict
}
def read_links(database: str, filenames: list, texts: list) -> dict:
"""Each output file's linked prompt text (None: unlinked), and which texts
are stored as prompts (#342)."""
uri = Path(database).resolve().as_uri() + "?mode=ro"
with closing(sqlite3.connect(uri, uri=True, timeout=0.25)) as connection:
connection.execute("PRAGMA query_only=ON")
links = {}
for name in filenames[:64]:
rows = connection.execute(
"SELECT p.text FROM generated_images g JOIN prompts p "
"ON p.id = g.prompt_id WHERE g.filename = ? LIMIT 2",
(name,),
).fetchall()
values = [row[0] for row in rows]
links[name] = values[0] if len(values) == 1 else None
if len(values) > 1:
links[name] = "<several links>"
stored = {
text: connection.execute(
"SELECT 1 FROM prompts WHERE text = ? LIMIT 1", (text,)
).fetchone()
is not None
for text in texts[:64]
}
return {"links": links, "stored": stored}
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("database")
parser.add_argument("job_id", nargs="?")
parser.add_argument("job_id")
parser.add_argument("--session-id")
parser.add_argument("--links", nargs="*", default=None)
parser.add_argument("--texts", nargs="*", default=[])
arguments = parser.parse_args()
if arguments.links is not None:
print(
json.dumps(read_links(arguments.database, arguments.links, arguments.texts))
)
else:
print(
json.dumps(
read_runs(arguments.database, arguments.job_id, arguments.session_id)
)
print(
json.dumps(
read_runs(arguments.database, arguments.job_id, arguments.session_id)
)
)
-404
View File
@@ -1,404 +0,0 @@
// #343: on the pinned real host, a STRING link dragged with the mouse drives each PromptManager node's text (and the
// Pair's negative) in Classic and Nodes 2.0, across renderer switches, a workflow reload, disconnect and removal.
import { readFile } from 'node:fs/promises';
import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
import { installHostHooks } from './host-fixture.mjs';
installHostHooks(test, ['PromptManager', 'PromptManagerText', 'PromptManagerFocused', 'PromptManagerSplit', 'PromptManagerPair']);
const editors = { PromptManagerFocused: 'Main prompt', PromptManagerSplit: 'Main prompt', PromptManagerPair: 'Positive prompt' };
const note = label => `${label} comes from a link. Your typed text is kept for when you disconnect it.`;
/** Viewport position of a node's slot: the slot dot in Nodes 2.0, the canvas slot position in Classic. */
function slotPosition(page, nodeId, kind, name) {
return page.evaluate(async ({ nodeId, kind, name }) => {
const { app } = await import('/scripts/app.js');
const node = app.graph.getNodeById(nodeId);
const slots = kind === 'out' ? node.outputs : node.inputs;
const index = typeof name === 'number' ? name : slots.findIndex(slot => slot.name === name);
if (LiteGraph.vueNodesMode) {
const dot = document.querySelector(`[data-slot-key="${nodeId}-${kind}-${index}"]`);
if (!dot) return null;
const box = dot.getBoundingClientRect();
return [box.x + box.width / 2, box.y + box.height / 2];
}
const position = app.canvas.ds.convertOffsetToCanvas(kind === 'out' ? node.getOutputPos(index) : node.getInputPos(index));
const box = app.canvas.canvas.getBoundingClientRect();
return [box.x + position[0], box.y + position[1]];
}, { nodeId, kind, name });
}
async function drag(page, from, to) {
await page.mouse.move(...from);
await page.mouse.down();
await page.mouse.move((from[0] + to[0]) / 2, (from[1] + to[1]) / 2, { steps: 5 });
await page.mouse.move(...to, { steps: 10 });
await page.mouse.up();
}
const center = box => [box.x + box.width / 2, box.y + box.height / 2];
const linkedInputs = (page, id) => page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
return app.graph.getNodeById(id).inputs.filter(input => input.link != null).map(input => input.name);
}, id);
const queued = (page, id) => page.evaluate(async id => (await (await import('/scripts/app.js')).app.graphToPrompt()).output[String(id)].inputs, id);
async function setup(page, type) {
return page.evaluate(async type => {
const { app } = await import('/scripts/app.js');
const node = LiteGraph.createNode(type);
app.graph.add(node);
node.pos = [500, 80];
node.setSize([type === 'PromptManagerPair' ? 1100 : 650, 760]);
const sources = ['text', 'negative_text'].map((name, index) => {
const source = LiteGraph.createNode('PrimitiveString');
app.graph.add(source);
source.pos = [40, 80 + index * 160];
source.widgets.find(widget => widget.name === 'value').value = `Linked ${name} ${type}`;
return source.id;
});
app.canvas.ds.scale = .8;
app.canvas.ds.offset = [40, 40];
app.canvas.setDirty(true, true);
return { id: node.id, sources };
}, type);
}
/** The prompt box a user drops the link on: the editor's textarea, or the core text box of the Classic nodes. */
async function promptBox(page, type, id) {
if (editors[type]) return page.getByRole('textbox', { name: editors[type], exact: true });
// The Classic nodes keep the core text widget: Nodes 2.0 draws it as its own textarea, Classic as widget.inputEl.
if (await page.evaluate(() => LiteGraph.vueNodesMode)) return page.locator(`[data-node-id="${id}"] textarea`).first();
await page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
app.graph.getNodeById(id).widgets.find(widget => widget.name === 'text').inputEl.dataset.pmTestPromptBox = String(id);
}, id);
return page.locator(`textarea[data-pm-test-prompt-box="${id}"]`);
}
/** What the node shows while its text is linked. */
function linkedNote(page, type) {
return editors[type] ? page.getByText(note(editors[type]), { exact: true }) : page.locator('.pm-text-link-note').getByText(note('Text'), { exact: true });
}
for (const type of ['PromptManagerFocused', 'PromptManagerSplit', 'PromptManagerPair', 'PromptManager', 'PromptManagerText']) {
test(`${type}: a STRING link dropped on the prompt box drives text and the typed text returns on disconnect`, async ({ page }, testInfo) => {
test.setTimeout(90000);
const { id, sources } = await setup(page, type);
const typed = `Typed ${type} ${testInfo.project.name}`;
const box = await promptBox(page, type, id);
await box.fill(typed);
await expect(linkedNote(page, type)).toBeHidden();
await page.screenshot({ path: testInfo.outputPath(`${type}-unlinked.png`) });
// The drop lands on text, never on prepend_text (#342).
await drag(page, await slotPosition(page, sources[0], 'out', 0), center(await box.boundingBox()));
await expect.poll(() => linkedInputs(page, id)).toEqual(['text']);
const links = { text: [String(sources[0]), 0] };
if (type === 'PromptManagerPair') {
await page.getByRole('textbox', { name: 'Negative prompt', exact: true }).fill(`Typed negative ${testInfo.project.name}`);
await drag(page, await slotPosition(page, sources[1], 'out', 0), await slotPosition(page, id, 'in', 'negative_text'));
await expect.poll(() => linkedInputs(page, id)).toEqual(['text', 'negative_text']);
links.negative_text = [String(sources[1]), 0];
await expect(page.getByText(note('Negative prompt'), { exact: true })).toBeVisible();
}
await expect(linkedNote(page, type)).toBeVisible();
expect(await queued(page, id)).toMatchObject(links);
await page.screenshot({ path: testInfo.outputPath(`${type}-linked.png`) });
for (const renderer of [!testInfo.project.metadata.initialRenderer, testInfo.project.metadata.initialRenderer]) {
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(linkedNote(page, type)).toBeVisible();
expect(await queued(page, id)).toMatchObject(links);
}
const saved = await page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
const workflow = app.graph.serialize();
await app.loadGraphData(workflow);
return workflow.nodes.find(node => String(node.id) === String(id)).widgets_values;
}, id);
expect(saved[0]).toBe(typed);
await expect(linkedNote(page, type)).toBeVisible();
expect(await queued(page, id)).toMatchObject(links);
const local = await page.evaluate(async id => {
const { app } = await import('/scripts/app.js');
const node = app.graph.getNodeById(id);
for (const input of [...node.inputs]) if (input.link != null) node.disconnectInput(node.inputs.indexOf(input));
return (await app.graphToPrompt()).output[String(id)].inputs;
}, id);
expect(local.text).toBe(typed);
if (type === 'PromptManagerPair') expect(local.negative_text).toBe(`Typed negative ${testInfo.project.name}`);
await expect(linkedNote(page, type)).toBeHidden();
const restored = await promptBox(page, type, id);
await expect(restored).toBeVisible();
await expect(restored).toHaveValue(typed);
await page.screenshot({ path: testInfo.outputPath(`${type}-disconnected.png`) });
await page.evaluate(async id => { const { app } = await import('/scripts/app.js'); app.graph.remove(app.graph.getNodeById(id)); }, id);
await expect(page.locator('.pm-focused, .pm-classic, .pm-text-link-note')).toHaveCount(0);
});
}
async function addEditors(page) {
return page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const add = (type, x, y) => { const node = LiteGraph.createNode(type); app.graph.add(node); node.pos = [x, y]; return node; };
const focused = add('PromptManagerFocused', 420, 40);
focused.setSize([560, 640]);
const pair = add('PromptManagerPair', 1020, 40);
pair.setSize([1000, 640]);
for (const [node, name, value] of [[focused, 'text', 'Typed focused'], [pair, 'text', 'Typed positive'], [pair, 'negative_text', 'Typed negative']]) {
const widget = node.widgets.find(item => item.name === name);
widget.value = value;
if (widget.inputEl) widget.inputEl.value = value;
}
const sources = [0, 1].map(index => add(index ? 'PrimitiveString' : 'PrimitiveNode', 20, 60 + index * 300).id);
app.canvas.ds.scale = .7;
// Clear of the floating graph menu (top left) and the PM run bar (top right).
app.canvas.ds.offset = [110, 150];
app.canvas.setDirty(true, true);
return { focused: focused.id, pair: pair.id, primitive: sources[0], string: sources[1] };
});
}
/** The prompt a Run sends to the server, through the real app.queuePrompt path. */
async function runPrompt(page) {
let captured;
const sent = new Promise(resolve => { captured = resolve; });
// Installed before the run starts, so the POST can never reach the real host.
await page.route('**/prompt', async route => {
if (route.request().method() !== 'POST') return route.fallback();
captured(route.request().postDataJSON().prompt);
return route.fulfill({ json: { prompt_id: 'captured', number: 1, node_errors: {} } });
});
await page.evaluate(async () => { const { app } = await import('/scripts/app.js'); await app.queuePrompt(0, 1); });
const prompt = await sent;
await page.unroute('**/prompt');
return prompt;
}
const box = (page, name, nth = 0) => page.getByRole('textbox', { name, exact: true }).nth(nth);
test('a legacy Primitive dropped on an editor box or socket drives the text it shows as linked (#343 review I1, M1)', async ({ page }) => {
test.setTimeout(90000);
const ids = await addEditors(page);
// A wildcard Primitive aimed at the Main prompt box, a PrimitiveString aimed at the Negative prompt box.
await drag(page, await slotPosition(page, ids.primitive, 'out', 0), center(await box(page, 'Main prompt').boundingBox()));
await expect.poll(() => linkedInputs(page, ids.focused)).toEqual(['text']);
await drag(page, await slotPosition(page, ids.string, 'out', 0), center(await box(page, 'Negative prompt').boundingBox()));
await expect.poll(() => linkedInputs(page, ids.pair)).toEqual(['negative_text']);
await expect(page.getByText(note('Main prompt'), { exact: true })).toBeVisible();
await expect(page.getByText(note('Negative prompt'), { exact: true })).toBeVisible();
await page.evaluate(async ({ primitive, string }) => {
const { app } = await import('/scripts/app.js');
app.graph.getNodeById(primitive).widgets[0].value = 'From the legacy Primitive';
app.graph.getNodeById(string).widgets.find(widget => widget.name === 'value').value = 'From PrimitiveString';
}, ids);
const prompt = await runPrompt(page);
expect(prompt[String(ids.focused)].inputs.text).toBe('From the legacy Primitive');
expect(prompt[String(ids.pair)].inputs).toMatchObject({ text: 'Typed positive', negative_text: [String(ids.string), 0] });
// The Primitive is re-read on every run; the typed text stays for disconnect.
await page.evaluate(async ({ primitive }) => { const { app } = await import('/scripts/app.js'); app.graph.getNodeById(primitive).widgets[0].value = 'Changed in the Primitive'; }, ids);
expect((await queued(page, ids.focused)).text).toBe('Changed in the Primitive');
await page.evaluate(async ({ focused }) => { const { app } = await import('/scripts/app.js'); const node = app.graph.getNodeById(focused); node.disconnectInput(node.inputs.findIndex(input => input.name === 'text')); }, ids);
expect((await queued(page, ids.focused)).text).toBe('Typed focused');
});
test('a wildcard legacy Primitive queues one resolved choice, drawn once per run, on every PromptManager node (#343 re-review R1)', async ({ page }) => {
const choices = `{${Array.from({ length: 10 }, (_, index) => `c${index}`).join('|')}}`;
const ids = await page.evaluate(async choices => {
const { app } = await import('/scripts/app.js');
const add = (type, x, y) => { const node = LiteGraph.createNode(type); app.graph.add(node); node.pos = [x, y]; return node; };
// [node, input, Primitive value, its "Run widget replace on values" option]
const targets = [['PromptManagerFocused', 'text', `Focused ${choices}`, false], ['PromptManagerSplit', 'text', `Split %date:yyyy% ${choices}`, true],
['PromptManagerPair', 'negative_text', `Pair ${choices}`, false], ['PromptManager', 'text', `Classic ${choices}`, false],
['PromptManagerText', 'text', `Text ${choices}`, false]];
const ids = {};
targets.forEach(([type, name, value, replace], index) => {
const node = add(type, 500, index * 800);
const primitive = add('PrimitiveNode', 20, index * 800);
if (!primitive.connect(0, node, node.inputs.findIndex(input => input.name === name))) throw new Error(`no Primitive link to ${type}.${name}`);
primitive.widgets[0].value = value;
primitive.properties['Run widget replace on values'] = replace;
ids[type] = [String(node.id), name];
});
// The Classic nodes install their own text serializer shortly after creation; run after it, as a user does.
await Promise.all(app.graph.nodes.map(node => node._pmTextSerializerReady));
// What another extension's graphToPrompt wrapper sees must be what the server gets.
const inner = app.graphToPrompt;
const seen = window.__pmSeen = [];
app.graphToPrompt = async function (...args) { const result = await inner.apply(this, args); seen.push(structuredClone(result.output)); return result; };
// Each draw takes the next choice, so a second draw for the same text always changes it.
const random = Math.random;
let draw = 0;
Math.random = () => ((draw++ % 10) + .5) / 10;
window.__pmRestore = () => { app.graphToPrompt = inner; Math.random = random; };
return ids;
}, choices);
const prompt = await runPrompt(page);
const seen = await page.evaluate(() => { window.__pmRestore(); return window.__pmSeen; });
expect(seen).toHaveLength(1);
const year = String(new Date().getFullYear());
const stems = { PromptManagerFocused: 'Focused', PromptManagerSplit: `Split ${year}`, PromptManagerPair: 'Pair', PromptManager: 'Classic', PromptManagerText: 'Text' };
// The Classic nodes keep their widget binding, so the host applies the Primitive and #343 leaves the value alone.
// As on main, their text widget's own serializer queues the text as written, wildcard included.
const classic = new Set(['PromptManager', 'PromptManagerText']);
for (const [type, [id, name]] of Object.entries(ids)) {
const value = prompt[id].inputs[name];
if (classic.has(type)) expect(value, type).toBe(`${stems[type]} ${choices}`);
else expect(value, type).toMatch(new RegExp(`^${stems[type]} c\\d$`));
expect(value, `${type}: one draw per run`).toBe(seen[0][id].inputs[name]);
}
});
test('a legacy Primitive inside a subgraph drives the inner editor text it shows as linked (#343 re-review R2)', async ({ page }) => {
const result = await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const add = (type, x, y) => { const node = LiteGraph.createNode(type); app.graph.add(node); node.pos = [x, y]; return node; };
const focused = add('PromptManagerFocused', 500, 40);
focused.widgets.find(widget => widget.name === 'text').value = 'Typed under the link';
const primitive = add('PrimitiveNode', 20, 40);
if (!primitive.connect(0, focused, focused.inputs.findIndex(input => input.name === 'text'))) throw new Error('no Primitive link');
const { node } = app.graph.convertToSubgraph(new Set([focused, primitive]));
const inner = node.subgraph.nodes.find(item => item.type === 'PromptManagerFocused');
// The value the user gives the Primitive inside the subgraph.
node.subgraph.nodes.find(item => item.type === 'PrimitiveNode').widgets[0].value = 'Inner {only choice}';
return { host: String(node.id), inner: String(inner.id), linked: inner.inputs.find(input => input.name === 'text').link != null };
});
expect(result.linked).toBe(true);
const prompt = await runPrompt(page);
expect(prompt[`${result.host}:${result.inner}`]?.inputs.text).toBe('Inner only choice');
});
test('a workflow saved before #343 with legacy Primitives on editor text keeps running their values (#343 review I1)', async ({ page }) => {
const workflow = JSON.parse(await readFile(new URL('./fixtures/workflow-before-343-primitive.json', import.meta.url), 'utf8'));
const result = await page.evaluate(async workflow => {
const { app } = await import('/scripts/app.js');
await app.loadGraphData(structuredClone(workflow));
const before = (await app.graphToPrompt()).output;
app.graph.getNodeById(4).widgets[0].value = 'Edited after the load';
const after = (await app.graphToPrompt()).output;
const bound = id => app.graph.getNodeById(id).inputs.filter(input => ['text', 'negative_text'].includes(input.name) && input.widget).length;
return { before: [before['1'].inputs.text, before['2'].inputs.text, before['2'].inputs.negative_text], after: after['2'].inputs.negative_text, bound: [bound(1), bound(2)],
links: [app.graph.getNodeById(1).inputs.find(input => input.name === 'text').link != null, app.graph.getNodeById(2).inputs.find(input => input.name === 'negative_text').link != null] };
}, workflow);
expect(result).toEqual({ before: ['Primitive focused text', 'Typed pair positive', 'Primitive pair negative'], after: 'Edited after the load', bound: [0, 0], links: [true, true] });
const prompt = await runPrompt(page);
expect([prompt['1'].inputs.text, prompt['2'].inputs.negative_text]).toEqual(['Primitive focused text', 'Edited after the load']);
});
test('moving a linked text and dropping it on the editor body keeps it on text, never prepend_text (#343 review I2)', async ({ page }) => {
test.setTimeout(90000);
const ids = await addEditors(page);
await page.evaluate(async ({ focused, pair, string }) => {
const { app } = await import('/scripts/app.js');
const source = app.graph.getNodeById(string);
for (const id of [focused, pair]) { const node = app.graph.getNodeById(id); source.connect(0, node, node.inputs.findIndex(input => input.name === 'text')); }
app.canvas.setDirty(true, true);
}, ids);
for (const [id, title] of [[ids.focused, 0], [ids.pair, 1]]) {
// Grab the link at the text socket and release it over the editor's toolbar, away from any prompt box.
const toolbar = page.locator('.pm-editor-toolbar strong').nth(title);
await drag(page, await slotPosition(page, id, 'in', 'text'), center(await toolbar.boundingBox()));
await expect.poll(() => linkedInputs(page, id)).toEqual(['text']);
}
});
test('paste, clone and undo keep the editor text a linked socket (#343 review M7)', async ({ page }) => {
const ids = await addEditors(page);
const state = await page.evaluate(async ({ pair, string }) => {
const { app } = await import('/scripts/app.js');
const node = app.graph.getNodeById(pair);
app.graph.getNodeById(string).connect(0, node, node.inputs.findIndex(input => input.name === 'text'));
const describe = target => ({ bound: target.inputs.filter(input => ['text', 'negative_text'].includes(input.name) && input.widget).length,
linked: target.inputs.find(input => input.name === 'text').link != null });
app.canvas.selectItems([node]);
app.canvas.copyToClipboard();
app.canvas.pasteFromClipboard({ connectInputs: true });
const pasted = app.graph._nodes.find(item => item.type === 'PromptManagerPair' && item.id !== pair);
const clone = node.clone();
app.graph.add(clone);
const output = (await app.graphToPrompt()).output;
return { pasted: describe(pasted), clone: describe(clone), queued: output[String(pasted.id)].inputs.text, pastedId: pasted.id };
}, ids);
expect(state).toMatchObject({ pasted: { bound: 0, linked: true }, clone: { bound: 0, linked: false }, queued: [String(ids.string), 0] });
// Undo restores the graph through a workflow load: the restored link is still a socket link.
const undone = await page.evaluate(async ({ pair }) => {
const { app } = await import('/scripts/app.js');
const tracker = app.extensionManager?.workflow?.activeWorkflow?.changeTracker;
if (!tracker) throw new Error('no change tracker on the active workflow');
tracker.checkState();
const node = app.graph.getNodeById(pair);
node.disconnectInput(node.inputs.findIndex(input => input.name === 'text'));
tracker.checkState();
await tracker.undo();
const restored = app.graph.getNodeById(pair);
return { bound: restored.inputs.filter(input => ['text', 'negative_text'].includes(input.name) && input.widget).length,
text: (await app.graphToPrompt()).output[String(pair)].inputs.text };
}, ids);
expect(undone).toEqual({ bound: 0, text: [String(ids.string), 0] });
});
test('PromptSearchList text is a core widget socket and takes a dragged STRING link too', async ({ page }) => {
const { id, sources } = await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
const node = LiteGraph.createNode('PromptSearchList');
app.graph.add(node);
node.pos = [500, 80];
const source = LiteGraph.createNode('PrimitiveString');
app.graph.add(source);
source.pos = [40, 80];
app.canvas.ds.scale = .8;
app.canvas.ds.offset = [40, 40];
app.canvas.setDirty(true, true);
return { id: node.id, sources: [source.id] };
});
// A core widget's socket shows while a link is dragged; aim at where the frontend places it.
await expect.poll(async () => Boolean(await slotPosition(page, id, 'in', 'text'))).toBe(true);
await drag(page, await slotPosition(page, sources[0], 'out', 0), await slotPosition(page, id, 'in', 'text'));
await expect.poll(() => linkedInputs(page, id)).toEqual(['text']);
expect((await queued(page, id)).text).toEqual([String(sources[0]), 0]);
});
test('workflows saved before #343 load unchanged and their linked text keeps working', async ({ page }) => {
const workflow = JSON.parse(await readFile(new URL('./fixtures/workflow-before-343.json', import.meta.url), 'utf8'));
const result = await page.evaluate(async workflow => {
const { app } = await import('/scripts/app.js');
await app.loadGraphData(structuredClone(workflow));
const output = (await app.graphToPrompt()).output;
const nodes = Object.fromEntries(app.graph._nodes.map(node => [node.id, node]));
const reloaded = app.graph.serialize();
return {
values: Object.fromEntries(reloaded.nodes.map(node => [node.id, node.widgets_values])),
links: Object.fromEntries(Object.entries(output).map(([id, node]) => [id, { text: node.inputs.text, negative_text: node.inputs.negative_text }])),
widgetBound: Object.fromEntries(Object.values(nodes).filter(node => node.type.startsWith('PromptManager')).map(node => [node.id, node.inputs.filter(input => ['text', 'negative_text'].includes(input.name) && input.widget).map(input => input.name)])),
};
}, workflow);
for (const node of workflow.nodes) {
// Every saved widget value comes back as it was saved, except the typed text main lost under a link (null).
if (node.widgets_values?.[0] !== null) expect(result.values[node.id], `${node.type} ${node.id}`).toEqual(node.widgets_values);
}
expect(result.links).toMatchObject({
1: { text: 'Saved PromptManager text' }, 2: { text: 'Saved PromptManagerText text' }, 3: { text: 'Saved PromptManagerFocused text' },
4: { text: 'Saved PromptManagerSplit text' }, 5: { text: 'Saved PromptManagerPair text', negative_text: 'Saved pair negative' },
6: { text: ['7', 0] }, 8: { text: ['9', 0], negative_text: ['10', 0] }, 11: { text: ['12', 0] },
});
// The editor nodes' text inputs are sockets now; the Classic nodes keep their core widget sockets.
expect(result.widgetBound).toEqual({ 1: ['text'], 2: ['text'], 3: [], 4: [], 5: [], 6: [], 8: [], 11: ['text'] });
// The linked sockets are drawn where a user can see and re-drag them, in either renderer.
for (const [node, name] of [[6, 'text'], [8, 'text'], [8, 'negative_text']]) {
await page.evaluate(async node => { const { app } = await import('/scripts/app.js'); app.canvas.centerOnNode(app.graph.getNodeById(node)); app.canvas.setDirty(true, true); }, node);
await expect.poll(async () => {
const position = await slotPosition(page, node, 'in', name);
return Boolean(position && position[0] > 0 && position[1] > 0 && position[0] < 1600 && position[1] < 1100);
}, { message: `${node}.${name} socket on screen` }).toBe(true);
}
const unplugged = await page.evaluate(async () => {
const { app } = await import('/scripts/app.js');
for (const id of [6, 8]) { const node = app.graph.getNodeById(id); for (const input of [...node.inputs]) if (input.link != null) node.disconnectInput(node.inputs.indexOf(input)); }
const output = (await app.graphToPrompt()).output;
return { focused: output['6'].inputs.text, positive: output['8'].inputs.text, negative: output['8'].inputs.negative_text };
});
expect(unplugged).toEqual({ focused: 'Typed under focused link', positive: 'Typed pair positive', negative: 'Typed pair negative' });
});
-51
View File
@@ -186,57 +186,6 @@
"prompt": null,
"workflow": "{\"nodes\": [{\"id\": 4, \"type\": \"CheckpointLoaderSimple\", \"mode\": 0, \"inputs\": [], \"widgets_values\": [\"example.safetensors\"]}, {\"id\": 6, \"type\": \"CLIPTextEncode\", \"mode\": 0, \"inputs\": [{\"name\": \"clip\", \"link\": 3}], \"widgets_values\": [\"a tidepool at noon\"]}, {\"id\": 7, \"type\": \"CLIPTextEncode\", \"mode\": 0, \"inputs\": [{\"name\": \"clip\", \"link\": 5}], \"widgets_values\": [\"noisy\"]}, {\"id\": 10, \"type\": \"Reroute\", \"mode\": 0, \"inputs\": [{\"name\": \"\", \"link\": 7}]}, {\"id\": 3, \"type\": \"KSampler\", \"mode\": 0, \"inputs\": [{\"name\": \"model\", \"link\": 1}, {\"name\": \"positive\", \"link\": 8}, {\"name\": \"negative\", \"link\": 4}, {\"name\": \"latent_image\", \"link\": 2}], \"widgets_values\": [314, \"randomize\", 25, 6.5, \"dpmpp_2m\", \"karras\", 1]}, {\"id\": 11, \"type\": \"CLIPTextEncode\", \"mode\": 2, \"inputs\": [], \"widgets_values\": [\"muted node text\"]}], \"links\": [[1, 4, 0, 3, 0, \"MODEL\"], [3, 4, 1, 6, 0, \"CLIP\"], [5, 4, 1, 7, 0, \"CLIP\"], [7, 6, 0, 10, 0, \"CONDITIONING\"], [8, 10, 0, 3, 1, \"CONDITIONING\"], [4, 7, 0, 3, 2, \"CONDITIONING\"], {\"id\": 2, \"origin_id\": 99, \"origin_slot\": 0, \"target_id\": 3, \"target_slot\": 3, \"type\": \"LATENT\"}]}"
},
{
"expected": {
"ok": true,
"result": {
"cfgScale": 5,
"checkpoint": "example.safetensors",
"negativePrompt": "plain negative",
"positivePrompt": "a detached positive",
"sampler": "euler",
"seed": 7,
"steps": 12
}
},
"name": "case:workflow-only-detached-editor-text",
"prompt": null,
"workflow": "{\"nodes\": [{\"id\": 4, \"type\": \"CheckpointLoaderSimple\", \"mode\": 0, \"inputs\": [], \"widgets_values\": [\"example.safetensors\"]}, {\"id\": 6, \"type\": \"PromptManagerFocused\", \"mode\": 0, \"inputs\": [{\"name\": \"clip\", \"link\": 3}, {\"name\": \"prepend_text\", \"link\": null}, {\"name\": \"append_text\", \"link\": null}, {\"name\": \"text\", \"link\": null}, {\"name\": \"category\", \"widget\": {\"name\": \"category\"}, \"link\": null}, {\"name\": \"tags\", \"widget\": {\"name\": \"tags\"}, \"link\": null}, {\"name\": \"search_text\", \"widget\": {\"name\": \"search_text\"}, \"link\": null}], \"widgets_values\": [\"a detached positive\", \"a category\", \"a tag\", \"\", \"\", \"\"]}, {\"id\": 7, \"type\": \"CLIPTextEncode\", \"mode\": 0, \"inputs\": [{\"name\": \"clip\", \"link\": 5}], \"widgets_values\": [\"plain negative\"]}, {\"id\": 3, \"type\": \"KSampler\", \"mode\": 0, \"inputs\": [{\"name\": \"model\", \"link\": 1}, {\"name\": \"positive\", \"link\": 8}, {\"name\": \"negative\", \"link\": 4}], \"widgets_values\": [7, \"fixed\", 12, 5, \"euler\", \"normal\", 1]}], \"links\": [[1, 4, 0, 3, 0, \"MODEL\"], [3, 4, 1, 6, 0, \"CLIP\"], [5, 4, 1, 7, 0, \"CLIP\"], [8, 6, 0, 3, 1, \"CONDITIONING\"], [4, 7, 0, 3, 2, \"CONDITIONING\"]]}"
},
{
"expected": {
"ok": true,
"result": {
"cfgScale": 5,
"checkpoint": "example.safetensors",
"negativePrompt": "a detached negative",
"positivePrompt": "a detached positive",
"sampler": "euler",
"seed": 7,
"steps": 12
}
},
"name": "case:workflow-only-detached-pair-text",
"prompt": null,
"workflow": "{\"nodes\": [{\"id\": 4, \"type\": \"CheckpointLoaderSimple\", \"mode\": 0, \"inputs\": [], \"widgets_values\": [\"example.safetensors\"]}, {\"id\": 6, \"type\": \"PromptManagerPair\", \"mode\": 0, \"inputs\": [{\"name\": \"clip\", \"link\": 3}, {\"name\": \"prepend_text\", \"link\": null}, {\"name\": \"append_text\", \"link\": null}, {\"name\": \"text\", \"link\": null}, {\"name\": \"negative_text\", \"link\": null}, {\"name\": \"category\", \"widget\": {\"name\": \"category\"}, \"link\": null}, {\"name\": \"tags\", \"widget\": {\"name\": \"tags\"}, \"link\": null}, {\"name\": \"search_text\", \"widget\": {\"name\": \"search_text\"}, \"link\": null}], \"widgets_values\": [\"a detached positive\", \"a detached negative\", \"a category\", \"a tag\", \"\", \"\", \"\"]}, {\"id\": 3, \"type\": \"KSampler\", \"mode\": 0, \"inputs\": [{\"name\": \"model\", \"link\": 1}, {\"name\": \"positive\", \"link\": 8}, {\"name\": \"negative\", \"link\": 4}], \"widgets_values\": [7, \"fixed\", 12, 5, \"euler\", \"normal\", 1]}], \"links\": [[1, 4, 0, 3, 0, \"MODEL\"], [3, 4, 1, 6, 0, \"CLIP\"], [5, 4, 1, 7, 0, \"CLIP\"], [8, 6, 0, 3, 1, \"CONDITIONING\"], [4, 6, 1, 3, 2, \"CONDITIONING\"]]}"
},
{
"expected": {
"ok": true,
"result": {
"cfgScale": 4.5,
"checkpoint": "example.safetensors",
"negativePrompt": "plain negative",
"positivePrompt": "a detached positive",
"sampler": "dpmpp_2m",
"seed": 9,
"steps": 30
}
},
"name": "case:workflow-only-core-widget-input-out-of-order",
"prompt": null,
"workflow": "{\"nodes\": [{\"id\": 4, \"type\": \"CheckpointLoaderSimple\", \"mode\": 0, \"inputs\": [], \"widgets_values\": [\"example.safetensors\"]}, {\"id\": 6, \"type\": \"PromptManagerFocused\", \"mode\": 0, \"inputs\": [{\"name\": \"clip\", \"link\": 3}, {\"name\": \"prepend_text\", \"link\": null}, {\"name\": \"append_text\", \"link\": null}, {\"name\": \"text\", \"link\": null}, {\"name\": \"category\", \"widget\": {\"name\": \"category\"}, \"link\": null}, {\"name\": \"tags\", \"widget\": {\"name\": \"tags\"}, \"link\": null}, {\"name\": \"search_text\", \"widget\": {\"name\": \"search_text\"}, \"link\": null}], \"widgets_values\": [\"a detached positive\", \"a category\", \"a tag\", \"\", \"\", \"\"]}, {\"id\": 7, \"type\": \"CLIPTextEncode\", \"mode\": 0, \"inputs\": [{\"name\": \"clip\", \"link\": 5}], \"widgets_values\": [\"plain negative\"]}, {\"id\": 3, \"type\": \"KSampler\", \"mode\": 0, \"inputs\": [{\"name\": \"model\", \"link\": 1}, {\"name\": \"positive\", \"link\": 8}, {\"name\": \"negative\", \"link\": 4}, {\"name\": \"cfg\", \"widget\": {\"name\": \"cfg\"}, \"link\": null}], \"widgets_values\": [9, \"fixed\", 30, 4.5, \"dpmpp_2m\", \"karras\", 1]}], \"links\": [[1, 4, 0, 3, 0, \"MODEL\"], [3, 4, 1, 6, 0, \"CLIP\"], [5, 4, 1, 7, 0, \"CLIP\"], [8, 6, 0, 3, 1, \"CONDITIONING\"], [4, 7, 0, 3, 2, \"CONDITIONING\"]]}"
},
{
"expected": {
"ok": true,
-89
View File
@@ -244,90 +244,6 @@ def _workflow_only():
}
def _detached_editor_text(pair):
"""#343: editor text inputs are sockets, not widget inputs; no named values."""
names = ["text"] + (["negative_text"] if pair else [])
sockets = [
{"name": "clip", "link": 3},
{"name": "prepend_text", "link": None},
{"name": "append_text", "link": None},
]
sockets += [{"name": name, "link": None} for name in names]
sockets += [
{"name": name, "widget": {"name": name}, "link": None}
for name in ("category", "tags", "search_text")
]
editor = {
"id": 6,
"type": "PromptManagerPair" if pair else "PromptManagerFocused",
"mode": 0,
"inputs": sockets,
"widgets_values": ["a detached positive"]
+ (["a detached negative"] if pair else [])
+ ["a category", "a tag", "", "", ""],
}
negative = (
[]
if pair
else [
{
"id": 7,
"type": "CLIPTextEncode",
"mode": 0,
"inputs": [{"name": "clip", "link": 5}],
"widgets_values": ["plain negative"],
}
]
)
return {
"nodes": [
{
"id": 4,
"type": "CheckpointLoaderSimple",
"mode": 0,
"inputs": [],
"widgets_values": ["example.safetensors"],
},
editor,
*negative,
{
"id": 3,
"type": "KSampler",
"mode": 0,
"inputs": [
{"name": "model", "link": 1},
{"name": "positive", "link": 8},
{"name": "negative", "link": 4},
],
"widgets_values": [7, "fixed", 12, 5, "euler", "normal", 1],
},
],
"links": [
[1, 4, 0, 3, 0, "MODEL"],
[3, 4, 1, 6, 0, "CLIP"],
[5, 4, 1, 7, 0, "CLIP"],
[8, 6, 0, 3, 1, "CONDITIONING"],
[4, 6 if pair else 7, 1 if pair else 0, 3, 2, "CONDITIONING"],
],
}
def _core_widget_input_out_of_order():
"""#343 review R4: a core KSampler whose only widget input is ``cfg``.
Older frontends saved only converted or linked widget inputs, so the list
is not a prefix of the schema and carries no named values; the positional
values must still land on the schema's names.
"""
workflow = _detached_editor_text(False)
sampler = workflow["nodes"][-1]
sampler["inputs"] = sampler["inputs"] + [
{"name": "cfg", "widget": {"name": "cfg"}, "link": None}
]
sampler["widgets_values"] = [9, "fixed", 30, 4.5, "dpmpp_2m", "karras", 1]
return workflow
def _nested(depth):
"""A value nested ``depth`` arrays deep, as a crafted file can hold."""
value = ["leaf", 7]
@@ -473,11 +389,6 @@ CASES = {
}
),
"workflow-only": case(workflow=_workflow_only()),
"workflow-only-detached-editor-text": case(workflow=_detached_editor_text(False)),
"workflow-only-detached-pair-text": case(workflow=_detached_editor_text(True)),
"workflow-only-core-widget-input-out-of-order": case(
workflow=_core_widget_input_out_of_order()
),
"overwritten-prompt-manager-text": case(
{
"4": CHECKPOINT,
-10
View File
@@ -388,16 +388,6 @@ test("ApiError keeps a validated running job on busy and a validated source on i
assert.deepEqual([new ApiError(409, "old caller").running, new ApiError(409, "old caller").source], [null, null]);
});
test("ApiError keeps a validated operation on model-busy only (#328)", () => {
const body = { operation: "Tagging model download · WD14 ViT" };
assert.equal(new ApiError(409, "busy", "model-busy", null, body).operation, "Tagging model download · WD14 ViT");
assert.equal(new ApiError(409, "busy", "busy", null, body).operation, null);
for (const operation of [null, 7, "", "x".repeat(201)]) {
assert.equal(new ApiError(409, "busy", "model-busy", null, { operation }).operation, null);
}
assert.equal(new ApiError(409, "busy", "model-busy").operation, null);
});
test("ApiError says whether the server itself refused (a parsed success:false body; S7 review I1)", () => {
assert.equal(new ApiError(400, "bad", null, null, { success: false, error: "bad" }).refused, true);
assert.equal(new ApiError(409, "busy", "busy", null, { success: false, code: "busy" }).refused, true);
-201
View File
@@ -1,201 +0,0 @@
// #343: a link can drive the editor nodes' text inputs (web/js/text-links.js).
const test = require('node:test');
const assert = require('node:assert/strict');
const source = import('../../web/js/text-links.js');
// The frontend's own slot order for the Focused/Split/Pair schemas: forceInput sockets first, then the widget sockets.
function pairNode() {
const inputs = [
{name:'clip',type:'CLIP',link:null},
{name:'prepend_text',type:'STRING',link:null},
{name:'append_text',type:'STRING',link:null},
{name:'text',type:'STRING',link:null,widget:{name:'text'},_widget:{name:'text'}},
{name:'negative_text',type:'STRING',link:null,widget:{name:'negative_text'}},
{name:'category',type:'STRING',link:null,widget:{name:'category'}},
];
return {inputs};
}
test('the editor note says the value comes from a link and that typed text is kept', async () => {
const {linkNoteText} = await source;
assert.equal(linkNoteText('Negative prompt'), 'Negative prompt comes from a link. Your typed text is kept for when you disconnect it.');
});
test('a linked widget stores its typed value; an unlinked one its live editor text', async () => {
const {storedWidgetValue} = await source;
const widget = {name:'text', value:'typed', inputEl:{value:'live'}};
assert.equal(storedWidgetValue({inputs:[{name:'text',link:3}]}, widget), 'typed');
assert.equal(storedWidgetValue({inputs:[{name:'text',link:null}]}, widget), 'live');
assert.equal(storedWidgetValue({}, {name:'tags', value:'a, b'}), 'a, b');
});
test('exposing a socket detaches only the named inputs from their hidden widgets', async () => {
const {exposeTextSockets} = await source;
const node = pairNode();
assert.deepEqual(exposeTextSockets(node, ['text','negative_text']), ['text','negative_text']);
const byName = Object.fromEntries(node.inputs.map(input => [input.name,input]));
for (const name of ['text','negative_text']) {
assert.equal('widget' in byName[name], false);
assert.equal(byName[name]._widget, undefined);
assert.equal(byName[name].type, 'STRING');
}
assert.deepEqual(byName.category.widget, {name:'category'});
assert.deepEqual(exposeTextSockets(node, ['text','negative_text']), [], 'idempotent');
assert.deepEqual(exposeTextSockets({}, ['text']), [], 'a node without inputs is left alone');
});
test('exposing a socket keeps an existing link and the widget value', async () => {
const {exposeTextSockets,isLinked} = await source;
const node = pairNode();
node.inputs[3].link = 12;
exposeTextSockets(node, ['text']);
assert.equal(node.inputs[3].link, 12);
assert.equal(isLinked(node,'text'), true);
assert.equal(isLinked(node,'negative_text'), false);
assert.equal(isLinked({},'text'), false);
});
test('a STRING link dropped on the editor prefers the first free text input over prepend', async () => {
const {preferredTextSlot} = await source;
const node = pairNode();
assert.deepEqual(preferredTextSlot(node, 'STRING', ['text','negative_text']), {index:3,slot:node.inputs[3]});
node.inputs[3].link = 4;
assert.deepEqual(preferredTextSlot(node, 'STRING', ['text','negative_text']), {index:4,slot:node.inputs[4]});
node.inputs[4].link = 5;
assert.equal(preferredTextSlot(node, 'STRING', ['text','negative_text']), undefined, 'both linked: the frontend chooses');
});
test('only exact STRING links are steered; wildcards keep the frontend choice (#343 review M2)', async () => {
const {preferredTextSlot} = await source;
const node = pairNode();
for (const type of ['STRING','STRING,INT',' STRING ']) assert.equal(preferredTextSlot(node, type, ['text'])?.index, 3, type);
for (const type of ['*','','CLIP','INT','IMAGE',undefined,null,0]) assert.equal(preferredTextSlot(node, type, ['text']), undefined, String(type));
assert.equal(preferredTextSlot({}, 'STRING', ['text']), undefined);
});
test('a link being moved off a text input leaves it free, as the frontend counts it (#343 review I2)', async () => {
const {preferredTextSlot} = await source;
const links = new Map([[7,{id:7,_dragging:true}],[8,{id:8}]]);
const node = Object.assign(pairNode(), {graph:{getLink:id => links.get(id)}});
node.inputs[3].link = 7;
assert.equal(preferredTextSlot(node, 'STRING', ['text','negative_text']).slot.name, 'text', 'the dragged link does not block text');
links.get(7)._dragging = false;
assert.equal(preferredTextSlot(node, 'STRING', ['text','negative_text']).slot.name, 'negative_text');
node.inputs[4].link = 8;
assert.equal(preferredTextSlot(node, 'STRING', ['text','negative_text']), undefined);
node.inputs[4].link = 99;
assert.equal(preferredTextSlot(node, 'STRING', ['text','negative_text']).slot.name, 'negative_text', 'a link the graph no longer has is free');
const legacy = Object.assign(pairNode(), {graph:{links:{5:{id:5,_dragging:true}}}});
legacy.inputs[3].link = 5;
assert.equal(preferredTextSlot(legacy, 'STRING', ['text']).slot.name, 'text', 'object-keyed link tables too');
});
function primitiveGraph() {
const nodes = new Map();
const links = new Map();
const graph = {getNodeById:id => nodes.get(id), getLink:id => links.get(id)};
const add = (id, node) => { nodes.set(id, {id, graph, ...node}); return nodes.get(id); };
return {graph, nodes, links, add};
}
test('a legacy frontend Primitive feeding a detached text input supplies its value (#343 review I1)', async () => {
const {primitiveTextValue} = await source;
const {nodes, links, add} = primitiveGraph();
const target = add(1, {inputs:[{name:'text',link:10},{name:'negative_text',link:11},{name:'prepend_text',link:12},{name:'category',link:null}]});
add(2, {type:'PrimitiveNode', isVirtualNode:true, widgets:[{value:'From the primitive'}]});
add(3, {type:'Reroute', isVirtualNode:true, inputs:[{link:13}]});
add(4, {type:'PrimitiveNode', isVirtualNode:true, widgets:[{value:42}]});
add(5, {type:'PrimitiveStringMultiline', widgets:[{value:'a real node'}]});
links.set(10, {origin_id:2}); links.set(11, {origin_id:3}); links.set(13, {origin_id:4}); links.set(12, {origin_id:5});
assert.equal(await primitiveTextValue(target, 'text'), 'From the primitive');
assert.equal(await primitiveTextValue(target, 'negative_text'), '42', 'through a virtual reroute');
assert.equal(await primitiveTextValue(target, 'prepend_text'), undefined, 'a real node is serialized as a link');
assert.equal(await primitiveTextValue(target, 'category'), undefined, 'not linked');
assert.equal(await primitiveTextValue(target, 'missing'), undefined);
nodes.get(2).widgets = [];
assert.equal(await primitiveTextValue(target, 'text'), undefined, 'a primitive without its widget yet');
links.set(13, {origin_id:3});
assert.equal(await primitiveTextValue(target, 'negative_text'), undefined, 'a reroute cycle ends');
assert.equal(await primitiveTextValue({inputs:[{name:'text',link:1}]}, 'text'), undefined, 'no graph');
});
test('an input still bound to its widget is left to the host, which applies the Primitive itself (#343 re-review R1)', async () => {
const {primitiveTextValue} = await source;
const {links, add} = primitiveGraph();
const target = add(1, {inputs:[{name:'text',link:10,widget:{name:'text'}}], widgets:[{name:'text',value:'typed'}]});
add(2, {type:'PrimitiveNode', isVirtualNode:true, widgets:[{value:'{red|blue}'}]});
links.set(10, {origin_id:2});
assert.equal(await primitiveTextValue(target, 'text'), undefined);
});
test('a detached input runs the Primitive value the way the host runs a widget value: replace, then the widget serializer (#343 re-review R1)', async () => {
const {primitiveTextValue} = await source;
const {graph, links, add} = primitiveGraph();
const calls = [];
const widget = {name:'text', value:'typed under the link', serializeValue(...args) { calls.push([this.value, args.length]); return this.value.replace('{alpha|beta}', 'alpha'); }};
const target = add(1, {inputs:[{name:'text',link:10},{name:'negative_text',link:11}], widgets:[widget]});
const primitive = add(2, {type:'PrimitiveNode', isVirtualNode:true, properties:{'Run widget replace on values':false}, widgets:[{value:'a %year% {alpha|beta} car'}]});
add(3, {type:'PrimitiveNode', isVirtualNode:true, properties:{'Run widget replace on values':true}, widgets:[{value:'no serializer %year%'}]});
links.set(10, {origin_id:2}); links.set(11, {origin_id:3});
const replaced = [];
const replaceText = (from, value) => { replaced.push(from); return value.replace('%year%', '2026'); };
assert.equal(await primitiveTextValue(target, 'text', {replaceText}), 'a %year% alpha car', 'replace stays off unless the Primitive asks for it');
assert.deepEqual(calls, [['a %year% {alpha|beta} car', 0]], 'serialized once, without a node, so widgets_values is not touched');
assert.equal(widget.value, 'typed under the link', 'the typed text is restored');
primitive.properties['Run widget replace on values'] = true;
assert.equal(await primitiveTextValue(target, 'text', {replaceText}), 'a 2026 alpha car');
assert.deepEqual(replaced, [graph]);
assert.equal(await primitiveTextValue(target, 'negative_text', {replaceText}), 'no serializer 2026', 'no widget serializer: the replaced value');
assert.equal(await primitiveTextValue(target, 'negative_text'), 'no serializer %year%', 'no replacer: the value as is');
const failing = () => { throw new Error('not this host'); };
assert.equal(await primitiveTextValue(target, 'negative_text', {replaceText:failing}), 'no serializer %year%', 'a replacer that fails leaves the value');
widget.serializeValue = () => { throw new Error('broken serializer'); };
await assert.rejects(primitiveTextValue(target, 'text'), /broken serializer/);
assert.equal(widget.value, 'typed under the link', 'restored even when the serializer throws');
});
test('installing the preference wraps findInputByType and restores nothing it did not own', async () => {
const {preferTextInputs} = await source;
const calls = [];
const prototype = {findInputByType(type) { calls.push(type); return {index:1,slot:this.inputs[1]}; }};
preferTextInputs(prototype, ['text']);
const node = Object.assign(Object.create(prototype), pairNode());
assert.equal(node.findInputByType('STRING').index, 3);
assert.deepEqual(calls, []);
assert.equal(node.findInputByType('CLIP').index, 1);
node.inputs[3].link = 9;
assert.equal(node.findInputByType('STRING').index, 1, 'the main prompt is taken: fall back to the frontend');
assert.deepEqual(calls, ['CLIP','STRING']);
const bare = {};
preferTextInputs(bare, ['text']);
assert.equal(bare.findInputByType, undefined, 'an older frontend without findInputByType is left unchanged');
});
test('a drop point over a visible prompt box or its note picks that box input (#343 review M1)', async () => {
const {textInputAtPoint} = await source;
const box = (left, top, width, height) => ({getBoundingClientRect:() => ({left, top, width, height, right:left + width, bottom:top + height})});
const node = pairNode();
const targets = [{name:'text',element:box(0,0,100,100)},{name:'negative_text',element:box(120,0,100,100)},{name:'text',element:box(0,0,0,0)},{name:'missing',element:box(300,0,50,50)}];
assert.equal(textInputAtPoint(node, [150,50], targets).name, 'negative_text');
assert.equal(textInputAtPoint(node, [10,10], targets).name, 'text');
assert.equal(textInputAtPoint(node, [110,50], targets), undefined, 'between the boxes');
assert.equal(textInputAtPoint(node, [320,10], targets), undefined, 'a target whose input the node lacks');
assert.equal(textInputAtPoint(node, [0,0], [{name:'text',element:box(0,0,0,0)}]), undefined, 'a hidden target');
});
test('a linked Classic text box gives its row back and gets its size back on disconnect (#343 review M5)', async () => {
const {collapseLinkedWidget} = await source;
const own = () => 120;
const widget = {options:{getMinHeight:own}};
collapseLinkedWidget(widget, true);
assert.equal(widget.options.getMinHeight(), 0);
assert.equal(widget.options.getMaxHeight(), 0);
collapseLinkedWidget(widget, true);
collapseLinkedWidget(widget, false);
assert.equal(widget.options.getMinHeight, own);
assert.equal('getMaxHeight' in widget.options, false, 'an option it did not have is removed again');
collapseLinkedWidget(widget, false);
assert.equal(widget.options.getMinHeight, own);
collapseLinkedWidget(undefined, true);
collapseLinkedWidget({}, true);
});
-23
View File
@@ -225,26 +225,3 @@ test('a failed duplicate cleanup says the server reason (S7 review M3)', async (
assert.equal(finishToast(failed), `Duplicate cleanup failed · ${reason}`);
assert.equal(finishToast({ ...failed, status_line: '' }), 'Duplicate cleanup failed · 0 removed · 1 error');
});
// #328: Tag output images (`autotag`) reuses the image tagging view: Tagged / Unchanged / Skipped / Errors and Cancel.
// The legacy page's hold of the same kind counts nothing and is not cancellable, so it keeps the generic held view.
test('autotag gets Tagged / Unchanged / Skipped / Errors and Cancel; its legacy hold keeps the generic view', async () => {
const { kindOf, viewOf, canCancel } = await source;
const { startToast, finishToast } = await store;
const counters = { tagged: 3, unchanged: 1, skipped: 2, errors: 0 };
const batch = job({ kind: 'autotag', title: 'Tag output images', counters, scope: { images: 6 } });
const view = viewOf(batch);
assert.deepEqual(view.tiles.map(([group, label]) => [group, label]),
[['tagged', 'Tagged'], ['unchanged', 'Unchanged'], ['skipped', 'Skipped'], ['errors', 'Errors']]);
assert.equal(kindOf('autotag').pillVerb, 'Tagging');
assert.equal(kindOf('autotag').cancelLabel, 'Cancel tagging');
assert.equal(canCancel(batch), true);
assert.equal(startToast(batch), 'Tag output images started.');
assert.match(finishToast({ ...batch, state: 'finished', finished_at: 2 }), /^Tag output images finished/);
const held = job({ kind: 'autotag', title: 'Tag output images (legacy page)', counters: {}, cancellable: false,
progress: 0, processed: 0, total: 0 });
const legacy = viewOf(held);
assert.deepEqual([legacy.tiles, legacy.outcomes, [...legacy.openable]], [[], [], []]);
assert.equal(canCancel(held), false);
});
+1 -47
View File
@@ -197,52 +197,6 @@ test('instructions default to the server prompt', async () => {
assert.equal(c.state.options.suggestion_limit, 7, 'the suggestion count survives a model change');
});
// #328: one tagging configuration. The dialogs start on the model saved in System › Auto-tagging, unless a preset
// (a review session's choice) or the user already picked one; an unknown saved model keeps the first option.
test('dialog starts on the saved model unless a preset or the user chose one', async () => {
const saved = { [`GET ${MODELS}`]: () => response(200, models({ model_type: 'gguf' })) };
const { c } = await opened(saved);
assert.equal(c.state.model, 'gguf');
assert.equal(c.state.options.instructions, DEFAULT_PROMPT);
const { c: preset } = await opened(saved, { preset: { model: 'wd14-vit', options: {} } });
assert.equal(preset.state.model, 'wd14-vit');
const { createImageTaggingController, createTaggingOptionsController } = await load();
const h = harness(defaultRoutes(saved));
const picked = createImageTaggingController({ api: h.api, target: TARGET, source: 'images' });
picked.setModel('hf');
await picked.open();
await settle();
assert.equal(picked.state.model, 'hf', 'the user picked a model before the read landed');
// A pick made while the models read is still in flight wins over the saved model that lands after it.
const gate = deferred();
const late = harness(defaultRoutes({ [`GET ${MODELS}`]: () => gate.promise }));
const racing = createImageTaggingController({ api: late.api, target: TARGET, source: 'images' });
const opening = racing.open();
await settle();
assert.equal(racing.state.phase, 'observing');
racing.setModel('wd14-vit');
gate.resolve(response(200, models({ model_type: 'gguf' })));
await opening;
await settle();
assert.equal(racing.state.model, 'wd14-vit');
racing.dispose();
const { c: unknown } = await opened({ [`GET ${MODELS}`]: () => response(200, models({ model_type: 'wd14-convnext' })) });
assert.equal(unknown.state.model, 'wd14-swinv2');
const options = createTaggingOptionsController({ api: harness(defaultRoutes(saved)).api });
await options.open();
assert.equal(options.state.model, 'gguf');
const chosen = createTaggingOptionsController({ api: harness(defaultRoutes(saved)).api });
chosen.setModel('wd14-vit');
await chosen.open();
assert.equal(chosen.state.model, 'wd14-vit');
for (const controller of [c, preset, picked, unknown, options, chosen]) controller.dispose();
});
test('an unreadable or malformed models read falls back to the documented defaults', async () => {
for (const route of [() => fail(500, null), () => response(200, models({ default_prompt: 4, suggestion_limit: 0, wd14_general_threshold: 2, wd14_character_threshold: 'x' }))]) {
const { c } = await opened({ [`GET ${MODELS}`]: route });
@@ -588,7 +542,7 @@ test('model-unavailable and model-busy set the status line only', async () => {
await c.generate();
const before = c.state;
for (const [code, message] of [
['model-unavailable', 'WD14 SwinV2 is not downloaded. Download it in System › Auto-tagging.'],
['model-unavailable', 'WD14 SwinV2 is not downloaded. Download it from the legacy Auto-tag page.'],
['model-busy', 'A tagging model is busy with another operation. Try again when it finishes.'],
['unsupported-media', 'Images larger than 4096 px per side or 16,777,216 px are not supported.'],
]) {
@@ -129,9 +129,9 @@ test('a model that is not downloaded is named with the server sentence', () => {
const { modelUnavailable } = state;
assert.equal(modelUnavailable(null, 'wd14-vit'), '');
assert.equal(modelUnavailable({ 'wd14-vit': { downloaded: true } }, 'wd14-vit'), '');
assert.equal(modelUnavailable({ 'wd14-vit': { downloaded: false } }, 'wd14-vit'), 'WD14 ViT is not downloaded. Download it in System › Auto-tagging.');
assert.equal(modelUnavailable({ 'wd14-vit': { downloaded: false } }, 'wd14-vit'), 'WD14 ViT is not downloaded. Download it from the legacy Auto-tag page.');
assert.equal(modelUnavailable({ gguf: { downloaded: false, name: 'GGUF (Recommended)' } }, 'gguf'),
'GGUF (Recommended) is not downloaded. Download it in System › Auto-tagging.');
'GGUF (Recommended) is not downloaded. Download it from the legacy Auto-tag page.');
assert.throws(() => modelUnavailable({}, 'nope'), TypeError);
});
+4 -31
View File
@@ -108,17 +108,13 @@ test('host compatibility shows the detected renderer as status (D106)', async ()
test('the import review shows replacement, blocked items and network effects (D107)', async () => {
const { importGrid } = await integrations;
const plan = { lora_files: 2221, with_metadata: 615, with_local_media: 476, skipped: 1606, current_imports: 5,
blocked_by_pairs: 0, planned_downloads: 4,
layout: { source: 'portable', settings_file: '~/ComfyUI/custom_nodes/LM/settings.json', error: null, mode: 'centralized',
library: 'comfyui', example_images: true },
const plan = { lora_files: 3, with_metadata: 2, current_imports: 5, blocked_by_pairs: 0, planned_downloads: 4,
scope: { count: 2, label: 'Replace 5 imported prompts with 2 LoRAs' }, protected: { count: 0, reasons: [] },
backup: { policy: 'on', destination_label: 'prompts.db.backup_<date and time>' }, network: { planned_downloads: 4, api_key: true },
media: { deletes_files: false, label: 'No files are removed; image links are replaced' } };
assert.deepEqual(importGrid(plan), [
['LoraManager', 'portable settings · centralized storage · library comfyui'],
['Scope', 'Replace 5 imported prompts with 2 LoRAs'],
['Found', '2,221 metadata files · 615 LoRAs · 476 with local images'],
['Found', '3 metadata files · 2 readable'],
['Protected', 'Nothing is protected'],
['Network', 'Downloads up to 4 example images from CivitAI with your saved key'],
['Backup', 'Create and verify before changes (prompts.db.backup_<date and time>)'],
@@ -126,34 +122,11 @@ test('the import review shows replacement, blocked items and network effects (D1
]);
const blocked = importGrid({ ...plan, planned_downloads: 1, network: { api_key: false }, protected: { count: 2,
reasons: ['2 imported prompts used by saved pairs'] } });
assert.deepEqual(blocked[3], ['Protected', '2 imported prompts used by saved pairs']);
assert.deepEqual(blocked[4], ['Network', 'Downloads up to 1 example image from CivitAI']);
// A plan without a layout (no LoraManager row) and with no counts.
assert.deepEqual(blocked[2], ['Protected', '2 imported prompts used by saved pairs']);
assert.deepEqual(blocked[3], ['Network', 'Downloads up to 1 example image from CivitAI']);
assert.deepEqual(importGrid({})[0], ['Scope', '0 LoRAs']);
assert.deepEqual(importGrid({})[1], ['Found', '0 metadata files · 0 LoRAs · 0 with local images']);
assert.deepEqual(importGrid({})[3], ['Network', 'No downloads']);
assert.deepEqual(importGrid({})[5], ['Media on disk', 'No files are removed']);
assert.deepEqual(importGrid({ ...plan, lora_files: 1, with_metadata: 1, with_local_media: 0 })[2],
['Found', '1 metadata file · 1 LoRA · 0 with local images']);
});
test('the LoraManager row names what the import read, and an unusable layout closes the review (#355)', async () => {
const { layoutText, closeNote, LAYOUT_UNUSABLE, BLOCKED_NOTE, NOTHING_TO_IMPORT } = await integrations;
assert.equal(layoutText({ source: 'environment', mode: 'alongside', library: 'default', error: null }),
'environment settings · alongside storage · library default');
assert.equal(layoutText({ source: 'user config', mode: 'alongside', library: 'x', error: 'the settings file is not valid JSON' }),
'Not usable: the settings file is not valid JSON');
assert.equal(layoutText(null), null);
assert.equal(layoutText('nope'), null);
const error = 'the sidecar folder does not exist';
assert.equal(closeNote({ token: null, layout: { error } }),
'LoraManager settings: not usable: the sidecar folder does not exist. Nothing was changed.');
assert.equal(LAYOUT_UNUSABLE(error), closeNote({ token: null, layout: { error }, blocked_by_pairs: 2 }));
assert.equal(closeNote({ token: null, blocked_by_pairs: 1, layout: { error: null } }), BLOCKED_NOTE);
assert.equal(closeNote({ token: null }), NOTHING_TO_IMPORT);
assert.equal(closeNote({ token: 't', with_metadata: 0, current_imports: 0 }), NOTHING_TO_IMPORT);
assert.equal(closeNote({ token: 't', with_metadata: 1, current_imports: 0 }), null);
assert.equal(closeNote({ token: 't', with_metadata: 0, current_imports: 3 }), null);
});
test('a LoRA import job reads in Activity: tiles, outcomes and the kept collection on failure', async () => {
-16
View File
@@ -341,22 +341,6 @@ test('a host save names the settings that apply after a restart (D128)', async (
assert.equal(drafts.statusText(), 'Unsaved changes.');
});
test('a host save refused for one setting says the server reason and names no source (S11 CUJ 2a)', async () => {
const { INVALID_SETTING_CODE } = await source;
const domains = adapters({ host: { autotag_general_threshold: 0.35 } });
const drafts = await loaded(domains);
const message = 'General-tag confidence must be between 0 and 1.';
domains.outcome.host = () => { throw Object.assign(new Error(message), { code: INVALID_SETTING_CODE }); };
drafts.set('host', 'autotag_general_threshold', 1.5);
assert.deepEqual(await drafts.save(), { browser: 'clean', host: 'failed', runtime: 'clean', invalid: { source: null, message } });
assert.equal(drafts.statusText(), message);
assert.equal(drafts.invalidSource(), null);
assert.deepEqual([...drafts.failed('host')], ['autotag_general_threshold']);
assert.equal(drafts.get('host', 'autotag_general_threshold'), 1.5);
drafts.set('host', 'autotag_general_threshold', 0.4);
assert.equal(drafts.statusText(), 'Unsaved changes.');
});
test('a host save refused for one source says the server reason and names it until the next edit', async () => {
const domains = adapters({ host: { gallery_root_paths: ['output', 'output/gone'] } });
const drafts = await loaded(domains);
-600
View File
@@ -1,600 +0,0 @@
// System › Auto-tagging (#328, System S11 Tasks 6–7): the concept's rows with real data (D132 model options and live
// status, D133 suggestions per image), host drafts saved by the one Save row, and the actions: Download (an Activity
// job), Unload (direct), Preview tagging review (D115) and the reviewed Tag output images batch (D134).
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const tagging = import('../../web/js/workspace/system-tagging.mjs');
const stateModule = import('../../web/js/workspace/system-state.mjs');
const coreModule = import('../../web/js/shared/toolkit/core.js');
const CONCEPT = fs.readFileSync(path.join(__dirname, '../../docs/concepts/workspace/app.js'), 'utf8');
const SOURCE = fs.readFileSync(path.join(__dirname, '../../web/js/workspace/system-tagging.mjs'), 'utf8');
/** A minimal DOM whose elements count their live listeners (a listener's signal aborts it, as in a browser). */
function fakeDOM() {
const previous = global.document;
const elements = [];
class Element {
constructor(tag) {
this.tagName = tag.toUpperCase();
this.children = [];
this.attributes = {};
this.listeners = [];
this.dataset = {};
this.className = '';
this.id = '';
this.value = '';
this.disabled = false;
this._text = '';
elements.push(this);
}
get localName() { return this.tagName.toLowerCase(); }
get options() { return this.children.filter(child => child.tagName === 'OPTION'); }
get textContent() { return this._text + this.children.map(child => child.textContent).join(''); }
set textContent(text) { this.children = []; this._text = String(text); }
append(...nodes) {
for (const node of nodes) {
node.parentNode?.children && (node.parentNode.children = node.parentNode.children.filter(child => child !== node));
node.parentNode = this;
this.children.push(node);
}
}
remove() { if (this.parentNode) this.parentNode.children = this.parentNode.children.filter(child => child !== this); this.parentNode = null; }
setAttribute(name, value) { this.attributes[name] = String(value); }
getAttribute(name) { return Object.hasOwn(this.attributes, name) ? this.attributes[name] : null; }
removeAttribute(name) { delete this.attributes[name]; }
addEventListener(type, callback, options = {}) {
if (options.signal?.aborted) return;
const entry = { type, callback };
this.listeners.push(entry);
options.signal?.addEventListener('abort', () => { this.listeners = this.listeners.filter(item => item !== entry); });
}
removeEventListener(type, callback) { this.listeners = this.listeners.filter(item => item.type !== type || item.callback !== callback); }
dispatch(type) { return Promise.all(this.listeners.filter(item => item.type === type).map(({ callback }) => callback({ type, target: this }))); }
}
global.document = { createElement: tag => new Element(tag), activeElement: null };
const walk = (node, found = []) => { found.push(node); node.children.forEach(child => walk(child, found)); return found; };
return {
listenerCount: () => elements.reduce((sum, element) => sum + element.listeners.length, 0),
find: (root, predicate) => walk(root).find(predicate),
restore() { global.document = previous; },
};
}
const HOST = { autotag_model_type: 'wd14-swinv2', autotag_existing: 'skip', autotag_general_threshold: 0.35,
autotag_character_threshold: 0.85, autotag_suggestion_limit: 10, autotag_template: 'Describe the image as tags.' };
const entry = (label, size, extra = {}) => ({ name: label, label, size_label: size, downloaded: true, loaded: false, ...extra });
const MODELS = {
success: true,
models: { 'wd14-swinv2': entry('WD14 SwinV2', '~400 MB'), 'wd14-vit': entry('WD14 ViT', '~400 MB', { downloaded: false }),
gguf: entry('GGUF JoyCaption', '~4.5 GB', { downloaded: false, model_exists: true, mmproj_exists: false }),
hf: entry('HuggingFace JoyCaption', '~16 GB', { downloaded: false }) },
model_loaded: false, loaded_model_type: null,
};
const tick = () => new Promise(resolve => setImmediate(resolve));
const IMAGE = { filename: 'dunes_00001.png' };
async function mounted({ host = HOST, models = MODELS, modelsFail = false, image = IMAGE, start, post, hostFails = false } = {}) {
const { mountTagging } = await tagging;
const { createSystemDrafts } = await stateModule;
const { createScope } = await coreModule;
const dom = fakeDOM();
const drafts = createSystemDrafts({
readBrowser: () => ({}), readRuntime: () => ({}),
readHost: async () => { if (hostFails) throw new Error('offline'); return { values: host, revision: 'sha256:one' }; },
writeBrowser: async () => ({ persisted: true }), writeHost: async () => ({ success: true, persisted: true }),
writeRuntime: async () => ({}),
});
const scope = createScope();
const calls = { get: [], post: [], toasts: [], navigated: [], opened: [], shown: [], started: [] };
let answer = models;
const api = {
get: async (url, options) => {
calls.get.push(url);
assert.equal(options.cache, 'no-store');
if (modelsFail) throw new Error('offline');
return answer;
},
post: async (url, body) => { calls.post.push([url, body]); return post ? post(url, body) : { success: true }; },
};
const activity = { start: async (kind, body) => { calls.started.push([kind, body]); return start ? start(kind, body) : { job: { id: 'job1' } }; } };
const [surface] = mountTagging(null, { api, activity, scope, drafts, notify: text => calls.toasts.push(text),
navigate: hash => calls.navigated.push(hash), selected: () => image, openTagging: options => calls.opened.push(options),
show: (plan, options) => calls.shown.push([plan, options]) });
const byId = id => dom.find(surface, node => node.id === id);
const button = label => dom.find(surface, node => node.tagName === 'BUTTON' && node.textContent === label);
const description = () => byId('tag-model').parentNode.children[0].children[1].textContent;
await drafts.reloadHost();
await tick();
return { dom, drafts, scope, surface, byId, button, description, calls, setModels: value => { answer = value; } };
}
test('the section keeps the concept surface, rows, labels and actions in order', () => {
const start = CONCEPT.indexOf('case "tagging":');
const body = CONCEPT.slice(start, CONCEPT.indexOf('break;', start));
const rows = [...body.matchAll(/setting\("([^"]+)", "([^"]+)", "([^"]+)"/g)].map(([, id, label, description]) => [id, label, description]);
assert.deepEqual(rows.map(([id]) => id), ['tag-model', 'tag-existing', 'threshold-general', 'threshold-character']);
for (const [id, label] of rows) assert.ok(SOURCE.includes(`id: '${id}', label: '${label}'`), `${id} keeps "${label}"`);
// OQ6: the true descriptions are kept verbatim; the model's design note is replaced by its live status (D132).
for (const [id, , description] of rows.slice(1)) assert.ok(SOURCE.includes(`'${description}'`), `${id} description`);
assert.ok(!SOURCE.includes(rows[0][2]));
for (const option of ['Skip already tagged', 'Merge with existing', 'Replace after review']) assert.ok(body.includes(option) && SOURCE.includes(`'${option}'`));
assert.ok(body.includes('<h2>Auto-tagging</h2>') && SOURCE.includes("'h2', 'Auto-tagging'"));
assert.ok(body.includes('One tagging configuration for images and prompt-library actions. Every result can be reviewed before applying.'));
assert.ok(body.includes('LLM tag template') && SOURCE.includes("'LLM tag template'"));
assert.ok(body.includes('"Preview tagging review", "tag-review", "", "ca-primary"'));
// Prototype copy is never shipped.
for (const sample of ['Simulate download', 'Simulate unload', 'Ollama']) assert.ok(!SOURCE.includes(sample), sample);
});
test('model options are the four real engines with their size, in the concept order (D132, OQ5)', async () => {
const { modelOptions, MODEL_IDS, modelLabel, modelSize } = await tagging;
assert.deepEqual(MODEL_IDS, ['wd14-swinv2', 'wd14-vit', 'gguf', 'hf']);
assert.deepEqual(modelOptions(MODELS.models).map(([, label]) => label),
['WD14 SwinV2 · ~400 MB', 'WD14 ViT · ~400 MB', 'GGUF JoyCaption · ~4.5 GB', 'HuggingFace JoyCaption · ~16 GB']);
// Before the status answered (or when it cannot be read) the documented labels stand in.
assert.deepEqual(modelOptions(null), modelOptions(MODELS.models));
assert.equal(modelLabel('hf', { label: '' }), 'HuggingFace JoyCaption');
assert.equal(modelLabel('other', null), 'other');
assert.equal(modelSize('other', null), '');
});
test('the Tagging model description is the chosen model’s live status (D132)', async () => {
const { modelDescription, readStatus, STATUS_COPY } = await tagging;
const status = readStatus(MODELS);
assert.equal(modelDescription({ phase: 'loading' }, 'wd14-vit'), 'Loading model status…');
assert.equal(modelDescription({ phase: 'failed' }, 'wd14-vit'), 'Model status could not be read.');
assert.equal(modelDescription(status, 'wd14-swinv2'), 'Downloaded. Not loaded.');
assert.equal(modelDescription(status, 'wd14-vit'), 'Not downloaded. Download size ~400 MB.');
assert.equal(modelDescription(status, 'hf'), 'Not downloaded. Download size ~16 GB.');
assert.equal(modelDescription(status, 'gguf'), 'Partly downloaded. Download again to finish.');
const both = readStatus({ ...MODELS, models: { ...MODELS.models, gguf: entry('GGUF JoyCaption', '~4.5 GB', { downloaded: false,
model_exists: false, mmproj_exists: false }) } });
assert.equal(modelDescription(both, 'gguf'), 'Not downloaded. Download size ~4.5 GB.');
const loaded = readStatus({ ...MODELS, model_loaded: true, loaded_model_type: 'wd14-swinv2',
models: { ...MODELS.models, 'wd14-swinv2': entry('WD14 SwinV2', '~400 MB', { loaded: true }) } });
assert.equal(loaded.loadedType, 'wd14-swinv2');
assert.equal(modelDescription(loaded, 'wd14-swinv2'), STATUS_COPY.loaded);
assert.equal(modelDescription(status, 'unknown'), STATUS_COPY.failed);
assert.deepEqual(readStatus(null), { phase: 'failed', models: null, loadedType: null });
assert.equal(readStatus({ models: {}, model_loaded: true, loaded_model_type: '/abs/path' }).loadedType, null);
});
test('controls draft their host keys; numbers as numbers, other text as typed', async () => {
const { SETTING_KEYS, coerceNumber, draftValue } = await tagging;
assert.deepEqual(SETTING_KEYS, { 'tag-model': 'autotag_model_type', 'tag-existing': 'autotag_existing',
'threshold-general': 'autotag_general_threshold', 'threshold-character': 'autotag_character_threshold',
'tag-suggestions': 'autotag_suggestion_limit', 'tag-template': 'autotag_template' });
assert.deepEqual(['0.4', ' 1.5 ', '15', '', 'abc', '1e-1'].map(coerceNumber), [0.4, 1.5, 15, '', 'abc', 0.1]);
assert.equal(draftValue('autotag_suggestion_limit', '15'), 15);
assert.equal(draftValue('autotag_template', ' 12 '), ' 12 ');
assert.equal(draftValue('autotag_existing', undefined), '');
const { dom, drafts, scope, byId } = await mounted();
try {
assert.equal(byId('tag-model').value, 'wd14-swinv2');
assert.equal(byId('threshold-general').value, '0.35');
assert.equal(byId('tag-template').value, HOST.autotag_template);
assert.deepEqual([byId('threshold-general').min, byId('threshold-general').max, byId('threshold-general').step], ['0', '1', '0.05']);
assert.deepEqual([byId('tag-suggestions').min, byId('tag-suggestions').max], ['1', '100']);
byId('tag-existing').value = 'merge'; await byId('tag-existing').dispatch('change');
byId('threshold-general').value = '0.4'; await byId('threshold-general').dispatch('change');
byId('tag-suggestions').value = '15'; await byId('tag-suggestions').dispatch('change');
byId('tag-template').value = 'Tags only.'; await byId('tag-template').dispatch('input');
assert.deepEqual(drafts.changes('host'), { autotag_existing: 'merge', autotag_general_threshold: 0.4,
autotag_suggestion_limit: 15, autotag_template: 'Tags only.' });
// Every control names its stored key, so a refused save highlights it (D92).
for (const id of Object.keys(SETTING_KEYS)) assert.equal(byId(id).dataset.key, SETTING_KEYS[id]);
assert.equal(byId('tag-template').dataset.setting, 'tag-template');
} finally { scope.dispose(); assert.equal(dom.listenerCount(), 0); dom.restore(); }
});
test('opening the section reads the model status once; the select waits for it', async () => {
const { dom, scope, byId, description, calls, surface } = await mounted();
try {
assert.deepEqual(calls.get, ['/prompt_manager/autotag/models']);
assert.deepEqual(calls.post, []);
assert.equal(byId('tag-model').disabled, false);
assert.equal(description(), 'Downloaded. Not loaded.');
assert.deepEqual(byId('tag-model').options.map(option => option.textContent),
['WD14 SwinV2 · ~400 MB', 'WD14 ViT · ~400 MB', 'GGUF JoyCaption · ~4.5 GB', 'HuggingFace JoyCaption · ~16 GB']);
assert.deepEqual(surface.children.map(node => node.className),
['', '', 'setting-row', 'setting-row', 'setting-row', 'setting-row', 'setting-row', 'field', 'row-actions', 'operation']);
} finally { scope.dispose(); dom.restore(); }
const failed = await mounted({ modelsFail: true });
try {
assert.equal(failed.description(), 'Model status could not be read.');
assert.equal(failed.button('Download').getAttribute('aria-disabled'), 'true');
assert.equal(failed.button('Unload').disabled, true);
await failed.button('Download').dispatch('click');
assert.deepEqual(failed.calls.toasts, ['Model status could not be read.']);
assert.deepEqual(failed.calls.started, []);
} finally { failed.scope.dispose(); failed.dom.restore(); }
});
test('server settings that cannot be read leave the rows disabled and say so', async () => {
const { dom, scope, byId, button, description, calls } = await mounted({ hostFails: true });
try {
assert.equal(byId('tag-existing').disabled, true);
assert.equal(byId('tag-existing').parentNode.children[0].children.at(-1).textContent, 'Server settings could not be read.');
assert.equal(description(), 'Model status could not be read.');
await button('Preview tagging review').dispatch('click');
assert.deepEqual(calls.toasts, ['Server settings could not be read.']);
} finally { scope.dispose(); dom.restore(); }
});
test('Download starts the model download job and opens it; the label follows the disk (OQ10)', async () => {
const { dom, drafts, scope, button, calls, byId } = await mounted();
try {
assert.ok(button('Download again'));
byId('tag-model').value = 'wd14-vit'; await byId('tag-model').dispatch('change');
assert.equal(drafts.get('host', 'autotag_model_type'), 'wd14-vit');
await button('Download').dispatch('click');
assert.deepEqual(calls.started, [['model-download', { model_type: 'wd14-vit' }]]);
assert.deepEqual(calls.navigated, ['#/activity/job1']);
} finally { scope.dispose(); dom.restore(); }
});
test('startJob toasts by code and never navigates on a refusal', async () => {
const { startJob, startRefusal } = await tagging;
const { createScope } = await coreModule;
const error = (code, message = 'Server says.', extra = {}) => Object.assign(new Error(message), { code, ...extra });
assert.equal(startRefusal(error('model-busy', 'x', { operation: 'Tagging model download · WD14 ViT' }), 'X'),
'The tagging model is busy: Tagging model download · WD14 ViT. Try again when it finishes.');
assert.equal(startRefusal(error('model-busy', 'x'), 'X'),
'The tagging model is busy: Another tagging operation. Try again when it finishes.');
for (const code of ['model-unavailable', 'review-required', 'insufficient-space']) assert.equal(startRefusal(error(code), 'X'), 'Server says.');
assert.equal(startRefusal(error('weird'), 'Tag output images'), 'Tag output images could not start. Try again.');
assert.equal(startRefusal(new Error('offline'), 'Tag output images'), 'Tag output images could not start. Try again.');
const scope = createScope();
const toasts = [];
const navigated = [];
const run = start => startJob({ activity: { start }, scope, kind: 'autotag', body: {}, what: 'Tag output images',
notify: text => toasts.push(text), navigate: hash => navigated.push(hash) });
assert.equal(await run(async () => { throw error('model-busy', 'busy', { operation: 'Generate suggestions' }); }), null);
assert.equal(await run(async () => ({ busy: { id: 'a1', title: 'Scan for metadata' } })), null);
assert.equal(await run(async () => ({ busy: null })), null);
assert.deepEqual(toasts, ['The tagging model is busy: Generate suggestions. Try again when it finishes.',
['Another job is running: ', { text: 'Scan for metadata', href: '#/activity/a1' }, '.'],
'Another job is running. Try again when it ends.']);
assert.deepEqual(await run(async () => ({ job: { id: 'j2' } })), { id: 'j2' });
assert.deepEqual(navigated, ['#/activity/j2']);
scope.dispose();
assert.equal(await run(async () => { throw error('weird'); }), null);
assert.deepEqual(await run(async () => ({ job: { id: 'j3' } })), { id: 'j3' });
assert.deepEqual(navigated, ['#/activity/j2']);
assert.equal(toasts.length, 3);
});
test('Unload is enabled only while a model is loaded; a busy refusal names the operation', async () => {
const loadedModels = { ...MODELS, model_loaded: true, loaded_model_type: 'wd14-swinv2' };
let refuse = null;
const post = url => {
if (refuse) throw refuse;
return url.endsWith('/unload') ? { success: true, message: 'WD14SWINV2 model unloaded successfully', model_loaded: false } : {};
};
const { dom, scope, button, calls, setModels } = await mounted({ models: loadedModels, post });
try {
assert.equal(button('Unload').disabled, false);
refuse = Object.assign(new Error('A tagging model is busy'), { code: 'model-busy', operation: 'Tagging model download · WD14 ViT' });
await button('Unload').dispatch('click');
assert.deepEqual(calls.toasts, ['The tagging model is busy: Tagging model download · WD14 ViT. Try again when it finishes.']);
refuse = new Error('offline');
await button('Unload').dispatch('click');
assert.equal(calls.toasts.at(-1), 'The tagging model could not be unloaded. Try again.');
refuse = null;
setModels(MODELS);
await button('Unload').dispatch('click');
assert.equal(calls.toasts.at(-1), 'WD14 SwinV2 unloaded.');
assert.deepEqual(calls.post.map(([url]) => url), Array(3).fill('/prompt_manager/autotag/unload'));
// Each attempt reads the status again; nothing is loaded now.
assert.equal(calls.get.length, 4);
assert.equal(button('Unload').disabled, true);
} finally { scope.dispose(); dom.restore(); }
});
test('Preview tagging review opens the Tag image dialog with the section’s values, else says why (D115)', async () => {
const { NO_SELECTION, taggingPreset, draftProblem } = await tagging.then(async module => ({ ...module,
NO_SELECTION: (await import('../../web/js/workspace/vocabulary.mjs')).NO_SELECTION }));
assert.equal(NO_SELECTION, 'Select an image in Images first.');
assert.deepEqual(taggingPreset(HOST), { model: 'wd14-swinv2', options: { general_threshold: 0.35, character_threshold: 0.85,
suggestion_limit: 10 } });
assert.deepEqual(taggingPreset({ ...HOST, autotag_model_type: 'gguf' }), { model: 'gguf',
options: { instructions: HOST.autotag_template, suggestion_limit: 10 } });
assert.equal(draftProblem(HOST), null);
assert.deepEqual(draftProblem({ ...HOST, autotag_general_threshold: 1.5 }),
{ key: 'autotag_general_threshold', message: 'General-tag confidence must be between 0 and 1.' });
assert.equal(draftProblem({ ...HOST, autotag_character_threshold: '' }).key, 'autotag_character_threshold');
// #369: every page shows the thresholds in steps of 0.05, so a value off that grid cannot be saved or previewed.
assert.deepEqual(draftProblem({ ...HOST, autotag_general_threshold: 0.37 }),
{ key: 'autotag_general_threshold', message: 'General-tag confidence must be a multiple of 0.05.' });
assert.deepEqual(draftProblem({ ...HOST, autotag_character_threshold: 0.33 }),
{ key: 'autotag_character_threshold', message: 'Character-tag confidence must be a multiple of 0.05.' });
assert.equal(draftProblem({ ...HOST, autotag_general_threshold: 0.15, autotag_character_threshold: 0.95 }), null);
assert.equal(draftProblem({ ...HOST, autotag_general_threshold: 0, autotag_character_threshold: 1 }), null);
// A computed value a hair off the grid passes and is handed on snapped to it, as the server stores it.
assert.equal(draftProblem({ ...HOST, autotag_general_threshold: 0.1 + 0.2 }), null);
assert.equal(taggingPreset({ ...HOST, autotag_general_threshold: 0.1 + 0.2 }).options.general_threshold, 0.3);
assert.equal(draftProblem({ ...HOST, autotag_suggestion_limit: 2.5 }).key, 'autotag_suggestion_limit');
assert.equal(draftProblem({ ...HOST, autotag_suggestion_limit: 0 }, { limit: false }), null);
assert.deepEqual(draftProblem({ ...HOST, autotag_model_type: 'hf', autotag_template: ' ' }),
{ key: 'autotag_template', message: 'LLM tag template must be non-empty text.' });
assert.deepEqual(draftProblem({ ...HOST, autotag_model_type: 'hf', autotag_template: 'x'.repeat(4001) }),
{ key: 'autotag_template', message: 'LLM tag template must be at most 4000 characters.' });
// Characters are code points, as Python's len counts them: 4000 emoji (8000 UTF-16 units) are allowed.
assert.equal(draftProblem({ ...HOST, autotag_model_type: 'hf', autotag_template: '🏜'.repeat(4000) }), null);
assert.equal(draftProblem({ ...HOST, autotag_model_type: 'hf', autotag_template: '🏜'.repeat(4001) }).key, 'autotag_template');
// A WD14 run never reads the template, nor a language model the thresholds.
assert.equal(draftProblem({ ...HOST, autotag_template: '' }), null);
assert.equal(draftProblem({ ...HOST, autotag_model_type: 'gguf', autotag_general_threshold: 7 }), null);
const none = await mounted({ image: null });
try {
const preview = none.button('Preview tagging review');
assert.equal(preview.getAttribute('aria-disabled'), 'true');
assert.equal(preview.className, 'ca-primary');
await preview.dispatch('click');
assert.deepEqual(none.calls.toasts, ['Select an image in Images first.']);
assert.deepEqual(none.calls.opened, []);
} finally { none.scope.dispose(); none.dom.restore(); }
const { dom, scope, button, byId, calls } = await mounted();
try {
await button('Preview tagging review').dispatch('click');
assert.equal(calls.opened.length, 1);
assert.equal(calls.opened[0].image, IMAGE);
assert.equal(calls.opened[0].source, 'tags');
assert.deepEqual(calls.opened[0].preset, taggingPreset(HOST));
byId('threshold-general').value = '1.5'; await byId('threshold-general').dispatch('change');
assert.equal(button('Preview tagging review').getAttribute('aria-disabled'), 'true');
await button('Preview tagging review').dispatch('click');
assert.deepEqual(calls.toasts, ['General-tag confidence must be between 0 and 1.']);
assert.equal(calls.opened.length, 1);
byId('threshold-general').value = '0.37'; await byId('threshold-general').dispatch('change');
assert.equal(button('Preview tagging review').getAttribute('aria-disabled'), 'true');
await button('Preview tagging review').dispatch('click');
assert.deepEqual(calls.toasts.at(-1), 'General-tag confidence must be a multiple of 0.05.');
assert.equal(calls.opened.length, 1);
} finally { scope.dispose(); dom.restore(); }
});
const PLAN = { images: 3, prompts: 2, already_tagged: 1, to_tag: 1,
model: { type: 'wd14-swinv2', label: 'WD14 SwinV2', size_label: '~400 MB', downloaded: true }, existing: 'skip',
review_required: false, backup: { policy: 'on' }, disk: { free_bytes: 10 * 2 ** 30, required_bytes: 2 ** 20, sufficient: true } };
test('the batch review grid, body and Cancel-only reasons (D134, CUJ 4–5)', async () => {
const { batchGrid, batchBody, batchCloseNote, existingLabel, REPLACE_NOTE, NOTHING_TO_TAG, NO_LINKED_IMAGES } = await tagging;
assert.deepEqual(batchGrid(PLAN, HOST), [['Model', 'WD14 SwinV2 · ~400 MB'], ['Existing tags', 'Skip already tagged'],
['Images linked', '3'], ['Prompts', '2'], ['Already tagged', '1'], ['Prompts to tag', 'up to 1'],
['Settings', 'General 0.35 · Character 0.85'], ['Backup', 'Create and verify before changes']]);
const llm = { ...HOST, autotag_model_type: 'gguf' };
assert.deepEqual(batchGrid({ ...PLAN, model: { type: 'gguf' }, backup: { policy: 'off' } }, llm).slice(-2),
[['Settings', 'Template of 27 characters'], ['Backup', 'No safety backup (the policy is off)']]);
assert.equal(batchGrid({}, HOST)[2][1], '0');
assert.equal(existingLabel('odd'), 'odd');
assert.deepEqual(batchBody(HOST), { model_type: 'wd14-swinv2', existing: 'skip',
options: { general_threshold: 0.35, character_threshold: 0.85 } });
// #369: a threshold a hair off the grid is sent snapped to it.
assert.equal(batchBody({ ...HOST, autotag_character_threshold: 0.1 + 0.2 }).options.character_threshold, 0.3);
assert.deepEqual(batchBody(llm), { model_type: 'gguf', existing: 'skip', options: { instructions: HOST.autotag_template } });
assert.equal(batchCloseNote(PLAN), null);
assert.equal(batchCloseNote({ ...PLAN, review_required: true }), REPLACE_NOTE);
assert.equal(REPLACE_NOTE, 'Replacing tags needs a review of each image. Use Preview tagging review, or choose Skip or Merge for a batch run.');
assert.equal(batchCloseNote({ ...PLAN, model: { ...PLAN.model, downloaded: false } }), 'WD14 SwinV2 is not downloaded. Download it first.');
assert.equal(batchCloseNote({ ...PLAN, disk: { free_bytes: 2 ** 20, required_bytes: 2 ** 21, sufficient: false } }),
'Not enough free disk space: the safety backup needs about 2.0 MB and 1.0 MB is free. Nothing is written until there is room.');
assert.equal(batchCloseNote({ ...PLAN, prompts: 0, to_tag: 0 }), NO_LINKED_IMAGES);
assert.equal(batchCloseNote({ ...PLAN, to_tag: 0 }), NOTHING_TO_TAG);
assert.equal(NOTHING_TO_TAG, 'Every prompt with an output image is already tagged.');
});
test('showBatchPlan opens the review with the backup box, or the Cancel-only review', async () => {
const { showBatchPlan, REVIEW_TITLE, BATCH_SENTENCE } = await tagging;
const { createScope } = await coreModule;
const scope = createScope();
const reviews = [];
const closed = [];
const started = [];
const options = { values: HOST, scope, activity: { start: async (kind, body) => { started.push([kind, body]); return { job: { id: 'b1' } }; } },
notify: () => {}, navigate: () => {}, openReview: review => reviews.push(review), openCloseOnly: review => closed.push(review) };
showBatchPlan(PLAN, options);
assert.equal(reviews.length, 1);
const [review] = reviews;
assert.equal(review.title, REVIEW_TITLE);
assert.equal(review.title, 'Review tag output images');
assert.equal(review.heading, 'Tag output images');
assert.equal(review.sentence, BATCH_SENTENCE);
assert.equal(review.primary.label, 'Start tagging');
assert.equal(review.cancelLabel, 'Cancel');
assert.equal(review.backup.copy.heading, 'Back up the database first (recommended)');
await review.onStart();
assert.deepEqual(started, [['autotag', { model_type: 'wd14-swinv2', existing: 'skip',
options: { general_threshold: 0.35, character_threshold: 0.85 } }]]);
showBatchPlan({ ...PLAN, backup: { policy: 'off' } }, options);
assert.equal(reviews[1].backup, null);
showBatchPlan({ ...PLAN, review_required: true }, options);
assert.equal(closed.length, 1);
assert.equal(closed[0].note.startsWith('Replacing tags needs a review'), true);
assert.equal(closed[0].grid[0][0], 'Model');
scope.dispose();
});
test('Tag output images › Review previews the drafts, else says why; a refused preview is toasted', async () => {
let refuse = null;
const post = url => {
if (refuse) throw refuse;
assert.equal(url, '/prompt_manager/autotag/batch/preview');
return PLAN;
};
const { dom, scope, button, byId, calls } = await mounted({ post });
try {
const review = button('Review');
assert.equal(review.getAttribute('aria-disabled'), null);
await review.dispatch('click');
await tick();
assert.deepEqual(calls.post, [['/prompt_manager/autotag/batch/preview', { model_type: 'wd14-swinv2', existing: 'skip' }]]);
assert.equal(calls.shown.length, 1);
assert.equal(calls.shown[0][0], PLAN);
assert.deepEqual(calls.shown[0][1].values, HOST);
assert.equal(review.textContent, 'Review');
refuse = Object.assign(new Error('busy'), { code: 'busy', running: { id: 'r1', title: 'Optimize database' } });
await review.dispatch('click');
await tick();
assert.deepEqual(calls.toasts.at(-1), ['Another job is running: ', { text: 'Optimize database', href: '#/activity/r1' }, '.']);
refuse = new Error('offline');
await review.dispatch('click');
await tick();
assert.equal(calls.toasts.at(-1), 'The plan could not be reviewed. Nothing was changed.');
// A model that is not on disk cannot be reviewed: the button explains instead.
byId('tag-model').value = 'wd14-vit'; await byId('tag-model').dispatch('change');
assert.equal(review.getAttribute('aria-disabled'), 'true');
await review.dispatch('click');
assert.equal(calls.toasts.at(-1), 'WD14 ViT is not downloaded. Download it first.');
assert.equal(calls.post.length, 3);
} finally { scope.dispose(); dom.restore(); }
});
test('settingRefusal marks only an Auto-tagging save\'s plain 400 as invalid-setting (CUJ 2a)', async () => {
const { settingRefusal } = await import('../../web/js/workspace/system.mjs');
const refusal = (extra = {}) => Object.assign(new Error('General-tag confidence must be between 0 and 1.'),
{ status: 400, refused: true, code: null, ...extra });
const tagging = { autotag_general_threshold: 1.5, expected_revision: 'sha256:one' };
assert.equal(settingRefusal(refusal(), tagging).code, 'invalid-setting');
// Another section's refusal keeps the generic copy, even with a plain 400.
const other = refusal({ message: 'Result timeout must be between 0 and 300.' });
assert.equal(settingRefusal(other, { result_timeout: 999, expected_revision: 'sha256:one' }).code, null);
assert.equal(settingRefusal(refusal(), undefined).code, null);
for (const extra of [{ status: 409 }, { refused: false }, { code: 'jxl-unavailable' }]) {
assert.notEqual(settingRefusal(refusal(extra), tagging).code, 'invalid-setting');
}
assert.equal(settingRefusal(undefined, tagging), undefined);
});
test('a part-downloaded GGUF offers Download again', async () => {
const { downloadLabel, partlyDownloaded } = await tagging;
const partial = MODELS.models.gguf;
assert.equal(partlyDownloaded('gguf', partial), true);
assert.equal(downloadLabel('gguf', partial), 'Download again');
assert.equal(downloadLabel('gguf', { ...partial, model_exists: false }), 'Download');
assert.equal(downloadLabel('hf', MODELS.models.hf), 'Download');
assert.equal(downloadLabel('wd14-swinv2', MODELS.models['wd14-swinv2']), 'Download again');
assert.equal(partlyDownloaded('hf', { model_exists: true, mmproj_exists: false }), false);
const { dom, scope, byId, button, description } = await mounted();
try {
byId('tag-model').value = 'gguf'; await byId('tag-model').dispatch('change');
assert.equal(description(), 'Partly downloaded. Download again to finish.');
assert.ok(button('Download again'));
} finally { scope.dispose(); dom.restore(); }
});
test('the disk note says what is short in the server\'s words, and guesses no unknown byte counts', async () => {
const { diskShortNote, DISK_SHORT_UNKNOWN } = await tagging;
assert.equal(diskShortNote({ required_bytes: 3 * 2 ** 20, free_bytes: 2 ** 19, sufficient: false }),
'Not enough free disk space: the safety backup needs about 3.0 MB and 0.5 MB is free. Nothing is written until there is room.');
for (const disk of [{ required_bytes: NaN, free_bytes: 1 }, { required_bytes: 1, free_bytes: Infinity }, { required_bytes: -1, free_bytes: 1 },
{ required_bytes: '1', free_bytes: 1 }, null]) {
assert.equal(diskShortNote(disk), DISK_SHORT_UNKNOWN);
}
assert.equal(DISK_SHORT_UNKNOWN, 'Not enough free disk space for the safety backup. Nothing is written until there is room.');
assert.ok(!diskShortNote({ required_bytes: 1, free_bytes: 0 }).includes('apply'));
});
test('a drafts repaint never re-enables Download while its start is in flight', async () => {
let answer;
const start = () => new Promise(resolve => { answer = resolve; });
const { dom, scope, byId, button, calls } = await mounted({ start });
try {
const download = button('Download again');
const pressed = download.dispatch('click');
await tick();
assert.equal(download.getAttribute('aria-disabled'), 'true');
byId('tag-existing').value = 'merge'; await byId('tag-existing').dispatch('change');
assert.equal(download.getAttribute('aria-disabled'), 'true');
// A second press while it starts is ignored.
await download.dispatch('click');
assert.equal(calls.started.length, 1);
answer({ job: { id: 'd1' } });
await pressed;
assert.equal(download.getAttribute('aria-disabled'), null);
assert.deepEqual(calls.navigated, ['#/activity/d1']);
} finally { scope.dispose(); dom.restore(); }
});
test('an Unload that found nothing loaded says nothing and reads the status again', async () => {
const { dom, scope, button, calls } = await mounted({ models: { ...MODELS, model_loaded: true, loaded_model_type: 'wd14-swinv2' },
post: () => ({ success: true, message: 'No model was loaded' }) });
try {
await button('Unload').dispatch('click');
assert.deepEqual(calls.toasts, []);
assert.equal(calls.get.length, 2);
} finally { scope.dispose(); dom.restore(); }
});
test('an unknown saved engine has its own reason', async () => {
const { UNKNOWN_MODEL } = await tagging;
const { dom, scope, button, calls } = await mounted({ host: { ...HOST, autotag_model_type: 'wd14-convnext' } });
try {
await button('Preview tagging review').dispatch('click');
await button('Download').dispatch('click');
await button('Review').dispatch('click');
assert.deepEqual(calls.toasts, [UNKNOWN_MODEL, UNKNOWN_MODEL, UNKNOWN_MODEL]);
assert.equal(UNKNOWN_MODEL, 'The saved tagging model is not one of these engines. Choose one and save preferences.');
assert.deepEqual(calls.started, []);
} finally { scope.dispose(); dom.restore(); }
});
test('a review whose dialog fails to open is toasted, not lost', async () => {
const { dom, scope, button, calls } = await mounted({ post: () => PLAN });
try {
calls.shown.push = () => { throw new Error('dialog failed'); };
await button('Review').dispatch('click');
await tick();
assert.equal(calls.toasts.at(-1), 'The plan could not be reviewed. Nothing was changed.');
assert.equal(button('Review').getAttribute('aria-disabled'), null);
} finally { scope.dispose(); dom.restore(); }
});
test('a status read, an unload and a preview that finish after the section left do nothing', async () => {
const { mountTagging } = await tagging;
const { createSystemDrafts } = await stateModule;
const { createScope } = await coreModule;
const dom = fakeDOM();
const pending = [];
const later = value => new Promise(resolve => pending.push(() => resolve(value)));
const drafts = createSystemDrafts({ readBrowser: () => ({}), readRuntime: () => ({}),
readHost: async () => ({ values: HOST, revision: 'sha256:one' }), writeBrowser: async () => ({}), writeHost: async () => ({}),
writeRuntime: async () => ({}) });
await drafts.load('host');
const scope = createScope();
const toasts = [];
const shown = [];
let gets = 0;
const loaded = { ...MODELS, model_loaded: true, loaded_model_type: 'wd14-swinv2',
models: { ...MODELS.models, 'wd14-swinv2': entry('WD14 SwinV2', '~400 MB', { loaded: true }) } };
const api = { get: () => { gets += 1; return gets === 1 ? Promise.resolve(loaded) : later(MODELS); }, post: url => later(url.endsWith('/unload') ? { success: true, model_loaded: false } : PLAN) };
const [surface] = mountTagging(null, { api, activity: {}, scope, drafts, notify: text => toasts.push(text), navigate: () => {},
selected: () => IMAGE, openTagging: () => {}, show: plan => shown.push(plan) });
try {
await tick();
const find = label => dom.find(surface, node => node.tagName === 'BUTTON' && node.textContent === label);
const description = () => dom.find(surface, node => node.id === 'tag-model').parentNode.children[0].children[1].textContent;
assert.equal(description(), 'Downloaded and loaded.');
const unloading = find('Unload').dispatch('click');
const reviewing = find('Review').dispatch('click');
await tick();
scope.dispose();
for (const settle of pending.splice(0)) settle();
await unloading;
await reviewing;
await tick();
for (const settle of pending.splice(0)) settle();
await tick();
assert.deepEqual(toasts, []);
assert.deepEqual(shown, []);
// The status read after the unload never reached the page either.
assert.equal(description(), 'Downloaded and loaded.');
assert.equal(dom.listenerCount(), 0);
} finally { dom.restore(); }
});
-5
View File
@@ -841,11 +841,6 @@ test('openReviewDialog lays out heading, grid, options, sentence, backup box and
[['dt', 'dd'], 'Scope', '2 stale image links'], [['dt', 'dd'], 'Media on disk', 'No source files removed']]);
review.setValue('Scope', '3 stale image links');
assert.equal(grid.children[0].children[1].textContent, '3 stale image links');
// #355: a row's hover title (the LoRA review's settings file); an unknown term changes nothing.
review.setTitle('Media on disk', '~/settings.json');
review.setTitle('Absent', 'x');
review.setValue('Absent', 'x');
assert.equal(grid.children[1].children[1].title, '~/settings.json');
const actions = byId(dom, 'modal-body').children.at(-1);
assert.deepEqual(actions.children.map(node => [node.textContent, node.className]),
[['Cancel', ''], ['Apply reviewed operation', 'ca-primary']]);
-166
View File
@@ -1,166 +0,0 @@
"""Run the Python suite with a hang watchdog: ``python -m tests.run_suite``.
Why: on PR #349 a macOS job stopped inside the test suite and sat silent until
GitHub cancelled it after six hours, with no stack to read. This runner does
the same discovery as ``python -m unittest discover -s tests`` (same
``sys.path`` when started with ``-m`` from the repo root, same warning
filters, and the exit codes of Python 3.12+ on every version: 0, 1, and 5
when no tests ran), and adds a watchdog built on
``faulthandler.dump_traceback_later``: when the timer is not re-armed in time,
every thread's stack goes to stderr and the process exits non-zero. It works
the same on Linux, macOS and Windows.
The timer is armed before discovery, re-armed at the start of every test with
PM_TEST_HANG_LIMIT seconds (default 120), and re-armed when the run ends with
PM_TEST_SHUTDOWN_LIMIT seconds (default 120) to cover interpreter exit
(non-daemon threads, atexit handlers, coverage's save). It is never
cancelled. Class and module fixtures (setUpClass, tearDownModule, ...) run
between two ``start:`` lines, so they count toward the test before them.
120 s stays below asyncio's 300 s wait for default-executor threads on Python
3.12 and 3.13 (3.10 and 3.11 wait without limit), so a hang in an
``IsolatedAsyncioTestCase`` loop shutdown is dumped inside the test that hung.
Reading a dump: the last ``start: <test id>`` line names the test. If its
"ok" line comes after it, the hang is after the test body: its teardown or
event-loop shutdown, or a class/module fixture before the next test (the
stack shows which). A dump after the summary is a hang at interpreter exit
(a non-daemon thread or an atexit handler).
A limit is a measurement, not a dial: change it only with new measurements
written next to it (TESTING.md "Time limits and hangs").
"""
import argparse
import faulthandler
import os
import shutil
import sys
import tempfile
import unittest
from pathlib import Path
DEFAULT_PER_TEST_LIMIT = 120
DEFAULT_SHUTDOWN_LIMIT = 120
NO_TESTS_EXIT = 5
USAGE_EXIT = 2
# LoraManager's settings-folder override (#355): every test reads an empty,
# disposable LoraManager settings folder, never the developer's real one.
LORA_MANAGER_SETTINGS_ENV = "LORA_MANAGER_SETTINGS_DIR"
class LimitError(ValueError):
"""An environment limit is not a positive integer."""
def read_limit(name, default):
"""Return the positive integer in env var ``name``, or ``default``."""
raw = os.environ.get(name)
if raw is None:
return default
text = raw.strip()
if not (text.isascii() and text.isdigit()) or int(text) <= 0:
raise LimitError(f"run_suite: {name} must be a positive integer, got {raw!r}")
return int(text)
def make_result_class(stream, per_test_limit, shutdown_limit):
"""Build a TextTestResult that names each test and re-arms the watchdog."""
def arm(seconds):
faulthandler.dump_traceback_later(seconds, exit=True, file=stream)
class WatchdogResult(unittest.TextTestResult):
def startTest(self, test):
# At default verbosity the progress dots share a line; start ours.
prefix = "\n" if self.dots else ""
stream.write(f"{prefix}start: {test.id()}\n")
stream.flush()
super().startTest(test)
arm(per_test_limit)
def stopTestRun(self):
super().stopTestRun()
arm(shutdown_limit)
return WatchdogResult
def parse_args(argv):
parser = argparse.ArgumentParser(
prog="python -m tests.run_suite",
description="Discover and run the tests with a hang watchdog.",
)
# As in unittest, -v and -q share one setting and the last one wins.
parser.set_defaults(verbosity=1)
parser.add_argument(
"-v",
"--verbose",
dest="verbosity",
action="store_const",
const=2,
help="verbose output",
)
parser.add_argument(
"-q",
"--quiet",
dest="verbosity",
action="store_const",
const=0,
help="quiet output",
)
parser.add_argument(
"-s",
"--start-directory",
default=str(Path(__file__).resolve().parent),
help="directory to start discovery (default: this tests folder)",
)
parser.add_argument(
"-p", "--pattern", default="test*.py", help="pattern to match test files"
)
return parser.parse_args(argv)
def isolate_lora_manager_settings():
"""Point LoraManager's settings lookup at a fresh temporary folder.
Returns the folder; the caller removes it when the run ends.
"""
folder = tempfile.mkdtemp(prefix="pm-lora-settings-")
os.environ[LORA_MANAGER_SETTINGS_ENV] = folder
return folder
def main(argv=None):
args = parse_args(sys.argv[1:] if argv is None else argv)
# Keep the real stderr: a test may replace sys.stderr, and the watchdog
# needs a stream with a file descriptor.
stream = sys.stderr
try:
per_test_limit = read_limit("PM_TEST_HANG_LIMIT", DEFAULT_PER_TEST_LIMIT)
shutdown_limit = read_limit("PM_TEST_SHUTDOWN_LIMIT", DEFAULT_SHUTDOWN_LIMIT)
except LimitError as error:
stream.write(f"{error}\n")
return USAGE_EXIT
faulthandler.dump_traceback_later(per_test_limit, exit=True, file=stream)
settings_folder = isolate_lora_manager_settings()
try:
start = os.path.abspath(args.start_directory)
suite = unittest.TestLoader().discover(start, args.pattern, top_level_dir=start)
runner = unittest.TextTestRunner(
stream=stream,
verbosity=args.verbosity,
# As unittest.main does: show warnings unless -W/PYTHONWARNINGS set them.
warnings=None if sys.warnoptions else "default",
resultclass=make_result_class(stream, per_test_limit, shutdown_limit),
)
result = runner.run(suite)
finally:
shutil.rmtree(settings_folder, ignore_errors=True)
if result.testsRun == 0 and not result.skipped:
return NO_TESTS_EXIT
return 0 if result.wasSuccessful() else 1
if __name__ == "__main__":
sys.exit(main())
-1
View File
@@ -2466,7 +2466,6 @@ class TestActivityJobs(AdminAPITestCase):
ambiguous=None,
untraced=None,
records=None,
history=None,
):
key = Path(media_file).relative_to(self.output_dir).as_posix()
outcome = names[key]
+1 -1
View File
@@ -36,7 +36,7 @@ SUGGEST = "/prompt_manager/image-annotations/suggest"
WAIT = 30.0
WD14 = {"general_threshold": 0.35, "character_threshold": 0.85}
LLM = {"instructions": "Generate tags."}
UNAVAILABLE = "Download it in System › Auto-tagging."
UNAVAILABLE = "Download it from the legacy Auto-tag page."
SCORED = [("sandstone", 0.98), ("desert", 0.96)] + [
(f"tag_{i:03d}", round(0.9 - i * 0.005, 4)) for i in range(150)
-2
View File
@@ -1145,10 +1145,8 @@ class TestEnvelopes(AnnotationAPITestCase):
sorted(await self.json_of(resp)),
[
"default_prompt",
"existing", # #328 adds the saved batch policy and model
"loaded_model_type",
"model_loaded",
"model_type",
"models",
"success",
"suggestion_limit", # B2 adds it (D68); nothing else changes
+3 -1066
View File
File diff suppressed because it is too large Load Diff
+2 -16
View File
@@ -687,23 +687,9 @@ class TestInstanceHelpers(FolderPathsFixture):
self.assertEqual(self._run(self.api._run_in_executor(add, 1, 2)), 3)
def test_invalidate_gallery_cache(self):
record = self.api._root_listing(self.output_dir)
record.files = [(self.output_dir / "a.png", 1.0)]
record.folders = record.folders_with_ancestors = frozenset()
record.walked_at = 1.0
self.api._favorite_listing_cache = {"x": 1}
self.api._workspace_order_cache = {"x": 1}
self.api._gallery_cache = {"x": 1}
self.api.invalidate_gallery_cache()
# The record stays: its generation outlives every drop (#348).
self.assertIs(self.api._root_listings[str(self.output_dir)], record)
self.assertIsNone(record.files)
self.assertIsNone(record.folders)
self.assertIsNone(record.walked_at)
self.assertEqual(record.generation, 1)
self.assertEqual(self.api._favorite_listing_cache, {})
self.assertEqual(self.api._workspace_order_cache, {})
self.api.reset_gallery_roots_cache()
self.assertEqual(record.generation, 2)
self.assertEqual(self.api._gallery_cache, {})
def test_enrich_images_adds_urls_and_thumbnails(self):
GalleryConfig.MONITORING_DIRECTORIES = [str(self.output_dir)]
+25 -2109
View File
File diff suppressed because it is too large Load Diff
-183
View File
@@ -1,12 +1,9 @@
"""Native library endpoints use bounded cursors and rooted media identities."""
import json
import os
import unittest
from unittest.mock import patch
from utils.hashing import generate_prompt_hash
try:
from test_api_images import ImageAPITestCase, make_png
except ImportError:
@@ -981,186 +978,6 @@ class TestLibraryAPI(ImageAPITestCase):
self.assertNotIn("literal", json.dumps(result))
class TestLibraryLoraMedia(ImageAPITestCase):
"""#355 (G6): media a LoRA import linked outside the output folders shows
on the Library page and opens in the inspector while the integration is on."""
async def setUpAsync(self):
await super().setUpAsync()
import tempfile
from pathlib import Path
self._lora_tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
self.addCleanup(self._lora_tmp.cleanup)
self.lora_dir = Path(self._lora_tmp.name).resolve() / "sidecars"
self.lora_dir.mkdir()
self.lora_on = True
self.api._lora_image_dirs = lambda: [self.lora_dir] if self.lora_on else []
async def get(self, path="", **params):
response = await self.client.get(
"/prompt_manager/library" + path, params=params
)
return response, await response.json()
def _imported(self):
"""A LoRA prompt linked like the import links it: the cover first."""
still = make_png(self.lora_dir / "abc" / "cover.png")
video = self.lora_dir / "abc" / "clip.mp4"
video.write_bytes(b"video")
self.api.db.replace_category_prompts(
"lora-manager",
[
{
"name": "neon",
"text": "neon street",
"hash": generate_prompt_hash("neon street"),
"tags": ["lora-manager"],
"images": [str(still), str(video)],
}
],
)
positive = self.api.db.search_prompts(category="lora-manager")[0]["id"]
ids = {
os.path.basename(row["image_path"]): row["id"]
for row in self.api.db.get_prompt_images(positive)
}
return positive, ids
async def test_list_previews_show_linked_lora_media_cover_first(self):
positive, _ids = self._imported()
response, result = await self.get()
self.assertEqual(response.status, 200, result)
item = next(i for i in result["items"] if i["id"] == positive)
previews = item["previews"]
self.assertEqual([p["filename"] for p in previews], ["cover.png", "clip.mp4"])
# The media keeps the rooted identity every workspace view checks; no
# preview is made inside LoraManager's folders, so the card loads the
# original and a video shows its label.
self.assertEqual({p["root_index"] for p in previews}, {1})
self.assertTrue(
all(
p["url"].startswith("/prompt_manager/images/serve/abc/")
for p in previews
)
)
self.assertEqual([p["thumbnail_url"] for p in previews], [None, None])
self.assertEqual([p["is_video"] for p in previews], [False, True])
self.assertEqual([p["linked_only"] for p in previews], [True, True])
self.assertNotIn(str(self.lora_dir), json.dumps(result))
file = await self.client.get(previews[0]["url"])
self.assertEqual(file.status, 200)
await file.read()
self.assertFalse((self.lora_dir / "thumbnails").exists())
async def test_detail_and_images_page_list_lora_media(self):
positive, _ids = self._imported()
make_png(self.output_dir / "out.png")
_, detail = await self.get(f"/{positive}")
self.assertEqual(
[p["filename"] for p in detail["positive"]["previews"]],
["cover.png", "clip.mp4"],
)
_, page = await self.get(f"/{positive}/images")
self.assertEqual(
[i["filename"] for i in page["images"]], ["cover.png", "clip.mp4"]
)
# No favorite star: favorites belong to output listings.
self.assertNotIn("favorite", page["images"][0])
self.assertTrue(page["images"][0]["linked_only"])
async def test_inspector_reads_lora_media_by_its_root(self):
positive, _ids = self._imported()
_, page = await self.get(f"/{positive}/images")
image = page["images"][0]
response = await self.client.get(
"/prompt_manager/images/inspect/" + image["path"],
params={
"root": str(image["root_index"]),
"root_scope": image["root_scope"],
},
)
self.assertEqual(response.status, 200, await response.text())
async def test_the_inspector_reads_only_linked_files_under_a_lora_root(self):
# Security review: a LoRA root exposes only the files the import linked.
self._imported()
make_png(self.lora_dir / "abc" / "unlinked.png")
scope = self.api._gallery_root_scope(self.lora_dir, 1)
for name, status in (("unlinked.png", 403), ("cover.png", 200)):
with self.subTest(name=name):
response = await self.client.get(
"/prompt_manager/images/inspect/abc/" + name,
params={"root": "1", "root_scope": scope},
)
self.assertEqual(response.status, status, await response.text())
async def test_only_recorded_lora_media_is_served_by_path(self):
self._imported()
make_png(self.lora_dir / "abc" / "unlinked.png")
served = await self.client.get(
"/prompt_manager/images/serve/abc/cover.png", params={"root": "1"}
)
self.assertEqual(served.status, 200)
await served.read()
unlinked = await self.client.get(
"/prompt_manager/images/serve/abc/unlinked.png", params={"root": "1"}
)
self.assertEqual(unlinked.status, 403)
preview = await self.client.get(
"/prompt_manager/images/serve/thumbnails/abc/cover_thumb.webp",
params={"root": "1"},
)
self.assertEqual(preview.status, 403)
self.assertFalse((self.lora_dir / "thumbnails").exists())
# Without a root, only the output folders are searched.
unrooted = await self.client.get("/prompt_manager/images/serve/abc/cover.png")
self.assertEqual(unrooted.status, 404)
async def test_a_download_recorded_through_a_symlinked_cache_is_served(self):
from pathlib import Path
real = self.lora_dir.parent / "real-cache"
real.mkdir()
link = self.lora_dir.parent / "cache-link"
try:
os.symlink(real, link, target_is_directory=True)
except (OSError, NotImplementedError):
self.skipTest("symlinks are not available here")
self.api._lora_image_dirs = lambda: [link]
image = make_png(real / "neon" / "a1b2.jpg")
stored = str(link / "neon" / "a1b2.jpg")
positive = self._save_prompt("downloaded")
self.api.db.link_image_to_prompt(positive, stored)
_, page = await self.get(f"/{positive}/images")
self.assertEqual([i["filename"] for i in page["images"]], [image.name])
served = await self.client.get(page["images"][0]["url"])
self.assertEqual(served.status, 200)
await served.read()
self.assertTrue(Path(stored).is_file())
async def test_a_malformed_root_is_refused_not_an_error(self):
response = await self.client.get(
"/prompt_manager/images/serve/abc/cover.png", params={"root": "\u00b2"}
)
self.assertEqual(response.status, 403)
async def test_lora_media_is_hidden_while_the_integration_is_off(self):
positive, _ids = self._imported()
self.lora_on = False
_, result = await self.get()
item = next(i for i in result["items"] if i["id"] == positive)
self.assertEqual(item["previews"], [])
response = await self.client.get(
"/prompt_manager/images/inspect/abc/cover.png", params={"root": "1"}
)
self.assertEqual(response.status, 400)
served = await self.client.get(
"/prompt_manager/images/serve/abc/cover.png", params={"root": "1"}
)
self.assertEqual(served.status, 403)
class TestNameCursorBudget(unittest.TestCase):
def test_lru_byte_budget_scope_and_oversize_keys_are_bounded(self):
from py.api.library import _NameCursorStore
+18 -83
View File
@@ -70,16 +70,8 @@ class LoraAPITestCase(AioHTTPTestCase):
# Never the repository's config.json (live user data): every save in
# these tests goes to a file in this test's temp directory.
self.config_file = self.tmp_root / "config.json"
# Never the developer's LoraManager settings either (#355): an empty
# settings folder of this test's own.
self.lm_settings = self.tmp_root / "lora-manager-settings"
self.lm_settings.mkdir()
self._env_patch = patch.dict(
os.environ,
{
"PROMPT_MANAGER_CONFIG_PATH": str(self.config_file),
"LORA_MANAGER_SETTINGS_DIR": str(self.lm_settings),
},
os.environ, {"PROMPT_MANAGER_CONFIG_PATH": str(self.config_file)}
)
self._env_patch.start()
@@ -202,8 +194,6 @@ def _make_lora_manager(where):
def _write_metadata(directory, stem, words=None, images=None, prompt=None):
"""A LoraManager sidecar beside its model, as LoraManager names them:
``<stem>.metadata.json`` next to ``<stem>.safetensors`` (#355)."""
meta = {"file_name": f"{stem}.safetensors", "model_name": stem, "civitai": {}}
if words is not None:
meta["civitai"]["trainedWords"] = words
@@ -211,8 +201,7 @@ def _write_metadata(directory, stem, words=None, images=None, prompt=None):
meta["civitai"]["images"] = [
{"url": url, "meta": {"prompt": prompt} if prompt else {}} for url in images
]
(directory / f"{stem}.safetensors").write_bytes(b"model")
path = directory / f"{stem}.metadata.json"
path = directory / f"{stem}.safetensors.metadata.json"
path.write_text(json.dumps(meta))
return path
@@ -311,7 +300,9 @@ class TestLoraEnableRoute(LoraAPITestCase):
_write_metadata(loras, "neon", words=["glow"])
self.detect_mock.return_value = str(lm)
with patch.object(lora_utils, "default_lora_roots", return_value=[str(loras)]):
with patch.object(
lora_utils, "find_lora_directories", return_value=[str(loras)]
):
resp = await self._post_json(
"/prompt_manager/lora/enable",
{
@@ -857,7 +848,9 @@ class TestLoraScanRoute(LoraAPITestCase):
cache_dir = self.tmp_root / "img_cache"
with (
patch.object(lora_utils, "default_lora_roots", return_value=[str(loras)]),
patch.object(
lora_utils, "find_lora_directories", return_value=[str(loras)]
),
patch.object(
lora_utils, "get_lora_image_cache_dir", return_value=cache_dir
),
@@ -876,18 +869,6 @@ class TestLoraScanRoute(LoraAPITestCase):
resp = await self.client.request("POST", "/prompt_manager/lora/scan")
self.assertEqual(resp.status, 400)
async def test_a_finished_scan_clears_the_layout_cache(self):
# #362: the legacy scan replaces through the same method.
self._enable()
loras = self.tmp_root / "loras"
loras.mkdir()
_write_metadata(loras, "plain")
(loras / "plain.png").write_bytes(_png_bytes())
with patch("py.lora_utils.clear_layout_cache") as clear:
resp, events, _calls = await self._scan(loras)
self.assertEqual(events[-1]["type"], "complete", events)
clear.assert_called_once_with()
async def test_scan_imports_loras_with_images_and_links_existing(self):
self._enable()
loras = self.tmp_root / "loras"
@@ -899,10 +880,9 @@ class TestLoraScanRoute(LoraAPITestCase):
images=["https://civitai.com/a.png", "https://evil.example/b.png"],
prompt="neon street",
)
# No local image: its CivitAI images are downloaded (#355).
(loras / "neon.png").write_bytes(_png_bytes())
_write_metadata(loras, "plain")
(loras / "broken.metadata.json").write_text("{{{")
(loras / "broken.safetensors").write_bytes(b"model")
existing_id = self.api.db.save_prompt(
text="plain",
category=None,
@@ -930,56 +910,8 @@ class TestLoraScanRoute(LoraAPITestCase):
self.assertEqual(imported[0]["text"], "neon street")
self.assertIn("lora:neon", imported[0]["tags"])
self.assertIn("glow", imported[0]["tags"])
self.assertEqual(len(self.api.db.get_prompt_images(imported[0]["id"])), 1)
self.assertEqual(len(self.api.db.get_prompt_images(imported[0]["id"])), 2)
self.assertEqual(len(self.api.db.get_prompt_images(existing_id)), 0)
# The unreadable sidecar is reported by its file name.
statuses = [e.get("status") for e in events if e.get("processed")]
self.assertIn("broken.metadata.json", statuses)
async def test_scan_links_local_media_without_downloading(self):
self._enable()
loras = self.tmp_root / "loras"
loras.mkdir()
_write_metadata(
loras, "neon", images=["https://civitai.com/a.png"], prompt="neon street"
)
(loras / "neon.preview.webp").write_bytes(b"x")
resp, events, opener_calls = await self._scan(loras)
self.assertEqual(events[-1]["imported"], 1)
self.assertEqual(opener_calls, [])
imported = self.api.db.search_prompts(category="lora-manager")
images = self.api.db.get_prompt_images(imported[0]["id"])
self.assertEqual(
[os.path.basename(i["image_path"]) for i in images], ["neon.preview.webp"]
)
async def test_scan_reports_unusable_settings_without_replacing(self):
self._enable()
loras = self.tmp_root / "loras"
loras.mkdir()
_write_metadata(loras, "neon")
(self.lm_settings / "settings.json").write_text("{not json")
old_id = self.api.db.save_prompt(
text="kept",
category="lora-manager",
tags=[],
rating=None,
prompt_hash=generate_prompt_hash("kept"),
)
resp, events, _ = await self._scan(loras)
self.assertEqual(resp.status, 200)
self.assertEqual(events[-1]["type"], "error")
self.assertEqual(
events[-1]["message"],
"LoraManager settings: not usable: the settings file is not valid "
"JSON. Nothing was changed.",
)
kept = self.api.db.search_prompts(category="lora-manager")
self.assertEqual([p["id"] for p in kept], [old_id])
async def test_save_failure_is_counted_as_skipped(self):
self._enable()
@@ -1017,7 +949,9 @@ class TestLoraTriggerWordRoutes(LoraAPITestCase):
loras.mkdir()
_write_metadata(loras, "neon", words=["glow", "bright"])
with patch.object(lora_utils, "default_lora_roots", return_value=[str(loras)]):
with patch.object(
lora_utils, "find_lora_directories", return_value=[str(loras)]
):
resp = await self.client.request(
"GET", "/prompt_manager/lora/trigger-words?name=NEON"
)
@@ -1051,16 +985,17 @@ class TestLoraTriggerWordRoutes(LoraAPITestCase):
loras.mkdir()
_write_metadata(loras, "neon", words=["glow"])
with patch.object(lora_utils, "default_lora_roots", return_value=[str(loras)]):
with patch.object(
lora_utils, "find_lora_directories", return_value=[str(loras)]
):
resp = await self.client.request(
"POST", "/prompt_manager/lora/refresh-cache"
)
data = await resp.json()
self.assertTrue(data["success"])
# Keyed by the model name and the sidecar name, which are the same
# for LoraManager's alongside layout.
self.assertEqual(data["loras_with_trigger_words"], 1)
# keyed by both the file_name stem and the metadata-file stem
self.assertEqual(data["loras_with_trigger_words"], 2)
async def test_refresh_error_is_500(self):
IntegrationConfig.LORA_MANAGER_ENABLED = True
-39
View File
@@ -171,45 +171,6 @@ class TestWorkspaceImages(ImageAPITestCase):
self.assertIsNone(second["next_cursor"])
self.assertEqual(second["total"], 4)
async def test_an_unchanged_refresh_keeps_the_listing_and_derived_caches(self):
from py.api import gallery_listing
self.media("a.png", 300)
self.media("b.png", 200)
_, first = await self.get()
response = await self.client.put(
"/prompt_manager/images/favorite",
json={"root_index": 0, "path": "a.png"},
)
self.assertEqual(response.status, 200)
record = self.api._root_listings[str(self.output_dir)]
files = record.files
key = ((0, str(self.output_dir)),)
newest = self.api._workspace_order_cache[key][1]["newest"]
favorites = self.api._favorite_listing_cache[str(self.output_dir)]
record.walked_at -= gallery_listing.REFRESH_AFTER + 1
record.last_started_at -= gallery_listing.REFRESH_AFTER + 1
with patch.object(
self.api,
"_scan_gallery_files_sync",
wraps=self.api._scan_gallery_files_sync,
) as scan:
with patch.object(gallery_listing, "REFRESH_DELAY", 0):
await self.get() # served at once; one refresh walk follows
while record.rewalk_handle is not None:
await asyncio.sleep(0.01)
while self.api._listing_tasks:
await asyncio.gather(*list(self.api._listing_tasks))
# A finished walk leaves the set in a done callback, which needs
# a loop turn; gather over finished tasks does not yield one.
await asyncio.sleep(0)
_, again = await self.get()
self.assertEqual(scan.call_count, 1)
self.assertIs(record.files, files)
self.assertIs(self.api._workspace_order_cache[key][1]["newest"], newest)
self.assertIs(self.api._favorite_listing_cache[str(self.output_dir)], favorites)
self.assertEqual(again["total"], first["total"])
async def test_filters_apply_before_limit_and_sorts_are_complete(self):
self.media("folder/Zebra.png", 200)
self.media("folder/apple.png", 300)
+12 -29
View File
@@ -19,15 +19,8 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from PIL import Image
# py.autotag reads its saved defaults from py.config, which imports ComfyUI's
# ``server`` at load: stand in for it when this module runs alone.
_mock_server = MagicMock()
_mock_server.PromptServer.instance.routes = MagicMock()
sys.modules.setdefault("server", _mock_server)
from py import autotag # noqa: E402
from py.autotag import MODELS, AutoTagService # noqa: E402
from py.config import AutotagConfig # noqa: E402
from py import autotag
from py.autotag import MODELS, AutoTagService
@contextlib.contextmanager
@@ -261,19 +254,12 @@ class TestWd14Thresholding(EngineTestCase):
self.assertEqual(list(feeds.keys()), ["input"])
self.assertEqual(tuple(feeds["input"].shape), (1, 8, 8, 3))
def test_service_thresholds_are_the_saved_defaults(self):
# #328: one configuration; the service has no thresholds of its own.
service = self._loaded([0.9, 0.3, 0.8, 0.99, 0.99, 0.5])
with (
patch.object(AutotagConfig, "GENERAL_THRESHOLD", 0.25),
patch.object(AutotagConfig, "CHARACTER_THRESHOLD", 0.75),
):
self.assertEqual(
(service.wd14_general_threshold, service.wd14_character_threshold),
(0.25, 0.75),
)
tags = service.generate_tags(str(self.image_path))
self.assertEqual(tags, ["1girl", "hatsune_miku", "long_hair", "smile"])
def test_service_threshold_setters_clamp(self):
service = self._loaded([0.0] * 6)
service.wd14_general_threshold = 1.7
service.wd14_character_threshold = -2
self.assertEqual(service.wd14_general_threshold, 1.0)
self.assertEqual(service.wd14_character_threshold, 0.0)
class TestGgufNormalisation(EngineTestCase):
@@ -383,13 +369,10 @@ class TestTagSuggestions(EngineTestCase):
def test_thresholds_default_to_the_service_values(self):
service = self._wd14()
with (
patch.object(AutotagConfig, "GENERAL_THRESHOLD", 0.95),
patch.object(AutotagConfig, "CHARACTER_THRESHOLD", 0.95),
):
suggestions = service.generate_tag_suggestions(
str(self.image_path), limit=10
)
service.wd14_general_threshold = 0.95
service.wd14_character_threshold = 0.95
suggestions = service.generate_tag_suggestions(str(self.image_path), limit=10)
self.assertEqual(suggestions, [("sandstone", 0.98), ("desert", 0.96)])
-16
View File
@@ -79,21 +79,5 @@ class TestBrowserServerLifetime(unittest.TestCase):
process.wait(timeout=1)
class TestBrowserServerLoraIsolation(unittest.TestCase):
"""#355: page tests never read the developer's LoraManager settings."""
def test_settings_folder_is_inside_the_server_root(self):
from unittest.mock import patch
from tests.browser.server import isolate_lora_manager_settings
with tempfile.TemporaryDirectory() as root:
with patch.dict(os.environ, {"LORA_MANAGER_SETTINGS_DIR": "/real/one"}):
folder = isolate_lora_manager_settings(Path(root))
self.assertEqual(os.environ["LORA_MANAGER_SETTINGS_DIR"], str(folder))
self.assertEqual(folder.parent, Path(root))
self.assertTrue(folder.is_dir())
if __name__ == "__main__":
unittest.main()
+2 -125
View File
@@ -413,21 +413,9 @@ class TestAutotagConfig(unittest.TestCase):
self.config_class = getattr(config_module, "AutotagConfig", None)
self.assertIsNotNone(self.config_class, "AutotagConfig is not implemented")
self._orig = PromptManagerConfig.get_config()
self._defaults = {
attr: getattr(self.config_class, attr, None)
for attr in (
"MODEL_TYPE",
"EXISTING",
"GENERAL_THRESHOLD",
"CHARACTER_THRESHOLD",
"TEMPLATE",
"SUGGESTION_LIMIT",
)
}
def tearDown(self):
for attr, value in self._defaults.items():
setattr(self.config_class, attr, value)
self.config_class.SUGGESTION_LIMIT = 10
PromptManagerConfig.update_config(self._orig)
def _load(self, value):
@@ -458,23 +446,8 @@ class TestAutotagConfig(unittest.TestCase):
self.assertEqual(self.config_class.suggestion_limit(), 10)
self.assertEqual(
PromptManagerConfig.get_config()["autotag"],
{
"defaults": {
"model_type": "wd14-swinv2",
"existing": "skip",
"general_threshold": 0.35,
"character_threshold": 0.85,
"suggestion_limit": 10,
}
},
{"defaults": {"suggestion_limit": 10}},
)
# The built-in template is not written; a saved one is.
self.config_class.TEMPLATE = "Saved."
self.assertEqual(
PromptManagerConfig.get_config()["autotag"]["defaults"]["template"],
"Saved.",
)
self.config_class.TEMPLATE = config_module.DEFAULT_TAG_TEMPLATE
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "config.json")
PromptManagerConfig.update_config(
@@ -493,102 +466,6 @@ class TestAutotagConfig(unittest.TestCase):
)
self.assertEqual(self.config_class.suggestion_limit(), value)
def test_autotag_defaults_fall_back_per_key_on_load(self):
"""#328: each saved tagging default is checked alone on load; a bad
hand-edited value falls back to its default with one warning and
leaves the other keys as saved."""
good = {
"model_type": "gguf",
"existing": "merge",
"general_threshold": 0.4,
"character_threshold": 0.7,
"template": "Tags only.",
"suggestion_limit": 15,
}
fallback = {
"model_type": "wd14-swinv2",
"existing": "skip",
"general_threshold": 0.35,
"character_threshold": 0.85,
"template": config_module.DEFAULT_TAG_TEMPLATE,
"suggestion_limit": 10,
}
bad_values = {
"model_type": ["wd14-convnext", 3, None],
"existing": ["replace", "", True],
# #369: a saved threshold off the 0.05 step no page can show.
"general_threshold": [1.5, -0.1, float("nan"), "0.4", True, 0.37],
"character_threshold": [2, float("inf"), None, 0.33],
# 4000 characters are at most 16,000 UTF-8 bytes, so the 16 KiB
# byte cap can only be met past the character cap.
"template": ["", " ", "x" * 4001, "\U0001f600" * 4097, "\ud800", 7],
}
for key, values in bad_values.items():
for value in values:
with self.subTest(key=key, value=value):
saved = dict(good, **{key: value})
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "config.json")
with open(path, "w") as f:
json.dump({"autotag": {"defaults": saved}}, f)
with self.assertLogs(
"prompt_manager.config", level="WARNING"
) as logs:
PromptManagerConfig.load_from_file(path)
self.assertEqual(len(logs.records), 1, logs.output)
self.assertIn(f"autotag.defaults.{key}", logs.output[0])
loaded = {
k: getattr(self.config_class, attr)
for k, (attr, _v, _d) in self.config_class._KEYS.items()
}
self.assertEqual(loaded, dict(good, **{key: fallback[key]}))
def test_tagging_validators_moved_with_their_messages(self):
validate_threshold = config_module.validate_threshold
validate_template = config_module.validate_template
self.assertEqual(validate_threshold("general_threshold", 1), 1.0)
for value, message in (
(float("nan"), "general_threshold must be a finite number"),
("0.4", "general_threshold must be a finite number"),
(1.01, "general_threshold must be between 0 and 1"),
):
with self.assertRaises(ValueError) as caught:
validate_threshold("general_threshold", value)
self.assertEqual(str(caught.exception), message)
self.assertEqual(validate_template("Tags."), "Tags.")
for value, message in (
(" ", "instructions must be non-empty text"),
("x" * 4001, "instructions must be at most 4000 characters"),
("\ud800", "instructions must be valid Unicode text"),
):
with self.assertRaises(ValueError) as caught:
validate_template(value)
self.assertEqual(str(caught.exception), message)
self.assertEqual(
config_module.MODEL_TYPES, {"gguf", "hf", "wd14-swinv2", "wd14-vit"}
)
def test_saved_threshold_is_a_multiple_of_the_step(self):
"""#369: every page shows the thresholds in steps of 0.05, so a saved
one must be on that grid; it is stored exactly on it."""
from py.tagging_defaults import validate_saved_threshold as validate
for value in (0, 0.05, 0.15, 0.35, 0.85, 1):
with self.subTest(value=value):
self.assertEqual(validate("x", value), value)
self.assertEqual(validate("x", 0.1 + 0.2), 0.3)
for value, message in (
(0.37, "x must be a multiple of 0.05"),
(0.351, "x must be a multiple of 0.05"),
(0.35000000001, "x must be a multiple of 0.05"),
(1.5, "x must be between 0 and 1"),
("0.4", "x must be a finite number"),
):
with self.subTest(value=value):
with self.assertRaises(ValueError) as caught:
validate("x", value)
self.assertEqual(str(caught.exception), message)
def test_suggestion_limit_setting_validated_on_load(self):
for value in (0, 101, "10", float("nan"), 2.5, True, None, [10]):
with self.subTest(value=value):
-44
View File
@@ -1398,50 +1398,6 @@ class TestModelHousekeeping(DatabaseTestCase):
self.assertIsNot(self.db.model.get_connection(), failing)
class TestAutotagBatchCandidates(DatabaseTestCase):
"""#328: the batch's candidate read never loads workflow documents."""
def test_autotag_candidates_skip_workflow_data(self):
from utils.hashing import generate_prompt_hash
first = self.db.save_prompt(
text="a", tags=["one", "two"], prompt_hash=generate_prompt_hash("a")
)
second = self.db.save_prompt(text="b", prompt_hash=generate_prompt_hash("b"))
old = self.db.link_image_to_prompt(
first, "/out/a.png", {"workflow": {"big": "x" * 1000}}
)
new = self.db.link_image_to_prompt(second, "/out/b.png")
statements = []
conn = self.db.model.get_connection() # this thread's own connection
conn.set_trace_callback(statements.append)
try:
rows = self.db.autotag_batch_candidates()
finally:
conn.set_trace_callback(None)
self.assertEqual(
rows[0],
{
"id": new,
"image_path": "/out/b.png",
"prompt_id": second,
"prompt_tags": [],
},
)
self.assertEqual(
{**rows[1], "prompt_tags": sorted(rows[1]["prompt_tags"])},
{
"id": old,
"image_path": "/out/a.png",
"prompt_id": first,
"prompt_tags": ["one", "two"],
},
)
self.assertEqual(len(rows), 2)
self.assertTrue(statements)
self.assertFalse(any("workflow_data" in sql for sql in statements))
if __name__ == "__main__":
unittest.main()
+3 -12
View File
@@ -28,8 +28,6 @@ except ImportError:
Status = namedtuple("ExecutionStatus", "status_str completed messages")
JOB = "a0c26da3-eb26-405c-b143-c662a84c3112"
# A cross-thread wait or a duration check fails only past this (#370).
CEILING_SECONDS = 30.0
class HostQueue:
@@ -211,9 +209,6 @@ class HistoryHooksTests(unittest.TestCase):
self.queue.get = unrelated_after
self.dequeue(0)
self.queue.task_done(0, {}, status())
# The writer drains first: this test checks wrappers and the session,
# not the 2 s bound, which a slow runner can spend on the write (#370).
self.drain(first)
self.assertTrue(first.dispose())
self.assertTrue(first.dispose())
self.assertIs(self.queue.get, unrelated_after)
@@ -344,20 +339,16 @@ class HistoryHooksTests(unittest.TestCase):
with patch.object(self.db, "write_execution_start", blocked):
registration = self.register()
self.dequeue(0)
# Ceilings, not tight wall clocks (#370): the return values show
# the bound; a ceiling still fails a call that waits unbounded.
self.assertTrue(entered.wait(CEILING_SECONDS))
self.assertTrue(entered.wait(1))
began = time.monotonic()
self.queue.task_done(0, {}, status())
self.dequeue(1)
self.assertLess(time.monotonic() - began, CEILING_SECONDS)
self.assertLess(time.monotonic() - began, 0.2)
began = time.monotonic()
self.assertFalse(registration.dispose())
self.assertGreaterEqual(time.monotonic() - began, 1.8)
self.assertLess(time.monotonic() - began, CEILING_SECONDS)
self.assertLess(time.monotonic() - began, 2.3)
release.set()
# Drain first: the 2 s bound is not under test here (#370).
self.drain(registration)
self.assertTrue(registration.dispose(2))
def test_restore_disables_capture_and_clears_handles_before_fresh_epoch(self):
+5 -14
View File
@@ -24,13 +24,6 @@ from utils.execution_history_events import (
)
from utils.execution_history_writer import HistoryWriter
# These calls must return while another thread holds the writer in a step that
# waits up to 3 s (release.wait(3)). 1 s still tells "returned at once" from
# "waited for the release" without failing on a loaded shared runner (#242:
# generous ceilings, never a short wall clock). A 0.1 s ceiling failed at
# 0.19 s on macOS py3.13 (PR #351).
NON_BLOCKING_CEILING = 1.0
class ManualClock:
def __init__(self):
@@ -597,10 +590,10 @@ class HistoryWriterTests(unittest.TestCase):
before = time.monotonic()
self.assertTrue(writer.status().accepting)
self.assertTrue(writer.submit(self.finish_command(start)))
self.assertLess(time.monotonic() - before, NON_BLOCKING_CEILING)
self.assertLess(time.monotonic() - before, 0.1)
before = time.monotonic()
self.assertFalse(writer.close(0))
self.assertLess(time.monotonic() - before, NON_BLOCKING_CEILING)
self.assertLess(time.monotonic() - before, 0.1)
self.assertGreater(writer.context_budget.charged_bytes, 0)
self.assertEqual(writer.status().pending, 1)
finally:
@@ -642,8 +635,6 @@ class HistoryWriterTests(unittest.TestCase):
== "succeeded"
)
self.assertTrue(writer.submit(self.start_command(2)))
# Drain first: the 2 s bound is not under test here (#370).
drain_writer(writer)
self.assertTrue(writer.close(2))
self.assertEqual(len(set(threads)), 1)
self.assertNotEqual(threads[0], threading.get_ident())
@@ -672,11 +663,11 @@ class HistoryWriterTests(unittest.TestCase):
before = time.monotonic()
self.assertFalse(writer.submit(self.start_command()))
self.assertIsNone(writer.status().epoch)
self.assertLess(time.monotonic() - before, NON_BLOCKING_CEILING)
self.assertLess(time.monotonic() - before, 0.1)
self.assertIsNone(self.db.get_execution_run(self.start_command().run_id))
before = time.monotonic()
self.assertFalse(writer.close(0.03))
self.assertLess(time.monotonic() - before, NON_BLOCKING_CEILING)
self.assertLess(time.monotonic() - before, 0.2)
release.set()
self.wait_for(lambda: not writer.status().alive)
self.assertFalse(writer.status().accepting)
@@ -967,7 +958,7 @@ class HistoryWriterTests(unittest.TestCase):
self.assertTrue(writer.submit(queued))
before = time.monotonic()
self.assertFalse(writer.close(0.03))
self.assertLess(time.monotonic() - before, NON_BLOCKING_CEILING)
self.assertLess(time.monotonic() - before, 0.2)
release.set()
self.wait_for(lambda: not writer.status().alive)
self.assertIsNone(self.db.get_execution_run(queued.run_id))
-6
View File
@@ -245,8 +245,6 @@ class OutputHooksTests(fixtures.unittest.TestCase):
registration = self.register()
self.dequeue(0)
late = registration._task_done
# Drain first: the 2 s bound is not under test here (#370).
self.drain(registration)
self.assertTrue(registration.dispose())
thread = threading.Thread(
target=lambda: late((0, history(), fixtures.status()), {})
@@ -399,10 +397,6 @@ class OutputHooksTests(fixtures.unittest.TestCase):
self.queue.task_done(1, history("rejected.png"), fixtures.status())
registration.writer.invalidate()
release.set()
# Let the writer work through the 1,024 queued commands first
# (#370's pattern): on a busy Windows runner that took longer
# than dispose's 2 s bound (merge queue, #382).
self.drain(registration)
self.assertTrue(registration.dispose(2))
self.assertEqual(self.outputs(0)["state"], "not_captured")
self.assertEqual(len(self.rows()), 1)
+4 -18
View File
@@ -17,7 +17,6 @@ from utils.execution_contexts import MAX_ATTEMPT_BYTES
from utils.execution_output_capture import seal_outputs
from utils.execution_outputs import snapshot_output_roots
from utils.execution_history_writer import HistoryWriter
from tests.history_waits import drain_writer
try:
from tests import test_execution_history_writer as fixtures
@@ -28,11 +27,6 @@ except ImportError:
# Upper bound a blocked fake write waits for its release. Tests prove ordering
# with events, so this only caps a failing run and never decides a pass.
BLOCK_SECONDS = 10
# A restore is a full verify/migrate/backup/replace cycle and is bound by the
# disk: a busy Windows runner took over 10 s (merge queue, #382). Waits that
# span a restore use this ceiling, which stays under the runner's 120 s hang
# watchdog, so a real hang still fails with the thread dump.
RESTORE_SECONDS = 90
class OutputWriterTests(fixtures.unittest.TestCase):
@@ -167,8 +161,6 @@ class OutputWriterTests(fixtures.unittest.TestCase):
start = replace(self.start_command(), run_id=str(fixtures.uuid4()))
self.assertTrue(writer.submit(start))
self.finalization(writer, start)
# Drain first: the 2 s bound is not under test here (#370).
drain_writer(writer)
self.assertTrue(writer.close(2))
self.assertEqual(
self.db.get_execution_run(start.run_id)["outcome"], "succeeded"
@@ -322,8 +314,8 @@ class OutputWriterTests(fixtures.unittest.TestCase):
Returns the patch and an event set once that rotation has returned on
the worker. A restore is a full verify/migrate/backup/replace cycle, so
the handoff waits on this event with the ``RESTORE_SECONDS`` ceiling
instead of the short worker-behavior deadline (TESTING.md,
the handoff waits on this event with the generous ``BLOCK_SECONDS``
ceiling instead of the short worker-behavior deadline (TESTING.md,
Windows portability preflight).
"""
backup = os.path.join(self.directory.name, f"restore-{start.run_id}.db")
@@ -366,7 +358,7 @@ class OutputWriterTests(fixtures.unittest.TestCase):
)
with rotation:
self.finalization(writer, start)
self.assertTrue(rotated.wait(RESTORE_SECONDS))
self.assertTrue(rotated.wait(BLOCK_SECONDS))
self.wait_for(lambda: writer.status().pending == 0)
self.assertEqual(
self.db.get_execution_outputs(start.run_id)["state"],
@@ -408,7 +400,7 @@ class OutputWriterTests(fixtures.unittest.TestCase):
self.assertEqual(writer.status().pending, 1)
self.assertEqual(writer.context_budget.charged_bytes, charge)
release.set()
self.assertTrue(rotated.wait(RESTORE_SECONDS))
self.assertTrue(rotated.wait(BLOCK_SECONDS))
self.wait_for(lambda: writer.status().pending == 0)
self.assertEqual(writer.context_budget.charged_bytes, 0)
self.assertFalse(writer.status().accepting)
@@ -572,8 +564,6 @@ class OutputWriterTests(fixtures.unittest.TestCase):
self.assertIs(replacement.context_budget, writer.context_budget)
self.assertIsNone(replacement.context_budget.reserve(8388608))
release.set()
# Drain first: the 2 s bound is not under test here (#370).
drain_writer(writer)
self.assertTrue(writer.close(2))
finally:
release.set()
@@ -615,8 +605,6 @@ class OutputWriterTests(fixtures.unittest.TestCase):
writer.invalidate()
self.assertEqual(writer.context_budget.charged_bytes, 0)
release.set()
# Drain first: the 2 s bound is not under test here (#370).
drain_writer(writer)
self.assertTrue(writer.close(2))
def test_malformed_context_omission_preserves_independent_valid_output(self):
@@ -707,8 +695,6 @@ class OutputWriterTests(fixtures.unittest.TestCase):
release.set()
invalidator.join(BLOCK_SECONDS)
self.assertFalse(invalidator.is_alive())
# Drain first: the 2 s bound is not under test here (#370).
drain_writer(writer)
self.assertTrue(writer.close(2))
self.assertEqual(
self.db.get_execution_outputs(start.run_id)["state"], "complete"
-478
View File
@@ -1,478 +0,0 @@
"""Pure helpers of the shared Images listing (py/api/gallery_listing.py, #348)."""
import ntpath
import os
import posixpath
import sys
import tempfile
import unittest
from pathlib import Path, PurePath, PurePosixPath, PureWindowsPath
from types import SimpleNamespace
from unittest.mock import patch
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from py.api import gallery_listing # noqa: E402
from py.api.gallery_listing import ( # noqa: E402
DISCARDED,
FAILED,
STORED,
RootListing,
WalkOutcome,
add_folder,
folder_sets,
insert_entry,
listing_delta,
relative_part,
rewalk_interval,
root_contains,
same_listing,
walk_root,
)
def _legacy_subfolders(root, files, include_ancestors):
"""``_root_subfolders`` as it was before #348, the reference for folder_sets."""
subfolders = set()
for f, _mtime in files:
rel_dir = str(f.relative_to(root).parent)
if rel_dir and rel_dir != ".":
subfolders.add(rel_dir)
if include_ancestors:
ancestors = set()
for folder in subfolders:
parts = folder.replace("\\", "/").split("/")
for i in range(1, len(parts)):
ancestors.add("/".join(parts[:i]))
subfolders.update(ancestors)
return subfolders
class FolderSetsTests(unittest.TestCase):
def test_nested_folders_match_the_previous_subfolder_listing(self):
root = Path("/out")
files = [
(root / "top.png", 1.0),
(root / "a" / "one.png", 2.0),
(root / "a" / "b" / "c" / "two.png", 3.0),
(root / "d" / "three.png", 4.0),
]
folders, with_ancestors = folder_sets(root, files)
self.assertEqual(folders, _legacy_subfolders(root, files, False))
self.assertEqual(with_ancestors, _legacy_subfolders(root, files, True))
self.assertIn("a/b", with_ancestors)
self.assertNotIn("a/b", folders)
self.assertIsInstance(folders, frozenset)
self.assertIsInstance(with_ancestors, frozenset)
def test_windows_separators_keep_the_previous_spelling(self):
root = PureWindowsPath("C:/out")
files = [
(root / "x" / "y" / "z.png", 1.0),
(root / "w.png", 2.0),
]
folders, with_ancestors = folder_sets(root, files)
self.assertEqual(folders, {"x\\y"})
self.assertEqual(with_ancestors, {"x\\y", "x"})
self.assertEqual(folders, _legacy_subfolders(root, files, False))
self.assertEqual(with_ancestors, _legacy_subfolders(root, files, True))
def test_an_empty_listing_has_no_folders(self):
self.assertEqual(folder_sets(Path("/out"), []), (frozenset(), frozenset()))
def test_folders_come_from_the_path_strings_not_relative_to(self):
# relative_to per file was half of a 20,000-file walk (#348 live test).
root = Path("/out")
files = [(root / "a" / "b" / "one.png", 1.0), (root / "two.png", 2.0)]
with patch.object(PurePath, "relative_to", side_effect=AssertionError):
folders, with_ancestors = folder_sets(root, files)
self.assertEqual(folders, {os.path.join("a", "b")})
self.assertEqual(with_ancestors, {os.path.join("a", "b"), "a"})
def test_a_path_not_spelled_like_the_root_falls_back_to_relative_to(self):
# Windows folds case: the string prefix misses, relative_to matches.
root = PureWindowsPath("C:/Out")
files = [(PureWindowsPath("c:/out/x/y.png"), 1.0)]
folders, with_ancestors = folder_sets(root, files)
self.assertEqual(folders, {"x"})
self.assertEqual(folders, _legacy_subfolders(root, files, False))
self.assertEqual(with_ancestors, _legacy_subfolders(root, files, True))
class SameListingTests(unittest.TestCase):
def test_equal_path_and_mtime_lists_are_the_same(self):
old = [(Path("/o/a.png"), 1.0), (Path("/o/b.png"), 2.0)]
self.assertTrue(same_listing(old, list(old)))
self.assertTrue(same_listing(old, old))
def test_a_changed_mtime_path_or_length_differs(self):
old = [(Path("/o/a.png"), 1.0), (Path("/o/b.png"), 2.0)]
self.assertFalse(same_listing(old, [(Path("/o/a.png"), 1.5), old[1]]))
self.assertFalse(same_listing(old, [(Path("/o/c.png"), 1.0), old[1]]))
self.assertFalse(same_listing(old, old[:1]))
self.assertFalse(same_listing(None, old))
class WalkRootTests(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
self.root = Path(self._tmp.name) / "output"
(self.root / "sub").mkdir(parents=True)
def _scan(self, files):
calls = []
def scan(root):
calls.append(root)
return list(files)
return scan, calls
def test_returns_the_listing_and_both_folder_sets(self):
files = [(self.root / "sub" / "a.png", 2.0), (self.root / "b.png", 1.0)]
scan, calls = self._scan(files)
listed, folders, with_ancestors, resolved = walk_root(scan, self.root, None)
self.assertEqual(calls, [self.root])
self.assertEqual(listed, files)
self.assertEqual(folders, frozenset({"sub"}))
self.assertEqual(with_ancestors, frozenset({"sub"}))
self.assertEqual(resolved, os.path.normcase(os.path.realpath(self.root)))
def test_the_resolved_root_follows_a_symlinked_root(self):
link = Path(self._tmp.name) / "linked"
try:
os.symlink(self.root, link, target_is_directory=True)
except (OSError, NotImplementedError) as error:
self.skipTest(f"symlinks unavailable: {error}")
scan, _calls = self._scan([])
resolved = walk_root(scan, link, None)[3]
self.assertEqual(resolved, os.path.normcase(os.path.realpath(self.root)))
self.assertNotEqual(resolved, os.path.normcase(str(link)))
def test_an_unchanged_walk_returns_the_previous_list_object(self):
files = [(self.root / "sub" / "a.png", 2.0)]
previous = list(files)
scan, _calls = self._scan(files)
listed, _folders, _ancestors, _resolved = walk_root(scan, self.root, previous)
self.assertIs(listed, previous)
def test_a_changed_walk_returns_the_new_list(self):
previous = [(self.root / "sub" / "a.png", 2.0)]
files = [(self.root / "sub" / "a.png", 3.0)]
scan, _calls = self._scan(files)
listed, _folders, _ancestors, _resolved = walk_root(scan, self.root, previous)
self.assertIsNot(listed, previous)
self.assertEqual(listed, files)
def test_a_deleted_root_raises_instead_of_listing_nothing(self):
scan, calls = self._scan([])
missing = self.root / "gone"
with self.assertRaises(OSError):
walk_root(scan, missing, None)
self.assertEqual(calls, [])
class RecordTests(unittest.TestCase):
def test_drop_clears_the_listing_and_bumps_the_generation(self):
record = RootListing()
record.files = [(Path("/o/a.png"), 1.0)]
record.folders = frozenset({"x"})
record.folders_with_ancestors = frozenset({"x"})
record.walked_at = 5.0
record.drop()
record.drop()
self.assertIsNone(record.files)
self.assertIsNone(record.folders)
self.assertIsNone(record.folders_with_ancestors)
self.assertIsNone(record.walked_at)
self.assertEqual(record.generation, 2)
def test_outcomes(self):
error = OSError("gone")
self.assertEqual(WalkOutcome.stored().kind, STORED)
self.assertEqual(WalkOutcome.discarded().kind, DISCARDED)
failed = WalkOutcome.failed(error)
self.assertEqual(failed.kind, FAILED)
self.assertIs(failed.error, error)
def test_refresh_constants(self):
self.assertEqual(gallery_listing.REFRESH_AFTER, 30)
self.assertEqual(gallery_listing.REFRESH_DELAY, 1.0)
self.assertIsNone(RootListing().rewalk_handle)
def test_a_new_record_is_clean_and_never_walked(self):
record = RootListing()
self.assertFalse(record.dirty)
self.assertIsNone(record.resolved_root)
self.assertIsNone(record.last_started_at)
self.assertIsNone(record.last_duration)
def test_drop_forgets_pending_adds(self):
record = RootListing(files=[], pending_adds=["a.png"], patched=2)
record.drop()
self.assertEqual((record.pending_adds, record.patched), ([], 0))
def test_drop_keeps_the_resolved_root_and_the_walk_timing(self):
# A drop clears the listing, not what is known about the root itself.
record = RootListing(resolved_root="/o", last_started_at=1.0)
record.last_duration = 0.5
record.dirty = True
record.drop()
self.assertEqual(record.resolved_root, "/o")
self.assertEqual((record.last_started_at, record.last_duration), (1.0, 0.5))
self.assertTrue(record.dirty)
class RewalkIntervalTests(unittest.TestCase):
def test_constants(self):
self.assertEqual(
(
gallery_listing.REWALK_FLOOR,
gallery_listing.REWALK_FACTOR,
gallery_listing.REWALK_CAP,
gallery_listing.MIN_REWALK_DELAY,
),
(5, 5, 15, 0.5),
)
def test_short_walks_wait_the_floor(self):
self.assertEqual(rewalk_interval(0.2), 5)
self.assertEqual(rewalk_interval(0), 5)
self.assertEqual(rewalk_interval(None), 5) # never walked
def test_the_interval_scales_with_the_last_walk(self):
self.assertEqual(rewalk_interval(1.0), 5)
self.assertEqual(rewalk_interval(2.0), 10)
self.assertAlmostEqual(rewalk_interval(2.5), 12.5)
def test_slow_walks_are_capped(self):
self.assertEqual(rewalk_interval(3.0), 15)
self.assertEqual(rewalk_interval(60.0), 15)
def test_the_constants_are_read_when_called(self):
with (
patch.object(gallery_listing, "REWALK_FLOOR", 0),
patch.object(gallery_listing, "REWALK_CAP", 0),
):
self.assertEqual(rewalk_interval(2.0), 0)
class RootContainsTests(unittest.TestCase):
def test_posix_paths_under_the_root_match(self):
root = posixpath.normcase("/srv/out")
self.assertTrue(root_contains(root, "/srv/out/a.png", posixpath))
self.assertTrue(root_contains(root, "/srv/out/sub/deep/a.png", posixpath))
def test_a_sibling_sharing_the_prefix_does_not_match(self):
self.assertFalse(root_contains("/srv/out", "/srv/out2/a.png", posixpath))
self.assertFalse(root_contains("/srv/out", "/srv/outside.png", posixpath))
self.assertFalse(root_contains("/srv/out", "/srv/other/a.png", posixpath))
def test_posix_keeps_case(self):
self.assertFalse(root_contains("/srv/out", "/srv/OUT/a.png", posixpath))
def test_the_root_itself_is_not_an_output(self):
self.assertFalse(root_contains("/srv/out", "/srv/out", posixpath))
def test_windows_folds_case_and_separators(self):
root = ntpath.normcase("C:\\ComfyUI\\Output")
self.assertTrue(root_contains(root, "c:\\comfyui\\output\\a.png", ntpath))
self.assertTrue(root_contains(root, "C:/ComfyUI/OUTPUT/sub/a.PNG", ntpath))
self.assertFalse(root_contains(root, "C:\\ComfyUI\\Output2\\a.png", ntpath))
self.assertFalse(root_contains(root, "D:\\ComfyUI\\Output\\a.png", ntpath))
def test_a_windows_drive_root_and_a_unc_share(self):
drive = ntpath.normcase("E:\\")
self.assertTrue(root_contains(drive, "e:\\x.png", ntpath))
self.assertFalse(root_contains(drive, "F:\\x.png", ntpath))
share = ntpath.normcase("\\\\Server\\Share\\Out")
self.assertTrue(root_contains(share, "\\\\server\\share\\out\\a.png", ntpath))
self.assertFalse(root_contains(share, "\\\\server\\share\\a.png", ntpath))
def test_a_windows_extended_length_prefix_is_ignored(self):
root = ntpath.normcase("C:\\ComfyUI\\Output")
long_file = "\\\\?\\C:\\ComfyUI\\Output\\deep\\a.png"
self.assertTrue(root_contains(root, long_file, ntpath))
prefixed_root = ntpath.normcase("\\\\?\\C:\\ComfyUI\\Output")
self.assertTrue(
root_contains(prefixed_root, "C:\\ComfyUI\\Output\\a.png", ntpath)
)
share = ntpath.normcase("\\\\Server\\Share\\Out")
unc_file = "\\\\?\\UNC\\server\\share\\out\\a.png"
self.assertTrue(root_contains(share, unc_file, ntpath))
self.assertFalse(
root_contains(root, "\\\\?\\C:\\ComfyUI\\Output2\\a.png", ntpath)
)
def test_posix_keeps_a_literal_question_mark_folder(self):
self.assertFalse(root_contains("/srv/out", "//?/srv/out/a.png", posixpath))
def test_the_default_is_this_platforms_rule(self):
root = os.path.normcase(os.path.realpath(tempfile.gettempdir()))
inside = os.path.join(root, "x.png")
self.assertTrue(root_contains(root, inside))
self.assertTrue(root_contains(root, inside.upper()) == (os.name == "nt"))
if __name__ == "__main__":
unittest.main()
class RelativePartTests(unittest.TestCase):
def test_posix_keeps_the_case_and_the_nested_folders(self):
self.assertEqual(
relative_part("/srv/out", "/srv/out/Day 1/A.png", posixpath),
"Day 1/A.png",
)
def test_posix_outside_the_root_a_sibling_or_the_root_itself_is_none(self):
for path in ("/srv/other/a.png", "/srv/out2/a.png", "/srv/out", "/srv/out/"):
self.assertIsNone(relative_part("/srv/out", path, posixpath), path)
def test_a_root_spelled_with_a_trailing_separator(self):
self.assertEqual(relative_part("/", "/a.png", posixpath), "a.png")
def test_windows_matches_folded_and_slices_the_original_case(self):
root = ntpath.normcase("C:\\ComfyUI\\Output")
self.assertEqual(
relative_part(root, "c:/COMFYUI/output/Sub/ComfyUI_001.PNG", ntpath),
"Sub\\ComfyUI_001.PNG",
)
def test_windows_extended_length_and_unc_forms(self):
root = ntpath.normcase("C:\\Out")
self.assertEqual(
relative_part(root, "\\\\?\\C:\\Out\\Deep\\a.png", ntpath),
"Deep\\a.png",
)
share = ntpath.normcase("\\\\Server\\Share\\Out")
self.assertEqual(
relative_part(share, "\\\\?\\UNC\\Server\\Share\\Out\\B.png", ntpath),
"B.png",
)
drive = ntpath.normcase("D:\\")
self.assertEqual(relative_part(drive, "D:\\X\\a.png", ntpath), "X\\a.png")
def test_a_case_fold_that_changes_the_length_is_not_sliced(self):
# str.lower folds the Turkish dotted I to two characters (Windows'
# own normcase keeps the length; the guard covers any fold that not).
folding = SimpleNamespace(
sep="\\", normcase=lambda text: text.replace("/", "\\").lower()
)
path = "C:\\Out\\\u0130mage.png"
self.assertNotEqual(len(folding.normcase(path)), len(path))
self.assertIsNone(relative_part("c:\\out", path, folding))
self.assertEqual(relative_part("c:\\out", "C:\\Out\\Ab.png", folding), "Ab.png")
def test_dot_parts_are_never_sliced(self):
for path in ("/srv/out/../x.png", "/srv/out/./x.png", "/srv/out//x.png"):
self.assertIsNone(relative_part("/srv/out", path, posixpath), path)
class InsertEntryTests(unittest.TestCase):
def setUp(self):
self.root = PurePosixPath("/out")
self.files = [
(self.root / "c.png", 30.0),
(self.root / "b.png", 20.0),
(self.root / "a.png", 10.0),
]
def test_newest_first_position_at_the_front_middle_and_end(self):
for mtime, index in ((40.0, 0), (25.0, 1), (15.0, 2), (5.0, 3)):
entry = (self.root / "n.png", mtime)
patched = insert_entry(self.files, entry, 100)
self.assertIs(patched[index], entry)
self.assertEqual(len(patched), 4)
self.assertEqual(len(self.files), 3) # never changed in place
def test_unchanged_entries_are_the_same_objects(self):
patched = insert_entry(self.files, (self.root / "n.png", 40.0), 100)
self.assertTrue(all(new is old for new, old in zip(patched[1:], self.files)))
def test_the_same_path_with_a_new_mtime_moves(self):
patched = insert_entry(self.files, (PurePosixPath("/out/a.png"), 35.0), 100)
self.assertEqual(
[(str(path), mtime) for path, mtime in patched],
[("/out/a.png", 35.0), ("/out/c.png", 30.0), ("/out/b.png", 20.0)],
)
def test_an_entry_already_listed_returns_the_listing_itself(self):
self.assertIs(
insert_entry(self.files, (PurePosixPath("/out/b.png"), 20.0), 3), self.files
)
def test_a_tie_in_mtime_is_not_placed(self):
self.assertIsNone(insert_entry(self.files, (self.root / "n.png", 20.0), 100))
def test_a_tie_with_the_replaced_entry_itself_is_placed(self):
files = [(self.root / "b.png", 20.0), (self.root / "a.png", 10.0)]
patched = insert_entry(files, (self.root / "a.png", 20.5), 100)
self.assertEqual([mtime for _path, mtime in patched], [20.5, 20.0])
def test_the_cap_refuses_a_new_path_but_replaces_a_listed_one(self):
self.assertIsNone(insert_entry(self.files, (self.root / "n.png", 40.0), 3))
patched = insert_entry(self.files, (self.root / "a.png", 40.0), 3)
self.assertEqual(str(patched[0][0]), "/out/a.png")
def test_an_empty_listing(self):
entry = (self.root / "n.png", 1.0)
self.assertEqual(insert_entry([], entry, 100), [entry])
class AddFolderTests(unittest.TestCase):
def test_a_new_nested_folder_adds_it_and_its_ancestors(self):
root = PurePath("/out")
folders, with_ancestors = folder_sets(root, [(root / "x" / "a.png", 1.0)])
new = add_folder(root, root / "y" / "z" / "b.png", folders, with_ancestors)
self.assertEqual(
new,
folder_sets(
root, [(root / "x" / "a.png", 1.0), (root / "y" / "z" / "b.png", 2.0)]
),
)
def test_a_known_folder_or_the_root_keeps_the_same_sets(self):
root = PurePath("/out")
sets = folder_sets(root, [(root / "x" / "a.png", 1.0)])
for path in (root / "x" / "b.png", root / "c.png"):
new = add_folder(root, path, *sets)
self.assertIs(new[0], sets[0])
self.assertIs(new[1], sets[1])
def test_windows_spelling_matches_folder_sets(self):
root = PureWindowsPath("C:/Out")
path = root / "A" / "B" / "c.png"
self.assertEqual(
add_folder(root, path, frozenset(), frozenset()),
folder_sets(root, [(path, 1.0)]),
)
class ListingDeltaTests(unittest.TestCase):
def setUp(self):
self.old = [(PurePath(f"/out/{i}.png"), float(i)) for i in range(10)]
def test_an_added_entry(self):
entry = (PurePath("/out/n.png"), 99.0)
self.assertEqual(listing_delta(self.old, [entry, *self.old]), ([entry], []))
def test_a_replaced_entry(self):
entry = (PurePath("/out/3.png"), 99.0)
new = insert_entry(self.old, entry, 100)
self.assertEqual(listing_delta(self.old, new), ([entry], [self.old[3]]))
def test_the_same_listing_has_no_change(self):
self.assertEqual(listing_delta(self.old, list(self.old)), ([], []))
def test_a_walk_builds_new_entries_and_is_past_the_limit(self):
walked = [(PurePath(str(path)), mtime) for path, mtime in self.old]
self.assertEqual(walked, self.old)
self.assertIsNone(listing_delta(self.old, walked, limit=5))
self.assertIsNone(listing_delta(self.old, walked)) # shares nothing
self.assertIsNone(listing_delta(self.old, self.old[:2], limit=5))
def test_an_emptied_listing_and_a_first_file(self):
self.assertEqual(listing_delta(self.old[:1], []), ([], self.old[:1]))
self.assertIsNone(listing_delta([], self.old[:1]))
-20
View File
@@ -82,26 +82,6 @@ class TracesPrompts(unittest.TestCase):
self.assertEqual(result["positivePrompt"], "the real positive")
self.assertEqual(result["negativePrompt"], "the real negative")
def test_core_sampler_with_an_out_of_order_widget_input_keeps_positions(self):
# #343 review R4: only ``cfg`` is widget-bound, so the bound names are not
# a schema prefix; the values follow the KSampler schema positions.
result = run("workflow-only-core-widget-input-out-of-order")
self.assertEqual(
(result["seed"], result["steps"], result["cfgScale"], result["sampler"]),
(9, 30, 4.5, "dpmpp_2m"),
)
def test_editor_text_inputs_without_widget_bindings_keep_their_positions(self):
# #343: Focused/Split/Pair text inputs are plain sockets. The widget-bound
# inputs that remain (category, tags, search_text) must not shift the
# positional widget values onto the wrong names.
result = run("workflow-only-detached-editor-text")
self.assertEqual(result["positivePrompt"], "a detached positive")
self.assertEqual(result["negativePrompt"], "plain negative")
result = run("workflow-only-detached-pair-text")
self.assertEqual(result["positivePrompt"], "a detached positive")
self.assertEqual(result["negativePrompt"], "a detached negative")
class ChoosesTheSampler(unittest.TestCase):
def test_first_sampler_follows_javascript_key_order(self):

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