Compare commits

..
Author SHA1 Message Date
Vito Sansevero 1fb4ad5495 chore: release 3.4.0 2026-10-30 19:29:26 -07:00
Vito Sansevero 92be8770c3 Merge remote-tracking branch 'origin/main' into docs/release-readme
# Conflicts:
#	README.md
2026-10-30 19:26:00 -07:00
Vito Sansevero 60ce03ba4b docs: retake README screenshots, add shortcuts, pinned bar and trash limits 2026-10-30 15:38:03 -07:00
Vito Sansevero c4eb487d02 docs: new README as the product page, keep the old one as README-legacy
Rename README.md to README-legacy.md with a short note saying what it is,
and write a new README: hero, visual feature tour, all six nodes, then
install, data locations, upgrade notes, legacy pages, limits and testing.
Images are compressed WebP in assets/readme. Point the TESTING.md link at
the section's new home.
2026-10-30 13:23:45 -07:00
159 changed files with 653 additions and 5326 deletions
+33 -5
View File
@@ -1,9 +1,9 @@
name: Windows
name: Windows and macOS
permissions:
contents: read
# Non-blocking Windows runs (#420; macOS dropped from CI in #571): never on pull requests or in the merge queue, and no context
# Non-blocking portability runs (#420): never on pull requests or in the merge queue, and no context
# here is a required check. A red run is fixed at its cause before the workspace cutover (#334).
on:
push:
@@ -25,7 +25,10 @@ env:
# run counts (tests/vendor/ci-workflow.test.mjs recomputes it, #493).
# Measured 2026-10-29 on main runs 40935337099 (dd247e3), 40938407252 (dd247e3), 40941240624 (dd247e3):
# slowest leg: 7m24s (py3.12 windows) / 7m31s (py3.13 windows) / 7m32s (py3.14 windows) -> 15 min
# (windows 4m56s-7m32s)
# (windows 4m56s-7m32s, macos 4m39s-6m14s)
# native-trash (#499) has no green main runs yet: its probe, run 40949370943 (2026-10-29), took
# about 1 minute per macOS leg, so ceil(1.5 x 2) + 3 gives it 6 minutes (step 4). Recalibrate
# after three green runs.
# native-trash-linux (#543) has no green main runs yet: it installs the dev requirements and runs
# one test module, about 1 minute by the same estimate, so ceil(1.5 x 2) + 3 gives it 6 minutes.
# Recalibrate after three green runs.
@@ -37,7 +40,7 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [windows-latest]
os: [windows-latest, macos-latest]
python-version: ["3.12", "3.13", "3.14"]
steps:
@@ -57,7 +60,7 @@ jobs:
# The runner's TEMP is on C:, the OS disk, where a flush took about 6 ms
# and a test database's create-to-delete about 111 ms; on the D: work
# disk the same took 0.5 ms and 23 ms. Tests write their
# disk the same took 0.5 ms and 23 ms (macOS: 19 ms). Tests write their
# databases under TEMP, so it moves to a folder on D: (measured in #487,
# run 40915866444). The runner's TEMP was an 8.3 short name
# (C:\Users\RUNNER~1\...), so the new one is spelled by its short name too
@@ -80,6 +83,31 @@ jobs:
PM_TEST_SLOWEST: "40"
run: python -m tests.run_suite -v
# The real Trash on both macOS CPUs (#499 design §10.2). It runs apart from the suite, so a red
# suite never skips the native proof.
native-trash:
name: native trash on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
timeout-minutes: 6
strategy:
fail-fast: false
matrix:
os: [macos-latest, macos-15-intel]
steps:
- uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v7
with:
python-version: "3.12"
- name: Native trash proof
timeout-minutes: 4
env:
PM_NATIVE_TRASH: macos
run: python -m unittest tests.test_trash_macos_native tests.test_trash_macos -v
# The same tests as the fake mount table, on a real ext4 mount used as the output root (#543). A
# runner can mount with sudo; the tests run as the normal user, so owner and mode checks are real.
native-trash-linux:
+2 -2
View File
@@ -28,7 +28,7 @@ env:
# 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.
# Windows moved to portability.yml (#420; macOS left CI in #571), which has its own measured limit (#493);
# Windows and macOS moved to portability.yml (#420), which has its own measured limit (#493);
# this Linux-only matrix keeps 27 minutes until it is re-measured.
# real ComfyUI compatibility: 13m32s / 8m55s / 11m46s -> 24 min
# coverage gate (85%): 7m26s / 7m22s / 7m54s -> 15 min
@@ -39,7 +39,7 @@ env:
# Pull requests run a fast subset (#420): Python 3.12 on ubuntu-latest, coverage, lint and frontend
# modules. The merge queue (merge_group), pushes to main and manual runs add Python 3.13 and 3.14 on
# Linux and the real ComfyUI job. "all tests" is the required gate that holds the queue to that set.
# Windows runs in portability.yml (push to main, nightly, on demand) and never gate a merge.
# Windows and macOS run in portability.yml (push to main, nightly, on demand) and never gate a merge.
jobs:
test:
name: py${{ matrix.python-version }} on ${{ matrix.os }}
+8 -12
View File
@@ -41,13 +41,9 @@ concurrency:
# 7.3m-12.2m), because --shard splits by test count and the slow concept specs sort into shard 1. The page
# workflows now run in three shards per browser with the limits unchanged; the per-shard times are
# re-measured after three green main runs.
# #563: coverage shards reached their 18-minute step limit three times on 2026-10-31 with no failing
# test (PR #560 shard 2/3: 814 passed in 17.9m; queue run 41206495451 shard 1/3: 827 passed; PR #564
# shard 2/3 twice), against recent green shards of 11m-17m. Coverage now runs in four shards with the
# limits unchanged; the per-shard times are re-measured after three green main runs.
jobs:
frontend-coverage:
name: frontend coverage (shard ${{ matrix.shard }}/4)
name: frontend coverage (shard ${{ matrix.shard }}/3)
runs-on: ubuntu-latest
# Layout comparisons depend on the fonts each browser falls back to, so browser jobs run in the
# same pinned Playwright image as local runs (tag = @playwright/test in tools/vendor/package-lock.json;
@@ -63,7 +59,7 @@ jobs:
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
shard: [1, 2, 3]
steps:
- uses: actions/checkout@v7
# The image's own Python 3.12 and Node 24: setup-python/setup-node install the runner's toolcache
@@ -92,13 +88,13 @@ jobs:
- run: npm ci --prefix tools/vendor --ignore-scripts
# Shard 1 also runs the node units and the viewer suite, so the vendor pins and the tooling tests
# run on the lightest shard instead.
- if: matrix.shard == 4
- if: matrix.shard == 3
run: npm run --prefix tools/vendor check
- if: matrix.shard == 4
- if: matrix.shard == 3
run: npm run --prefix tools/vendor test
- name: Page workflows with coverage, shard ${{ matrix.shard }}/4
- name: Page workflows with coverage, shard ${{ matrix.shard }}/3
timeout-minutes: 18
run: npm run --prefix tools/vendor test:coverage:shard -- ${{ matrix.shard }}/4
run: npm run --prefix tools/vendor test:coverage:shard -- ${{ matrix.shard }}/3
- name: Upload raw coverage
uses: actions/upload-artifact@v7
with:
@@ -140,13 +136,13 @@ jobs:
with:
pattern: coverage-raw-*
path: ${{ runner.temp }}/coverage-raw
# coverage:merge only checks that raw files exist; merging three of four shards would report
# coverage:merge only checks that raw files exist; merging two of three shards would report
# partial coverage, so a missing or empty shard artifact fails here.
- name: Verify every shard's raw coverage arrived
shell: bash
env:
STAGED_RAW: ${{ runner.temp }}/coverage-raw
EXPECTED_SHARDS: 4
EXPECTED_SHARDS: 3
run: |
found=0
for shard in $(seq 1 "$EXPECTED_SHARDS"); do
+2 -4
View File
@@ -167,8 +167,6 @@ Searches your library by text, category, tags and minimum rating, then outputs t
![Prompt Search List node producing three images](assets/readme/node-search.webp)
To get the images of a Search List batch linked to their prompts, wire the Search List output into a Prompt Manager Text node, not straight into CLIP Text Encode. Wired straight in, the images stay unlinked: the search text is never saved as a prompt.
## Install
**Requirements:** ComfyUI and Python 3.12 or newer. `watchdog` and `Pillow` are the only dependencies of their own; the rest comes with ComfyUI.
@@ -238,12 +236,12 @@ The documentation for those pages is in [README-legacy.md](README-legacy.md).
| Layer | What runs |
| --- | --- |
| Python | Over 200 test files on Linux and Windows with Python 3.12 to 3.14, with an 85% coverage gate |
| Python | Over 200 test files on Linux, Windows and macOS with Python 3.12 to 3.14, with an 85% coverage gate |
| Frontend | About 100 unit test files, with coverage floors per file |
| Browser | Over 60 Playwright spec files in Chromium, Firefox, WebKit and touch emulation |
| Real ComfyUI | A pinned CPU ComfyUI, started in Classic and Nodes 2.0, running real workflows |
Linux runs on every pull request and in the merge queue. Windows runs on every push to main and nightly. macOS is not tested; it should work, but support is best-effort. To run the tests yourself, see [TESTING.md](TESTING.md).
Linux runs on every pull request and in the merge queue. Windows and macOS run on every push to main and nightly. To run the tests yourself, see [TESTING.md](TESTING.md).
## License
+36 -183
View File
@@ -74,14 +74,14 @@ V8 files it writes under `coverage/js/raw`:
```sh
PM_TEST_WORKERS=8 npm run --prefix tools/vendor test:coverage # whole run, one report
npm run --prefix tools/vendor test:coverage:shard -- 1/4 # node units, viewer suite, workflows 1/4
npm run --prefix tools/vendor test:coverage:shard -- 2/4 # workflows 2/4 (likewise 3/4, 4/4)
npm run --prefix tools/vendor test:coverage:shard -- 1/3 # node units, viewer suite, workflows 1/3
npm run --prefix tools/vendor test:coverage:shard -- 2/3 # workflows 2/3 (likewise 3/3)
npm run --prefix tools/vendor coverage:merge # report and baseline check of every raw file
npm run --prefix tools/vendor test:workflows -- --project webkit --shard 2/2
```
`coverage:merge` refuses an empty raw directory; it does not count shards, so run
every shard first (CI's merge job checks all four arrived). Measured on a 32-CPU
every shard first (CI's merge job checks all three arrived). Measured on a 32-CPU
machine in the pinned image (2026-10-21): `test:coverage` takes about 5 minutes
with 8 workers and 24 minutes with 1; all four projects, 8 workers, each test 3
times (6132 tests), 24 minutes. A CI-sized run (`docker run --cpus=4`,
@@ -189,14 +189,14 @@ check, so run it before pushing a new or deleted frontend file instead of
waiting for the `frontend coverage` jobs.
The `viewerjs` check runs on every PR, including backend-only changes. It is an
aggregate job. It requires the four `frontend coverage (shard N/4)` jobs and
aggregate job. It requires the three `frontend coverage (shard N/3)` jobs and
`frontend coverage (merge)` to succeed on every event. The four
`page workflows (firefox|webkit, shard N/2)` jobs run only in the merge queue,
on `main` and on `workflow_dispatch` (see "Pull requests and the merge queue").
`viewerjs` requires them to be skipped on a pull request and to succeed
everywhere else. Any other failed, cancelled or skipped job fails it. Every browser job runs with `PM_TEST_WORKERS: 3` (the public
runners have 4 vCPUs). Each coverage shard runs `test:coverage:shard -- N/4`
(shard 1 also runs the node units and the viewer suite, shard 4 the vendor pins
runners have 4 vCPUs). Each coverage shard runs `test:coverage:shard -- N/3`
(shard 1 also runs the node units and the viewer suite, shard 3 the vendor pins
and tooling tests) and uploads its raw V8 files as `coverage-raw-N` (kept 3
days). The merge job downloads them, fails unless all three arrived with raw
files and without clashing names, then runs `coverage:merge` (report plus
@@ -261,7 +261,7 @@ the process exits non-zero.
job fails anyway and the dump is what matters.
- `PM_TEST_SLOWEST=N` (off by default) lists the N slowest tests after the
summary, each timed from its `start:` line to the next one like the hang
limit. The Windows jobs (`portability.yml`) set it to 40, so every
limit. The Windows and macOS jobs (`portability.yml`) set it to 40, so every
run shows where its time went (#487). The list prints only when the run
ends: a job GitHub cancels or a watchdog exit prints none, so read the
timestamps between `start:` lines instead.
@@ -327,7 +327,7 @@ Python 3.13/3.14, real ComfyUI and Firefox/WebKit failures first show up in the
merge queue, on the merge-group commit rather than the
pushed one. Find that run with `gh run list --event merge_group --workflow test.yml`
(or `viewerjs-check.yml`) and match its head branch
(`gh-readonly-queue/main/pr-<N>-...`). Windows failures show up only
(`gh-readonly-queue/main/pr-<N>-...`). Windows and macOS failures show up only
after the merge, in `gh run list --workflow portability.yml`.
Start fixing as soon as a failed log shows the cause, but push the fix only after
@@ -345,10 +345,8 @@ and the time left. The steps, in order:
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()` and the
observer join get what is left, which can be 0 s. `stop()` joins watchdog's
emitters without a limit, so it runs on a daemon thread and is waited for
only until the deadline; a later stop waits for that same call (#561).
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).
@@ -363,15 +361,6 @@ 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.
The observer is started from a short-lived daemon thread, so every thread it
creates is a daemon (a thread inherits that flag from the thread that creates
it). watchdog 7 on Windows starts a plain `threading.Thread` per watch for its
`ReadDirectoryChangesW` reader; created from ComfyUI's main thread it would not
be a daemon, and the interpreter waits for every such thread before it runs any
`atexit` callback, so the teardown would never start (#561).
`tests/test_package_exit.py` runs a child whose observer does both (a plain
reader thread, a `stop()` that never returns) and requires its exit.
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.
@@ -461,7 +450,7 @@ measurement: `ubuntu-latest`, one Python, no coverage, only the wall-clock tests
It runs on every push to `main` and on `workflow_dispatch`, never on pull
requests, because shared-runner timing must not block a merge. Dispatch it on a
branch to measure before merging.
Shared Windows runners do not run them; the structural tests cover
Shared Windows and macOS runners do not run them; the structural tests cover
portability. Run it locally with
`PROMPTMANAGER_PERF_BUDGET=1 python -m unittest tests.test_api_workspace_images.TestWorkspaceImages.test_filter_budget`
and quote the printed p95 in the PR body.
@@ -657,10 +646,10 @@ Before changing paths, SQLite, streams, restore or cleanup:
ordinary busy timeout; cross-thread handoffs wait on events with generous
ceilings, never a short wall clock (#242).
- Run native Windows CI early for these changes. Pull requests and the merge
queue run only Linux, so start Windows on the branch with
queue run only Linux, so start Windows and macOS on the branch with
`gh workflow run portability.yml --ref <branch>`.
Include a Windows impact and evidence note in the PR. All three Windows
legs in that run, every Linux leg and every applicable
Include a Windows impact and evidence note in the PR. All six Windows/macOS
combinations in that run, every Linux leg and every applicable
exact-head check must pass before merge. Portable `ntpath`/`PureWindowsPath`
controls do not establish native filesystem, locking or cleanup success.
@@ -675,11 +664,11 @@ what runs:
| `py3.13 on ubuntu-latest`, `py3.14 on ubuntu-latest` | not created | run |
| `real ComfyUI compatibility` | skipped | runs |
| `coverage gate (85%)`, `black and flake8`, `frontend modules`, `bandit (high severity)` | run | run |
| `frontend coverage (shard N/4)`, `frontend coverage (merge)` (vendor pins and tooling tests on shard 4) | run | run |
| `frontend coverage (shard N/3)`, `frontend coverage (merge)` (vendor pins and tooling tests on shard 3) | run | run |
| `page workflows (firefox\|webkit, shard N/2)` | skipped | run |
| `all tests`, `viewerjs` (gates) | run | run |
| `perf budget` | skipped | push and dispatch only (not in the queue) |
| `py3.12-3.14 on windows-latest` (`portability.yml`) | not run | push to `main`, nightly, `workflow_dispatch` only; never required |
| `py3.12-3.14 on windows-latest/macos-latest` (`portability.yml`) | not run | push to `main`, nightly, `workflow_dispatch` only; never required |
GitHub has one required set for pull requests and the queue. A job skipped by
its `if:` reports Success. A dropped matrix leg never reports, so it would stay
@@ -692,9 +681,8 @@ guarantee:
So a merge group cannot turn green unless all three Linux Python legs, real
ComfyUI and the four browser shards passed.
Windows (Python 3.12-3.14) is out of the blocking path for now
(maintainer, 2026-10-28). macOS is not tested in CI (maintainer, 2026-10-31, #571);
its code stays and support is best-effort. They run in `portability.yml` on every push to `main`,
Windows and macOS (Python 3.12-3.14) are out of the blocking path for now
(maintainer, 2026-10-28). They run in `portability.yml` on every push to `main`,
nightly and on demand, and no context there is required. A red portability run
is still a failure: fix it at its cause, not by rerunning, and have every one
green before the workspace cutover (#334). `tests/vendor/ci-workflow.test.mjs` pins the
@@ -1419,8 +1407,6 @@ is shorter than the row it reserves.
Each Overview card is the count of the page it names: the shell's Library, Images and Tags sidebar counts, one source,
so a card and its badge cannot differ. `/prompt_manager/stats` is for the legacy page and counts raw rows. A visit re-reads
the three unless a read is in flight. An unread or failed card shows a dash; Retry re-reads only the failed counts.
System › Overview's Images metric reads the same shell count (#555), so it, the Overview card and the Images badge show
one number; its Statistics dialog labels the legacy row counts as "Database records".
Tests: `tests/js/workspace-state.test.js`, `tests/browser/workspace.spec.mjs`, `tests/comfyui/workspace.spec.mjs`.
### Images page controls and recorded deviations (#249 PR C)
@@ -1851,8 +1837,7 @@ Inspect plan keeps D60–D66 (plan D60 → D83, D61 → D84, D65 → D88, D68
engines in the mock's order with legacy labels and sizes (`WD14 SwinV2 · ~400
MB`, `WD14 ViT · ~400 MB`, `GGUF JoyCaption (Recommended) · ~4.5 GB` in the
mock's Ollama slot, `HuggingFace JoyCaption · ~16 GB`); the instructions start
from the chosen engine's default saved instructions (#431, D204; before, the
server's `default_prompt`).
from the server's `default_prompt`.
- **D91** (plan D68, ruling T3-bulk): a **Suggestions** number field (1–100) for
every model, prefilled from `autotag.defaults.suggestion_limit` (10); a model
change keeps it.
@@ -2564,8 +2549,8 @@ files to the system trash. **No code path unlinks or overwrites a media file.**
("Moving files to the Recycle Bin isn't available on Windows: files larger than the bin's
capacity would be deleted permanently."). Library-only removal works everywhere. Tests use `tests/fake_trash.py`; the
browser test server installs it, so no test touches a real trash. The native proof,
`tests/test_trash_macos_native.py`, runs only with `PM_NATIVE_TRASH=macos` on a Mac, by hand
(CI dropped its `native-trash` macOS job in #571; it last passed on `macos-latest` and `macos-15-intel`): the startup
`tests/test_trash_macos_native.py`, runs only with `PM_NATIVE_TRASH=macos` in
portability.yml's `native-trash` job (`macos-latest` and `macos-15-intel`): the startup
volume, a name clash, fresh APFS and FAT32 disk images, an image used as an output root,
a read-only image and an unwritable folder (design and probe:
`docs/egm/499-macos-trash/`, local).
@@ -4021,11 +4006,7 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
"Save preferences" is `aria-disabled` (opacity .55, `not-allowed`) while clean or
saving; a setting the last save could not save gets a red outline. CSS under D92.
- **D93** (Overview): real values. The metrics are the Library's eligible positives
and active saved negatives and the Images count (#555, amending Q2's indexed
`generated_images` rows): the shell's Images sidebar count, the Images page's own
total, which also lists files no row links, as the concept's `images.length` is
both. A visit re-reads it unless a read is in flight; "–" until read or after a
failed read. `images.indexed` stays in the API and is not shown. "Database
and active saved negatives and the indexed `generated_images` rows (Q2). "Database
integrity" is "Not checked" until a check ran this session, then "N issues" (checks
that found issues, even beside a check that could not run), "Passed" or "Incomplete".
The review row is "N ambiguous image links" (the Needs review queue's pending count,
@@ -4063,11 +4044,10 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
- **D96** (Statistics; amended by D153): "Usage statistics" without the sample note: the
concept's "Runs by category" table without its Avg. rating column (Category / Runs)
with the real runs of eligible parents per category ("Uncategorized"), then a second
table "Database records" (Total / Count: Prompt records, Tags on prompts, Image
records: the raw row counts of the legacy statistics, labelled as rows so they never
read as the Library or Images page counts, #555; no Rated images). The API still reports `average_rating` and
table "Library totals" (Total / Count: Prompts, Tags, Images from the legacy
statistics; no Rated images). The API still reports `average_rating` and
`rated_images`; the workspace does not show them.
The Overview row says "Runs by category, with database record counts." because
The Overview row says "Runs by category, with library totals." because
that is what the dialog shows (the concept's own dialog shows only "Runs by
category"); per-prompt run counts, prompt tags and dated runs are recorded, but no
table of them exists yet. A failed read toasts "Statistics could not
@@ -4555,9 +4535,9 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
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, Already auto-tagged, Images to tag
("about N"), Time, Settings ("General <g> · Character <c>", then " · up to L tags
per image" with the preview's saved `suggestion_limit`; for a language model, since
D205, an "Instructions" row and "Up to L tags per image") and Backup, the estimate sentence ("Nothing is written until you
("about N"), Time, Settings ("General <g> · Character <c>" or "Template of N
characters", then " · up to L tags per image" with the preview's saved
`suggestion_limit`) and Backup, the estimate sentence ("Nothing is written until you
start. The number to tag is an estimate: images changed since they were tagged are
tagged again, and missing files are skipped. Activity reports what was tagged.") in the
`p.small`, the D55 backup box and Cancel / "Start tagging", which starts the
@@ -4579,71 +4559,6 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
Cancel-only review matches the concept review shell` (system-tagging-concept.spec) and
the batch tests of workspace-system-tagging.spec; the mutation check renames each grid
label in turn.
- **D202** (Auto-tagging, #431, beyond the concept): a "Skipped tags" block
(`.setting-block`, `data-setting="tag-skipped"`, `data-key="autotag_skipped_tags"`)
follows "Suggestions per image": the label and description in the concept's
setting-row tokens ("Suggested tags that contain one of these words are hidden.
Words are separated by spaces, _ or -; other characters such as ( ) belong to the
word."), an "Add a word or words" input (`#tag-skipped-input`) with Add (Enter adds
too), the entries as removable chips in the Tag image dialog's D83 chip markup (each
with its count, title "Hides N tags", or muted "none" with "Hides no known tag"), and
a `role="status"` preview line: "**Hides N tags from suggestions**: <up to 12>[ and K
more]. Tags already on images are not changed." ("Checked against library tags only;
no WD14 label list is downloaded." before the last sentence without a label list;
"No tags are skipped." for an empty list; "Checking…" while waiting; "The preview
could not be read." on failure). The client refuses an entry with "Enter a word.",
"No commas or line breaks.", "At most 50 characters.", "Already in the list." or "The
list is full (200)." under the input. The Tag image dialog's chip hint adds "N skipped
tags were not suggested." when the server hid any (the static mock's copy). Pinned by
the comparison's mapping (system-tagging-concept.spec: the block's tokens are the
concept row's), `skipped tags add, preview, remove, save and reload (D202)`, `skipped
tags preview checks library tags only …` (workspace-system-tagging.spec) and
`suggestions omit skipped tags and say how many were hidden` (workspace-tagging.spec).
- **D203** (Auto-tagging, #431, beyond the concept): "Saved instructions" replaces the
concept's "LLM tag template" field (the mock's state 2): a `.setting-block`
(`data-setting="tag-instructions"`, `data-key="autotag_instructions"`) with the label,
"Instructions for the language-model engines. Built-in can’t be changed; duplicate it
to make your own. N of 20 saved · Built‑in is not counted.", a `role="listbox"` list
(Built-in first with a "Read-only" chip, then the saved entries; "Default for GGUF" /
"Default for HuggingFace" chips), "+ New" ("New instructions", "(2)" if taken),
"Duplicate" ("<name> copy"), "Delete" (`.danger`, disabled for Built-in), "At most 20
saved instructions." at the cap, and the editor beside the list (under it below 700px):
"Name" ("N of 60") and "Text" ("N of 4,000 characters"), read-only for Built-in with
"Built-in instructions can’t be changed. Duplicate them to make your own.", else the
entry's problem ("The name “X” is already used.", "The text must not be empty."…).
Then two concept select rows, "Default for GGUF JoyCaption" and "Default for
HuggingFace JoyCaption" ("The Tag image dialog and batches start from this.",
`data-key="autotag_default_instructions"`), and the note a deleted default leaves
("GGUF now uses Built-in."; nothing is written until Save preferences). Pinned by the
comparison's mapping (system-tagging-concept.spec: the block in the concept row's
tokens, the two rows on the concept's select row) and `saved instructions: new, edit,
per-engine default, delete, save and reload (D203)`, `a refused instructions save names
the entry and highlights the block (D203)` (workspace-system-tagging.spec).
- **D204** (Tag image and Auto-tag selected dialogs, #431, beyond the concept): for a
language-model engine a "Saved instructions" select (`#image-tag-instructions-pick`,
`#bulk-tag-instructions-pick`) sits above "Tagging instructions" (the mock's state 3),
on the concept's select field: Built-in, then the saved entries (`GET /autotag/models`
`instructions`, full text), the engine's default marked "<name> · default". It starts
at that default; a pick replaces the text. The text stays editable for the run: while
it differs from the picked entry the select shows a disabled "Edited for this run" and
a muted line under the textarea reads "Edits apply to this run only. Manage saved
instructions in System › Auto-tagging." Choosing an entry again replaces the edit.
Switching between GGUF and HF starts from the new engine's default and drops an edit;
WD14 hides both. A preset (Preview tagging review, the bulk review) shows the entry
with its text, else "Edited for this run". When the read fails the select holds only
Built-in with the client's copy (`BUILTIN_INSTRUCTIONS`, pinned equal to
`DEFAULT_TAG_TEMPLATE` by workspace-image-tagging-state.test). Request bodies are
unchanged (`options.instructions` is the textarea's text); there is no "Save as new".
The workspace no longer reads `default_prompt`. Pinned by the tagging comparison's
mapping (tagging-canonical-comparison.mjs: the select before the instructions),
`the saved instructions picker fills, edits one-off and resets per engine (D204)`
(workspace-tagging.spec) and `Auto-tag selected picks saved instructions and sends the
text shown (D204)` (workspace-tagging-bulk.spec).
- **D205** (Auto-tagging, #431; amends D134): for a language-model engine the batch
review adds a read-only "Instructions" row with the drafted engine default's name
before "Settings", which reads "Up to L tags per image"; the batch and Preview tagging
review send that entry's text. WD14 is unchanged. Pinned by `the batch review names the
language model's default instructions (D205)` (workspace-system-tagging.spec).
- **D158** (Sources, #247 D-Q4): the concept has no switch for prompts from workflows
without a PromptManager node; the maintainer chose a visible switch over a config key.
A `.setting-row` in the concept's toggle markup follows "Watch for new outputs":
@@ -5229,16 +5144,16 @@ server-side tagging configuration, `autotag.defaults` in config.json: `model_typ
(`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),
`suggestion_limit` (1–100), and since #431 `skipped_tags`, `instructions` and
`default_instructions` (below). They are host drafts (`autotag_model_type`, `autotag_existing`,
`autotag_general_threshold`, `autotag_character_threshold`, `autotag_suggestion_limit`,
`autotag_skipped_tags`, `autotag_instructions`, `autotag_default_instructions`) saved by the one Save row through `POST
`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.
Instruction text limits count code points, as Python's `len` does. A hand-edited bad value
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
@@ -5249,70 +5164,8 @@ stored exactly on the grid (#369). A per-request threshold keeps its own check:
`validate_options` and starts the `autotag` job titled "Tag output images (legacy page)";
the legacy `/autotag/single` only requires a number. `GET /autotag/models` reports the
saved values (`wd14_general_threshold`, `wd14_character_threshold`, `suggestion_limit`,
`default_prompt`, `model_type`, `existing`, and since #431 `instructions` and
`default_instructions`), so the Tag image and Auto-tag selected dialogs start from them;
a preset (the bulk review, Preview tagging review) wins.
**Saved instructions** (#431, D203, D205): `autotag.defaults.instructions` holds the
saved entries `[{id, name, text}]` in order (at most 20; `id` 12 lowercase hex made by
the client, unique, `builtin` reserved; `name` trimmed, 1–60 characters, no control
character, unique case-folded, not "Built-in"; `text` as `validate_template`), and
`autotag.defaults.default_instructions` is `{gguf, hf}`, each `builtin` or a saved id.
Built-in (`{id: "builtin", name: "Built-in", text: DEFAULT_TAG_TEMPLATE}`) is never
stored; readers put it first (`AutotagConfig.all_instructions`), so a later release's
built-in text reaches everyone who uses it. Refusals (400, the Save row shows them and
highlights the editor): "Saved instructions: at most 20 entries besides Built-in.",
"Saved instructions: entry N has an invalid id.", "… repeats the id of entry M.", "…
needs a name.", "… name must be at most 60 characters.", "… name must not contain a line
break.", "… name “X” is already used.", "Saved instructions: the name “Built-in” is
reserved.", "Saved instructions: entry N text must be …", "Default for GGUF JoyCaption
must be one of the saved instructions." (and HuggingFace). A save carrying either key
without `expected_revision` is refused ("Reload the page and try again."); a save
carrying one is paired with the other from the running configuration and both are
written, plus the old `template` key as the GGUF default's text (deleted when that
default is Built-in), so an older build reads that default as its one template. `POST`
with `autotag_template` is refused ("LLM tag template was replaced by Saved instructions.
Reload the page."); `GET` keeps returning it (the GGUF default's text, read-only) for one
release and adds `autotag_builtin_instructions` (Built-in's text, read-only). Load: each
key alone (a damaged list → Built-in only, a default naming nothing → Built-in, each with
one warning), then the in-memory migration of the old `template`: with no `instructions`
key, a template other than Built-in becomes "Custom template" (id = first 12 hex of its
SHA-256), the default of both engines unless `default_instructions` is stored; with the
key, a template matching no entry (edited on an older build) is appended as "Custom
template (n)" with one INFO line (a full list: one WARNING, nothing imported). Nothing is
written at load; the next save that carries either key writes the list.
`AutotagConfig.default_instructions_text(engine)` feeds the legacy batch and single
blank-prompt fallbacks (by the request's engine) and `default_prompt`. Tests:
`tests/test_saved_instructions.py`, `tests/test_saved_instructions_routes.py`,
`tests/js/workspace-system-instructions.test.js`.
**Skipped tags** (#431, D202): `autotag.defaults.skipped_tags`, host draft
`autotag_skipped_tags`, a list of at most 200 entries, each trimmed, 1–50 code points,
no comma or control character, at least one word, no two with the same words (refused
with "Skipped tags: at most 200 entries.", "Skipped tags: entry N is empty.", "… must be
at most 50 characters.", "… must not contain a comma or a line break.", "… repeats entry
M."); a save carrying it without `expected_revision` is refused ("Reload the page and try
again."). A damaged saved list falls back to none with one warning. Words are the
NFC, case-folded text split on runs of space, `_` and `-` (`tag_words`); an entry hides
a suggested tag whose words contain the entry's words consecutively (`SkipMatcher`:
`blurry` hides `very_blurry` and `blurry_background`, not `unblurry`; `(fate)` hides
`saber_(fate)`, `fate` does not). `generate_tag_suggestions(skip=)` drops a match after
the name check and before the dedupe and the limit, so skipped tags use up no slot
(WD14 fills from lower scores; a language model may return fewer). Every path filters:
`/image-annotations/suggest` (compiled per request; the answer adds `skipped`, the
number of distinct names it hid), `/autotag/single`, and the `image-tags` (Auto-tag
selected) and `autotag` (System batch, legacy `/autotag/start`) jobs, which snapshot the
list at their start and log "Skipped tags: N entries" or "Skipped tags: none"; a save
during a run does not change it. Hand-typed and stored tags are never filtered; the
language models' hard-coded exclude list (`_parse_tags`) runs first. `POST
/prompt_manager/autotag/skipped-tags/preview {entries}` (validated as a save) answers
`count`, `sample` (12, library tags first, each part sorted case-folded),
`sample_limit`, `per_entry [{entry, count}]` and `sources {library, wd14}` over the
library's tags plus the category 0/4 names of each downloaded WD14 `selected_tags.csv`
(read as UTF-8, cached by mtime and size; a missing list is skipped). It loads no model,
opens no image, writes nothing and is `409 busy` while an exclusive job runs. Tests:
`tests/test_skipped_tags.py`, `tests/test_skipped_tags_routes.py`, the settings and
config tests, `tests/js/workspace-system-skipped-tags.test.js`.
`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`,
@@ -5560,7 +5413,7 @@ list stay unscoped: any row's control proves them.
| Worker threads | `admin.html:437–445` | S4 Sources | covered | `sources` · slider · "Worker threads" | worker range max is cpu_count (workspace-system-sources.spec.mjs) |
| LoraManager badge, enable, path, trigger words, CivitAI key, clear key | `admin.html:448–490`; `admin.js:1052–1156` | S10 Integrations | covered | `integrations` · switch · "LoraManager integration" | LoRA rows show detected / enabled / cache-loaded separately (workspace-system-integrations.spec.mjs) |
| LoRA data import with progress | `admin.html:492–498,517–541`; `admin.js:1157` | S10 Integrations | covered | `integrations` · button · "Review import" | Review import shows replacement, blocked items and network effects with the backup box; Start opens the job (D107) (workspace-system-integrations.spec.mjs) |
| Statistics | `admin.html:159`; `admin.js:1627` | S2 Overview | covered | `overview` · button · "Explore" | statistics dialog lists runs by category and database record counts (workspace-system.spec.mjs) |
| Statistics | `admin.html:159`; `admin.js:1627` | S2 Overview | covered | `overview` · button · "Explore" | statistics dialog lists runs by category and library totals (workspace-system.spec.mjs) |
| Export components JSON `/export` | `admin.html:163`; `admin.js:1607`; `py/api/prompts.py:238` | S9 Backup & export | covered | `backups` · button · "Export all prompts JSON" | Export JSON downloads /library/export; secondary buttons download all prompts (/export) and /pairs/export (D103) (workspace-system-backups.spec.mjs) |
| Saved pairs export | `py/api/pairs.py:84` | S9 Backup & export | covered | `backups` · button · "Export saved pairs JSON" | Export JSON downloads /library/export; secondary buttons download all prompts (/export) and /pairs/export (D103) (workspace-system-backups.spec.mjs) |
| Library export | `py/api/library.py:223` | S9 Backup & export | covered | `backups` · button · "Export JSON" | Export JSON downloads /library/export; secondary buttons download all prompts (/export) and /pairs/export (D103) (workspace-system-backups.spec.mjs) |
+9 -9
View File
@@ -120,7 +120,6 @@ except Exception as e:
logger = get_logger("prompt_manager.init")
logger.error(f"Failed to register API routes: {e}")
except Exception:
# logging failed too; a report must never stop the node loading
pass
# Shared database, set up with the monitoring system below. Stays None when that
@@ -412,7 +411,6 @@ except Exception as e:
f"Failed to register the exit signal: {e}"
)
except Exception:
# logging failed too; a report must never stop the node loading
pass
try:
@@ -430,14 +428,16 @@ except Exception as e:
f"Failed to register the background work teardown: {e}"
)
except Exception:
# logging failed too; a report must never stop the node loading
pass
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
# One startup line. ASCII only: a Windows console using a legacy code page
# cannot print emoji, and a UnicodeEncodeError here would abort loading the node.
print(
f"\033[94m[ComfyUI-PromptManager] Version:\033[0m {get_version()}"
f" - {len(NODE_CLASS_MAPPINGS)} tools loaded"
)
# Print startup message with loaded tools
print()
print(f"\033[94m[ComfyUI-PromptManager] Version:\033[0m {get_version()}")
for node_key, display_name in NODE_DISPLAY_NAME_MAPPINGS.items():
# ASCII only: a Windows console using a legacy code page cannot print emoji,
# and a UnicodeEncodeError here would abort loading the whole node.
print(f" \033[94mLoaded:\033[0m {display_name}")
print(f"\033[94mTotal: {len(NODE_CLASS_MAPPINGS)} tools loaded\033[0m")
print()
-1
View File
@@ -590,7 +590,6 @@ class _Move:
try:
os.rmdir(self.plan.target_dir)
except OSError:
# not empty or already gone: the undo leaves it alone
pass
+24 -64
View File
@@ -35,51 +35,38 @@ from __future__ import annotations
import contextlib
import logging
import operator
import sys
import threading
import time
import weakref
from collections import deque
from typing import Any, Callable, Iterable, Iterator, List, Optional
from typing import Iterable, Iterator, List, Optional
import sqlite3
CLOSED_MESSAGE = "Cannot operate on a closed database."
# Rows fetched per step when a cursor is iterated: the connection is marked
# in use once per chunk instead of once per row (iteration costs about what
# a plain cursor costs). Prefetched rows belong to the cursor so iteration
# and explicit fetches always advance the same position.
# a plain cursor costs). A loop that stops early leaves up to this many rows
# fetched; ``fetchone``/``fetchmany`` after a partial loop skip them.
ITER_CHUNK = 128
class ManagedCursor(sqlite3.Cursor):
"""A cursor whose calls into SQLite count as its connection being in use."""
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self._pending = deque()
self._closed = False
def execute(self, sql, parameters=(), /):
return self._run_statement(super().execute, sql, parameters)
def executemany(self, sql, seq_of_parameters, /):
return self._run_statement(super().executemany, sql, seq_of_parameters)
def _run_statement(self, operation: Callable, *args: Any) -> ManagedCursor:
conn = self.connection
conn._enter()
description = self.description
completed = False
try:
result = operation(*args)
completed = True
return result
return super().execute(sql, parameters)
finally:
conn._leave()
def executemany(self, sql, seq_of_parameters, /):
conn = self.connection
conn._enter()
try:
return super().executemany(sql, seq_of_parameters)
finally:
# Argument parsing can fail before SQLite replaces the old statement.
if completed or self.description is not description:
self._pending.clear()
conn._leave()
def executescript(self, sql_script, /):
@@ -94,8 +81,6 @@ class ManagedCursor(sqlite3.Cursor):
conn = self.connection
conn._enter()
try:
if self._pending and not self._closed:
return self._pending.popleft()
return super().fetchone()
finally:
conn._leave()
@@ -104,20 +89,7 @@ class ManagedCursor(sqlite3.Cursor):
conn = self.connection
conn._enter()
try:
# Let SQLite validate the original arguments, including version-specific
# handling of zero/negative sizes, before touching prefetched rows.
rows = super().fetchmany(*args, **kwargs)
if not self._pending:
return rows
size = operator.index(
kwargs.get("size", args[0] if args else self.arraysize)
)
# Python 3.12 reads all rows for size=0; 3.13+ reads none.
if size == 0 and sys.version_info >= (3, 13):
return rows
self._pending.extend(rows)
count = len(self._pending) if size <= 0 else min(size, len(self._pending))
return [self._pending.popleft() for _ in range(count)]
return super().fetchmany(*args, **kwargs)
finally:
conn._leave()
@@ -125,31 +97,25 @@ class ManagedCursor(sqlite3.Cursor):
conn = self.connection
conn._enter()
try:
rows = super().fetchall()
pending = list(self._pending)
self._pending.clear()
return pending + rows
return super().fetchall()
finally:
conn._leave()
def __iter__(self) -> Iterator:
return self
return self._chunks()
def _chunks(self) -> Iterator:
while True:
rows = self.fetchmany(ITER_CHUNK)
if not rows:
return
yield from rows
def __next__(self):
conn = self.connection
pending = self._pending
# Buffered Python rows need no SQLite lock, but retirement and cursor
# close must still stop an iterator obtained before either operation.
if conn._retired or self._closed:
raise sqlite3.ProgrammingError(CLOSED_MESSAGE)
if pending:
return pending.popleft()
conn._enter()
try:
pending.extend(super().fetchmany(ITER_CHUNK))
if not pending:
raise StopIteration
return pending.popleft()
return super().__next__()
finally:
conn._leave()
@@ -160,10 +126,7 @@ class ManagedCursor(sqlite3.Cursor):
except sqlite3.ProgrammingError:
return super().close() # the connection is closed or closing
try:
result = super().close()
self._pending = deque()
self._closed = True
return result
return super().close()
finally:
conn._leave()
@@ -330,9 +293,6 @@ class ManagedConnection(sqlite3.Connection):
cursors = list(self._cursors)
try:
for cursor in cursors:
if isinstance(cursor, ManagedCursor):
cursor._pending = deque()
cursor._closed = True
with contextlib.suppress(sqlite3.Error):
sqlite3.Cursor.close(cursor)
super().close()
+1 -1
View File
@@ -34,7 +34,7 @@ from .history_reads import HistoryReadOperations
from .health import HealthOperations, iter_consistency_issues
from .duplicate_copies import DuplicateCopyOperations
from .workflow_storage import WorkflowStorageOperations
from .category_import import CategoryImportOperations
from .category_import import CategoryImportOperations, EmptyReplacement # noqa: F401
from .image_categories import ImageCategoryOperations
from .workflow_documents import (
DocumentReader,
-1
View File
@@ -46,7 +46,6 @@ def remove_quietly(*paths):
if path and os.path.exists(path):
os.remove(path)
except OSError:
# cleanup after a failure must not replace that failure
pass
+1
View File
@@ -66,6 +66,7 @@ def _tag_rows(page: str, order: str = "page.id") -> str:
)
_TAG_PAGE = _tag_rows("SELECT id,name FROM tags WHERE id>? ORDER BY id LIMIT ?")
# #444 tag sorts: sort -> (key expression on tags/counted, direction). Ties and
# "created" follow the id, so every order is total and keyset pages are exact.
TAG_SORTS = {
+1 -2
View File
@@ -176,7 +176,7 @@ def _physical_file_key(path, stat):
volumes on Windows) falls back to the resolved, case-folded path.
"""
if stat.st_ino:
return ("inode", (stat.st_dev, stat.st_ino))
return ("inode", stat.st_dev, stat.st_ino)
return ("path", os.path.normcase(os.path.realpath(str(path))))
@@ -539,7 +539,6 @@ def _diagnose_output_dirs(configured_dirs, public_path=None):
candidates.append(folder_paths.get_output_directory())
except ImportError:
# outside ComfyUI there is no output folder to add
pass
output_dirs = []
+3 -6
View File
@@ -13,6 +13,7 @@ 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,
@@ -102,14 +103,11 @@ def model_refusal(status: dict, model_type: str, model_name: str):
return None
def run_suggestions(
service, capture_path: str, model_type: str, options: dict, skip=None
):
def run_suggestions(service, capture_path: str, model_type: str, options: dict):
"""Blocking: load (or swap to) *model_type*, then suggest for the capture.
The caller holds the model gate for the whole call; nothing is ever
downloaded (``load_model`` refuses a model that is not on disk). ``skip``
is the request's or job's compiled Skipped tags (#431).
downloaded (``load_model`` refuses a model that is not on disk).
"""
if not service.is_model_loaded() or service.get_loaded_model_type() != model_type:
service.load_model(model_type, options["use_gpu"])
@@ -119,7 +117,6 @@ def run_suggestions(
prompt=options.get("instructions"),
general_threshold=options.get("general_threshold"),
character_threshold=options.get("character_threshold"),
skip=skip,
)
+2 -8
View File
@@ -35,7 +35,6 @@ from .media_target import media_target as _target
from .images import _unavailable_link, _verified_title
from .prompts import bad_request
from .. import model_gate
from ..tagging_defaults import SkipMatcher
try:
from ...database.annotations import (
@@ -641,8 +640,6 @@ class AnnotationRoutesMixin:
owner = model_gate.new_owner("image-annotations")
if not model_gate.try_acquire(owner):
return model_busy_response()
# #431: the Skipped tags as they are when the request is accepted.
skip = SkipMatcher(AutotagConfig.SKIPPED_TAGS).counting()
try:
# From here the pin is the gate's to end: the worker ends it,
# or ``on_skipped`` does when the worker never starts.
@@ -656,7 +653,6 @@ class AnnotationRoutesMixin:
capture,
model_type,
options,
skip,
on_skipped=functools.partial(store.release_use, token),
)
except MissingBackendPackages as exc:
@@ -681,8 +677,6 @@ class AnnotationRoutesMixin:
"model_type": model_type,
"options": options,
"suggestions": suggestion_rows(pairs),
# How many distinct suggestions the Skipped tags hid (#431).
"skipped": len(skip.hidden),
},
headers={"Cache-Control": "no-store"},
)
@@ -738,10 +732,10 @@ def _byte_range(request: web.Request, size: int):
return start, stop
def _pinned_suggestions(store, token, service, capture, model_type, options, skip=None):
def _pinned_suggestions(store, token, service, capture, model_type, options):
"""Worker thread: infer on the capture, then end its pin (even on error)."""
try:
return run_suggestions(service, capture.path, model_type, options, skip)
return run_suggestions(service, capture.path, model_type, options)
finally:
store.release_use(token)
+12 -128
View File
@@ -40,12 +40,6 @@ BATCH_PREVIEW_BODY_MAX = 1024
APPLY_BODY_MAX = 64 * 1024 # POST /autotag/apply: one image and its tags
_HEX64 = re.compile(r"[0-9a-f]{64}")
BATCH_JOB_BODY_MAX = 32 * 1024
# Skipped tags preview (#431): 200 entries of 50 characters, with room for JSON.
SKIPPED_PREVIEW_BODY_MAX = 64 * 1024
SKIPPED_PREVIEW_LEASE = "Skipped tags preview"
SKIPPED_PREVIEW_SAMPLE = 12
# WD14 label lists the preview found missing or unreadable (logged once each).
_MISSING_LABEL_LISTS = set()
REVIEW_REQUIRED = (
"Replacing tags needs a review of each image. Use Preview tagging review, "
"or choose Skip or Merge for a batch run."
@@ -78,12 +72,6 @@ MODEL_DOWNLOAD_BODY_MAX = 1024
_download_titles = {}
def _instruction_engine(model_type: str) -> str:
"""The language-model engine whose default instructions ``model_type``
reads: itself, or GGUF for a WD14 engine (which takes none)."""
return model_type if model_type in ("gguf", "hf") else "gguf"
def _holder_label(holder: Optional[str]) -> str:
if not holder:
return ANOTHER_OPERATION
@@ -259,10 +247,6 @@ class AutotagRoutesMixin:
async def preview_autotag_batch_route(request):
return await self.preview_autotag_batch(request)
@routes.post("/prompt_manager/autotag/skipped-tags/preview")
async def preview_skipped_tags_route(request):
return await self.preview_skipped_tags(request)
@routes.post("/prompt_manager/autotag/start")
async def start_autotag_route(request):
return await self.start_autotag(request)
@@ -300,15 +284,7 @@ class AutotagRoutesMixin:
"success": True,
"models": public_models(status, loaded_type),
# The saved tagging defaults (#328, System › Auto-tagging).
# The saved engine's default instructions (GGUF for a
# WD14 engine); kept one release for cached pages (#431).
"default_prompt": AutotagConfig.default_instructions_text(
_instruction_engine(AutotagConfig.MODEL_TYPE)
),
# #431: Built-in first, with every text, and each
# language-model engine's default.
"instructions": AutotagConfig.all_instructions(),
"default_instructions": dict(AutotagConfig.DEFAULT_INSTRUCTIONS),
"default_prompt": AutotagConfig.TEMPLATE,
"model_loaded": loaded,
"loaded_model_type": loaded_type,
"wd14_general_threshold": AutotagConfig.GENERAL_THRESHOLD,
@@ -448,7 +424,6 @@ class AutotagRoutesMixin:
title = f"Tagging model download · {label}"
_download_titles.clear()
_download_titles[owner] = title
admitted = False
try:
job = self._jobs.start(
MODEL_DOWNLOAD_JOB_KIND,
@@ -464,13 +439,13 @@ class AutotagRoutesMixin:
self._run_model_download, service, model_type, owner
),
)
admitted = True
except JobBusy as refused:
return None, busy_response(refused)
finally:
if not admitted: # JobBusy, or anything else: give the gate back.
model_gate.release(owner)
_download_titles.pop(owner, None)
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)
@@ -563,10 +538,7 @@ class AutotagRoutesMixin:
else:
prompt = params.get("prompt")
template = prompt if isinstance(prompt, str) and prompt.strip() else None
options = {
"instructions": template
or AutotagConfig.default_instructions_text(model_type)
}
options = {"instructions": template or AutotagConfig.TEMPLATE}
try:
_model, options = validate_options(
model_type,
@@ -726,86 +698,6 @@ class AutotagRoutesMixin:
return None, "existing must be one of " + ", ".join(EXISTING_POLICIES)
return MODELS[model_type], None
async def preview_skipped_tags(self, request):
"""Which known tags a Skipped tags list would hide (#431); no write.
``{entries}`` is checked as a save checks it (the same 400 sentence).
The known tags are the library's plus the general and character names
of each downloaded WD14 label list; no model is loaded and no image is
opened. Refused (409 ``busy``) while an exclusive job runs.
"""
from ..tagging_defaults import validate_skipped_tags
data, error = await self._batch_body(
request, ("entries",), SKIPPED_PREVIEW_BODY_MAX
)
if error is not None:
return error
try:
entries = validate_skipped_tags(data["entries"])
except ValueError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=400)
try:
async with self._jobs.lease(SKIPPED_PREVIEW_LEASE):
result = await self._run_in_executor(self._skipped_preview, entries)
except JobBusy as busy:
return busy_response(busy)
except Exception:
self.logger.exception("The Skipped tags preview failed")
return web.json_response(
{"success": False, "error": _INTERNAL_ERROR}, status=500
)
return web.json_response({"success": True, **result})
def _skipped_preview(self, entries):
"""Worker thread: the preview's counts over the known tags."""
from ..autotag import MODELS, get_autotag_service
from ..tagging_defaults import (
WD14_MODEL_TYPES,
SkipMatcher,
wd14_label_names,
)
library = self.db.get_all_tags()
known = {} # case-folded -> (0 library / 1 label list, spelling)
for name in library:
known.setdefault(name.casefold(), (0, name))
lists = []
models_dir = getattr(get_autotag_service(), "models_dir", None)
for model_type in sorted(WD14_MODEL_TYPES, key=lambda t: MODELS[t]["name"]):
if models_dir is None:
break
path = Path(models_dir) / MODELS[model_type]["subdir"] / "selected_tags.csv"
try:
names = wd14_label_names(path)
except (OSError, ValueError) as exc:
if path not in _MISSING_LABEL_LISTS: # logged once per list
_MISSING_LABEL_LISTS.add(path)
self.logger.debug(f"No WD14 label list for {model_type}: {exc}")
continue
_MISSING_LABEL_LISTS.discard(path)
lists.append(MODELS[model_type]["name"])
for name in names:
known.setdefault(name.casefold(), (1, name))
matchers = [(entry, SkipMatcher([entry])) for entry in entries]
whole = SkipMatcher(entries)
hidden = [item for item in known.values() if whole.matches(item[1])]
hidden.sort(key=lambda item: (item[0], item[1].casefold()))
per_entry = [
{
"entry": entry,
"count": sum(1 for _, name in hidden if matcher.matches(name)),
}
for entry, matcher in matchers
]
return {
"count": len(hidden),
"sample": [name for _, name in hidden[:SKIPPED_PREVIEW_SAMPLE]],
"sample_limit": SKIPPED_PREVIEW_SAMPLE,
"per_entry": per_entry,
"sources": {"library": len(library), "wd14": lists},
}
async def preview_autotag_batch(self, request):
"""Review Tag output images: counts, model, backup and disk; no write.
@@ -1109,10 +1001,7 @@ class AutotagRoutesMixin:
else:
prompt = data.get("prompt")
template = prompt if isinstance(prompt, str) and prompt.strip() else None
options = {
"instructions": template
or AutotagConfig.default_instructions_text(model_type)
}
options = {"instructions": template or AutotagConfig.TEMPLATE}
use_gpu = data.get("use_gpu", True)
options["use_gpu"] = use_gpu if isinstance(use_gpu, bool) else True
return validate_options(
@@ -1152,12 +1041,12 @@ class AutotagRoutesMixin:
return self._bad_request('existing must be "skip" or "merge"')
try:
options = self._single_options(data, model_type)
except OptionError as error:
return self._bad_request(str(error))
except (ValueError, TypeError):
return self._bad_request(
"general_threshold and character_threshold must be numeric"
)
except OptionError as error:
return self._bad_request(str(error))
target, error = await self._select_autotag_target(data)
if error is not None:
return error
@@ -1206,8 +1095,6 @@ class AutotagRoutesMixin:
async def _suggest_single(self, target, model_type, options, base):
from ..autotag import MODELS, MissingBackendPackages, get_autotag_service
from ..config import AutotagConfig
from ..tagging_defaults import SkipMatcher
from .annotation_suggest import model_refusal
from .annotations import _pinned_suggestions, _settled, _submit
@@ -1224,8 +1111,6 @@ class AutotagRoutesMixin:
owner = model_gate.new_owner("legacy-single")
if not model_gate.try_acquire(owner):
return model_busy_response()
# #431: the legacy review hides the Skipped tags too (19A).
skip = SkipMatcher(AutotagConfig.SKIPPED_TAGS)
store = self._capture_store()
try:
epoch = await self._run_in_executor(self.db.get_execution_epoch)
@@ -1265,7 +1150,6 @@ class AutotagRoutesMixin:
capture,
model_type,
options,
skip,
on_skipped=functools.partial(store.release_use, capture.token),
)
except MissingBackendPackages as exc:
-1
View File
@@ -971,7 +971,6 @@ class BackupRestoreRoutesMixin:
line = restored_line(staged.summary)
job.log_line(line)
job.update(progress=100, status_line=line)
return None
async def _outlive_cancel(future):
+1 -2
View File
@@ -574,13 +574,12 @@ class DuplicateFilesMixin:
return _fail_job(job, "sources-changed", SOURCES_CHANGED)
ok, backup = await self._safety_backup_step(job, db, backup_on)
if not ok:
return None
return
try:
await self._remove_duplicates(job, db, plan, backup)
finally:
if backup:
await self._prune_backup_pool(job, db)
return None
async def _remove_duplicates(self, job, db, plan, backup):
targets = plan.payload["targets"]
+1 -1
View File
@@ -27,7 +27,7 @@ def _identity(path):
except OSError:
return None
if info.st_ino:
return ("inode", (info.st_dev, info.st_ino))
return ("inode", info.st_dev, info.st_ino)
return ("path", os.path.normcase(os.path.realpath(str(path))))
+1 -2
View File
@@ -330,13 +330,12 @@ class ImageDeleteMixin:
return _fail_job(job, "sources-changed", SOURCES_CHANGED)
ok, backup = await self._safety_backup_step(job, db, backup_on)
if not ok:
return None
return
try:
await self._delete_images(job, db, targets, mode, backup)
finally:
if backup:
await self._prune_backup_pool(job, db)
return None
async def _delete_images(self, job, db, targets, mode, backup):
job.update(total=len(targets), status_line=TITLES[mode])
+9 -47
View File
@@ -39,7 +39,6 @@ from typing import NamedTuple
from aiohttp import web
from .. import model_gate
from ..tagging_defaults import SkipMatcher
from .admin import _plural, _time_left
from .annotation_suggest import (
OptionError,
@@ -171,8 +170,6 @@ class _Run:
owner: object
service: object = None
roots: dict = field(default_factory=dict)
# The job's Skipped tags, compiled once at its start (#431).
skip: object = None
@dataclass(frozen=True)
@@ -186,9 +183,6 @@ class _Spec:
options: dict | None = None
# A listing filter the start resolves into ``targets`` (#392).
filter: dict | None = None
# The Skipped tags when the job was requested (#431); a later save does
# not change a running job.
skip: tuple = ()
@property
def title(self) -> str:
@@ -244,11 +238,7 @@ def _parse_auto(targets, data):
)
except OptionError as exc:
return None, bad_request(str(exc))
skip = tuple(AutotagConfig.SKIPPED_TAGS)
return (
_Spec("auto", targets, model_type=model_type, options=options, skip=skip),
None,
)
return _Spec("auto", targets, model_type=model_type, options=options), None
def parse_image_tag_request(data: dict):
@@ -339,8 +329,6 @@ class AutotagRun:
keep_in_memory: bool
backup_on: bool
owner: object
# The Skipped tags when the job was started (#431).
skip: tuple = ()
@dataclass(frozen=True)
@@ -412,14 +400,6 @@ def _load_model(service, model_type: str, use_gpu: bool) -> None:
raise RuntimeError(f"{model_type} could not be loaded")
def _skipped_tags_line(entries: tuple) -> str:
"""The job log's one line about the Skipped tags it runs with (#431)."""
if not entries:
return "Skipped tags: none"
count = len(entries)
return f"Skipped tags: {count} {'entry' if count == 1 else 'entries'}"
def _refusal(status: int, code: str, message: str) -> Refusal:
response = web.json_response(
{"success": False, "code": code, "error": message}, status=status
@@ -527,7 +507,6 @@ class ImageTagJobsMixin:
released when the task ends, also before its runner ever starts.
"""
from ..autotag import MODELS, get_autotag_service
from ..config import AutotagConfig
backup_on = self._safety_backup_policy() == "on"
status = await self._run_in_executor(get_autotag_service().get_models_status)
@@ -550,15 +529,8 @@ class ImageTagJobsMixin:
response = model_busy_response()
return None, Refusal(409, "model-busy", MODEL_BUSY_ERROR, response)
run = AutotagRun(
model_type,
existing,
options,
keep_in_memory,
backup_on,
owner,
skip=tuple(AutotagConfig.SKIPPED_TAGS),
model_type, existing, options, keep_in_memory, backup_on, owner
)
admitted = False
try:
job = self._jobs.start(
AUTOTAG_BATCH_JOB_KIND,
@@ -569,12 +541,11 @@ class ImageTagJobsMixin:
groups=AUTOTAG_GROUP_NAMES,
runner=functools.partial(self._run_autotag_batch, run),
)
admitted = True
except JobBusy as refused:
return None, _busy_refusal(refused)
finally:
if not admitted:
model_gate.release(owner)
except BaseException as refused:
model_gate.release(owner)
if isinstance(refused, JobBusy):
return None, _busy_refusal(refused)
raise
job.task.add_done_callback(lambda _task: model_gate.release(owner))
return job, None
@@ -625,9 +596,7 @@ class ImageTagJobsMixin:
return _batch_cancelled(job)
job.publish({"type": "phase", "phase": "model"})
job.update(status_line="Loading the tagging model")
spec = _Spec(
"auto", (), model_type=run.model_type, options=run.options, skip=run.skip
)
spec = _Spec("auto", (), model_type=run.model_type, options=run.options)
service = await self._prepare_tagging_model(job, spec, run.owner)
if service is None:
return "failed"
@@ -653,7 +622,6 @@ class ImageTagJobsMixin:
f"Tagging started · {job.scope.get('label') or run.model_type} · "
f"{_plural(total, 'image')} · {policy}"
)
job.log_line(_skipped_tags_line(spec.skip))
if job.cancelled:
return _batch_cancelled(job)
remaining = await self._autotag_prepass(job, targets, run.existing == "skip")
@@ -663,9 +631,7 @@ class ImageTagJobsMixin:
spec = dataclasses.replace(
spec, targets=tuple(item.target for item in remaining)
)
loop_run = _Run(
job, spec, epoch, run.owner, service, skip=SkipMatcher(spec.skip)
)
loop_run = _Run(job, spec, epoch, run.owner, service)
if not await self._tag_each_file(loop_run, done_before=done):
job.update(status_line=STORAGE_RESTORED)
job.log_line(f"Tagging stopped · {STORAGE_RESTORED}")
@@ -771,9 +737,6 @@ class ImageTagJobsMixin:
started += f" · {spec.model_type}"
job.log_line(started)
run = _Run(job, spec, epoch, owner)
if spec.mode == "auto":
job.log_line(_skipped_tags_line(spec.skip))
run.skip = SkipMatcher(spec.skip)
try:
if spec.mode == "auto":
run.service = await self._prepare_tagging_model(job, spec, owner)
@@ -929,7 +892,6 @@ class ImageTagJobsMixin:
capture,
spec.model_type,
spec.options,
run.skip,
on_skipped=functools.partial(store.release_use, capture.token),
)
except Exception:
+7 -2
View File
@@ -39,8 +39,10 @@ from .thumbnail_encode import (
PREVIEW_HELD,
EncoderClosed,
PreviewWriteHeld,
replace_unless_held,
run_encoder,
sweep_stray_temps,
temp_sibling,
thumbnail_frame,
writer_settings,
write_thumbnail,
@@ -337,6 +339,11 @@ def _thumbnail_batch_size(workers):
return max(4, workers * 2)
# Preview files are written by thumbnail_encode in the configured format
# (thumbnail_settings, #317); these names stay importable from here.
_temp_sibling = temp_sibling
_replace_unless_held = replace_unless_held
# A preview that could not be replaced (#249 S5 M6). Windows refuses to rename
# over a file another process has open (WinError 5 or 32), such as a preview
# the gallery is serving; the old preview stays and the file counts as an error.
@@ -389,7 +396,6 @@ def _video_placeholder_image(thumbnail_size):
font=font,
)
except (OSError, AttributeError):
# a placeholder without its label still works
pass
return img
@@ -493,7 +499,6 @@ def _preview_names(folders, folder):
except OSError:
continue
except OSError:
# an unreadable folder lists as empty; nothing there to cache
pass
folders[folder] = names
return names
+1 -1
View File
@@ -250,7 +250,7 @@ class JobRoutesMixin:
try:
await response.write(_summary_event(job))
async with contextlib.aclosing(job.events()) as events:
async for _ in events:
async for _payload in events:
await response.write(_summary_event(job))
# The last event is published before the job takes its final state.
await response.write(_summary_event(job))
-2
View File
@@ -207,7 +207,6 @@ class LibraryLocationRoutesMixin:
try:
size = os.path.getsize(location.db_path)
except OSError:
# no library file yet: report size 0
pass
return {
"path": self._library_public_path(location.db_path),
@@ -374,7 +373,6 @@ class LibraryLocationRoutesMixin:
line += ". Logs move at the next restart."
job.log_line(line)
job.update(progress=100, status_line=line)
return None
@staticmethod
def _fail_library_move(job, code, message):
-1
View File
@@ -205,4 +205,3 @@ class LogCleanupMixin:
line += f", {failed} could not be deleted"
job.log_line(line)
job.update(processed=len(files), progress=100, status_line=line)
return None
+3 -5
View File
@@ -436,7 +436,7 @@ class LoraImportRoutesMixin:
job.log_line(f"{CANCELLED} {CACHE_NOTE}")
job.update(status_line=CANCELLED)
job.finish("cancelled")
return None
return
except Exception:
self.logger.exception("LoRA import staging failed")
return _fail_job(job, "import-failed", f"{FAILED} {CACHE_NOTE}")
@@ -455,10 +455,10 @@ class LoraImportRoutesMixin:
job.log_line(f"{CANCELLED} {CACHE_NOTE}")
job.update(status_line=CANCELLED)
job.finish("cancelled")
return None
return
ok, _backup = await self._safety_backup_step(job, db, backup_on)
if not ok:
return None
return
# I1: the files are listed and fingerprinted again right before the
# replace, so a file changed, removed or added while the downloads ran
# (LoraManager refreshing its CivitAI info) aborts with nothing deleted.
@@ -466,7 +466,6 @@ class LoraImportRoutesMixin:
return _fail_job(job, "plan-changed", f"{SOURCE_CHANGED} {CACHE_NOTE}")
job.update(status_line="Replacing the LoRA collection", progress=95)
await self._replace_in_job(job, db, plan, staged)
return None
def _count_downloads(self, job, staged):
downloaded = staged.stats.get("downloaded", 0)
@@ -519,4 +518,3 @@ class LoraImportRoutesMixin:
staged.stats.get("download_errors", 0),
)
job.update(progress=100, processed=job.total, status_line=line)
return None
+2 -15
View File
@@ -44,7 +44,6 @@ _FILES_CHANGED = (
"The LoRA metadata files changed during the scan; the previous imports "
"were kept. Run the scan again."
)
_LORA_SCAN_FAILED = "The LoRA import failed. Check the server log for details."
_IMPORTS_CHANGED = (
"The imported LoRA prompts changed during the scan; the previous imports "
"were kept. Run the scan again."
@@ -408,21 +407,10 @@ class LoraIntegrationMixin:
return
if isinstance(error, PlanChanged):
message = _IMPORTS_CHANGED
elif isinstance(
error,
(
LoraFilesChanged,
LoraSettingsUnusable,
EmptyReplacement,
PairDependencyError,
),
):
elif isinstance(error, (LoraFilesChanged, LoraSettingsUnusable)):
message = str(error)
elif isinstance(error, OSError):
# Reduced to its reason and the file's basename.
message = self._public_error(error)
else:
message = _LORA_SCAN_FAILED
message = self._public_error(error)
if isinstance(
error,
(
@@ -548,7 +536,6 @@ class LoraIntegrationMixin:
except asyncio.CancelledError:
interrupted = True
except Exception:
# the await after this loop raises it for the caller
pass
if interrupted:
raise asyncio.CancelledError
-1
View File
@@ -673,7 +673,6 @@ class MaintenanceRoutesMixin:
line = _changed_line(plan.operation, result["changed"], result.get("marks", 0))
job.log_line(line)
job.update(progress=100, status_line=f"{line} · {backup_note(backup)}")
return None
def _maintenance_rows(self, operation, described):
"""Outcome rows for the changed records (root-relative paths only)."""
+13 -114
View File
@@ -54,25 +54,9 @@ _SETTINGS_FILE_KEYS = {
"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"),
"AUTOTAG_SKIPPED_TAGS": ("autotag", "defaults", "skipped_tags"),
# #431: the saved instructions and each engine's default, always written
# together (server-side pairing).
"AUTOTAG_INSTRUCTIONS": ("autotag", "defaults", "instructions"),
"AUTOTAG_DEFAULT_INSTRUCTIONS": ("autotag", "defaults", "default_instructions"),
}
# The old single template: written only as the GGUF default's mirror for a
# downgrade (#431 B1.1), deleted when that default is Built-in.
_TEMPLATE_MIRROR_KEY = ("autotag", "defaults", "template")
TEMPLATE_REPLACED_ERROR = (
"LLM tag template was replaced by Saved instructions. Reload the page."
)
# Whole-list settings (#431): a save without ``expected_revision`` would merge
# a stale copy over a list changed elsewhere, so it is refused.
_REVISION_REQUIRED = frozenset(
{"autotag_skipped_tags", "autotag_instructions", "autotag_default_instructions"}
)
REVISION_REQUIRED_ERROR = "Reload the page and try again."
# 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.
@@ -153,20 +137,13 @@ def _settings_mutation(updates, new_roots):
"""The config commit for a settings save: only the submitted keys change.
Every other section and key (integrations and the CivitAI key, database,
unknown sections) is kept as it is on disk. A save of the instructions
also writes the old ``template`` key as the GGUF default's mirror.
unknown sections) is kept as it is on disk.
"""
def mutate(config):
for name, value in updates.items():
*sections, key = _SETTINGS_FILE_KEYS[name]
_set_nested(config, sections, key, _jsonable(value))
if "AUTOTAG_INSTRUCTIONS" in updates:
*sections, key = _TEMPLATE_MIRROR_KEY
mirror = _mirror_text(
updates["AUTOTAG_INSTRUCTIONS"], updates["AUTOTAG_DEFAULT_INSTRUCTIONS"]
)
_set_nested(config, sections, key, _DELETE if mirror is None else mirror)
_set_nested(config, sections, key, value)
if new_roots is not None:
gallery = _section(config, "gallery")
monitoring = _section(gallery, "monitoring")
@@ -178,37 +155,10 @@ def _settings_mutation(updates, new_roots):
return mutate
# ``_set_nested``'s value that removes the key instead of setting it.
_DELETE = object()
def _jsonable(value):
"""A runtime value as config.json holds it (tuples become lists)."""
if isinstance(value, (list, tuple)):
return [_jsonable(item) for item in value]
if isinstance(value, dict):
return {key: _jsonable(item) for key, item in value.items()}
return value
def _mirror_text(entries, defaults):
"""The GGUF default's text, or None when it is Built-in (#431 B1.1)."""
from ..tagging_defaults import BUILTIN_INSTRUCTIONS_ID
for item in entries:
if item["id"] == defaults["gguf"] != BUILTIN_INSTRUCTIONS_ID:
return item["text"]
return None
def _set_nested(config, sections, key, value):
"""``config[s1][s2]...[key] = value``, each section a fresh dict;
``_DELETE`` removes the key."""
"""``config[s1][s2]...[key] = value``, each section a fresh dict."""
if not sections:
if value is _DELETE:
config.pop(key, None)
else:
config[key] = value
config[key] = value
return
merged = _section(config, sections[0])
_set_nested(merged, sections[1:], key, value)
@@ -226,7 +176,7 @@ def _validate_autotag_settings(data):
MAX_SUGGESTION_LIMIT,
MODEL_TYPES,
validate_saved_threshold,
validate_skipped_tags,
validate_template,
)
updates = {}
@@ -255,7 +205,12 @@ def _validate_autotag_settings(data):
raise ValueError(f"{error}.") from None
updates[key.upper()] = value
if "autotag_template" in data:
raise ValueError(TEMPLATE_REPLACED_ERROR)
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:
@@ -264,41 +219,9 @@ def _validate_autotag_settings(data):
f"{MAX_SUGGESTION_LIMIT}."
)
updates["AUTOTAG_SUGGESTION_LIMIT"] = value
if "autotag_skipped_tags" in data:
updates["AUTOTAG_SKIPPED_TAGS"] = tuple(
validate_skipped_tags(data["autotag_skipped_tags"])
)
updates.update(_validate_instruction_settings(data))
return updates
def _validate_instruction_settings(data):
"""Server-side pairing (#431): a save that carries either instruction key
gets the other from the running configuration (which holds the migrated
list), the defaults are checked against the merged list, and both are
written. Empty when the save carries neither."""
from ..config import AutotagConfig
from ..tagging_defaults import (
validate_default_instructions,
validate_instructions,
)
if not {"autotag_instructions", "autotag_default_instructions"} & set(data):
return {}
if "autotag_instructions" in data:
entries = validate_instructions(data["autotag_instructions"])
else:
entries = [dict(item) for item in AutotagConfig.INSTRUCTIONS]
defaults = data.get(
"autotag_default_instructions", dict(AutotagConfig.DEFAULT_INSTRUCTIONS)
)
defaults = validate_default_instructions(defaults, entries)
return {
"AUTOTAG_INSTRUCTIONS": tuple(entries),
"AUTOTAG_DEFAULT_INSTRUCTIONS": defaults,
}
async def _read_json(request):
"""Parse a JSON object body through the package's capped reader."""
from . import _read_json_body
@@ -326,7 +249,6 @@ class SettingsRoutesMixin:
"""Get current settings."""
try:
from ..config import AutotagConfig, PromptManagerConfig, GalleryConfig
from ..tagging_defaults import DEFAULT_TAG_TEMPLATE
root_paths = [
self._public_path(d) for d in GalleryConfig.MONITORING_DIRECTORIES
@@ -359,21 +281,8 @@ class SettingsRoutesMixin:
"autotag_character_threshold": (
AutotagConfig.CHARACTER_THRESHOLD
),
# Read-only for one release (#431): a page cached
# from before the upgrade still renders.
"autotag_template": AutotagConfig.default_instructions_text(
"gguf"
),
"autotag_instructions": [
dict(item) for item in AutotagConfig.INSTRUCTIONS
],
"autotag_default_instructions": dict(
AutotagConfig.DEFAULT_INSTRUCTIONS
),
# Built-in's text, which System shows read-only.
"autotag_builtin_instructions": DEFAULT_TAG_TEMPLATE,
"autotag_template": AutotagConfig.TEMPLATE,
"autotag_suggestion_limit": AutotagConfig.SUGGESTION_LIMIT,
"autotag_skipped_tags": list(AutotagConfig.SKIPPED_TAGS),
"cpu_count": PromptManagerConfig.max_worker_threads(),
"gallery_root_paths": root_paths,
"gallery_root_path": root_paths[0] if root_paths else "",
@@ -474,10 +383,6 @@ class SettingsRoutesMixin:
{"success": False, "error": "expected_revision must be a string"},
status=400,
)
if expected_revision is None and _REVISION_REQUIRED & set(data):
return web.json_response(
{"success": False, "error": REVISION_REQUIRED_ERROR}, status=400
)
try:
updates = self._validate_settings(data)
except ValueError as ve:
@@ -606,12 +511,6 @@ class SettingsRoutesMixin:
if name in _AUTOTAG_SETTINGS:
setattr(AutotagConfig, name[len(_AUTOTAG_PREFIX) :], value)
continue
if "AUTOTAG_INSTRUCTIONS" in updates:
# What config.json now holds as the old key (the mirror).
AutotagConfig._LEGACY_TEMPLATE = AutotagConfig.mirror_template()
for name, value in updates.items():
if name in _AUTOTAG_SETTINGS:
continue
owner = GalleryConfig if name in _GALLERY_SETTINGS else PromptManagerConfig
setattr(owner, name, value)
restart_fields = []
-1
View File
@@ -533,7 +533,6 @@ class AutoThumbnailer:
try:
process.kill()
except OSError:
# the process has already exited
pass
if worker is None:
return True
-4
View File
@@ -158,7 +158,6 @@ def _unlink_quietly(path):
try:
Path(path).unlink()
except FileNotFoundError:
# already gone, which is what was wanted
pass
except OSError as exc:
logger.debug(f"Could not remove temp file {Path(path).name}: {exc}")
@@ -222,7 +221,6 @@ def shutdown_encoders():
try:
process.kill()
except OSError:
# the process has already exited
pass
@@ -361,12 +359,10 @@ def thumbnail_frame(img, edge):
try:
img.seek(0)
except (EOFError, AttributeError, ValueError):
# not seekable or a single frame: use the current one
pass
try:
img.draft("RGB", (edge, edge)) # JPEG: decode at a reduced scale
except (AttributeError, ValueError, OSError):
# draft is only a speedup; the full decode below still works
pass
frame = img.convert("RGB") if img.mode != "RGB" else img.copy()
frame.thumbnail((edge, edge), Image.Resampling.LANCZOS)
-1
View File
@@ -275,4 +275,3 @@ class WorkflowStorageRoutesMixin:
job.record_counted("changed", 1)
job.log_line(line)
job.update(progress=100, status_line=line)
return None
+10 -10
View File
@@ -30,9 +30,9 @@ except ImportError:
# 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, read_wd14_label_rows
from .tagging_defaults import DEFAULT_TAG_TEMPLATE
except ImportError:
from py.tagging_defaults import DEFAULT_TAG_TEMPLATE, read_wd14_label_rows
from py.tagging_defaults import DEFAULT_TAG_TEMPLATE
try:
from ..utils import exit_signal
@@ -559,6 +559,7 @@ class AutoTagService:
# A missing onnxruntime is refused by load_model before this runs
# (#424); a ModuleNotFoundError here becomes the same refusal.
import onnxruntime as ort
import csv
config = MODELS[model_type]
model_dir = self.models_dir / config["subdir"]
@@ -575,8 +576,12 @@ class AutoTagService:
session = ort.InferenceSession(str(model_path), providers=providers)
# Read tag list from CSV (the reader the Skipped tags preview shares)
tags_list = read_wd14_label_rows(tags_path)
# Read tag list from CSV
tags_list = []
with open(tags_path, "r", encoding="utf-8") as f:
reader = csv.DictReader(f)
for row in reader:
tags_list.append(row)
self.logger.info(
f"WD14 model loaded ({len(tags_list)} tags, "
@@ -987,7 +992,6 @@ class AutoTagService:
prompt: Optional[str] = None,
general_threshold: Optional[float] = None,
character_threshold: Optional[float] = None,
skip: Any = None,
) -> List[Tuple[str, Optional[float]]]:
"""The top ``limit`` suggestions as ``(name, confidence)`` pairs.
@@ -996,9 +1000,7 @@ class AutoTagService:
``None``. Names are distinct case-insensitively (first one wins).
A name Save would reject (over 50 characters, a control character
such as a newline, a comma) is skipped before the dedupe and the
``limit`` count. A name ``skip`` matches (a ``SkipMatcher``, the
Skipped tags compiled for this request or job, #431) is dropped next,
so it uses up no slot. ``prompt`` is used for this call only;
``limit`` count. ``prompt`` is used for this call only;
``custom_prompt`` is never changed.
"""
if type(limit) is not int or not 1 <= limit <= MAX_SUGGESTION_LIMIT:
@@ -1015,8 +1017,6 @@ class AutoTagService:
name = validate_vocabulary_tag_name(raw)
except ValueError:
continue
if skip is not None and skip.matches(name):
continue
key = name.casefold()
if key in seen:
continue
+11 -184
View File
@@ -30,7 +30,6 @@ routes = server_instance.routes
# Extension info
extension_uri = None # Will be set in __init__.py
import hashlib # noqa: E402
import os # noqa: E402
from typing import Any, Dict, List, Optional, Tuple # noqa: E402
@@ -588,15 +587,8 @@ try:
MODEL_TYPES,
WD14_MODEL_TYPES,
validate_saved_threshold,
validate_skipped_tags,
validate_template,
validate_threshold,
BUILTIN_INSTRUCTIONS_ID,
INSTRUCTION_ENGINES,
MAX_SAVED_INSTRUCTIONS,
builtin_instructions,
validate_default_instructions,
validate_instructions,
)
except ImportError:
from py.tagging_defaults import ( # noqa: F401 - re-exported
@@ -608,15 +600,8 @@ except ImportError:
MODEL_TYPES,
WD14_MODEL_TYPES,
validate_saved_threshold,
validate_skipped_tags,
validate_template,
validate_threshold,
BUILTIN_INSTRUCTIONS_ID,
INSTRUCTION_ENGINES,
MAX_SAVED_INSTRUCTIONS,
builtin_instructions,
validate_default_instructions,
validate_instructions,
)
@@ -632,31 +617,6 @@ def _valid_existing(value: Any) -> str:
return value
def _valid_skipped_tags(value: Any) -> Tuple[str, ...]:
return tuple(validate_skipped_tags(value))
def _reason(error: ValueError) -> str:
"""A settings sentence as a load warning's reason ("entry 2 is empty")."""
text = str(error).rstrip(".")
for prefix in ("Skipped tags", "Saved instructions"):
if text.startswith(prefix):
return text.removeprefix(prefix).lstrip(": ")
return text
def _text_id(text: str) -> str:
"""A migrated entry's id: deterministic, so every load gives the same."""
return hashlib.sha256(text.encode("utf-8")).hexdigest()[:12]
def _fallback_warning(key: str, error: ValueError, default: Any) -> str:
"""The one load warning of a saved tagging default that fails its check."""
if key == "skipped_tags":
return f"autotag.defaults.skipped_tags {_reason(error)}; using no skipped tags"
return f"autotag.defaults.{key} {error}; using {default!r}"
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}")
@@ -677,18 +637,8 @@ class AutotagConfig:
EXISTING = "skip"
GENERAL_THRESHOLD = 0.35
CHARACTER_THRESHOLD = 0.85
TEMPLATE = DEFAULT_TAG_TEMPLATE
SUGGESTION_LIMIT = DEFAULT_SUGGESTION_LIMIT
SKIPPED_TAGS: Tuple[str, ...] = ()
# #431: the saved instructions (Built-in is never stored) and the default
# of each language-model engine, by id.
INSTRUCTIONS: Tuple[Dict[str, str], ...] = ()
DEFAULT_INSTRUCTIONS: Dict[str, str] = {
"gguf": BUILTIN_INSTRUCTIONS_ID,
"hf": BUILTIN_INSTRUCTIONS_ID,
}
# The old single template as read at load (None when absent or invalid);
# only the migration reads it. Saves keep it as the GGUF default's mirror.
_LEGACY_TEMPLATE: Optional[str] = None
# config.json key -> (attribute, validator, default)
_KEYS = {
@@ -704,13 +654,12 @@ class AutotagConfig:
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,
),
# #431: words whose suggested tags are never offered.
"skipped_tags": ("SKIPPED_TAGS", _valid_skipped_tags, ()),
}
@classmethod
@@ -723,142 +672,17 @@ class AutotagConfig:
type(value) is int and 1 <= value <= MAX_SUGGESTION_LIMIT
) # bool, float and str are rejected
@classmethod
def all_instructions(cls) -> List[Dict[str, str]]:
"""Built-in first, then the saved entries (copies)."""
return [builtin_instructions()] + [dict(item) for item in cls.INSTRUCTIONS]
@classmethod
def default_instructions_text(cls, engine: str) -> str:
"""The text of ``engine``'s (``gguf`` or ``hf``) default instructions."""
if engine not in INSTRUCTION_ENGINES:
raise ValueError(f"{engine} takes no instructions")
wanted = cls.DEFAULT_INSTRUCTIONS.get(engine, BUILTIN_INSTRUCTIONS_ID)
for item in cls.INSTRUCTIONS:
if item["id"] == wanted:
return item["text"]
return DEFAULT_TAG_TEMPLATE
@classmethod
def mirror_template(cls) -> Optional[str]:
"""What an older build reads as its one template (#431 B1.1): the
GGUF default's text, or None (no key) when that default is Built-in."""
if cls.DEFAULT_INSTRUCTIONS.get("gguf") == BUILTIN_INSTRUCTIONS_ID:
return None
return cls.default_instructions_text("gguf")
@classmethod
def get_config(cls) -> Dict[str, Any]:
"""The saved defaults; ``template`` only as the GGUF default's mirror."""
"""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()
}
defaults["skipped_tags"] = list(defaults["skipped_tags"])
defaults["instructions"] = [dict(item) for item in cls.INSTRUCTIONS]
defaults["default_instructions"] = dict(cls.DEFAULT_INSTRUCTIONS)
mirror = cls.mirror_template()
if mirror is not None:
defaults["template"] = mirror
if defaults["template"] == DEFAULT_TAG_TEMPLATE:
del defaults["template"]
return {"defaults": defaults}
@classmethod
def _load_instructions(cls, defaults: Dict[str, Any]) -> None:
"""Each instruction key alone, then the cross-key default check, then
the in-memory migration of the old template (#431). Nothing is
written here, so the revision never changes at load."""
cls._LEGACY_TEMPLATE = None
if "template" in defaults:
try:
cls._LEGACY_TEMPLATE = validate_template(defaults["template"])
except ValueError as error:
config_logger.warning(
f"autotag.defaults.template {error}; using the built-in one"
)
entries: List[Dict[str, str]] = []
damaged = False
if "instructions" in defaults:
try:
entries = validate_instructions(defaults["instructions"])
except ValueError as error:
damaged = True
config_logger.warning(
f"autotag.defaults.instructions {_reason(error)}; "
"using Built-in only"
)
chosen = {engine: BUILTIN_INSTRUCTIONS_ID for engine in INSTRUCTION_ENGINES}
stored = defaults.get("default_instructions")
if damaged:
# Both defaults fall back with the list (its one warning says so);
# the old template is migrated as on a first upgrade.
pass
elif "default_instructions" in defaults and (
not isinstance(stored, dict) or set(stored) != set(INSTRUCTION_ENGINES)
):
config_logger.warning(
"autotag.defaults.default_instructions must name gguf and hf; "
"using Built-in"
)
elif isinstance(stored, dict):
ids = {item["id"] for item in entries}
for engine in INSTRUCTION_ENGINES:
value = stored[engine]
if isinstance(value, str) and (
value == BUILTIN_INSTRUCTIONS_ID or value in ids
):
chosen[engine] = value
else:
config_logger.warning(
f"autotag.defaults.default_instructions.{engine} names no "
"saved instructions; using Built-in"
)
cls._migrate_template(
entries,
chosen,
first=damaged or "instructions" not in defaults,
keep_defaults=not damaged and "default_instructions" in defaults,
)
cls.INSTRUCTIONS = tuple(entries)
cls.DEFAULT_INSTRUCTIONS = chosen
@classmethod
def _migrate_template(cls, entries, chosen, *, first, keep_defaults) -> None:
"""Bring a template saved by an older build in as a named entry."""
legacy = cls._LEGACY_TEMPLATE
if legacy is None or legacy == DEFAULT_TAG_TEMPLATE:
return
if first:
# The first upgrade: it becomes "Custom template", the default for
# both engines unless defaults are stored already (20A).
entries.append(
{"id": _text_id(legacy), "name": "Custom template", "text": legacy}
)
if not keep_defaults:
for engine in INSTRUCTION_ENGINES:
chosen[engine] = entries[-1]["id"]
return
if any(item["text"] == legacy for item in entries):
return # the mirror of a saved default
# Edited on an older build after an upgrade (B1.2): import it.
if len(entries) >= MAX_SAVED_INSTRUCTIONS:
config_logger.warning(
"The tagging template edited on an older version was not imported: "
f"the saved instructions are full ({MAX_SAVED_INSTRUCTIONS})"
)
return
names = {item["name"].casefold() for item in entries}
number = 2
while f"custom template ({number})" in names:
number += 1
name = f"Custom template ({number})"
ids = {item["id"] for item in entries}
new_id, n = _text_id(legacy), 2
while new_id in ids:
new_id, n = _text_id(f"{legacy}\n{n}"), n + 1
entries.append({"id": new_id, "name": name, "text": legacy})
config_logger.info(
"Imported the tagging template edited on an older version as " f"'{name}'"
)
@classmethod
def update_config(cls, new_config: Any) -> None:
if not isinstance(new_config, dict):
@@ -874,9 +698,12 @@ class AutotagConfig:
try:
setattr(cls, attr, validate(defaults[key]))
except ValueError as error:
config_logger.warning(_fallback_warning(key, error, default))
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)
cls._load_instructions(defaults)
class PromptManagerConfig:
-1
View File
@@ -109,7 +109,6 @@ def _write_atomically(path: str, payload: bytes) -> None:
try:
os.unlink(temp_path)
except OSError:
# nothing to clean up; the original error is raised below
pass
raise
-1
View File
@@ -642,7 +642,6 @@ def layout_summary(layout: LoraManagerLayout, home: Path) -> Dict:
relative = Path(settings_file).relative_to(home)
settings_file = os.path.join("~", str(relative))
except ValueError:
# outside the home folder: show the full path
pass
return {
"source": layout.source,
-2
View File
@@ -585,7 +585,6 @@ def _read_capped(resp, cap: int) -> Optional[bytes]:
if int(declared) > cap:
return None
except ValueError:
# a bad header says nothing; the read below enforces the cap
pass
chunks = []
@@ -708,7 +707,6 @@ def _save_atomically(image, local_path: Path) -> None:
try:
os.remove(temp)
except OSError:
# nothing to clean up; the original error is raised below
pass
raise
+1 -233
View File
@@ -4,13 +4,8 @@ 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 csv
import math
import os
import re
import threading
import unicodedata
from typing import Any, Dict, Iterable, List, Set, Tuple
from typing import Any
# The tagging engines (``py/autotag.py`` MODELS) and the options they take.
WD14_MODEL_TYPES = frozenset({"wd14-swinv2", "wd14-vit"})
@@ -93,230 +88,3 @@ def validate_template(value: Any, name: str = "instructions") -> str:
if len(encoded) > MAX_INSTRUCTION_BYTES:
raise ValueError(f"{name} {'are' if name == 'instructions' else 'is'} too long")
return value
# Skipped tags (#431): words whose suggested tags are never offered.
MAX_SKIPPED_TAGS = 200
MAX_SKIPPED_TAG_CHARS = 50
_WORD_SEPARATORS = re.compile(r"[ _-]+")
_FORBIDDEN_IN_ENTRY = re.compile(r"[,\x00-\x1f\x7f-\x9f\u2028\u2029]")
def tag_words(text: str) -> Tuple[str, ...]:
"""The words of a tag or a skipped entry: NFC, case-folded, split on runs
of space, ``_`` and ``-``. Every other character belongs to a word."""
folded = unicodedata.normalize("NFC", text).casefold()
return tuple(word for word in _WORD_SEPARATORS.split(folded) if word)
def validate_skipped_tags(value: Any) -> List[str]:
"""The Skipped tags list, each entry trimmed, or ValueError with the
sentence the System page shows (entries are numbered from 1)."""
if not isinstance(value, list):
raise ValueError("Skipped tags must be a list of words.")
if len(value) > MAX_SKIPPED_TAGS:
raise ValueError(f"Skipped tags: at most {MAX_SKIPPED_TAGS} entries.")
entries: List[str] = []
seen: Dict[Tuple[str, ...], int] = {}
for number, raw in enumerate(value, 1):
if not isinstance(raw, str):
raise ValueError(f"Skipped tags: entry {number} must be text.")
entry = raw.strip()
if not entry:
raise ValueError(f"Skipped tags: entry {number} is empty.")
if len(entry) > MAX_SKIPPED_TAG_CHARS:
raise ValueError(
f"Skipped tags: entry {number} must be at most "
f"{MAX_SKIPPED_TAG_CHARS} characters."
)
if _FORBIDDEN_IN_ENTRY.search(entry):
raise ValueError(
f"Skipped tags: entry {number} must not contain a comma "
"or a line break."
)
words = tag_words(entry)
if not words:
raise ValueError(f"Skipped tags: entry {number} is empty.")
if words in seen:
raise ValueError(
f"Skipped tags: entry {number} repeats entry {seen[words]}."
)
seen[words] = number
entries.append(entry)
return entries
class SkipMatcher:
"""Whether a suggested tag contains a skipped entry's words, consecutive
and whole (#431, 17B). Compiled once per request or job; the cost of a
check depends on the tag's words, not on the number of entries."""
def __init__(self, entries: Iterable[str] = ()):
self._entries = frozenset(filter(None, (tag_words(e) for e in entries)))
self._longest = max((len(words) for words in self._entries), default=0)
def __bool__(self) -> bool:
return bool(self._entries)
def matches(self, name: str) -> bool:
if not self._entries:
return False
words = tag_words(name)
for start in range(len(words)):
stop = min(len(words), start + self._longest)
for end in range(start + 1, stop + 1):
if words[start:end] in self._entries:
return True
return False
def counting(self) -> "CountingSkip":
"""A per-request view that also remembers which names it hid."""
return CountingSkip(self)
class CountingSkip:
"""A :class:`SkipMatcher` that records the distinct names it hid, so the
Tag image dialog can say how many skipped tags were not suggested."""
def __init__(self, matcher: SkipMatcher):
self._matcher = matcher
self.hidden: Set[str] = set()
def matches(self, name: str) -> bool:
if self._matcher.matches(name):
self.hidden.add(name.casefold())
return True
return False
# A WD14 label list's tags the engine can suggest: general (0) and character (4).
WD14_SUGGESTED_CATEGORIES = frozenset({"0", "4"})
_label_cache: Dict[str, Tuple[Tuple[int, int], Tuple[str, ...]]] = {}
_label_cache_lock = threading.Lock()
def read_wd14_label_rows(path) -> List[Dict[str, str]]:
"""Every row of a WD14 ``selected_tags.csv``, read as UTF-8 and closed
before returning (the handle never outlives the call, for Windows)."""
with open(path, "r", encoding="utf-8", newline="") as handle:
return list(csv.DictReader(handle))
def wd14_label_names(path) -> Tuple[str, ...]:
"""The names a WD14 label list can suggest (categories 0 and 4), cached
by ``(mtime_ns, size)``. OSError (missing) and ValueError (unreadable)
propagate; the caller skips that list."""
key = os.fspath(path)
stat = os.stat(key)
stamp = (stat.st_mtime_ns, stat.st_size)
with _label_cache_lock:
cached = _label_cache.get(key)
if cached is not None and cached[0] == stamp:
return cached[1]
try:
rows = read_wd14_label_rows(key)
except csv.Error as exc: # a damaged list (a NUL byte, an oversized field)
raise ValueError(f"unreadable label list: {exc}") from None
names = tuple(
name
for row in rows
if row.get("category", "0") in WD14_SUGGESTED_CATEGORIES
and (name := (row.get("name") or "").strip())
)
with _label_cache_lock:
_label_cache[key] = (stamp, names)
return names
# Saved instructions (#431): named language-model instructions with a default
# per engine. Built-in is never stored; readers put it first.
BUILTIN_INSTRUCTIONS_ID = "builtin"
BUILTIN_INSTRUCTIONS_NAME = "Built-in"
MAX_SAVED_INSTRUCTIONS = 20
MAX_INSTRUCTION_NAME_CHARS = 60
INSTRUCTION_ENGINES = ("gguf", "hf")
ENGINE_LABELS = {"gguf": "GGUF JoyCaption", "hf": "HuggingFace JoyCaption"}
_INSTRUCTION_ID = re.compile(r"[0-9a-f]{12}")
_CONTROL = re.compile(r"[\x00-\x1f\x7f-\x9f

]")
def builtin_instructions() -> Dict[str, str]:
"""The read-only Built-in entry, always the shipped template."""
return {
"id": BUILTIN_INSTRUCTIONS_ID,
"name": BUILTIN_INSTRUCTIONS_NAME,
"text": DEFAULT_TAG_TEMPLATE,
}
def _instruction_entry(raw: Any, number: int) -> Dict[str, str]:
prefix = f"Saved instructions: entry {number}"
if (
not isinstance(raw, dict)
or set(raw) != {"id", "name", "text"}
or not isinstance(raw["id"], str)
or _INSTRUCTION_ID.fullmatch(raw["id"]) is None
):
raise ValueError(f"{prefix} has an invalid id.")
name = raw["name"].strip() if isinstance(raw["name"], str) else ""
if not name:
raise ValueError(f"{prefix} needs a name.")
if len(name) > MAX_INSTRUCTION_NAME_CHARS:
raise ValueError(
f"{prefix} name must be at most {MAX_INSTRUCTION_NAME_CHARS} characters."
)
if _CONTROL.search(name):
raise ValueError(f"{prefix} name must not contain a line break.")
if name.casefold() == BUILTIN_INSTRUCTIONS_NAME.casefold():
raise ValueError("Saved instructions: the name “Built-in” is reserved.")
try:
text = validate_template(raw["text"], f"{prefix} text")
except ValueError as error:
raise ValueError(f"{error}.") from None
return {"id": raw["id"], "name": name, "text": text}
def validate_instructions(value: Any) -> List[Dict[str, str]]:
"""The saved entries ``[{id, name, text}]`` in order (names trimmed), or
ValueError with the sentence System shows (Built-in is not counted)."""
if not isinstance(value, list):
raise ValueError("Saved instructions must be a list.")
if len(value) > MAX_SAVED_INSTRUCTIONS:
raise ValueError(
f"Saved instructions: at most {MAX_SAVED_INSTRUCTIONS} entries besides "
"Built-in."
)
entries: List[Dict[str, str]] = []
ids: Dict[str, int] = {}
names: Dict[str, int] = {}
for number, raw in enumerate(value, 1):
item = _instruction_entry(raw, number)
if item["id"] in ids:
raise ValueError(
f"Saved instructions: entry {number} repeats the id of entry "
f"{ids[item['id']]}."
)
key = item["name"].casefold()
if key in names:
raise ValueError(
f"Saved instructions: entry {number} name “{item['name']}” is "
"already used."
)
ids[item["id"]] = number
names[key] = number
entries.append(item)
return entries
def validate_default_instructions(value: Any, entries) -> Dict[str, str]:
"""``{gguf: id, hf: id}``, each Built-in or an id of ``entries``."""
if not isinstance(value, dict) or set(value) != set(INSTRUCTION_ENGINES):
raise ValueError("Default instructions must name GGUF and HuggingFace.")
ids = {BUILTIN_INSTRUCTIONS_ID} | {item["id"] for item in entries}
for engine in INSTRUCTION_ENGINES:
if not isinstance(value[engine], str) or value[engine] not in ids:
raise ValueError(
f"Default for {ENGINE_LABELS[engine]} must be one of the saved "
"instructions."
)
return {engine: value[engine] for engine in INSTRUCTION_ENGINES}
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
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.4.1"
version = "3.4.0"
license = {file = "LICENSE"}
requires-python = ">=3.12"
dependencies = ["watchdog>=2.1.0", "Pillow>=8.0.0"]
+1 -1
View File
@@ -1,4 +1,4 @@
# Runtime dependencies for PromptManager.
# aiohttp, numpy and torch come from ComfyUI itself and are not listed here.
watchdog>=2.1.0 # Output directory monitoring
watchdog>=2.1.0 # Output directory monitoring
Pillow>=8.0.0 # Image metadata extraction and thumbnails
+1
View File
@@ -6,6 +6,7 @@ import { THEMES, seedConcept, openConcept, openProduction, diffInventory, mutate
// app.js 1428–1432) and its backup box with the concept's System `.notice`; the differences are D55. The job view
// has no concept counterpart: it is compared with the approved mockup `activity-scan.html` (a local, ignored file),
// whose structure and CSS values are restated below (D56). Each comparison is shown to notice a mutation.
const workspace = '/prompt_manager/workspace';
test.beforeEach(async ({ page, request, browserName }) => {
await startCoverage(page, browserName);
+10 -19
View File
@@ -85,9 +85,6 @@ class TestModel:
suggestions: list | None = None
# The engine's model table (``py/autotag.py`` MODELS), set at startup.
models_config: dict = {}
# Where the WD14 label lists live (the Skipped tags preview, #431), set at
# startup; the tagging fixture writes or removes a small list there.
models_dir = None
def get_models_status(self) -> dict:
from py.autotag import package_fields
@@ -118,26 +115,21 @@ class TestModel:
return ["generated", Path(image_path).parent.parent.name]
def generate_tag_suggestions(
self, image_path: str, *, limit: int, skip=None, **kwargs: object
self, image_path: str, *, limit: int, **kwargs: object
) -> list[tuple[str, float | None]]:
"""Fixed scored suggestions (WD14) or unscored ones (language models),
without the ones ``skip`` hides before the limit, as the engine does
(#431)."""
"""Fixed scored suggestions (WD14) or unscored ones (language models)."""
scored = self.loaded is not None and self.loaded.startswith("wd14")
if self.suggestions is not None:
pairs = [
return [
(name, confidence if scored else None)
for name, confidence in self.suggestions
for name, confidence in self.suggestions[:limit]
]
else:
names = ["sandstone", "desert", "arch", "sky", "rock", "dune", "canyon"]
names += [f"tag_{index:02d}" for index in range(100)]
pairs = [
(name, round(0.98 - index * 0.008, 3) if scored else None)
for index, name in enumerate(names)
]
kept = [pair for pair in pairs if skip is None or not skip.matches(pair[0])]
return kept[:limit]
names = ["sandstone", "desert", "arch", "sky", "rock", "dune", "canyon"]
names += [f"tag_{index:02d}" for index in range(100)]
return [
(name, round(0.98 - index * 0.008, 3) if scored else None)
for index, name in enumerate(names[:limit])
]
def download_model(self, model_type: str, progress_callback=None) -> bool:
raise AssertionError("browser tests never download a model")
@@ -241,7 +233,6 @@ def create_app(root: Path) -> web.Application:
TestModel.models_config = autotag.MODELS
model = TestModel()
model.models_dir = root / "models"
autotag.get_autotag_service = lambda: model
api = PromptManagerAPI()
api.add_routes(routes)
-1
View File
@@ -109,7 +109,6 @@ class ResetWatch:
stream.write(text)
stream.flush()
except (OSError, ValueError):
# the stream closed; a watcher must not fail the server
pass
+11 -19
View File
@@ -162,15 +162,13 @@ async function serverJson(request, url) {
}
const number = value => value.toLocaleString('en-US');
// The sidebar's Images count (state.mjs), which System's Images metric shows (#555).
const IMAGES_COUNT_URL = '/prompt_manager/images/output?mode=workspace&limit=1';
/** The concept's shell and Overview with the recorded deviations applied, given the server's real overview. */
function mapOverviewConcept(reference, overview, images) {
function mapOverviewConcept(reference, overview) {
// D165: the discreet legacy-pages line follows the settings layout.
reference = { ...reference, tree: [...reference.tree, 'p.legacy-pages.small'] };
const relabel = { 'Database integrity': 'Not checked', 'Positive prompts': number(overview.library.positive_prompts),
'Negative variants': number(overview.library.negative_variants), Images: number(images) };
'Negative variants': number(overview.library.negative_variants), Images: number(overview.images.indexed) };
const rows = {
// D93: the real pending count; Review metadata opens Needs review (the concept's go-review).
'3 ambiguous image links': row => ({ ...row,
@@ -181,9 +179,8 @@ function mapOverviewConcept(reference, overview, images) {
'One display configuration': row => ({ ...row, description: 'Grid and density settings are shared across views.' }),
// D93: the real sources, not samples.
'2 sample output sources': row => ({ ...row, title: `${number(overview.sources.effective)} output sources` }),
// D96: the dialog shows runs by category and database record counts, not prompt, tag or date usage (D153: no
// ratings; #555: the counts are labelled as records, not as the Library or Images page counts).
Statistics: row => ({ ...row, description: 'Runs by category, with database record counts.' }),
// D96: the dialog shows runs by category and library totals, not prompt, tag or date usage (D153: no ratings).
Statistics: row => ({ ...row, description: 'Runs by category, with library totals.' }),
};
const surfaces = reference.columns.surfaces.map(surface => ({ ...surface,
rows: surface.rows.map(row => (rows[row.title] ?? (value => value))(row)) }));
@@ -221,8 +218,6 @@ async function conceptOverview(page, theme) {
async function productionOverview(page, theme) {
await openProduction(page, 'system/overview', theme);
await expect(page.locator('#workspace-main .summary-metrics')).toBeVisible();
// #555: the Images metric is the shell's Images count: compare once it was read.
await expect(page.locator('#workspace-main .summary-metrics > .metric:nth-child(3) strong')).toHaveText(/^[\d,]+$/);
// The thumbnail row is its own read (I2): compare once it answered.
await expect(page.locator('#workspace-main .operation h3').filter({ hasText: 'thumbnails' })).toHaveText('No missing thumbnails');
// So is the backup row (S9).
@@ -236,7 +231,6 @@ for (const theme of THEMES) for (const [width, height] of SIZES) {
test(`system shell and overview match the concept ${theme} ${width}x${height}`, async ({ page, request }, testInfo) => {
await page.setViewportSize({ width, height });
const overview = await serverJson(request, '/prompt_manager/system/overview');
const images = (await serverJson(request, IMAGES_COUNT_URL)).total;
// The fixture's thumbnails all exist, so the concept's "No missing thumbnails" is also the real title.
expect((await serverJson(request, '/prompt_manager/images/thumbnails/status')).pending).toBe(0);
const concept = await conceptOverview(page, theme);
@@ -245,7 +239,7 @@ for (const theme of THEMES) for (const [width, height] of SIZES) {
await page.screenshot({ path: testInfo.outputPath(`concept-system-${theme}-${width}.png`), fullPage: true });
const production = await productionOverview(page, theme);
await page.screenshot({ path: testInfo.outputPath(`production-system-${theme}-${width}.png`), fullPage: true });
expect(diffInventory(mapOverviewConcept(concept, overview, images), production)).toEqual([]);
expect(diffInventory(mapOverviewConcept(concept, overview), production)).toEqual([]);
expect(production.pageScroll).toBe(false);
});
}
@@ -271,9 +265,8 @@ const UNMAPPED_PATHS = [
test('system comparison detects mutations', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const overview = await serverJson(request, '/prompt_manager/system/overview');
const images = (await serverJson(request, IMAGES_COUNT_URL)).total;
const concept = await conceptOverview(page, 'dark');
const expected = mapOverviewConcept(concept, overview, images);
const expected = mapOverviewConcept(concept, overview);
const production = await productionOverview(page, 'dark');
expect(diffInventory(expected, production)).toEqual([]);
// Unmapped, the concept differs exactly where a deviation is recorded, and nowhere else.
@@ -297,7 +290,7 @@ test('system comparison detects mutations', async ({ page, request }) => {
});
/**
* D96: the concept's table, its rows the real ones; then "Database records" (#555) in the same table markup. D153 (#447): the
* D96: the concept's table, its rows the real ones; then "Library totals" in the same table markup. D153 (#447): the
* concept's "Avg. rating" column and a "Rated images" total are not drawn.
*/
function mapStatisticsConcept(reference, statistics) {
@@ -309,10 +302,9 @@ function mapStatisticsConcept(reference, statistics) {
tables: [
{ ...runs, head: runs.head.filter(([, , text]) => text !== 'Avg. rating'),
rows: statistics.by_category.map(row => [row.category ?? 'Uncategorized', number(row.runs)]) },
{ caption: 'Database records', head: [['th', 'col', 'Total'], ['th', 'col', 'Count']],
rows: [['Prompt records', number(statistics.totals.total_prompts)],
['Tags on prompts', number(statistics.totals.total_tags)],
['Image records', number(statistics.totals.total_images)]] },
{ caption: 'Library totals', head: [['th', 'col', 'Total'], ['th', 'col', 'Count']],
rows: [['Prompts', number(statistics.totals.total_prompts)], ['Tags', number(statistics.totals.total_tags)],
['Images', number(statistics.totals.total_images)]] },
],
};
}
@@ -428,7 +420,7 @@ for (const theme of THEMES) test(`statistics and health dialogs match the concep
expect(diffInventory(mapHealthConcept(concept.health), await page.evaluate(measureDialog)).map(item => item.path))
.toEqual(['operations.rows.0.title']);
await page.evaluate(() => { document.querySelector('#modal .operation h3').textContent = 'Integrity check'; });
// And a removed "Database records" table and a changed caption in the statistics dialog.
// And a removed "Library totals" table and a changed caption in the statistics dialog.
await page.keyboard.press('Escape');
await page.locator('#workspace-main .section-nav').getByRole('button', { name: 'Overview', exact: true }).click();
await page.locator('#workspace-main').getByRole('button', { name: 'Explore', exact: true }).click();
+18 -95
View File
@@ -33,10 +33,6 @@ test.afterEach(async ({ page, request, browserName }) => {
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 SKIPPED_DESCRIPTION = 'Suggested tags that contain one of these words are hidden. Words are separated by spaces, _ or -; '
+ 'other characters such as ( ) belong to the word.';
const DEFAULT_DESCRIPTION = 'The Tag image dialog and batches start from this.';
const INSTRUCTIONS_DESCRIPTION = 'Instructions for the language-model engines. Built-in can’t be changed; duplicate it to make your own.';
const BATCH = 'Tag each output image from its own pixels with the model above. Prompts are not changed. Review the scope first.';
/** Runs in the page: Auto-tagging's surface under `mainSelector`: rows, field, actions, operation, geometry and tokens. */
@@ -66,7 +62,7 @@ function measureTagging(mainSelector) {
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: field && { tree: [...field.children].map(name), label: [text(field.querySelector('label')), field.querySelector('label').htmlFor],
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') },
@@ -74,31 +70,6 @@ function measureTagging(mainSelector) {
...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') },
// D202 (#431): the Skipped tags block (the concept has none).
skipped: (block => block && { tree: [...block.children].map(name), key: block.dataset.key,
text: [...block.firstElementChild.children].map(node => [node.localName, text(node), node.localName === 'label' ? node.htmlFor : null]),
add: [...block.querySelector('.skip-add').children].map(node => [name(node), node.id, node.getAttribute('placeholder'), text(node)]),
preview: [text(block.querySelector('.skip-preview')), block.querySelector('.skip-preview').getAttribute('role')],
...css(block, 'paddingTop', 'paddingBottom', 'borderTopWidth', 'borderTopColor'),
label: css(block.querySelector('label'), 'fontSize', 'fontWeight', 'color'),
description: css(block.querySelector('.setting-block-text p'), 'fontSize', 'color', 'marginTop'),
input: css(block.querySelector('input'), 'fontSize', 'backgroundColor', 'color', 'borderTopColor') })(
surface.querySelector(':scope > .setting-block[data-setting="tag-skipped"]')),
// D203 (#431): Saved instructions (the concept has a template field instead).
instructions: (block => block && { tree: [...block.children].map(name), key: block.dataset.key,
text: [...block.firstElementChild.children].map(node => [node.localName, text(node)]),
list: [...block.querySelectorAll('[role="option"]')].map(option => [text(option.firstElementChild),
[...option.querySelectorAll('.badges > .chip')].map(chip => [text(chip), chip.className]), option.getAttribute('aria-selected')]),
listRole: block.querySelector('.instruction-list').getAttribute('role'),
actions: [...block.querySelectorAll('.instruction-actions > button')].map(item => [text(item), item.className, item.disabled]),
editor: [...block.querySelectorAll('.instruction-editor .field')].map(item => [text(item.querySelector('label > span')),
item.querySelector('input, textarea').value, item.querySelector('input, textarea').readOnly]),
note: text(block.querySelector('#instruction-note')),
layout: getComputedStyle(block.querySelector('.instructions-layout')).gridTemplateColumns.split(' ').length,
...css(block, 'paddingTop', 'paddingBottom', 'borderTopWidth', 'borderTopColor'),
label: css(block.querySelector('label'), 'fontSize', 'fontWeight', 'color'),
textarea: css(block.querySelector('textarea'), 'fontSize', 'backgroundColor', 'borderTopColor') })(
surface.querySelector(':scope > .setting-block[data-setting="tag-instructions"]')),
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]) })),
@@ -132,21 +103,13 @@ function measureTagging(mainSelector) {
* 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, { builtin, saved, phone }) {
function mapTaggingConcept(reference, { template, phone }) {
const [model, existing, general, character] = reference.rows;
const [preview] = reference.actions.buttons;
// D203: Built-in first, then the saved entries, in each default select.
const names = ['Built-in', ...saved.map(entry => entry.name)];
const defaultRow = (engine, long) => ({ ...existing,
text: [['label', `Default for ${long}`, `instruction-default-${engine}`], ['p', DEFAULT_DESCRIPTION, null]],
control: { ...existing.control, id: `instruction-default-${engine}`, options: names.map(label => [label, false]),
selected: names[1] } });
return {
...reference,
// D133 adds a row after the thresholds, D202 the Skipped tags block after it; D203 replaces the template field
// with Saved instructions, the two default rows and their note; D134 adds the operation after the actions.
tree: [...reference.tree.slice(0, 6), 'div.setting-row', 'div.setting-block', 'div.setting-block', 'div.setting-row',
'div.setting-row', 'p.instruction-reset.small', reference.tree[7], 'div.operation'],
// 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]],
@@ -157,49 +120,16 @@ function mapTaggingConcept(reference, { builtin, saved, phone }) {
// 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' } },
// D203: the default of each language-model engine (the fixture's saved entry is both), on the concept's
// select row.
defaultRow('gguf', 'GGUF JoyCaption'), defaultRow('hf', 'HuggingFace JoyCaption'),
],
// D203: Saved instructions in the concept's setting-row tokens (as D202), Built-in selected and read-only.
instructions: { tree: ['div.setting-block-text', 'div.instructions-layout'], key: 'autotag_instructions',
text: [['label', 'Saved instructions'], ['p', `${INSTRUCTIONS_DESCRIPTION} ${saved.length} of 20 saved · Built\u2011in is not counted.`]],
list: [['Built-in', [['Read-only', 'chip']], 'true'],
...saved.map((entry, index) => [entry.name, index === 0
? [['Default for GGUF', 'chip blue'], ['Default for HuggingFace', 'chip blue']] : [], 'false'])],
listRole: 'listbox',
actions: [['+ New', '', false], ['Duplicate', '', false], ['Delete', 'danger', true]],
editor: [['Name', 'Built-in', true], ['Text', builtin, true]],
note: 'Built-in instructions can’t be changed. Duplicate them to make your own.',
layout: phone ? 1 : 2,
paddingTop: reference.row.paddingTop, paddingBottom: reference.row.paddingBottom,
borderTopWidth: reference.row.borderTopWidth, borderTopColor: reference.row.borderTopColor,
label: reference.label,
textarea: { fontSize: reference.field.textarea.fontSize, backgroundColor: reference.field.textarea.backgroundColor,
borderTopColor: reference.field.textarea.borderTopColor } },
// D202 (#431): the Skipped tags block in the concept's setting-row tokens: its label and description as a row's,
// the row's padding and rule above, the concept's field input; an empty list says so.
skipped: { tree: ['div.setting-block-text', 'div.skip-add', 'p.skip-problem.small', 'div.skip-chips.tags', 'p.skip-preview.small'],
key: 'autotag_skipped_tags',
text: [['label', 'Skipped tags', 'tag-skipped-input'], ['p', SKIPPED_DESCRIPTION, null]],
add: [['input', 'tag-skipped-input', 'Add a word or words', ''], ['button', 'tag-skipped-add', null, 'Add']],
preview: ['No tags are skipped.', 'status'],
paddingTop: reference.row.paddingTop, paddingBottom: reference.row.paddingBottom,
borderTopWidth: reference.row.borderTopWidth, borderTopColor: reference.row.borderTopColor,
label: reference.label,
description: { fontSize: reference.description.fontSize, color: reference.description.color,
marginTop: reference.description.marginTop },
input: { fontSize: '12px', backgroundColor: reference.field.textarea.backgroundColor, color: reference.field.textarea.color,
borderTopColor: reference.field.textarea.borderTopColor } },
// D203: no template field.
field: null,
// 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 (#398): every select shows its whole chosen option at every width (at 390 the concept's 120px cap cuts
// them; production lets each grow past it).
selectsFit: [...reference.selectsFit, true, true].map(() => true),
selectsFit: reference.selectsFit.map(() => true),
};
}
@@ -223,9 +153,7 @@ async function productionTagging(page, theme) {
async function savedTemplate(request, page) {
const response = await request.get('/prompt_manager/settings');
await expect(response).toBeOK();
const { settings } = await response.json();
return { builtin: settings.autotag_builtin_instructions, saved: settings.autotag_instructions,
phone: page.viewportSize().width <= 700 };
return { template: (await response.json()).settings.autotag_template, phone: page.viewportSize().width <= 700 };
}
for (const theme of THEMES) for (const [width, height] of SIZES) {
@@ -249,15 +177,14 @@ for (const theme of THEMES) for (const width of [390, 320]) {
await page.locator('#workspace-main #tag-existing').selectOption('replace_after_review');
await page.locator('#workspace-main section.surface').screenshot({ path: testInfo.outputPath(`production-tagging-selects-${theme}-${width}.png`) });
const selects = page.locator('#workspace-main section.surface > .setting-row > select');
// D203 adds the two per-engine default selects.
await expect(selects).toHaveCount(4);
for (let index = 0; index < 4; index += 1) {
await expect(selects).toHaveCount(2);
for (let index = 0; index < 2; index += 1) {
const select = selects.nth(index);
const values = await select.evaluate(node => [...node.options].map(option => option.value));
for (const value of values) {
await select.selectOption(value);
const measured = await page.evaluate(measureTagging, '#workspace-main');
expect(measured.selectsFit, `${await select.getAttribute('id')}=${value}`).toEqual([true, true, true, true]);
expect(measured.selectsFit, `${await select.getAttribute('id')}=${value}`).toEqual([true, true]);
expect(measured.pageScroll, value).toBe(false);
}
}
@@ -271,16 +198,12 @@ test('auto-tagging comparison detects mutations', async ({ page, request }) => {
const expected = mapTaggingConcept(concept, saved);
const production = await productionTagging(page, 'dark');
expect(diffInventory(expected, production)).toEqual([]);
// Unmapped, the section differs only where D132, D133, D134, D202 and D203 are recorded (and the saved template's text).
// (The concept has no Skipped tags block, so every path under it is one D202 difference.)
expect(diffInventory(concept, production).map(item => (/^(skipped|instructions|field)\b/.test(item.path) ? item.path.split('.')[0]
: item.path.split('.').slice(0, 3).join('.')))
// 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', 'instructions', 'operations.0.actions', 'operations.0.description',
'operations.0.title', 'operations.0.tree', 'operations.length', 'rows.0.control', 'rows.0.text',
...[4, 5, 6].flatMap(row => ['control', 'controlBelow', 'flexWrap', 'text', 'tree'].map(key => `rows.${row}.${key}`)),
'rows.length', 'selectsFit.2', 'selectsFit.3', 'selectsFit.length', 'skipped',
...Array.from({ length: 8 }, (_, index) => `tree.${index + 6}`), 'tree.length'].sort());
'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.
@@ -331,8 +254,8 @@ test('auto-tagging at 390 wraps only the engine row', async ({ page, request })
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', 'nowrap', 'nowrap']);
expect(production.selectsFit).toEqual([true, true, true, true]);
expect(production.rows.map(row => row.flexWrap)).toEqual(['wrap', 'nowrap', 'nowrap', 'nowrap', 'nowrap']);
expect(production.selectsFit).toEqual([true, true]);
});
/** Runs in the page: the open review dialog: its title, body and the concept's tokens. */
+2 -11
View File
@@ -92,8 +92,6 @@ export function cardActionsInventory(card) {
};
}
// #431: the fixture's saved "Describe the image" entry, the default of both engines (workspace_tagging_fixture.py).
export const FIXTURE_INSTRUCTIONS_ID='d35c71be0a1e';
// D84 (plan D61): the legacy engines' values, labels and sizes, GGUF JoyCaption in the concept's Ollama slot.
const MODEL_LABELS=Object.freeze({'wd14-swinv2':['wd14-swinv2','WD14 SwinV2 · ~400 MB'],'wd14-vit':['wd14-vit','WD14 ViT · ~400 MB'],
ollama:['gguf','GGUF JoyCaption (Recommended) · ~4.5 GB'],huggingface:['hf','HuggingFace JoyCaption · ~16 GB']});
@@ -108,24 +106,17 @@ const CONCEPT_LABELS=Object.freeze({'WD14 SwinV2 · fast classifier':'WD14 SwinV
* (`chip`: a concept `.chip` inventory, the heading's size chip) around the row's own text (12px) in the checked
* row's colours (accent border, `--soft-blue`);
* - D84 (plan D61): the legacy engine labels and values, and the server's default instructions (`instructions`);
* - D204 (#431): a "Saved instructions" select (`picker`: its value and options) before the instructions, on the
* concept's select field tokens;
* - D91 (plan D68): a Suggestions count field after the model's own fields, on the concept's number field tokens
* (`numberField`: the concept's General confidence field) with the configured count (`limit`);
* - D29: the prototype's "Sample suggestions from …" status reads "Suggestions from …".
* The opened state is compared unmapped: both show the concept's on-screen "Generate suggestions with the selected
* model before approving." (its renderTagModelOptions, a1062–1076, replaces the markup's sentence at once).
*/
export function mapConceptTagging(reference,{instructions,limit=10,chip,numberField,
picker={value:FIXTURE_INSTRUCTIONS_ID,options:[['builtin','Built-in'],[FIXTURE_INSTRUCTIONS_ID,`${instructions} · default`]]}}) {
export function mapConceptTagging(reference,{instructions,limit=10,chip,numberField}) {
const [select,...own]=reference.modelPanel.fields;
// D204 (#431): a "Saved instructions" select on the concept's select field, before the instructions.
const pickField={...select,label:'Saved instructions',control:{...select.control,id:'image-tag-instructions-pick',
value:picker.value,options:picker.options}};
const suggestionsField={...numberField,label:'Suggestions',control:{...numberField.control,id:'image-suggestion-limit',
value:String(limit),min:'1',max:'100',step:'1'}};
const mappedOwn=own.flatMap(field=>field.control.id==='image-tag-instructions'
?[pickField,{...field,control:{...field.control,value:instructions}}]:[field]);
const mappedOwn=own.map(field=>field.control.id==='image-tag-instructions'?{...field,control:{...field.control,value:instructions}}:field);
const rows=reference.suggestions.rows.map(row=>({...row,
control:row.control?.kind==='checkbox'&&row.control.checked?{kind:'remove',label:`Remove ${row.name}`}:row.control,
borderTopLeftRadius:chip.borderTopLeftRadius}));
@@ -156,9 +156,8 @@ test('at 390px the window fits the screen, actions stay reachable and the page s
expect(frame.bottom-frame.top).toBeLessThan(844);
expect(frame.bottom-frame.top).toBeGreaterThanOrEqual(280);
expect(await page.evaluate(()=>document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
// The first page's last row's actions can be scrolled to inside the window and fit its width.
// Pinned to row 100: reaching it loads the next page (D183), and last() would then follow row 200.
const last=rows(page).nth(99).getByRole('button',{name:'Rename / merge',exact:true});
// The last row's actions can be scrolled to inside the window and fit its width.
const last=rows(page).last().getByRole('button',{name:'Rename / merge',exact:true});
await last.scrollIntoViewIfNeeded();await expect(last).toBeInViewport();
const [button]=await boxes(last);const [after]=await boxes(tagWindow(page));
expect(button.right).toBeLessThanOrEqual(after.right);
+1 -1
View File
@@ -233,7 +233,7 @@ test('a view stored under older rules still lists and opens with its usable valu
// D153 (#447): the workspace no longer offers "Highest rated"; the API still stores such a view as it was saved.
test('a view saved under Highest rated opens on Recently added on Images and the Library', async ({ page, request }) => {
const images = await create(request, { page: 'images', name: 'Rated cats', state: { ...IMAGE_DEFAULTS, q: 'cat', sort: 'rating' } });
await create(request, { page: 'library', name: 'Rated dogs', state: { q: 'dog', category: '', sort: 'rating', tags: [], tag_mode: 'all', folder: '' } });
const library = await create(request, { page: 'library', name: 'Rated dogs', state: { q: 'dog', category: '', sort: 'rating', tags: [], tag_mode: 'all', folder: '' } });
expect((await listed(request)).map(view => view.state.sort)).toEqual(['rating', 'rating']);
const listings = [];
page.on('request', item => {
@@ -60,7 +60,7 @@ async function openReview(page, request, items = 30) {
const inViewport = (rect, page) => rect.top >= 0 && rect.bottom <= page.viewportSize().height;
test.beforeEach(async ({ page, browserName }) => { await startCoverage(page, browserName); });
test.afterEach(async ({ page, browserName }) => { await saveCoverage(page, browserName); });
test.afterEach(async ({ page, browserName }, testInfo) => { await saveCoverage(page, browserName, testInfo); });
for (const viewport of [null, PHONE]) {
const label = viewport ? ' at 390px' : '';
@@ -11,6 +11,7 @@ const REPORT_URL = '/prompt_manager/system/diagnostics';
const LOGS_URL = '/prompt_manager/logs';
const CONFIG_URL = '/prompt_manager/logs/config';
const PREVIEW_URL = '/prompt_manager/maintenance/preview';
const APPLY_URL = '/prompt_manager/maintenance/apply';
const TEST_LINK_URL = '/prompt_manager/diagnostics/test-link';
const CHANGED = 'The log files changed since the review; nothing was deleted. Review again.';
const KEY = 'sk-s12-browser-SECRET-0123456789';
+3 -157
View File
@@ -112,14 +112,13 @@ test('model options are the four real engines with size and the description show
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', 'Default for GGUF JoyCaption',
'Default for HuggingFace JoyCaption']);
'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');
// D203 (#431): Saved instructions replace the template field.
await expect(page.locator('#tag-template')).toHaveCount(0);
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();
});
@@ -419,156 +418,3 @@ test('a model that is not downloaded cannot be reviewed for a batch; the button
await expect(dialog(page)).toHaveCount(0);
expect(page.modelCalls.filter(call => call.includes('/batch/preview'))).toEqual([]);
});
// D202 (#431): Skipped tags: add (button and Enter), the preview over the library's tags and the WD14 label list, the
// per-chip counts, remove, Save preferences and a reload; a list the server refuses highlights the block.
const SKIPPED_PREVIEW = '/prompt_manager/autotag/skipped-tags/preview';
const skippedBlock = page => surface(page).locator(':scope > .setting-block[data-setting="tag-skipped"]');
const skippedInput = page => page.locator('#tag-skipped-input');
const skippedChips = page => skippedBlock(page).locator('.skip-chips .chip.tag-suggestion');
const skippedPreview = page => skippedBlock(page).locator('.skip-preview');
test('skipped tags add, preview, remove, save and reload (D202)', async ({ page, request }) => {
await openTagging(page);
await expect(skippedBlock(page).locator('label')).toHaveText('Skipped tags');
await expect(skippedBlock(page)).toHaveAttribute('data-key', 'autotag_skipped_tags');
await expect(skippedPreview(page)).toHaveText('No tags are skipped.');
await expect(skippedPreview(page)).toHaveAttribute('role', 'status');
// The label list on disk (blurry, blurry_background, signature, …) and the library tags (warm_light, …).
let answer = page.waitForResponse(item => item.url().endsWith(SKIPPED_PREVIEW));
await skippedInput(page).fill('blurry');
await skippedInput(page).press('Enter');
expect((await answer).status()).toBe(200);
await expect(skippedInput(page)).toHaveValue('');
await expect(skippedPreview(page)).toHaveText('Hides 2 tags from suggestions: blurry, blurry_background. '
+ 'Tags already on images are not changed.');
answer = page.waitForResponse(item => item.url().endsWith(SKIPPED_PREVIEW));
await skippedInput(page).fill('light');
await page.getByRole('button', { name: 'Add', exact: true }).click();
await answer;
await expect(skippedPreview(page)).toHaveText('Hides 3 tags from suggestions: warm_light, blurry, blurry_background. '
+ 'Tags already on images are not changed.');
await expect(skippedChips(page)).toHaveText(['blurry2×', 'light1×']);
await expect(skippedChips(page).first()).toHaveAttribute('title', 'Hides 2 tags');
// An entry that hides nothing is muted; a duplicate is not added and the line under the input says so.
answer = page.waitForResponse(item => item.url().endsWith(SKIPPED_PREVIEW));
await skippedInput(page).fill('zebra');
await skippedInput(page).press('Enter');
await answer;
await expect(skippedChips(page).last()).toHaveClass(/\bnone\b/);
await expect(skippedChips(page).last()).toHaveAttribute('title', 'Hides no known tag');
await skippedInput(page).fill('Blurry');
await skippedInput(page).press('Enter');
await expect(page.locator('#tag-skipped-problem')).toHaveText('Already in the list.');
await expect(skippedInput(page)).toHaveAttribute('aria-invalid', 'true');
await skippedInput(page).fill('a, b');
await skippedInput(page).press('Enter');
await expect(page.locator('#tag-skipped-problem')).toHaveText('No commas or line breaks.');
// Remove zebra, then save.
await skippedChips(page).last().getByRole('button', { name: 'Remove zebra', exact: true }).click();
await expect(skippedChips(page)).toHaveCount(2);
await expect(status(page)).toHaveText('Unsaved changes.');
await save(page).click();
await expect(status(page)).toHaveText('Saved to the server configuration.');
expect((await serverJson(request, SETTINGS_URL)).settings.autotag_skipped_tags).toEqual(['blurry', 'light']);
await openTagging(page);
await expect(skippedChips(page)).toHaveText(['blurry2×', 'light1×']);
// Removing a chip and adding it back is no change.
await skippedChips(page).last().getByRole('button', { name: 'Remove light', exact: true }).click();
await expect(status(page)).toHaveText('Unsaved changes.');
await skippedInput(page).fill('light');
await skippedInput(page).press('Enter');
await expect(status(page)).toHaveText('No unsaved changes.');
});
test('skipped tags preview checks library tags only without a label list, and a refused save highlights it', async ({ page, request }) => {
await expect(await request.post('/__test__/tagging/defaults', { data: { general: 0.35, character: 0.85, limit: 10,
labels: false } })).toBeOK();
await openTagging(page);
const answer = page.waitForResponse(item => item.url().endsWith(SKIPPED_PREVIEW));
await skippedInput(page).fill('blurry');
await skippedInput(page).press('Enter');
await answer;
await expect(skippedPreview(page)).toHaveText('Hides 0 tags from suggestions. Checked against library tags only; no WD14 '
+ 'label list is downloaded. Tags already on images are not changed.');
// The server is the authority: a list it refuses keeps the draft, says why and highlights the block.
await page.route(`**${SETTINGS_URL}`, route => (route.request().method() === 'POST'
? route.fulfill({ status: 400, contentType: 'application/json',
body: JSON.stringify({ success: false, error: 'Skipped tags: entry 1 is empty.' }) })
: route.fallback()));
await save(page).click();
await expect(status(page)).toHaveText('Skipped tags: entry 1 is empty.');
await expect(skippedBlock(page)).toHaveAttribute('aria-invalid', 'true');
await page.unrouteAll({ behavior: 'ignoreErrors' });
});
// D203 (#431): Saved instructions: Built-in read-only, New, edit, per-engine defaults, Delete resets a default, Save
// preferences and a reload; the server pairs the two keys. D205: the batch review names the engine's default.
const instructionOption = (page, name) => page.locator('#instruction-list [role="option"]').filter({ has: page.locator(`span:text-is("${name}")`) });
test('saved instructions: new, edit, per-engine default, delete, save and reload (D203)', async ({ page, request }) => {
await openTagging(page);
const settings = (await serverJson(request, SETTINGS_URL)).settings;
await expect(page.locator('#instructions-count')).toHaveText('1 of 20 saved · Built\u2011in is not counted.');
await expect(page.locator('#instruction-list [role="option"] > span:first-child')).toHaveText(['Built-in', 'Describe the image']);
await expect(page.locator('#instruction-name')).toHaveValue('Built-in');
await expect(page.locator('#instruction-text')).toHaveValue(settings.autotag_builtin_instructions);
await expect(page.locator('#instruction-text')).toHaveAttribute('readonly', '');
await expect(page.locator('#instruction-delete')).toBeDisabled();
await expect(page.locator('#instruction-default-gguf')).toHaveValue(settings.autotag_instructions[0].id);
// + New, then name and text it, and make it HuggingFace's default.
await page.locator('#instruction-new').click();
await expect(page.locator('#instruction-name')).toHaveValue('New instructions');
await expect(page.locator('#instruction-note')).toHaveText('The text must not be empty.');
await page.locator('#instruction-name').fill('Photo realism');
await page.locator('#instruction-text').fill('Tag camera angle and lighting.');
await expect(page.locator('#instruction-text').locator('xpath=..').locator('label .muted')).toHaveText('30 of 4,000 characters');
await page.locator('#instruction-default-hf').selectOption({ label: 'Photo realism' });
await expect(instructionOption(page, 'Photo realism').locator('.chip')).toHaveText(['Default for HuggingFace']);
await expect(status(page)).toHaveText('Unsaved changes.');
await save(page).click();
await expect(status(page)).toHaveText('Saved to the server configuration.');
let saved = (await serverJson(request, SETTINGS_URL)).settings;
expect(saved.autotag_instructions.map(entry => [entry.name, entry.text])).toEqual([['Describe the image', 'Describe the image'],
['Photo realism', 'Tag camera angle and lighting.']]);
expect(saved.autotag_default_instructions.hf).toBe(saved.autotag_instructions[1].id);
// A reload shows the saved list; deleting GGUF's default resets it to Built-in and says so.
await openTagging(page);
await instructionOption(page, 'Describe the image').click();
await page.locator('#instruction-delete').click();
await expect(page.locator('#instruction-reset')).toHaveText('GGUF now uses Built-in.');
await expect(page.locator('#instruction-default-gguf')).toHaveValue('builtin');
await save(page).click();
await expect(status(page)).toHaveText('Saved to the server configuration.');
saved = (await serverJson(request, SETTINGS_URL)).settings;
expect(saved.autotag_instructions.map(entry => entry.name)).toEqual(['Photo realism']);
expect(saved.autotag_default_instructions.gguf).toBe('builtin');
expect(saved.autotag_template).toBe(saved.autotag_builtin_instructions);
});
test('a refused instructions save names the entry and highlights the block (D203)', async ({ page }) => {
await openTagging(page);
await page.locator('#instruction-duplicate').click();
await page.locator('#instruction-name').fill('describe the IMAGE');
await expect(page.locator('#instruction-note')).toHaveText('The name “describe the IMAGE” is already used.');
await save(page).click();
await expect(status(page)).toHaveText('Saved instructions: entry 2 name “describe the IMAGE” is already used.');
await expect(surface(page).locator('.setting-block[data-setting="tag-instructions"]')).toHaveAttribute('aria-invalid', 'true');
});
test('the batch review names the language model\'s default instructions (D205)', async ({ page }) => {
await openTagging(page);
await page.locator('#tag-model').selectOption('gguf');
await page.locator('#tag-existing').selectOption('merge');
const answer = page.waitForResponse(item => item.url().endsWith(PREVIEW_URL));
await reviewButton(page).click();
await answer;
await expect(dialog(page).locator('#modal-title')).toHaveText('Review tag output images');
const grid = await gridOf(page);
expect(grid.slice(-3)).toEqual([['Instructions', 'Describe the image'], ['Settings', 'Up to 10 tags per image'],
grid.at(-1)]);
// The batch sends the default's text.
const start = page.waitForRequest(item => item.url().endsWith('/prompt_manager/jobs/autotag'));
await dialog(page).getByRole('button', { name: 'Start tagging', exact: true }).click();
expect((await start).postDataJSON().options).toEqual({ instructions: 'Describe the image' });
});
+6 -31
View File
@@ -8,8 +8,6 @@ import { readCoverageTable, parseControl, CONTROL_EXEMPTIONS } from '../../tools
const workspace = '/prompt_manager/workspace';
const OVERVIEW_URL = '/prompt_manager/system/overview';
const THUMBNAILS_URL = '/prompt_manager/images/thumbnails/status';
// The sidebar's Images count (state.mjs NAV_COUNT_SOURCES): the Images page's own total.
const IMAGES_COUNT_URL = '/prompt_manager/images/output?mode=workspace&limit=1';
const number = value => value.toLocaleString('en-US');
test.beforeEach(async ({ page, request, browserName }) => {
@@ -57,7 +55,6 @@ test('overview shows real counts and Not checked', async ({ page, request }) =>
page.on('request', item => { if (!['GET', 'HEAD', 'OPTIONS'].includes(item.method())) writes.push(item.url()); });
const overview = await serverJson(request, OVERVIEW_URL);
const thumbnails = await serverJson(request, THUMBNAILS_URL);
const images = (await serverJson(request, IMAGES_COUNT_URL)).total;
// The concept fixture: six positives, their saved negatives and 24 linked images, all with thumbnails.
expect([overview.library.positive_prompts, overview.images.indexed, thumbnails.pending]).toEqual([6, 24, 0]);
// I2: the Overview never walks the sources for thumbnails; the row reads the status route itself.
@@ -69,8 +66,7 @@ test('overview shows real counts and Not checked', async ({ page, request }) =>
[item.querySelector('strong').textContent, item.querySelector('span').textContent]))).toEqual([
[number(overview.library.positive_prompts), 'Positive prompts'],
[number(overview.library.negative_variants), 'Negative variants'],
// #555: the Images page's own count, the same number as its sidebar badge.
[number(images), 'Images'],
[number(overview.images.indexed), 'Images'],
['Not checked', 'Database integrity'],
]);
expect(await rowsOf(layout, 'Library health')).toEqual([
@@ -83,31 +79,11 @@ test('overview shows real counts and Not checked', async ({ page, request }) =>
expect(await rowsOf(layout, 'Workspace preferences')).toEqual([
['One display configuration', 'Grid and density settings are shared across views.', 'Appearance'],
['2 output sources', 'Monitoring and enrichment share one source list.', 'Manage sources'],
['Statistics', 'Runs by category, with database record counts.', 'Explore'],
['Statistics', 'Runs by category, with library totals.', 'Explore'],
]);
expect(writes).toEqual([]);
});
test('#555: the Images metric is the sidebar Images count, not the indexed image rows', async ({ page, request }) => {
const overview = await serverJson(request, OVERVIEW_URL);
// The Images page lists files no row links (the demo library: 122 files, 110 rows); the listing says so.
const listed = overview.images.indexed + 12;
await page.route(`**${IMAGES_COUNT_URL}`, async route => {
const response = await route.fetch();
await route.fulfill({ response, json: { ...(await response.json()), total: listed } });
});
const layout = await openOverview(page);
const imagesMetric = layout.locator('.summary-metrics > .metric').filter({ has: page.locator('span', { hasText: /^Images$/ }) });
await expect(imagesMetric.locator('strong')).toHaveText(number(listed));
await expect(page.locator('#primary-nav a[data-route="images"] .nav-count')).toHaveText(number(listed));
// A failed count reads as a dash, never the row count.
await page.unroute(`**${IMAGES_COUNT_URL}`);
await page.route(`**${IMAGES_COUNT_URL}`, route => route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } }));
await page.goto(`${workspace}#/system/appearance`);
await page.locator('#workspace-main .section-nav').getByRole('button', { name: 'Overview', exact: true }).click();
await expect(imagesMetric.locator('strong')).toHaveText('–');
});
// D129 (#249 S6b): where the library lives. The hook makes the fixture's database read as a node-folder library; its
// Move library is a stand-in that touches no file (workspace_system_fixture.py).
const banner = page => page.locator('#library-banner');
@@ -388,7 +364,7 @@ test('View backups / Appearance / Manage sources switch sections', async ({ page
}
});
test('statistics dialog lists runs by category and database record counts', async ({ page, request }) => {
test('statistics dialog lists runs by category and library totals', async ({ page, request }) => {
const statistics = await serverJson(request, '/prompt_manager/system/statistics');
expect(statistics.by_category.length).toBeGreaterThan(1);
const layout = await openOverview(page);
@@ -397,14 +373,13 @@ test('statistics dialog lists runs by category and database record counts', asyn
const dialog = page.locator('dialog#modal[open]');
await expect(dialog.locator('#modal-title')).toHaveText('Usage statistics');
const tables = dialog.locator('#modal-body > table');
await expect(tables.locator('caption')).toHaveText(['Runs by category', 'Database records']);
await expect(tables.locator('caption')).toHaveText(['Runs by category', 'Library totals']);
// D153 (#447): no rating column and no rated-images total.
await expect(tables.first().locator('thead th')).toHaveText(['Category', 'Runs']);
const cells = table => table.locator('tbody tr').evaluateAll(rows => rows.map(row => [...row.cells].map(cell => cell.textContent)));
expect(await cells(tables.first())).toEqual(statistics.by_category.map(row => [row.category ?? 'Uncategorized', number(row.runs)]));
// #555: raw rows of the legacy /stats, labelled as rows so they never read as the Library or Images page counts.
expect(await cells(tables.nth(1))).toEqual([['Prompt records', number(statistics.totals.total_prompts)],
['Tags on prompts', number(statistics.totals.total_tags)], ['Image records', number(statistics.totals.total_images)]]);
expect(await cells(tables.nth(1))).toEqual([['Prompts', number(statistics.totals.total_prompts)],
['Tags', number(statistics.totals.total_tags)], ['Images', number(statistics.totals.total_images)]]);
await page.keyboard.press('Escape');
await expect(explore).toBeFocused();
// A statistics read that fails says so; the Overview stays.
@@ -341,21 +341,3 @@ test('a running scan makes the start report busy', async ({ page, request }) =>
await request.post(`/__test__/jobs/${scan.job.id}/finish`, { data: { state: 'finished' } });
}
});
// D204 (#431): Auto-tag selected has the same picker; the job sends the text the dialog shows.
test('Auto-tag selected picks saved instructions and sends the text shown (D204)', async ({ page }) => {
await select(page, FIRST);
await barButton(page, 'Auto-tag selected').click();
await expect(title(page)).toHaveText('Auto-tag selected images');
const body = modal(page).locator('#modal-body');
await body.getByRole('combobox', { name: 'Tagging model', exact: true }).selectOption('gguf');
const pick = body.getByRole('combobox', { name: 'Saved instructions', exact: true });
await expect(pick).toHaveAttribute('id', 'bulk-tag-instructions-pick');
await expect(pick.locator('option')).toHaveText(['Built-in', 'Describe the image · default']);
const text = body.getByRole('textbox', { name: 'Tagging instructions', exact: true });
await text.fill('Bulk one-off.');
await expect(pick.locator('option:checked')).toHaveText('Edited for this run');
const started = jobStart(page);
await actionButtons(page).filter({ hasText: 'Start auto-tagging' }).click();
expect((await started).postDataJSON().options.instructions).toBe('Bulk one-off.');
});
+1 -59
View File
@@ -4,7 +4,7 @@
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
import { saveImageTags } from './concept-inventory.mjs';
import { FIXTURE_INSTRUCTIONS_ID, taggingInventory } from './tagging-canonical-comparison.mjs';
import { taggingInventory } from './tagging-canonical-comparison.mjs';
const images = '/prompt_manager/workspace#/images';
const MODULE = '/prompt_manager/js/workspace/image-tagging.mjs';
@@ -849,61 +849,3 @@ test('a failed listing re-read after a save says so and keeps the cards (M2)', a
await expect(page.locator('#gallery-continuation [aria-disabled="true"]')).toHaveCount(0);
await page.unroute(failing);
});
// #431: the saved Skipped tags hide matching suggestions before the limit (WD14 fills it from lower scores), and the
// chip hint says how many were not suggested.
test('suggestions omit skipped tags and say how many were hidden', async ({ page, request }) => {
await expect(await request.post('/__test__/tagging/defaults', { data: { general: 0.35, character: 0.85, limit: 4,
skipped: ['desert', 'rock'] } })).toBeOK();
await openTag(page);
const answer = await (await generated(page)).json();
expect(answer.skipped).toBe(2);
await expect(chips(page).locator(':scope > span:first-child')).toHaveText(['sandstone', 'arch', 'sky', 'dune']);
await expect(dialog(page).locator('#tag-suggestions > p.small')).toHaveText(`${CHIP_HINT} 2 skipped tags were not suggested.`);
});
// D204 (#431): the Saved instructions select above Tagging instructions: the engine default, a pick, a one-off edit
// ("Edited for this run" and the muted note), an engine switch resets, WD14 hides both; Generate sends the text shown.
test('the saved instructions picker fills, edits one-off and resets per engine (D204)', async ({ page, request }) => {
const settings = await (await request.get('/prompt_manager/settings')).json();
const builtin = settings.settings.autotag_builtin_instructions;
await openTag(page);
const options = dialog(page).locator('#image-tag-model-options');
const pick = options.getByRole('combobox', { name: 'Saved instructions', exact: true });
const text = options.getByRole('textbox', { name: 'Tagging instructions', exact: true });
const note = options.locator('.instructions-edited');
await expect(pick).toHaveCount(0);
await model(page).selectOption('gguf');
await expect(pick.locator('option')).toHaveText(['Built-in', 'Describe the image · default']);
await expect(pick).toHaveAttribute('id', 'image-tag-instructions-pick');
await expect(text).toHaveValue('Describe the image');
await expect(note).toBeHidden();
// A keyboard pick (the select focused) keeps focus on the select the fields are re-rendered with.
await pick.focus();
await pick.selectOption('builtin');
await expect(text).toHaveValue(builtin);
await expect(pick).toBeFocused();
await text.fill('Only the sky.');
await expect(text).toHaveAttribute('aria-describedby', 'image-tag-instructions-pick-note');
await expect(pick.locator('option:checked')).toHaveText('Edited for this run');
await expect(pick.locator('option[value="edited"]')).toBeDisabled();
await expect(note).toHaveText('Edits apply to this run only. Manage saved instructions in System › Auto-tagging.');
await expect(text).toBeFocused();
// Generate sends the edited text; the saved entries are unchanged.
const sent = page.waitForRequest(item => item.url().endsWith('/image-annotations/suggest'));
await generate(page).click();
expect((await sent).postDataJSON().options.instructions).toBe('Only the sky.');
// Choosing an entry replaces the edit; switching engines starts from the new engine's default.
await pick.selectOption('builtin');
await expect(text).toHaveValue(builtin);
await expect(note).toBeHidden();
await text.fill('Edited again.');
await model(page).selectOption('hf');
await expect(text).toHaveValue('Describe the image');
await expect(pick).toHaveValue(FIXTURE_INSTRUCTIONS_ID);
await model(page).selectOption('wd14-vit');
await expect(pick).toHaveCount(0);
await expect(text).toHaveCount(0);
expect((await (await request.get('/prompt_manager/settings')).json()).settings.autotag_instructions)
.toEqual(settings.settings.autotag_instructions);
});
+3 -4
View File
@@ -145,10 +145,9 @@ test('long vocabularies append and Load more is keyboard reachable',async({page,
await expect(loadMore(page)).toHaveCount(0);
await expect(rows(page).nth(200).getByRole('button',{name:'Browse',exact:true})).toBeFocused();
await expect(rows(page).locator('td:nth-child(1)')).toHaveText(manifest.tags);
// Open the screen as a new history entry, not a reload: Firefox restores a reloaded page's scrolled tag window, and
// its end then appends a page before the check (#567). Scrolling the window to its top first does not always stick.
await page.goto('about:blank');
await page.goto(route);
// Reload with the window at its top: Firefox restores a scrolled window, whose end then appends a page before the check.
await main(page).locator('.two-col .table-wrap').evaluate(node=>new Promise(resolve=>{node.scrollTop=0;requestAnimationFrame(()=>requestAnimationFrame(resolve));}));
await page.reload();
await expect(rows(page)).toHaveCount(100);
// D183: the end of the list arrives inside the tag window.
const scroll=top=>main(page).locator('.two-col .table-wrap').evaluate((node,y)=>new Promise(resolve=>{node.scrollTop=y ?? node.scrollHeight;requestAnimationFrame(()=>requestAnimationFrame(resolve));}),top);
+6 -74
View File
@@ -226,7 +226,6 @@ def register_tagging_commands(
return download["result"]
_set_defaults(model, DEFAULTS) # the server starts on the standard ones
_write_labels(model, True)
@routes.post("/__test__/tagging/reset")
async def reset(request: web.Request) -> web.Response:
@@ -245,7 +244,6 @@ def register_tagging_commands(
model.loaded = None
restore_download()
_set_defaults(model, DEFAULTS)
await api._run_in_executor(_write_labels, model, True)
# 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.
await api._run_in_executor(api.close_annotation_captures)
@@ -355,22 +353,13 @@ def register_tagging_commands(
``{"general": g, "character": c, "limit": n}``: the WD14 thresholds
(in [0, 1]) and the persisted suggestion count (1..100), so a test can
tell the server's values from the dialog's built-in ones. Optional
``skipped`` sets the saved Skipped tags (#431) and ``labels`` (true or
false) writes or removes the fake WD14 SwinV2 label list the Skipped
tags preview reads. Reset restores the standard 0.35 / 0.85 / 10, no
skipped tags and the label list.
tell the server's values from the dialog's built-in ones. Reset
restores the standard 0.35 / 0.85 / 10.
"""
data = await request.json() if request.can_read_body else None
if request.query or not _valid_defaults(data):
raise web.HTTPBadRequest()
_set_defaults(model, (data["general"], data["character"], data["limit"]))
if "skipped" in data:
from py.config import AutotagConfig
AutotagConfig.SKIPPED_TAGS = tuple(data["skipped"])
if "labels" in data:
await api._run_in_executor(_write_labels, model, data["labels"])
return web.json_response({"success": True})
@routes.get("/__test__/tagging/captures")
@@ -391,15 +380,10 @@ DOWNLOAD_HOLD_LIMIT_S = 60
DEFAULTS = (0.35, 0.85, 10)
# The saved "Describe the image" entry's id (#431).
FIXTURE_INSTRUCTIONS_ID = "d35c71be0a1e"
def _set_defaults(model, values) -> None:
"""Set the saved tagging defaults (``AutotagConfig``, #328); a reset also
puts back the saved model, batch policy, skipped tags and instructions
(the fake model's short instructions, which the concept comparisons
show)."""
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
@@ -409,48 +393,7 @@ def _set_defaults(model, values) -> None:
if values == DEFAULTS:
AutotagConfig.MODEL_TYPE = "wd14-swinv2"
AutotagConfig.EXISTING = "skip"
# #431: Built-in is always the shipped text, so the fake model's short
# instructions are a saved entry, the default of both engines (the
# concept comparisons show it).
AutotagConfig.INSTRUCTIONS = (
{
"id": FIXTURE_INSTRUCTIONS_ID,
"name": model.default_prompt,
"text": model.default_prompt,
},
)
AutotagConfig.DEFAULT_INSTRUCTIONS = {
"gguf": FIXTURE_INSTRUCTIONS_ID,
"hf": FIXTURE_INSTRUCTIONS_ID,
}
AutotagConfig._LEGACY_TEMPLATE = None
AutotagConfig.SKIPPED_TAGS = ()
# The fake WD14 SwinV2 label list (general 0, character 4, rating 9): the
# names the Skipped tags preview checks beside the library's tags (#431).
FAKE_LABELS = (
("blurry", "0"),
("blurry_background", "0"),
("signature", "0"),
("watermark", "0"),
("t-shirt", "0"),
("half-closed_eyes", "0"),
("saber_(fate)", "4"),
("general", "9"),
)
def _write_labels(model, present: bool) -> None:
"""Write (or remove) the fake label list under the fake models folder."""
path = Path(model.models_dir) / "WD14-SwinV2" / "selected_tags.csv"
if not present:
path.unlink(missing_ok=True)
return
path.parent.mkdir(parents=True, exist_ok=True)
lines = ["tag_id,name,category,count"]
lines += [f"{i},{name},{cat},1" for i, (name, cat) in enumerate(FAKE_LABELS)]
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
AutotagConfig.TEMPLATE = model.default_prompt
def _valid_defaults(data: object) -> bool:
@@ -459,20 +402,9 @@ def _valid_defaults(data: object) -> bool:
and not isinstance(value, bool)
and 0 <= value <= 1
)
optional = {"skipped", "labels"}
if isinstance(data, dict) and "skipped" in data:
try:
from py.tagging_defaults import validate_skipped_tags
validate_skipped_tags(data["skipped"])
except ValueError:
return False
if isinstance(data, dict) and not isinstance(data.get("labels", True), bool):
return False
return (
isinstance(data, dict)
and {"general", "character", "limit"} <= set(data)
and set(data) <= {"general", "character", "limit"} | optional
and set(data) == {"general", "character", "limit"}
and unit(data["general"])
and unit(data["character"])
and type(data["limit"]) is int
-5
View File
@@ -105,7 +105,6 @@
"POST /prompt_manager/autotag/batch/preview",
"POST /prompt_manager/autotag/download/{model_type}",
"POST /prompt_manager/autotag/single",
"POST /prompt_manager/autotag/skipped-tags/preview",
"POST /prompt_manager/autotag/start",
"POST /prompt_manager/autotag/unload",
"POST /prompt_manager/bulk/category",
@@ -185,14 +184,10 @@
"monitor.scheduled",
"revision",
"settings",
"settings.autotag_builtin_instructions",
"settings.autotag_character_threshold",
"settings.autotag_default_instructions",
"settings.autotag_existing",
"settings.autotag_general_threshold",
"settings.autotag_instructions",
"settings.autotag_model_type",
"settings.autotag_skipped_tags",
"settings.autotag_suggestion_limit",
"settings.autotag_template",
"settings.cpu_count",
+10
View File
@@ -21,6 +21,14 @@ function jsonResponse(status, body) {
json: async () => body };
}
function sseResponse(frames) {
const bytes = new TextEncoder().encode(frames.map(frame => `data: ${JSON.stringify(frame)}\n\n`).join(''));
let sent = false;
return { ok: true, status: 200, body: { getReader: () => ({
read: async () => (sent ? { done: true } : (sent = true, { done: false, value: bytes })),
}) } };
}
function load(file, className, fetchUrl, extra = {}, dom = elements()) {
const timers = [];
const notifications = [];
@@ -55,6 +63,8 @@ function load(file, className, fetchUrl, extra = {}, dom = elements()) {
return { page, dom, timers, notifications, storage };
}
const BUSY = 'Scan for metadata already running';
test('admin settings save shows why config.json refused it', async () => {
const message = 'config.json cannot be read, so nothing was saved. Fix or remove the file, then save again.';
const { page, notifications } = load('admin.js', 'PromptAdmin',
+3 -106
View File
@@ -53,12 +53,8 @@ function observed(overrides = {}, capture = {}) {
...overrides,
};
}
// #431: the saved instructions; the fixture's entry is the default of both engines.
const SAVED_ENTRY = { id: '0123456789ab', name: 'Saved', text: DEFAULT_PROMPT };
const models = (overrides = {}) => ({
success: true, models: { 'wd14-swinv2': { downloaded: true } },
instructions: [{ id: 'builtin', name: 'Built-in', text: 'Built-in text.' }, SAVED_ENTRY],
default_instructions: { gguf: SAVED_ENTRY.id, hf: SAVED_ENTRY.id },
success: true, models: { 'wd14-swinv2': { downloaded: true } }, default_prompt: DEFAULT_PROMPT,
wd14_general_threshold: 0.35, wd14_character_threshold: 0.85, suggestion_limit: 10, ...overrides,
});
const SANDSTONE = [
@@ -248,13 +244,10 @@ test('dialog starts on the saved model unless a preset or the user chose one', a
});
test('an unreadable or malformed models read falls back to the documented defaults', async () => {
for (const route of [() => fail(500, null), () => response(200, models({ instructions: 4, suggestion_limit: 0, wd14_general_threshold: 2, wd14_character_threshold: 'x' }))]) {
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 });
assert.equal(c.state.phase, 'ready');
// #431: the client's Built-in copy (D204's read failure).
const { BUILTIN_INSTRUCTIONS } = await load();
assert.deepEqual({ ...c.state.options }, { general_threshold: 0.35, character_threshold: 0.85, instructions: BUILTIN_INSTRUCTIONS,
suggestion_limit: 10 });
assert.deepEqual({ ...c.state.options }, { general_threshold: 0.35, character_threshold: 0.85, instructions: '', suggestion_limit: 10 });
}
});
@@ -943,99 +936,3 @@ test('#515: text typed before Retry after a failed first observation follows the
assert.equal(c.state.manual, 'Minimal, warm_light, dunes');
assert.equal(c.state.statusText, '');
});
test('the chip hint says how many skipped tags were not suggested (#431)', async () => {
const { skippedNote } = await load();
assert.equal(skippedNote(0), '');
assert.equal(skippedNote(undefined), '');
assert.equal(skippedNote(1), ' 1 skipped tag was not suggested.');
assert.equal(skippedNote(2), ' 2 skipped tags were not suggested.');
for (const [skipped, hint] of [[2, 'Remove anything you do not want to add. 2 skipped tags were not suggested.'],
[0, 'Remove anything you do not want to add.'], ['3', 'Remove anything you do not want to add.']]) {
const { c } = await opened({ [`POST ${SUGGEST}`]: () => response(200, { ...suggested(), skipped }) });
await c.generate();
assert.equal(c.state.chipHint, hint, String(skipped));
}
});
// D204 (#431): the Saved instructions picker of both dialogs.
const PHOTO_ENTRY = { id: '0123456789ac', name: 'Photo', text: 'Photo tags.' };
const picker = (overrides = {}) => ({ [`GET ${MODELS}`]: () => response(200, models({ model_type: 'gguf',
instructions: [{ id: 'builtin', name: 'Built-in', text: 'Built-in text.' }, SAVED_ENTRY, PHOTO_ENTRY],
default_instructions: { gguf: SAVED_ENTRY.id, hf: PHOTO_ENTRY.id }, ...overrides })) });
test('the picker starts at the engine default; a pick replaces the text and an edit is one-off (D204)', async () => {
const { c } = await opened(picker());
assert.deepEqual([c.state.instructionId, c.state.options.instructions, c.state.instructionsEdited], [SAVED_ENTRY.id, DEFAULT_PROMPT, false]);
const revision = c.state.optionsRevision;
c.pickInstructions(PHOTO_ENTRY.id);
assert.deepEqual([c.state.instructionId, c.state.options.instructions, c.state.instructionsEdited], [PHOTO_ENTRY.id, 'Photo tags.', false]);
assert.equal(c.state.optionsRevision, revision + 1, 'a pick re-renders the textarea');
c.setOption('instructions', 'Photo tags, please.');
assert.equal(c.state.instructionsEdited, true);
assert.equal(c.state.optionsRevision, revision + 1, 'typing never re-renders');
// Choosing an entry again replaces the edit without asking; an unknown id changes nothing.
c.pickInstructions(PHOTO_ENTRY.id);
assert.deepEqual([c.state.options.instructions, c.state.instructionsEdited], ['Photo tags.', false]);
c.pickInstructions('nope');
assert.equal(c.state.instructionId, PHOTO_ENTRY.id);
// Switching engines resets to the new engine's default and drops a one-off edit; WD14 is never "edited".
c.setOption('instructions', 'edited');
c.setModel('hf');
assert.deepEqual([c.state.instructionId, c.state.options.instructions, c.state.instructionsEdited], [PHOTO_ENTRY.id, 'Photo tags.', false]);
c.setModel('wd14-vit');
assert.equal(c.state.instructionsEdited, false);
c.setModel('gguf');
assert.deepEqual([c.state.instructionId, c.state.options.instructions], [SAVED_ENTRY.id, DEFAULT_PROMPT]);
c.dispose();
});
test('a preset shows the entry with its text, else "Edited for this run" (D204)', async () => {
const { c } = await opened(picker(), { preset: { model: 'hf', options: { instructions: DEFAULT_PROMPT } } });
assert.deepEqual([c.state.instructionId, c.state.instructionsEdited], [SAVED_ENTRY.id, false]);
const { c: other } = await opened(picker(), { preset: { model: 'hf', options: { instructions: 'Something else' } } });
assert.deepEqual([other.state.instructionId, other.state.options.instructions, other.state.instructionsEdited],
[PHOTO_ENTRY.id, 'Something else', true]);
for (const controller of [c, other]) controller.dispose();
});
test('a models read without instructions offers Built-in only with the client copy (D204)', async () => {
const { BUILTIN_INSTRUCTIONS, readChoices } = await load();
assert.equal(BUILTIN_INSTRUCTIONS.startsWith('Generate only comma-separated Danbooru tags'), true);
for (const value of [null, {}, { instructions: [] }, { instructions: [{ id: 'x', name: 'X', text: 'y' }] }]) {
assert.deepEqual(readChoices(value).entries.map(entry => entry.id), ['builtin'], JSON.stringify(value));
}
// A default naming no entry is Built-in.
assert.deepEqual({ ...readChoices({ instructions: [{ id: 'builtin', name: 'Built-in', text: 't' }], default_instructions: { gguf: 'gone' } }).defaults },
{ gguf: 'builtin', hf: 'builtin' });
const { c } = await opened(picker({ instructions: 'broken' }));
assert.deepEqual([c.state.instructionId, c.state.options.instructions], ['builtin', BUILTIN_INSTRUCTIONS]);
c.dispose();
});
test('the bulk options pick and reset like the Tag image dialog (D204)', async () => {
const { createTaggingOptionsController } = await load();
const options = createTaggingOptionsController({ api: harness(defaultRoutes(picker())).api });
await options.open();
assert.deepEqual([options.state.model, options.state.instructionId, options.state.options.instructions], ['gguf', SAVED_ENTRY.id, DEFAULT_PROMPT]);
options.pickInstructions(PHOTO_ENTRY.id);
assert.equal(options.request().options.instructions, 'Photo tags.');
options.setOption('instructions', 'One-off.');
assert.equal(options.state.instructionsEdited, true);
assert.equal(options.request().options.instructions, 'One-off.');
options.setModel('hf');
assert.deepEqual([options.state.instructionId, options.state.instructionsEdited], [PHOTO_ENTRY.id, false]);
options.pickInstructions('nope');
options.dispose();
options.pickInstructions(SAVED_ENTRY.id);
assert.equal(options.state.instructionId, PHOTO_ENTRY.id);
});
test('the client Built-in copy equals the server DEFAULT_TAG_TEMPLATE (#431)', async () => {
const { BUILTIN_INSTRUCTIONS } = await load();
const source = require('node:fs').readFileSync(require('node:path').join(__dirname, '../../py/tagging_defaults.py'), 'utf8');
const block = source.slice(source.indexOf('DEFAULT_TAG_TEMPLATE = ('), source.indexOf('\n)\n', source.indexOf('DEFAULT_TAG_TEMPLATE = (')));
const text = [...block.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map(([, part]) => JSON.parse(`"${part}"`)).join('');
assert.ok(text.length > 100);
assert.equal(BUILTIN_INSTRUCTIONS, text);
});
@@ -180,9 +180,7 @@ test('the bulk options start from the server defaults and wait for them before S
assert.equal(options.state.canStart, false);
assert.equal(options.state.canGenerate, false);
const opening = options.open();
answer({ wd14_general_threshold: 0.5, wd14_character_threshold: 0.9, suggestion_limit: 7,
instructions: [{ id: 'builtin', name: 'Built-in', text: 'Built-in text.' }, { id: '0123456789ab', name: 'Say', text: 'Say it' }],
default_instructions: { gguf: '0123456789ab', hf: 'builtin' },
answer({ wd14_general_threshold: 0.5, wd14_character_threshold: 0.9, default_prompt: 'Say it', suggestion_limit: 7,
models: { 'wd14-swinv2': { downloaded: true }, 'wd14-vit': { downloaded: false }, gguf: { downloaded: true } } });
await opening;
assert.equal(options.state.canStart, true);
@@ -1,257 +0,0 @@
// System › Auto-tagging "Saved instructions" (#431, D203): Built-in read-only, New, Duplicate, Delete (a deleted default
// resets to Built-in), the caps, the editor drafts and the per-engine default selects.
const test = require('node:test');
const assert = require('node:assert/strict');
const instructionsModule = import('../../web/js/workspace/system-instructions.mjs');
const stateModule = import('../../web/js/workspace/system-state.mjs');
const coreModule = import('../../web/js/shared/toolkit/core.js');
/** A minimal DOM: elements with children, attributes, listeners (aborted by their signal) and focus. */
function fakeDOM() {
const previous = global.document;
class Element {
constructor(tag) {
this.tagName = tag.toUpperCase();
this.children = [];
this.attributes = {};
this.listeners = [];
this.dataset = {};
this.className = '';
this.id = '';
this.value = '';
this.title = '';
this.disabled = false;
this._text = '';
}
get localName() { return this.tagName.toLowerCase(); }
get textContent() { return this._text + this.children.map(child => (typeof child === 'string' ? child : child.textContent)).join(''); }
set textContent(text) { this.children = []; this._text = String(text); }
append(...nodes) { for (const node of nodes) { if (typeof node !== 'string') node.parentNode = this; this.children.push(node); } }
replaceChildren(...nodes) { this.children = []; this._text = ''; this.append(...nodes); }
querySelectorAll(selector) {
assert.equal(selector, 'button');
const found = [];
const walk = node => { for (const child of node.children) if (typeof child !== 'string') { if (child.tagName === 'BUTTON') found.push(child); walk(child); } };
walk(this);
return found;
}
focus() { global.document.activeElement = this; }
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); });
}
dispatch(type, event = {}) {
return Promise.all(this.listeners.filter(item => item.type === type).map(({ callback }) => callback({ type, target: this, ...event })));
}
}
global.document = { createElement: tag => new Element(tag), activeElement: null };
const walk = (node, found = []) => { found.push(node); node.children.forEach(child => typeof child !== 'string' && walk(child, found)); return found; };
return { find: (root, predicate) => walk(root).find(predicate), restore() { global.document = previous; } };
}
const PHOTO = { id: '0123456789ab', name: 'Custom template', text: 'Photo tags.' };
const ANIME = { id: '0123456789ac', name: 'Anime', text: 'Anime tags.' };
const HOST = { autotag_builtin_instructions: 'Built-in text.', autotag_instructions: [PHOTO, ANIME],
autotag_default_instructions: { gguf: PHOTO.id, hf: PHOTO.id } };
async function mounted({ host = HOST } = {}) {
const { savedInstructionsBlock } = await instructionsModule;
const { createSystemDrafts } = await stateModule;
const { createScope } = await coreModule;
const dom = fakeDOM();
const drafts = createSystemDrafts({
readBrowser: () => ({}), readRuntime: () => ({}), readHost: async () => ({ values: host, revision: 'sha256:one' }),
writeBrowser: async () => ({}), writeHost: async () => ({ success: true, persisted: true }), writeRuntime: async () => ({}),
});
const scope = createScope();
let counter = 0;
const random = bytes => bytes.map(() => (counter += 1));
const block = savedInstructionsBlock({ scope, drafts, random });
const root = global.document.createElement('div');
root.append(...block.nodes);
scope.cleanup(drafts.subscribe(block.repaint));
block.repaint();
await drafts.reloadHost();
const byId = id => dom.find(root, node => node.id === id);
const options = () => byId('instruction-list').children.map(option => [option.children[0].textContent,
option.children[1]?.children.map(badge => badge.textContent) ?? [], option.getAttribute('aria-selected')]);
const click = id => byId(id).dispatch('click');
// A click on an option reaches the list's one listener (as it bubbles in a browser).
const pick = name => byId('instruction-list').dispatch('click', { target: byId('instruction-list').children
.find(option => option.children[0].textContent === name).children[0] });
return { dom, drafts, scope, block, byId, options, click, pick, root };
}
test('helpers: Built-in first, defaults, free names, ids and the server sentences', async () => {
const { allEntries, defaultEntry, freeName, newId, listProblem, entryNote } = await instructionsModule;
assert.deepEqual(allEntries(HOST).map(entry => entry.name), ['Built-in', 'Custom template', 'Anime']);
assert.equal(allEntries(HOST)[0].text, 'Built-in text.');
assert.equal(defaultEntry(HOST, 'hf').text, 'Photo tags.');
assert.equal(defaultEntry({ ...HOST, autotag_default_instructions: { gguf: 'gone', hf: 'builtin' } }, 'gguf').name, 'Built-in');
assert.equal(freeName('New instructions', allEntries(HOST)), 'New instructions');
assert.equal(freeName('anime', allEntries(HOST)), 'anime (2)');
assert.equal(freeName('Anime', [...allEntries(HOST), { name: 'Anime (2)' }]), 'Anime (3)');
let calls = 0;
const id = newId([{ id: '000000000000' }], bytes => { calls += 1; return bytes.fill(calls === 1 ? 0 : 171); });
assert.equal(id, 'abababababab');
assert.match(newId([]), /^[0-9a-f]{12}$/);
const list = entries => listProblem({ autotag_instructions: entries });
assert.equal(list([PHOTO]), null);
assert.equal(list([{ ...PHOTO, name: ' ' }]), 'Saved instructions: entry 1 needs a name.');
assert.equal(list([PHOTO, { ...ANIME, name: 'x'.repeat(61) }]), 'Saved instructions: entry 2 name must be at most 60 characters.');
assert.equal(list([{ ...PHOTO, name: 'a\nb' }]), 'Saved instructions: entry 1 name must not contain a line break.');
assert.equal(list([{ ...PHOTO, name: 'built-in' }]), 'Saved instructions: the name “Built-in” is reserved.');
assert.equal(list([PHOTO, { ...ANIME, name: ' custom TEMPLATE ' }]), 'Saved instructions: entry 2 name “custom TEMPLATE” is already used.');
assert.equal(list([{ ...PHOTO, text: ' ' }]), 'Saved instructions: entry 1 text must be non-empty text.');
assert.equal(list([{ ...PHOTO, text: 'x'.repeat(4001) }]), 'Saved instructions: entry 1 text must be at most 4000 characters.');
const long = { ...PHOTO, text: 'x'.repeat(4001) };
assert.equal(entryNote(long, [long]), 'The text must be at most 4,000 characters.');
});
test('the list shows Built-in read-only first, the saved entries and their default chips', async () => {
const view = await mounted();
try {
assert.deepEqual(view.options(), [['Built-in', ['Read-only'], 'true'],
['Custom template', ['Default for GGUF', 'Default for HuggingFace'], 'false'], ['Anime', [], 'false']]);
assert.equal(view.byId('instructions-count').textContent, '2 of 20 saved · Built‑in is not counted.');
assert.equal(view.byId('instruction-name').value, 'Built-in');
assert.equal(view.byId('instruction-name').readOnly, true);
assert.equal(view.byId('instruction-text').value, 'Built-in text.');
assert.equal(view.byId('instruction-text').readOnly, true);
assert.equal(view.byId('instruction-note').textContent, 'Built-in instructions can’t be changed. Duplicate them to make your own.');
assert.equal(view.byId('instruction-delete').disabled, true);
assert.equal(view.byId('instruction-list').getAttribute('role'), 'listbox');
assert.equal(view.block.nodes[0].dataset.key, 'autotag_instructions');
assert.deepEqual(view.byId('instruction-default-gguf').children.map(option => option.textContent), ['Built-in', 'Custom template', 'Anime']);
assert.equal(view.byId('instruction-default-gguf').value, PHOTO.id);
assert.equal(view.byId('instruction-default-hf').dataset.key, 'autotag_default_instructions');
} finally { view.scope.dispose(); view.dom.restore(); }
});
test('editing a saved entry drafts the list; its counts and note follow', async () => {
const view = await mounted();
try {
await view.pick('Anime');
assert.equal(view.byId('instruction-name').readOnly, false);
assert.equal(view.byId('instruction-delete').disabled, false);
view.byId('instruction-name').value = 'Anime tags';
await view.byId('instruction-name').dispatch('input');
view.byId('instruction-text').value = 'Tags, anime.';
await view.byId('instruction-text').dispatch('input');
assert.deepEqual(view.drafts.get('host', 'autotag_instructions'), [PHOTO, { ...ANIME, name: 'Anime tags', text: 'Tags, anime.' }]);
assert.equal(view.byId('instruction-name').parentNode.children[0].children[1].textContent, '10 of 60');
assert.equal(view.byId('instruction-text').parentNode.children[0].children[1].textContent, '12 of 4,000 characters');
view.byId('instruction-name').value = 'custom template';
await view.byId('instruction-name').dispatch('input');
assert.equal(view.byId('instruction-note').textContent, 'The name “custom template” is already used.');
// Built-in is never edited.
await view.pick('Built-in');
view.byId('instruction-text').value = 'changed';
await view.byId('instruction-text').dispatch('input');
assert.equal(view.drafts.get('host', 'autotag_instructions')[0], PHOTO);
} finally { view.scope.dispose(); view.dom.restore(); }
});
test('New and Duplicate append an entry with a fresh id and a free name; the caps disable them', async () => {
const view = await mounted();
try {
await view.click('instruction-new');
const added = view.drafts.get('host', 'autotag_instructions').at(-1);
assert.match(added.id, /^[0-9a-f]{12}$/);
assert.deepEqual([added.name, added.text], ['New instructions', '']);
assert.equal(view.options().at(-1)[2], 'true');
assert.equal(view.byId('instruction-note').textContent, 'The text must not be empty.');
await view.pick('Built-in');
await view.click('instruction-duplicate');
assert.deepEqual(view.drafts.get('host', 'autotag_instructions').at(-1).name, 'Built-in copy');
assert.equal(view.drafts.get('host', 'autotag_instructions').at(-1).text, 'Built-in text.');
const full = await mounted({ host: { ...HOST, autotag_instructions: Array.from({ length: 20 },
(_, i) => ({ id: i.toString(16).padStart(12, '0'), name: `n${i}`, text: 't' })) } });
try {
assert.equal(full.byId('instruction-new').disabled, true);
assert.equal(full.byId('instruction-duplicate').disabled, true);
assert.equal(full.byId('instruction-cap').hidden, false);
await full.click('instruction-new');
assert.equal(full.drafts.get('host', 'autotag_instructions').length, 20);
} finally { full.scope.dispose(); }
} finally { view.scope.dispose(); view.dom.restore(); }
});
test('deleting a default resets that engine to Built-in and says so; a select change clears the note', async () => {
const view = await mounted();
try {
await view.pick('Custom template');
await view.click('instruction-delete');
assert.deepEqual(view.drafts.get('host', 'autotag_instructions'), [ANIME]);
assert.deepEqual(view.drafts.get('host', 'autotag_default_instructions'), { gguf: 'builtin', hf: 'builtin' });
assert.equal(view.byId('instruction-reset').textContent, 'GGUF and HuggingFace now use Built-in.');
assert.equal(view.options()[1][2], 'true', 'the next entry is selected');
view.byId('instruction-default-hf').value = ANIME.id;
await view.byId('instruction-default-hf').dispatch('change');
assert.deepEqual(view.drafts.get('host', 'autotag_default_instructions'), { gguf: 'builtin', hf: ANIME.id });
assert.equal(view.byId('instruction-reset').textContent, '');
await view.click('instruction-delete');
assert.equal(view.byId('instruction-reset').textContent, 'HuggingFace now uses Built-in.');
assert.deepEqual(view.options().map(([name]) => name), ['Built-in']);
} finally { view.scope.dispose(); view.dom.restore(); }
});
test('arrow keys move the selection; before the read everything is disabled', async () => {
const view = await mounted();
try {
const list = view.byId('instruction-list');
await list.dispatch('keydown', { key: 'ArrowDown' });
assert.equal(view.byId('instruction-name').value, 'Custom template');
await list.dispatch('keydown', { key: 'End' });
assert.equal(view.byId('instruction-name').value, 'Anime');
await list.dispatch('keydown', { key: 'ArrowDown' });
assert.equal(view.byId('instruction-name').value, 'Anime');
await list.dispatch('keydown', { key: 'Home' });
assert.equal(view.byId('instruction-name').value, 'Built-in');
await list.dispatch('keydown', { key: 'a' });
assert.equal(list.getAttribute('aria-activedescendant'), 'instruction-option-builtin');
} finally { view.scope.dispose(); view.dom.restore(); }
const unread = await mounted({ host: {} });
try {
assert.equal(unread.byId('instruction-new').disabled, true);
assert.equal(unread.byId('instruction-default-gguf').disabled, true);
} finally { unread.scope.dispose(); unread.dom.restore(); }
});
test('the reset note goes once the defaults are saved; a long name is cut to fit its copy suffix', async () => {
const view = await mounted();
try {
await view.pick('Custom template');
await view.click('instruction-delete');
assert.equal(view.byId('instruction-reset').textContent, 'GGUF and HuggingFace now use Built-in.');
await view.drafts.save();
assert.equal(view.byId('instruction-reset').textContent, '');
await view.pick('Anime');
view.byId('instruction-name').value = 'x'.repeat(60);
await view.byId('instruction-name').dispatch('input');
await view.click('instruction-duplicate');
const copy = view.drafts.get('host', 'autotag_instructions').at(-1).name;
assert.equal(copy, `${'x'.repeat(55)} copy`);
await view.click('instruction-duplicate');
assert.equal([...view.drafts.get('host', 'autotag_instructions').at(-1).name].length <= 60, true);
} finally { view.scope.dispose(); view.dom.restore(); }
});
test('option clicks are one listener on the list, not one per repaint', async () => {
const view = await mounted();
try {
const list = view.byId('instruction-list');
const before = list.listeners.length;
for (let i = 0; i < 5; i += 1) await view.pick('Anime');
assert.equal(list.listeners.length, before);
assert.equal(view.byId('instruction-list').children.every(option => option.listeners.length === 0), true);
assert.equal(view.byId('instruction-name').value, 'Anime');
} finally { view.scope.dispose(); view.dom.restore(); }
});
+2 -12
View File
@@ -16,22 +16,12 @@ const OVERVIEW = Object.freeze({
test('metrics are the server counts and Not checked before any health check', async () => {
const { metricValues } = await overviewModule;
assert.deepEqual(metricValues(OVERVIEW, 24), [
assert.deepEqual(metricValues(OVERVIEW), [
[(1234).toLocaleString(), 'Positive prompts'], ['7', 'Negative variants'], ['24', 'Images'],
['Not checked', 'Database integrity'],
]);
});
test('#555: the Images metric is the Images page count (the sidebar badge), not the indexed rows', async () => {
const { metricValues } = await overviewModule;
// The demo library: 110 indexed rows, 122 files on the Images page (unlinked ones included).
const overview = { ...OVERVIEW, images: { indexed: 110, rated: 0 } };
assert.deepEqual(metricValues(overview, 122)[2], [(122).toLocaleString(), 'Images']);
// Not read yet (undefined) or failed (null): a dash, as on the workspace Overview, never the row count.
assert.deepEqual(metricValues(overview, undefined)[2], ['–', 'Images']);
assert.deepEqual(metricValues(overview, null)[2], ['–', 'Images']);
});
test('metric 4 is the last health result', async () => {
const { integrityText } = await overviewModule;
assert.equal(integrityText(null), 'Not checked');
@@ -160,7 +150,7 @@ test('statistics rows are the real runs, then the library totals, without rating
rated_images: 20,
};
assert.deepEqual(categoryRows(statistics), [['Landscapes', (1200).toLocaleString()], ['Uncategorized', '3']]);
assert.deepEqual(totalsRows(statistics), [['Prompt records', '12'], ['Tags on prompts', '15'], ['Image records', '24']]);
assert.deepEqual(totalsRows(statistics), [['Prompts', '12'], ['Tags', '15'], ['Images', '24']]);
});
/** A fake api answering each `get`/`post` from a queue per URL, recording the calls. */
@@ -1,247 +0,0 @@
// System › Auto-tagging "Skipped tags" (#431, D202): the add input, the chips, the client checks and the preview line.
const test = require('node:test');
const assert = require('node:assert/strict');
const skippedModule = import('../../web/js/workspace/system-skipped-tags.mjs');
const stateModule = import('../../web/js/workspace/system-state.mjs');
const coreModule = import('../../web/js/shared/toolkit/core.js');
/** A minimal DOM: elements with children, attributes, listeners (aborted by their signal) and focus. */
function fakeDOM() {
const previous = global.document;
class Element {
constructor(tag) {
this.tagName = tag.toUpperCase();
this.children = [];
this.attributes = {};
this.listeners = [];
this.dataset = {};
this.className = '';
this.id = '';
this.value = '';
this.title = '';
this.disabled = false;
this._text = '';
}
get localName() { return this.tagName.toLowerCase(); }
get textContent() { return this._text + this.children.map(child => (typeof child === 'string' ? child : child.textContent)).join(''); }
set textContent(text) { this.children = []; this._text = String(text); }
append(...nodes) { for (const node of nodes) { if (typeof node !== 'string') node.parentNode = this; this.children.push(node); } }
replaceChildren(...nodes) { this.children = []; this._text = ''; this.append(...nodes); }
querySelectorAll(selector) {
assert.equal(selector, 'button');
const found = [];
const walk = node => { for (const child of node.children) if (typeof child !== 'string') { if (child.tagName === 'BUTTON') found.push(child); walk(child); } };
walk(this);
return found;
}
focus() { global.document.activeElement = this; }
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); });
}
dispatch(type, event = {}) {
return Promise.all(this.listeners.filter(item => item.type === type).map(({ callback }) => callback({ type, target: this, ...event })));
}
}
global.document = { createElement: tag => new Element(tag), activeElement: null };
const walk = (node, found = []) => { found.push(node); node.children.forEach(child => typeof child !== 'string' && walk(child, found)); return found; };
return { find: (root, predicate) => walk(root).find(predicate), restore() { global.document = previous; } };
}
const tick = () => new Promise(resolve => setTimeout(resolve, 5));
async function mounted({ saved = ['blurry'], answer, host = true } = {}) {
const { skippedTagsBlock } = await skippedModule;
const { createSystemDrafts } = await stateModule;
const { createScope } = await coreModule;
const dom = fakeDOM();
const drafts = createSystemDrafts({
readBrowser: () => ({}), readRuntime: () => ({}),
readHost: async () => ({ values: host ? { autotag_skipped_tags: saved } : {}, revision: 'sha256:one' }),
writeBrowser: async () => ({}), writeHost: async () => ({ success: true, persisted: true }), writeRuntime: async () => ({}),
});
const scope = createScope();
const posts = [];
const api = {
post: (url, body, options) => {
posts.push({ url, body, signal: options.signal });
if (answer) return answer(body, options);
return Promise.resolve({ success: true, count: body.entries.length, sample: body.entries, sample_limit: 12,
per_entry: body.entries.map((entry, index) => ({ entry, count: index })), sources: { library: 3, wd14: ['WD14 SwinV2'] } });
},
};
const block = skippedTagsBlock({ api, scope, drafts, delay: 1 });
scope.cleanup(drafts.subscribe(block.repaint));
block.repaint();
await drafts.reloadHost();
const byId = id => dom.find(block.node, node => node.id === id);
const chips = () => dom.find(block.node, node => node.className === 'tags skip-chips');
const preview = () => dom.find(block.node, node => node.className === 'small skip-preview');
const add = async text => { byId('tag-skipped-input').value = text; await byId('tag-skipped-add').dispatch('click'); };
return { dom, drafts, scope, block, byId, chips, preview, posts, add };
}
test('words split like the server: NFC, lower case, space, _ and - only', async () => {
const { entryWords } = await skippedModule;
assert.deepEqual(entryWords('Half-closed_eyes'), ['half', 'closed', 'eyes']);
assert.deepEqual(entryWords('saber_(fate)'), ['saber', '(fate)']);
assert.deepEqual(entryWords(' _ - '), []);
assert.deepEqual(entryWords('CAFÉ'), ['café']);
});
test('the client checks say why an entry is not added', async () => {
const { entryProblem, SKIPPED_COPY } = await skippedModule;
assert.equal(entryProblem(' sky ', ['blurry']), '');
assert.equal(entryProblem(' ', []), SKIPPED_COPY.empty);
assert.equal(entryProblem('_--', []), 'Enter a word.');
assert.equal(entryProblem('a, b', []), 'No commas or line breaks.');
assert.equal(entryProblem('a\nb', []), 'No commas or line breaks.');
assert.equal(entryProblem('x'.repeat(51), []), 'At most 50 characters.');
assert.equal(entryProblem('🏜'.repeat(50), []), '');
assert.equal(entryProblem('Blurry-Background', ['blurry background']), 'Already in the list.');
assert.equal(entryProblem('sky', Array.from({ length: 200 }, (_, i) => `w${i}`)), 'The list is full (200).');
});
test('the preview line states the count, the sample and the sources', async () => {
const { previewText, chipCount } = await skippedModule;
assert.deepEqual(previewText({ count: 15, sample: ['blurry', 'signature'], sources: { wd14: ['WD14 SwinV2'] } }), {
lead: 'Hides 15 tags from suggestions', rest: ': blurry, signature and 13 more. Tags already on images are not changed.' });
assert.deepEqual(previewText({ count: 1, sample: ['blurry'], sources: { wd14: [] } }), {
lead: 'Hides 1 tag from suggestions',
rest: ': blurry. Checked against library tags only; no WD14 label list is downloaded. Tags already on images are not changed.' });
assert.deepEqual(previewText({ count: 0, sample: [], sources: { wd14: ['WD14 ViT'] } }), {
lead: 'Hides 0 tags from suggestions', rest: '. Tags already on images are not changed.' });
assert.deepEqual(previewText(null).lead, 'Hides 0 tags from suggestions');
assert.deepEqual(chipCount(3), { title: 'Hides 3 tags', text: '3' });
assert.deepEqual(chipCount(1), { title: 'Hides 1 tag', text: '1' });
assert.deepEqual(chipCount(0), { title: 'Hides no known tag', text: 'none' });
assert.equal(chipCount(undefined), null);
});
test('the block shows the saved list, previews it and carries its setting keys', async () => {
const view = await mounted({ saved: ['blurry', 'signature'] });
try {
assert.equal(view.block.node.dataset.setting, 'tag-skipped');
assert.equal(view.block.node.dataset.key, 'autotag_skipped_tags');
assert.equal(view.preview().textContent, 'Checking…');
await tick();
assert.deepEqual(view.posts.map(post => [post.url, post.body]),
[['/prompt_manager/autotag/skipped-tags/preview', { entries: ['blurry', 'signature'] }]]);
assert.equal(view.preview().textContent, 'Hides 2 tags from suggestions: blurry, signature. Tags already on images are not changed.');
const chips = view.chips().children;
assert.deepEqual(chips.map(chip => [chip.className, chip.title, chip.textContent]), [
['chip tag-suggestion none', 'Hides no known tag', 'blurrynone×'],
['chip tag-suggestion', 'Hides 1 tag', 'signature1×']]);
assert.equal(chips[1].children[2].getAttribute('aria-label'), 'Remove signature');
} finally { view.scope.dispose(); view.dom.restore(); }
});
test('add and remove draft the list; a list set back is clean; Enter adds', async () => {
const view = await mounted();
try {
await view.add(' closed eyes ');
assert.deepEqual(view.drafts.get('host', 'autotag_skipped_tags'), ['blurry', 'closed eyes']);
assert.equal(view.byId('tag-skipped-input').value, '');
assert.equal(view.drafts.dirty(), true);
view.byId('tag-skipped-input').value = 'sky';
let prevented = false;
await view.byId('tag-skipped-input').dispatch('keydown', { key: 'Enter', preventDefault: () => { prevented = true; } });
assert.equal(prevented, true);
await view.byId('tag-skipped-input').dispatch('keydown', { key: 'a' });
assert.deepEqual(view.drafts.get('host', 'autotag_skipped_tags'), ['blurry', 'closed eyes', 'sky']);
// Remove the two added chips: the list equals the saved one and nothing is unsaved (sameDraft).
const removeButton = index => view.chips().children[index].children.at(-1);
removeButton(2).focus();
await removeButton(2).dispatch('click');
assert.equal(global.document.activeElement, removeButton(1), 'focus moves to the neighbour');
await removeButton(1).dispatch('click');
assert.deepEqual(view.drafts.get('host', 'autotag_skipped_tags'), ['blurry']);
assert.equal(view.drafts.dirty(), false);
await tick();
// Only the last list of a quick series is asked for.
assert.deepEqual(view.posts.map(post => post.body.entries), [['blurry']]);
} finally { view.scope.dispose(); view.dom.restore(); }
});
test('a refused entry is not added and the line under the input says why', async () => {
const view = await mounted();
try {
const problem = () => view.byId('tag-skipped-problem').textContent;
await view.add('Blurry');
assert.equal(problem(), 'Already in the list.');
assert.equal(view.byId('tag-skipped-input').getAttribute('aria-invalid'), 'true');
assert.equal(view.byId('tag-skipped-input').value, 'Blurry');
await view.byId('tag-skipped-input').dispatch('input');
assert.equal(problem(), '');
assert.equal(view.byId('tag-skipped-input').getAttribute('aria-invalid'), null);
await view.add('a,b');
assert.equal(problem(), 'No commas or line breaks.');
await view.add(' ');
assert.equal(problem(), 'Enter a word.');
assert.deepEqual(view.drafts.get('host', 'autotag_skipped_tags'), ['blurry']);
} finally { view.scope.dispose(); view.dom.restore(); }
});
test('a full list disables Add and says so', async () => {
const view = await mounted({ saved: Array.from({ length: 200 }, (_, i) => `w${i}`) });
try {
assert.equal(view.byId('tag-skipped-add').disabled, true);
assert.equal(view.byId('tag-skipped-problem').textContent, 'The list is full (200).');
await view.chips().children[0].children.at(-1).dispatch('click');
assert.equal(view.byId('tag-skipped-add').disabled, false);
assert.equal(view.byId('tag-skipped-problem').textContent, '');
} finally { view.scope.dispose(); view.dom.restore(); }
});
test('an empty list says no tags are skipped without a request', async () => {
const view = await mounted({ saved: [] });
try {
await tick();
assert.equal(view.preview().textContent, 'No tags are skipped.');
assert.deepEqual(view.posts, []);
} finally { view.scope.dispose(); view.dom.restore(); }
});
test('a failed preview says so; a newer list aborts the older request', async () => {
let calls = 0;
const view = await mounted({ answer: (body, options) => {
calls += 1;
if (calls === 1) return new Promise((_resolve, reject) => options.signal.addEventListener('abort', () => reject(new Error('aborted'))));
return Promise.reject(Object.assign(new Error('busy'), { code: 'busy' }));
} });
try {
await tick();
assert.equal(view.posts.length, 1);
await view.add('sky');
assert.equal(view.posts[0].signal.aborted, true);
await tick();
assert.equal(view.preview().textContent, 'The preview could not be read.');
// The list stays editable.
assert.equal(view.byId('tag-skipped-input').disabled, false);
} finally { view.scope.dispose(); view.dom.restore(); }
});
test('before the settings are read the controls are disabled', async () => {
const view = await mounted({ host: false });
try {
assert.equal(view.byId('tag-skipped-input').disabled, true);
assert.equal(view.byId('tag-skipped-add').disabled, true);
await view.add('sky');
assert.equal(view.drafts.get('host', 'autotag_skipped_tags'), undefined);
} finally { view.scope.dispose(); view.dom.restore(); }
});
test('leaving the section aborts a request in flight and the pending timer', async () => {
const view = await mounted({ answer: (_body, options) => new Promise((_r, reject) => options.signal.addEventListener('abort', () => reject(new Error('x')))) });
await tick();
assert.equal(view.posts.length, 1);
view.scope.dispose();
assert.equal(view.posts[0].signal.aborted, true);
view.dom.restore();
});
+2 -39
View File
@@ -402,12 +402,11 @@ test('a partial host save keeps what saved, adopts its revision and fails only t
assert.equal(drafts.statusText(), 'missing can\'t be used: not found. Remove it or reconnect it, then save.');
// The next save carries the revision the saved part produced: no false "changed elsewhere".
domains.outcome.host = null;
// (#431: a list equal to the saved one is no change, so a different one is drafted.)
drafts.set('host', 'gallery_root_paths', ['a', 'b']);
drafts.set('host', 'gallery_root_paths', ['a']);
const second = await drafts.save();
assert.equal(second.host, 'saved');
assert.equal(domains.calls.at(-1)[2].expected_revision, 'sha256:lora');
assert.deepEqual(domains.calls.at(-1)[1], { gallery_root_paths: ['a', 'b'] });
assert.deepEqual(domains.calls.at(-1)[1], { gallery_root_paths: ['a'] });
// Without a server reason, the status says some were saved and the highlighted ones were not.
domains.outcome.host = () => { throw Object.assign(new Error('boom'), { partial: { saved: ['x'], revision: 'sha256:three' } }); };
drafts.set('host', 'x', 1);
@@ -430,39 +429,3 @@ test('runtime adapter maps config.level to runtime_level and back', async () =>
assert.deepEqual(runtimeBody({ runtime_level: 'WARNING' }), { level: 'WARNING' });
assert.deepEqual(runtimeBody({ runtime_level: 'CRITICAL', console_logging: true }), { level: 'CRITICAL', console_logging: true });
});
test('sameDraft compares arrays and plain objects by value, keys in any order (#431)', async () => {
const { sameDraft } = await source;
assert.equal(sameDraft(1, 1), true);
assert.equal(sameDraft(Number.NaN, Number.NaN), true);
assert.equal(sameDraft(0, -0), false);
assert.equal(sameDraft(['a', 'b'], ['a', 'b']), true);
assert.equal(sameDraft(['a', 'b'], ['b', 'a']), false);
assert.equal(sameDraft([], []), true);
assert.equal(sameDraft([], {}), false);
assert.equal(sameDraft({ gguf: 'x', hf: 'y' }, { hf: 'y', gguf: 'x' }), true);
assert.equal(sameDraft([{ id: 'a', name: 'n' }], [{ name: 'n', id: 'a' }]), true);
assert.equal(sameDraft({ a: 1 }, { a: 2 }), false);
assert.equal(sameDraft('a', ['a']), false);
assert.equal(sameDraft(null, {}), false);
assert.equal(sameDraft(new Date(0), new Date(0)), false);
});
test('a list draft set back to the saved list is clean, and a save cleans it (#431)', async () => {
const domains = adapters({ host: { autotag_skipped_tags: ['blurry'] } });
const drafts = await loaded(domains);
drafts.set('host', 'autotag_skipped_tags', ['blurry', 'signature']);
assert.equal(drafts.dirty('host'), true);
// Removing the added chip builds a new array equal to the saved one.
drafts.set('host', 'autotag_skipped_tags', ['blurry']);
assert.equal(drafts.dirty('host'), false);
drafts.set('host', 'autotag_skipped_tags', ['signature']);
await drafts.save();
assert.equal(drafts.dirty('host'), false);
assert.deepEqual(drafts.saved('host').autotag_skipped_tags, ['signature']);
// A reload whose list equals the draft's (a new array) keeps nothing dirty.
drafts.set('host', 'autotag_skipped_tags', ['sky']);
domains.readHost = async () => ({ values: { autotag_skipped_tags: ['sky'] }, revision: 'sha256:three' });
await drafts.load('host');
assert.equal(drafts.dirty('host'), false);
});
+21 -32
View File
@@ -42,8 +42,6 @@ function fakeDOM() {
this.children.push(node);
}
}
replaceChildren(...nodes) { this.children = []; this._text = ''; this.append(...nodes); }
focus() { global.document.activeElement = this; }
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; }
@@ -67,9 +65,7 @@ function fakeDOM() {
}
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_builtin_instructions: 'Built-in tags.',
autotag_instructions: [{ id: '0123456789ab', name: 'Describe', text: 'Describe the image as tags.' }],
autotag_default_instructions: { gguf: '0123456789ab', hf: 'builtin' } };
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,
@@ -128,8 +124,7 @@ test('the section keeps the concept surface, rows, labels and actions in order',
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.'));
// D203 (#431): Saved instructions replace the concept's template field.
assert.ok(body.includes('LLM tag template') && !SOURCE.includes("'LLM tag template'"));
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);
@@ -172,25 +167,27 @@ test('controls draft their host keys; numbers as numbers, other text as typed',
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-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_existing', ' 12 '), ' 12 ');
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_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'), undefined);
assert.equal(byId('tag-template').dataset.setting, 'tag-template');
} finally { scope.dispose(); assert.equal(dom.listenerCount(), 0); dom.restore(); }
});
@@ -204,8 +201,7 @@ test('opening the section reads the model status once; the select waits for it',
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', 'setting-block', 'setting-block', 'setting-row',
'setting-row', 'small instruction-reset', 'row-actions', 'operation']);
['', '', '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 {
@@ -306,9 +302,7 @@ test('Preview tagging review opens the Tag image dialog with the section’s val
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: 'Describe the image as tags.', suggestion_limit: 10 } });
// D205 (#431): each language-model engine reads its own drafted default; Built-in's text comes from the server.
assert.deepEqual(taggingPreset({ ...HOST, autotag_model_type: 'hf' }).options.instructions, 'Built-in tags.');
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.' });
@@ -325,17 +319,15 @@ test('Preview tagging review opens the Tag image dialog with the section’s val
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);
// #431: a saved entry the server would refuse holds back a language-model run, with the server's sentence.
const entries = list => ({ ...HOST, autotag_model_type: 'hf', autotag_instructions: list });
assert.deepEqual(draftProblem(entries([{ id: '0123456789ab', name: 'A', text: ' ' }])),
{ key: 'autotag_instructions', message: 'Saved instructions: entry 1 text must be non-empty text.' });
assert.deepEqual(draftProblem(entries([{ id: '0123456789ab', name: 'A', text: 'x'.repeat(4001) }])),
{ key: 'autotag_instructions', message: 'Saved instructions: entry 1 text must be at most 4000 characters.' });
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(entries([{ id: '0123456789ab', name: 'A', text: '🏜'.repeat(4000) }])), null);
assert.equal(draftProblem(entries([{ id: '0123456789ab', name: 'A', text: '🏜'.repeat(4001) }])).key, 'autotag_instructions');
// A WD14 run never reads the instructions, nor a language model the thresholds.
assert.equal(draftProblem({ ...HOST, autotag_instructions: [{ id: '0123456789ab', name: '', text: '' }] }), null);
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 });
@@ -380,12 +372,9 @@ test('the batch review grid, body and Cancel-only reasons (D134, #379, CUJ 4–5
['Settings', 'General 0.35 · Character 0.85 · up to 10 tags per image'], ['Backup', 'Create and verify before changes']]);
const llm = { ...HOST, autotag_model_type: 'gguf' };
assert.deepEqual(batchGrid({ ...PLAN, model: { type: 'gguf' }, backup: { policy: 'off' }, estimate_seconds: null,
estimate_device: null, suggestion_limit: 4 }, llm).slice(-4),
estimate_device: null, suggestion_limit: 4 }, llm).slice(-3),
[['Time', 'Language-model engines take longer per image. Activity shows the time left once tagging starts.'],
['Instructions', 'Describe'], ['Settings', 'Up to 4 tags per image'], ['Backup', 'No safety backup (the policy is off)']]);
// D205 (#431): HF shows its own default by name.
assert.deepEqual(batchGrid({ ...PLAN, model: { type: 'hf' } }, { ...llm, autotag_model_type: 'hf' }).find(([term]) => term === 'Instructions'),
['Instructions', 'Built-in']);
['Settings', 'Template of 27 characters · up to 4 tags per image'], ['Backup', 'No safety backup (the policy is off)']]);
assert.equal(batchGrid({}, HOST)[2][1], '0');
assert.equal(batchGrid({}, HOST)[6][1], 'General 0.35 · Character 0.85');
assert.equal(existingLabel('odd'), 'odd');
@@ -393,7 +382,7 @@ test('the batch review grid, body and Cancel-only reasons (D134, #379, CUJ 4–5
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: 'Describe the image as tags.' } });
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.');
+1 -1
View File
@@ -2584,7 +2584,7 @@ class TestActivityJobs(AdminAPITestCase):
async def test_cancel_after_first_batch_keeps_partial_counts(self):
"""Review M7: the real scan stops at the batch boundary, keeping batch 1."""
import threading
import unittest.mock as mock
from unittest import mock
from py.api import admin
+2 -7
View File
@@ -73,7 +73,6 @@ class FakeTagger:
self.calls = []
self.read_paths = []
self.read_bytes = []
self.skips = []
self.download_model = MagicMock(side_effect=AssertionError("downloaded"))
self.load_error = None
self.block = None # (entered, proceed) events
@@ -116,10 +115,8 @@ class FakeTagger:
prompt=None,
general_threshold=None,
character_threshold=None,
skip=None,
):
self.read_paths.append(image_path)
self.skips.append(skip)
with open(image_path, "rb") as handle:
self.read_bytes.append(handle.read())
self.calls.append(
@@ -135,11 +132,9 @@ class FakeTagger:
entered, proceed = self.block
entered.set()
proceed.wait(WAIT)
# As the engine does (#431): a skipped tag uses up no slot.
kept = [pair for pair in SCORED if skip is None or not skip.matches(pair[0])]
if self.loaded.startswith("wd14"):
return list(kept[:limit])
return [(name, None) for name, _ in kept[:limit]]
return list(SCORED[:limit])
return [(name, None) for name, _ in SCORED[:limit]]
class SuggestTestCase(AnnotationAPITestCase):
-2
View File
@@ -1144,10 +1144,8 @@ class TestEnvelopes(AnnotationAPITestCase):
self.assertEqual(
sorted(await self.json_of(resp)),
[
"default_instructions", # #431 adds the saved instructions
"default_prompt",
"existing", # #328 adds the saved batch policy and model
"instructions",
"loaded_model_type",
"model_loaded",
"model_type",
+4 -62
View File
@@ -129,10 +129,8 @@ class FakeAutotagService:
prompt=None,
general_threshold=None,
character_threshold=None,
skip=None,
):
"""The suggestion entry point (#379): the fixed tags, at most ``limit``,
without the ones ``skip`` hides (#431)."""
"""The suggestion entry point (#379): the fixed tags, at most ``limit``."""
self.suggest_calls += 1
tags = self.generate_tags(
image_path,
@@ -140,7 +138,6 @@ class FakeAutotagService:
general_threshold=general_threshold,
character_threshold=character_threshold,
)
tags = [name for name in tags if skip is None or not skip.matches(name)]
return [(name, None) for name in tags[:limit]]
@@ -603,33 +600,6 @@ class TestAutotagSingleEdgeCases(AutotagAPITestCase):
{"path": str(image), "general_threshold": "high"},
)
self.assertEqual(resp.status, 400)
self.assertEqual(
(await resp.json())["error"],
"general_threshold and character_threshold must be numeric",
)
async def test_invalid_single_options_report_the_validation_error(self) -> None:
image = self._make_image(self.output_dir)
cases = (
(
{"model_type": "wd14-vit", "general_threshold": 2},
"general_threshold must be between 0 and 1",
),
(
{"model_type": "gguf", "prompt": "x" * 4001},
"instructions must be at most 4000 characters",
),
)
for options, message in cases:
with self.subTest(options=options["model_type"]):
resp = await self._post_json(
"/prompt_manager/autotag/single",
{"path": str(image), **options},
)
self.assertEqual(resp.status, 400)
self.assertEqual(
await resp.json(), {"success": False, "error": message}
)
async def test_prompt_lookup_failure_still_returns_tags(self):
image = self._make_image(self.output_dir)
@@ -1181,9 +1151,7 @@ class TestModelStatus(AutotagAPITestCase):
)
# Kept fields legacy pages and the workspace read.
for model in models.values():
self.assertLessEqual(
{"name", "description", "size", "downloaded"}, set(model)
)
self.assertTrue({"name", "description", "size", "downloaded"} <= set(model))
self.assertTrue(data["model_loaded"])
self.assertEqual(data["loaded_model_type"], "wd14-vit")
@@ -1536,8 +1504,7 @@ class TestSavedTaggingDefaults(AutotagAPITestCase):
"EXISTING",
"GENERAL_THRESHOLD",
"CHARACTER_THRESHOLD",
"INSTRUCTIONS",
"DEFAULT_INSTRUCTIONS",
"TEMPLATE",
"SUGGESTION_LIMIT",
):
self.addCleanup(setattr, AutotagConfig, attr, getattr(AutotagConfig, attr))
@@ -1558,11 +1525,7 @@ class TestSavedTaggingDefaults(AutotagAPITestCase):
self.config.EXISTING = "replace_after_review"
self.config.GENERAL_THRESHOLD = 0.4
self.config.CHARACTER_THRESHOLD = 0.7
# #431: the saved engine's default instructions.
self.config.INSTRUCTIONS = (
{"id": "0123456789ab", "name": "Saved", "text": "Saved template"},
)
self.config.DEFAULT_INSTRUCTIONS = {"gguf": "0123456789ab", "hf": "builtin"}
self.config.TEMPLATE = "Saved template"
self.config.SUGGESTION_LIMIT = 15
data = await (await self.client.get("/prompt_manager/autotag/models")).json()
self.assertEqual(
@@ -1586,27 +1549,6 @@ class TestSavedTaggingDefaults(AutotagAPITestCase):
"suggestion_limit": 15,
},
)
# Built-in first with every text, and each engine's default.
self.assertEqual(
data["instructions"],
[
{"id": "builtin", "name": "Built-in", "text": DEFAULT_TAG_TEMPLATE},
{"id": "0123456789ab", "name": "Saved", "text": "Saved template"},
],
)
self.assertEqual(
data["default_instructions"], {"gguf": "0123456789ab", "hf": "builtin"}
)
# HF reads its own default; a WD14 engine reads GGUF's.
for model_type, prompt in (
("hf", DEFAULT_TAG_TEMPLATE),
("wd14-vit", "Saved template"),
):
self.config.MODEL_TYPE = model_type
data = await (
await self.client.get("/prompt_manager/autotag/models")
).json()
self.assertEqual(data["default_prompt"], prompt, model_type)
async def test_single_without_thresholds_uses_saved_ones(self):
image = self._make_image(self.output_dir)
+4
View File
@@ -351,6 +351,8 @@ class TestLongJobLock(unittest.TestCase):
self.api = PromptManagerAPI(db=database)
def _run(self, coro):
import asyncio
return asyncio.run(coro)
def test_second_acquire_is_refused_until_first_releases(self):
@@ -823,6 +825,8 @@ class TestInstanceHelpers(FolderPathsFixture):
self.addCleanup(setattr, GalleryConfig, "MONITORING_DIRECTORIES", orig_dirs)
def _run(self, coro):
import asyncio
return asyncio.run(coro)
def test_run_in_executor_with_kwargs(self):
+1 -1
View File
@@ -16,7 +16,7 @@ import re
import sqlite3
import sys
import unittest
import unittest.mock as mock
from unittest import mock
from unittest.mock import MagicMock
_mock_server = MagicMock()
+1 -1
View File
@@ -381,7 +381,7 @@ class TestAnotherMount(SafetyTestCase):
self.assertTrue(other.is_file() and keeper.is_file())
async def test_a_volume_without_file_ids_is_named_truthfully(self):
self.mount("x.png", "x.png")
keeper, other, patches = self.mount("x.png", "x.png")
def zero(path, *args, **kwargs):
return _fake_stat(self.real_stat(path), st_dev=0, st_ino=0)
+1 -1
View File
@@ -3713,7 +3713,7 @@ class TestSharedListing(ImageAPITestCase):
async def test_a_root_read_again_after_the_roots_change_listens_again(self):
make_png(self.output_dir / "a.png")
await self._listed()
record = await self._listed()
self.api.reset_gallery_roots_cache()
record = await self._listed() # still configured: read and walked
self._interval(0)
-17
View File
@@ -840,23 +840,6 @@ class TestLoraScanRoute(LoraAPITestCase):
("error", error["message"], 0),
)
async def test_unexpected_scan_error_is_generic_in_the_stream(self):
self._enable()
secret = str(self.tmp_root / "x.db")
err = RuntimeError(f"could not open {secret}")
with patch.object(self.api.db, "replace_category_prompts", side_effect=err):
with self.assertLogs("prompt_manager.api", level="ERROR") as logs:
resp = await self.client.request("POST", "/prompt_manager/lora/scan")
body = await resp.text() # the stream ends inside the patch
self.assertNotIn("x.db", body)
error = self._sse_events(body)[-1]
self.assertEqual(
(error["type"], error["status"], error["progress"]),
("error", error["message"], 0),
)
self.assertIn("server log", error["message"])
self.assertIn(secret, "\n".join(logs.output))
@staticmethod
def _sse_events(text):
return [
+1 -1
View File
@@ -675,7 +675,7 @@ class TestBackupPoolAndDiskSpace(ApplyTestCase):
self.assertEqual(len(backups), MAX_SAFETY_BACKUPS)
self.assertNotIn(restore_style[0], backups)
self.assertNotIn(restore_style[1], backups)
self.assertLessEqual(set(maintenance_style), set(backups))
self.assertTrue(set(maintenance_style) <= set(backups))
# A restore prunes the same pool, maintenance backups included.
self.old_backup("prompts.db.backup_20020101_000000", 3000)
_prune_safety_backups(self.api.db.model.db_path)
-2
View File
@@ -54,8 +54,6 @@ class PerImageTagger(HookedTagger):
names = self.by_bytes.get(self.read_bytes[-1])
if names is None:
return result
skip = kwargs.get("skip") # as the engine does (#431)
names = [name for name in names if skip is None or not skip.matches(name)]
return [(name, 0.9) for name in names][: kwargs["limit"]]
+1 -24
View File
@@ -158,30 +158,7 @@ class TestLegacyBatch(LegacyTestCase):
self.image("b.png", (2, 2, 2))
await self.relist()
await self.legacy("model_type=gguf&prompt=%20")
self.assertEqual(
self.tagger.calls[-1]["prompt"],
AutotagConfig.default_instructions_text("gguf"),
)
# #431: a blank prompt falls back to the request engine's own default.
saved = (AutotagConfig.INSTRUCTIONS, AutotagConfig.DEFAULT_INSTRUCTIONS)
self.addCleanup(
lambda: (
setattr(AutotagConfig, "INSTRUCTIONS", saved[0]),
setattr(AutotagConfig, "DEFAULT_INSTRUCTIONS", saved[1]),
)
)
AutotagConfig.INSTRUCTIONS = (
{"id": "0123456789ab", "name": "Photo", "text": "Photo tags."},
)
AutotagConfig.DEFAULT_INSTRUCTIONS = {"gguf": "builtin", "hf": "0123456789ab"}
for model_type, expected in (
("hf", "Photo tags."),
("gguf", AutotagConfig.default_instructions_text("gguf")),
):
self.image(f"{model_type}.png", (3, 3, len(model_type)))
await self.relist()
await self.legacy(f"model_type={model_type}&prompt=%20&skip_tagged=false")
self.assertEqual(self.tagger.calls[-1]["prompt"], expected, model_type)
self.assertEqual(self.tagger.calls[-1]["prompt"], AutotagConfig.TEMPLATE)
self.assertEqual(self.tagger.custom_prompt, "legacy prompt")
async def test_a_missing_backend_package_is_one_error_frame(self):
+1 -1
View File
@@ -11,7 +11,7 @@ import time
import types
import unittest
from pathlib import Path
import unittest.mock as mock
from unittest import mock
try:
from tests.open_handles import assert_closed
+1 -1
View File
@@ -6,7 +6,7 @@ Large frees then go back to the OS instead of fragmenting per-thread arenas.
import ctypes
import sys
import unittest
import unittest.mock as mock
from unittest import mock
from tests.browser import malloc_tuning
+1
View File
@@ -46,6 +46,7 @@ class TestResetWatch(unittest.TestCase):
self.assertFalse(is_reset_path("/__test__/reset/extra"))
def test_a_stalled_reset_reports_state_and_stacks_while_it_waits(self):
stream = io.StringIO()
reported = threading.Event()
class Stream(io.StringIO):
+3 -3
View File
@@ -385,8 +385,8 @@ class TestArchivedReaders(LibraryDeleteCase):
def setUp(self):
super().setUp()
self.kept, _, _ = self.paired("Kept cat", "kept blur")
self.gone, _, _ = self.paired("Gone cat", "gone blur")
self.kept, _kn, _kp = self.paired("Kept cat", "kept blur")
self.gone, _gn, _gp = self.paired("Gone cat", "gone blur")
for pid, category, tag in ((self.kept, "keep", "k"), (self.gone, "gone", "g")):
self.db.update_prompt_metadata(pid, category=category, tags=[tag])
self.apply([self.gone])
@@ -642,7 +642,7 @@ class TestPairedDelete(LibraryDeleteCase):
# The negatives stay, and the image file stays on disk.
self.assertTrue((self.root / "owl.png").exists())
texts = [row[0] for row in rows(self.db, "SELECT text FROM prompts")]
self.assertLessEqual({"dark", "fog", "Bystander"}, set(texts))
self.assertTrue({"dark", "fog", "Bystander"} <= set(texts))
def test_a_negative_used_by_another_positive_is_archived_never_deleted(self):
shared = save(self.db, "Shared")
+1 -1
View File
@@ -14,7 +14,7 @@ import threading
import types
import unittest
from collections import namedtuple
import unittest.mock as mock
from unittest import mock
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+12 -40
View File
@@ -442,11 +442,8 @@ class TestAutotagConfig(unittest.TestCase):
"EXISTING",
"GENERAL_THRESHOLD",
"CHARACTER_THRESHOLD",
"TEMPLATE",
"SUGGESTION_LIMIT",
"SKIPPED_TAGS",
"INSTRUCTIONS",
"DEFAULT_INSTRUCTIONS",
"_LEGACY_TEMPLATE",
)
}
@@ -490,14 +487,16 @@ class TestAutotagConfig(unittest.TestCase):
"general_threshold": 0.35,
"character_threshold": 0.85,
"suggestion_limit": 10,
"skipped_tags": [],
"instructions": [],
"default_instructions": {"gguf": "builtin", "hf": "builtin"},
}
},
)
# #431: the old template key is written only as the GGUF default's
# mirror (test_saved_instructions covers it).
# 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(
@@ -525,16 +524,16 @@ class TestAutotagConfig(unittest.TestCase):
"existing": "merge",
"general_threshold": 0.4,
"character_threshold": 0.7,
"template": "Tags only.",
"suggestion_limit": 15,
"skipped_tags": ("blurry", "closed eyes"),
}
fallback = {
"model_type": "wd14-swinv2",
"existing": "skip",
"general_threshold": 0.35,
"character_threshold": 0.85,
"template": config_module.DEFAULT_TAG_TEMPLATE,
"suggestion_limit": 10,
"skipped_tags": (),
}
bad_values = {
"model_type": ["wd14-convnext", 3, None],
@@ -545,8 +544,6 @@ class TestAutotagConfig(unittest.TestCase):
# 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],
# #431: the whole list falls back; entries are never dropped one by one.
"skipped_tags": ["blurry", ["a,b"], ["sky", "Sky"], [""], ["x"] * 201],
}
for key, values in bad_values.items():
for value in values:
@@ -555,7 +552,7 @@ class TestAutotagConfig(unittest.TestCase):
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "config.json")
with open(path, "w") as f:
json.dump({"autotag": {"defaults": saved}}, f, default=list)
json.dump({"autotag": {"defaults": saved}}, f)
with self.assertLogs(
"prompt_manager.config", level="WARNING"
) as logs:
@@ -566,32 +563,7 @@ class TestAutotagConfig(unittest.TestCase):
k: getattr(self.config_class, attr)
for k, (attr, _v, _d) in self.config_class._KEYS.items()
}
# The old template is no attribute any more (#431): a bad
# one is only warned about and imports nothing.
expected = (
dict(good, **{key: fallback[key]}) if key in fallback else good
)
self.assertEqual(loaded, expected)
def test_skipped_tags_load_trimmed_and_warn_once_when_damaged(self):
"""#431: the list is kept as typed (trimmed); a damaged list is
replaced by no skipped tags with one warning naming the reason."""
PromptManagerConfig.update_config(
{"autotag": {"defaults": {"skipped_tags": [" blurry ", "(fate)"]}}}
)
self.assertEqual(self.config_class.SKIPPED_TAGS, ("blurry", "(fate)"))
with self.assertLogs("prompt_manager.config", level="WARNING") as logs:
PromptManagerConfig.update_config(
{"autotag": {"defaults": {"skipped_tags": ["sky", "a,b"]}}}
)
self.assertEqual(self.config_class.SKIPPED_TAGS, ())
self.assertEqual(
[record.getMessage() for record in logs.records],
[
"autotag.defaults.skipped_tags entry 2 must not contain a comma"
" or a line break; using no skipped tags"
],
)
self.assertEqual(loaded, dict(good, **{key: fallback[key]}))
def test_tagging_validators_moved_with_their_messages(self):
validate_threshold = config_module.validate_threshold
+49 -85
View File
@@ -140,10 +140,8 @@ class TestIdempotentMigrations(MigrationTestCase):
def test_stale_prompts_new_from_a_failed_rebuild_does_not_block_startup(self):
self._legacy_db(
[
(
"INSERT INTO prompts (text, hash, workflow_name)"
" VALUES ('a', 'h1', 'wf')"
),
"INSERT INTO prompts (text, hash, workflow_name)"
" VALUES ('a', 'h1', 'wf')",
"CREATE TABLE prompts_new (id INTEGER PRIMARY KEY, junk TEXT)",
"INSERT INTO prompts_new (junk) VALUES ('leftover')",
]
@@ -159,14 +157,10 @@ class TestIdempotentMigrations(MigrationTestCase):
# DROP TABLE prompts with foreign keys ON cascades into generated_images
self._legacy_db(
[
(
"INSERT INTO prompts (text, hash, workflow_name)"
" VALUES ('a', 'h1', 'wf')"
),
(
"INSERT INTO generated_images (prompt_id, image_path, filename)"
" VALUES (1, '/out/a.png', 'a.png')"
),
"INSERT INTO prompts (text, hash, workflow_name)"
" VALUES ('a', 'h1', 'wf')",
"INSERT INTO generated_images (prompt_id, image_path, filename)"
" VALUES (1, '/out/a.png', 'a.png')",
]
)
@@ -178,15 +172,11 @@ class TestIdempotentMigrations(MigrationTestCase):
def test_rebuild_keeps_library_state_rows(self):
self._legacy_db(
[
(
"INSERT INTO prompts (text, hash, workflow_name)"
" VALUES ('a', 'h1', 'wf')"
),
(
"CREATE TABLE prompt_library_state (prompt_id INTEGER PRIMARY KEY,"
" title TEXT, FOREIGN KEY(prompt_id) REFERENCES prompts(id)"
" ON DELETE CASCADE)"
),
"INSERT INTO prompts (text, hash, workflow_name)"
" VALUES ('a', 'h1', 'wf')",
"CREATE TABLE prompt_library_state (prompt_id INTEGER PRIMARY KEY,"
" title TEXT, FOREIGN KEY(prompt_id) REFERENCES prompts(id)"
" ON DELETE CASCADE)",
"INSERT INTO prompt_library_state VALUES (1, 'kept')",
]
)
@@ -242,59 +232,43 @@ class TestIdempotentMigrations(MigrationTestCase):
byte_id = "6a1b2c3d-4e5f-4a6b-8c9d-0e1f2a3b4c5d"
self._legacy_db(
[
(
"INSERT INTO prompts (text, hash, workflow_name)"
" VALUES ('a', 'h1', 'wf')"
),
(
"CREATE TABLE tags (id INTEGER PRIMARY KEY AUTOINCREMENT,"
" name TEXT NOT NULL UNIQUE)"
),
"INSERT INTO prompts (text, hash, workflow_name)"
" VALUES ('a', 'h1', 'wf')",
"CREATE TABLE tags (id INTEGER PRIMARY KEY AUTOINCREMENT,"
" name TEXT NOT NULL UNIQUE)",
"INSERT INTO tags (id, name) VALUES (3, 'img-only')",
(
"CREATE TABLE media_annotation_metadata (singleton_id INTEGER"
" PRIMARY KEY CHECK(singleton_id=1), schema_version INTEGER NOT"
" NULL CHECK(schema_version=1), image_index_revision INTEGER NOT"
" NULL DEFAULT 0 CHECK(image_index_revision>=0))"
),
"CREATE TABLE media_annotation_metadata (singleton_id INTEGER"
" PRIMARY KEY CHECK(singleton_id=1), schema_version INTEGER NOT"
" NULL CHECK(schema_version=1), image_index_revision INTEGER NOT"
" NULL DEFAULT 0 CHECK(image_index_revision>=0))",
"INSERT INTO media_annotation_metadata VALUES (1, 1, 4)",
(
"CREATE TABLE media_annotation_files (id TEXT PRIMARY KEY"
" CHECK(length(id)=36), locator TEXT NOT NULL UNIQUE,"
" created_at TEXT NOT NULL)"
),
"CREATE TABLE media_annotation_files (id TEXT PRIMARY KEY"
" CHECK(length(id)=36), locator TEXT NOT NULL UNIQUE,"
" created_at TEXT NOT NULL)",
f"INSERT INTO media_annotation_files VALUES ('{file_id}',"
" '/out/a.png', '2026-10-22T00:00:00.000000+00:00')",
(
"CREATE TABLE media_annotation_bytes (id TEXT PRIMARY KEY"
" CHECK(length(id)=36), file_id TEXT NOT NULL REFERENCES"
" media_annotation_files(id) ON DELETE CASCADE, sha256 TEXT NOT"
" NULL CHECK(length(sha256)=64 AND sha256 NOT GLOB"
" '*[^0-9a-f]*'), size INTEGER NOT NULL CHECK(size>=0), mtime_ns"
" INTEGER NOT NULL, verified_at TEXT NOT NULL, first_seen_at TEXT"
" NOT NULL, tag_revision INTEGER NOT NULL DEFAULT 0"
" CHECK(tag_revision>=0), UNIQUE(file_id, sha256))"
),
(
"CREATE INDEX idx_media_annotation_bytes_fingerprint ON"
" media_annotation_bytes(file_id, size, mtime_ns)"
),
"CREATE TABLE media_annotation_bytes (id TEXT PRIMARY KEY"
" CHECK(length(id)=36), file_id TEXT NOT NULL REFERENCES"
" media_annotation_files(id) ON DELETE CASCADE, sha256 TEXT NOT"
" NULL CHECK(length(sha256)=64 AND sha256 NOT GLOB"
" '*[^0-9a-f]*'), size INTEGER NOT NULL CHECK(size>=0), mtime_ns"
" INTEGER NOT NULL, verified_at TEXT NOT NULL, first_seen_at TEXT"
" NOT NULL, tag_revision INTEGER NOT NULL DEFAULT 0"
" CHECK(tag_revision>=0), UNIQUE(file_id, sha256))",
"CREATE INDEX idx_media_annotation_bytes_fingerprint ON"
" media_annotation_bytes(file_id, size, mtime_ns)",
f"INSERT INTO media_annotation_bytes VALUES ('{byte_id}',"
f" '{file_id}', '{'a' * 64}', 3, 1700000000000000000,"
" '2026-10-22T00:00:00.000000+00:00',"
" '2026-10-22T00:00:00.000000+00:00', 2)",
(
"CREATE TABLE media_image_tags (byte_id TEXT NOT NULL REFERENCES"
" media_annotation_bytes(id) ON DELETE CASCADE, tag_id INTEGER"
" NOT NULL REFERENCES tags(id) ON DELETE CASCADE, position"
" INTEGER NOT NULL CHECK(position>=0), PRIMARY KEY(byte_id,"
" tag_id))"
),
"CREATE TABLE media_image_tags (byte_id TEXT NOT NULL REFERENCES"
" media_annotation_bytes(id) ON DELETE CASCADE, tag_id INTEGER"
" NOT NULL REFERENCES tags(id) ON DELETE CASCADE, position"
" INTEGER NOT NULL CHECK(position>=0), PRIMARY KEY(byte_id,"
" tag_id))",
"CREATE INDEX idx_media_image_tags_tag ON media_image_tags(tag_id)",
(
"CREATE UNIQUE INDEX idx_media_image_tags_position ON"
" media_image_tags(byte_id, position)"
),
"CREATE UNIQUE INDEX idx_media_image_tags_position ON"
" media_image_tags(byte_id, position)",
f"INSERT INTO media_image_tags VALUES ('{byte_id}', 3, 0)",
]
)
@@ -393,28 +367,18 @@ class TestIdempotentMigrations(MigrationTestCase):
def test_image_table_is_rebuilt_with_uniqueness_by_full_path(self):
self._legacy_db(
[
(
"INSERT INTO prompts (text, hash, workflow_name)"
" VALUES ('a', 'h1', 'wf')"
),
"INSERT INTO prompts (text, hash, workflow_name)"
" VALUES ('a', 'h1', 'wf')",
# same basename in two folders: both must survive
(
"INSERT INTO generated_images (prompt_id, image_path, filename)"
" VALUES (1, '/out/2026-01-01/a.png', 'a.png')"
),
(
"INSERT INTO generated_images (prompt_id, image_path, filename)"
" VALUES (1, '/out/2026-01-02/a.png', 'a.png')"
),
"INSERT INTO generated_images (prompt_id, image_path, filename)"
" VALUES (1, '/out/2026-01-01/a.png', 'a.png')",
"INSERT INTO generated_images (prompt_id, image_path, filename)"
" VALUES (1, '/out/2026-01-02/a.png', 'a.png')",
# two spellings of one file: only the newest row survives
(
"INSERT INTO generated_images (prompt_id, image_path, filename)"
" VALUES (1, '/out/x/../b.png', 'b.png')"
),
(
"INSERT INTO generated_images (prompt_id, image_path, filename)"
" VALUES (1, '/out/b.png', 'b.png')"
),
"INSERT INTO generated_images (prompt_id, image_path, filename)"
" VALUES (1, '/out/x/../b.png', 'b.png')",
"INSERT INTO generated_images (prompt_id, image_path, filename)"
" VALUES (1, '/out/b.png', 'b.png')",
]
)
+1 -1
View File
@@ -182,7 +182,7 @@ class TestAdminEndpointsUseConfigPath(unittest.TestCase):
self.assertIsNone(warning)
self.assertEqual(os.path.dirname(snapshot), folder)
with open(snapshot, "rb") as handle:
self.assertEqual(handle.read(16), b"SQLite format 3\x00")
self.assertTrue(handle.read(16) == b"SQLite format 3\x00")
finally:
os.unlink(snapshot)
finally:
+12 -20
View File
@@ -31,20 +31,6 @@ def message(kind, job_id=JOB, **fields):
return (kind, {"prompt_id": job_id, **fields})
def hash_recording_kind():
"""A str subclass for event kinds, and the list of hashes taken of it."""
hashed = []
class OversizedKind(str):
__eq__ = str.__eq__
def __hash__(self):
hashed.append(len(self))
return str.__hash__(self)
return OversizedKind, hashed
def finish(messages, completed=True):
return make_finish(SESSION, 0, EPOCH, Status("success", completed, messages), 500)
@@ -360,10 +346,13 @@ class ExecutionHistoryEventsTests(unittest.TestCase):
self.assertLessEqual(len(encode_command(command)), 2048)
def test_oversized_unknown_kind_is_ignored_without_hashing(self):
kind_class, hashed = hash_recording_kind()
class OversizedKind(str):
def __hash__(self):
raise AssertionError("Oversized event kind must not be hashed")
command = finish(
[
message(kind_class("x" * 1_000_000)),
message(OversizedKind("x" * 1_000_000)),
message("execution_success", timestamp=300),
]
)
@@ -372,16 +361,19 @@ class ExecutionHistoryEventsTests(unittest.TestCase):
(result.outcome, result.reason), ("succeeded", "terminal_success")
)
self.assertLessEqual(len(encode_command(command)), 2048)
self.assertEqual(hashed, [], "Oversized event kind must not be hashed")
def test_encoder_rejects_oversized_kind_without_hashing(self):
kind_class, hashed = hash_recording_kind()
class OversizedKind(str):
def __hash__(self):
raise AssertionError("Oversized event kind must not be hashed")
command = finish([message("execution_success")])
event = LifecycleEvent(kind_class("x" * 1_000_000), JOB, None, None, None, None)
event = LifecycleEvent(
OversizedKind("x" * 1_000_000), JOB, None, None, None, None
)
with self.assertRaises(ValueError):
encode_command(
replace(
command, evidence=TerminalEvidence((event,), False, False, False)
)
)
self.assertEqual(hashed, [], "Oversized event kind must not be hashed")
+1 -1
View File
@@ -17,7 +17,7 @@ class Hostile:
raise AssertionError("host scalar coercion")
def __bool__(self):
raise TypeError("host truth test")
raise AssertionError("host truth test")
def __eq__(self, other):
raise AssertionError("host equality")
+1 -1
View File
@@ -24,7 +24,7 @@ import tempfile
import textwrap
import threading
import unittest
import unittest.mock as mock
from unittest import mock
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
+1 -1
View File
@@ -26,7 +26,7 @@ import types
import unittest
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import unittest.mock as mock
from unittest import mock
from py.api import thumbnail_auto
from utils import image_monitor, parallel
+1 -61
View File
@@ -7,7 +7,7 @@ I/O: history comes in through ``lookup``.
import json
import unittest
import unittest.mock as mock
from unittest import mock
from database.run_attribution import RecordedOutput
from tests.test_pair_scan import flux_graph, sampled_graph
@@ -564,66 +564,6 @@ class TestUndecidedGraph(unittest.TestCase):
self.assertEqual(decision.review, "untraced")
def _search_list(text="fox", side="positive"):
"""f22: a PromptSearchList batch (no PromptManager node) feeds CLIPTextEncode."""
graph = parity.build("f22")["prompt"]
graph["10"]["inputs"]["text"] = text
if side == "negative":
# The search feeds the negative; the positive is typed text.
graph["6"]["inputs"]["text"] = parity.POS
graph["7"]["inputs"]["text"] = ["10", 0]
return graph
class TestSearchTextIsNeverAPrompt(unittest.TestCase):
"""#548: the PromptSearchList query is a search, never the prompt."""
def test_the_search_text_is_never_linked_or_stored(self):
for policy in Policy:
with self.subTest(policy=policy):
decision = _decide(
_graph(_search_list()), policy=policy, lookup=_Lookup()
)
self.assertEqual(decision.kind, "never")
self.assertIsNone(decision.text)
self.assertEqual(decision.unlinked_status, "unresolved")
self.assertTrue(decision.try_recorded_run)
def test_a_search_text_from_a_primitive_is_never_linked(self):
graph = _search_list()
graph["10"]["inputs"]["text"] = ["30", 0]
graph["30"] = {"class_type": "PrimitiveString", "inputs": {"value": "fox"}}
for policy in Policy:
with self.subTest(policy=policy):
decision = _decide(_graph(graph), policy=policy, lookup=_Lookup())
self.assertEqual(decision.kind, "never")
self.assertIsNone(decision.text)
def test_an_empty_search_text_is_unchanged(self):
"""Regression guard (passes on the base): nothing to exclude."""
for policy in Policy:
with self.subTest(policy=policy):
decision = _decide(
_graph(_search_list("")), policy=policy, lookup=_Lookup()
)
self.assertEqual((decision.kind, decision.text), ("never", None))
def test_without_a_sampler_any_search_list_query_is_excluded(self):
graph = _search_list()
del graph["3"]
self.assertTrue(file_prompt._search_text(graph, " fox "))
self.assertFalse(file_prompt._search_text(graph, "a fox"))
self.assertFalse(file_prompt._search_text(None, "fox"))
def test_a_search_on_the_negative_keeps_the_typed_positive(self):
"""Regression guard (passes on the base): only the positive's source
is excluded, so the legacy answer from typed text still links."""
graph = _search_list(side="negative")
decision = _decide(_graph(graph), lookup=_Lookup())
self.assertEqual((decision.kind, decision.text), ("positive", parity.POS))
self.assertEqual(decision.source, "legacy-parser")
class TestStepsWithoutAnUndecidedGraph(unittest.TestCase):
def test_step_9_ambiguous_parameters_only(self):
meta = {
+1 -1
View File
@@ -2,7 +2,7 @@
import logging
import unittest
import unittest.mock as mock
from unittest import mock
from prompt_manager import PromptManager, PromptManagerFocused
from utils.prompt_graph import positive_prompt_nodes, run_prompt_nodes
+1 -1
View File
@@ -312,7 +312,7 @@ class RootContainsTests(unittest.TestCase):
root = os.path.normcase(os.path.realpath(tempfile.gettempdir()))
inside = os.path.join(root, "x.png")
self.assertTrue(root_contains(root, inside))
self.assertEqual(root_contains(root, inside.upper()), os.name == "nt")
self.assertTrue(root_contains(root, inside.upper()) == (os.name == "nt"))
if __name__ == "__main__":
+1 -78
View File
@@ -392,10 +392,7 @@ class TestStartStop(unittest.TestCase):
self.assertLogs("prompt_manager.image_monitor", "WARNING") as logs,
):
self.assertFalse(self.monitor.stop_monitoring(timeout=5.0))
# The stop runs on its own thread and gets 0 s too: it may not have
# returned yet, and then nothing is joined at all.
joins = [call for call in fake.calls if call[0] == "join"]
self.assertTrue(all(call == ("join", 0.0) for call in joins), fake.calls)
self.assertEqual(fake.calls[-1], ("join", 0.0))
self.assertIn(
"watchdog observer still running after 0.0 s", "\n".join(logs.output)
)
@@ -404,80 +401,6 @@ class TestStartStop(unittest.TestCase):
def test_stop_monitoring_without_an_observer_returns_true(self):
self.assertTrue(self.monitor.stop_monitoring())
def test_threads_the_observer_starts_are_daemons(self):
# watchdog 7 on Windows starts a plain threading.Thread per watch in
# observer.start(); a non-daemon one holds the interpreter's exit
# before any atexit callback runs (#561).
fake = ReaderObserver()
self.addCleanup(fake.release.set)
self._start_with(fake)
self.assertIsNotNone(fake.reader)
self.assertTrue(fake.reader.daemon)
self.assertFalse(threading.current_thread().daemon)
def test_an_observer_that_fails_to_start_on_its_thread_is_reported(self):
fake = FakeObserver()
fake.start = MagicMock(side_effect=OSError("handle refused"))
with self.assertLogs("prompt_manager.image_monitor", "ERROR") as logs:
with patch.object(im_mod, "Observer", return_value=fake):
self.monitor.start_monitoring([self.tmp.name])
self.assertIn("handle refused", "\n".join(logs.output))
self.assertFalse(self.monitor.is_monitoring)
def test_an_observer_stop_that_never_returns_is_bounded(self):
# watchdog's observer.stop() joins its emitters (and, in 7.x on
# Windows, their reader threads) without a limit (#561).
fake = StuckStopObserver()
self.addCleanup(fake.release.set)
self._start_with(fake)
with self.assertLogs("prompt_manager.image_monitor", "WARNING") as logs:
self.assertFalse(self.monitor.stop_monitoring(timeout=0.05))
self.assertIn("watchdog observer still running", "\n".join(logs.output))
self.assertTrue(fake.entered.wait(30))
self.assertFalse(self.monitor.is_monitoring)
# A second stop waits for the stop already running; it never calls
# observer.stop() a second time.
fake.release.set()
self.assertTrue(self.monitor.stop_monitoring())
self.assertEqual(fake.stop_calls, 1)
self.assertFalse(self.monitor._unfinished)
class ReaderObserver(FakeObserver):
"""A FakeObserver whose start() starts a plain thread, as watchdog 7's
Windows emitter does for its ReadDirectoryChangesW reader."""
def __init__(self):
super().__init__()
self.release = threading.Event()
self.reader = None
def start(self):
self.reader = threading.Thread(target=self.release.wait)
self.reader.start()
super().start()
def stop(self):
self.release.set()
self.reader.join(30)
super().stop()
class StuckStopObserver(FakeObserver):
"""A FakeObserver whose stop() does not return until ``release``."""
def __init__(self):
super().__init__()
self.entered = threading.Event()
self.release = threading.Event()
self.stop_calls = 0
def stop(self):
self.stop_calls += 1
self.entered.set()
self.release.wait(30)
super().stop()
class FakeEvent:
def __init__(self, path, is_directory=False):
+5 -17
View File
@@ -390,16 +390,7 @@ class TestConsoleBanner(unittest.TestCase):
stream.flush()
banner = stream.buffer.getvalue().decode("cp1252")
lines = [
line
for line in banner.splitlines()
if "PromptManager" in line or "loaded" in line
]
self.assertEqual(len(lines), 1, banner)
self.assertRegex(
lines[0], r"\[ComfyUI-PromptManager\] Version:.* \S+ - \d+ tools loaded$"
)
self.assertNotIn("Loaded:", banner)
self.assertIn("Loaded:", banner)
class PriorFileHandler(logging.FileHandler):
@@ -536,7 +527,7 @@ class TestPackageLoggerIsolation(unittest.TestCase):
def log_from_background_thread():
try:
logging.getLogger("prompt_manager.late_emitter").warning(message)
except Exception as error:
except BaseException as error:
errors.append(error)
self.handler.addFilter(pause_before_handler_lock)
@@ -562,7 +553,7 @@ class TestPackageLoggerIsolation(unittest.TestCase):
original_print = print
def fail_banner(*args, **kwargs):
if any("tools loaded" in str(arg) for arg in args):
if any("Total:" in str(arg) for arg in args):
captured["package"] = sys.modules[name]
captured["handlers"] = [
h
@@ -596,16 +587,13 @@ PACKAGE_THREAD_NAMES = ("PromptManagerPromptCleanup", "PromptManagerImageWorker"
def package_threads():
"""Live threads this design owns: the tracker, the image worker, and the
watchdog observer and emitters (which have no name of their own).
watchdog 7's InotifyFD, one daemon reader per process that no observer
stop ends, is not the package's."""
watchdog observer and emitters (which have no name of their own)."""
from watchdog.utils import BaseThread
return {
thread
for thread in threading.enumerate()
if thread.name in PACKAGE_THREAD_NAMES
or (isinstance(thread, BaseThread) and type(thread).__name__ != "InotifyFD")
if thread.name in PACKAGE_THREAD_NAMES or isinstance(thread, BaseThread)
}
-27
View File
@@ -108,15 +108,6 @@ LEGACY_WRONG = {
"a cozy library with tall shelves",
"legacy shows branch 1; the file name names branch 4's saver",
),
# #548, the one shape where nothing is read on purpose: legacy shows a
# PromptSearchList's search text ("fox") as the positive, but that is the
# library query, not the prompt the batch ran. The scan and the monitor
# never store or link it either (utils.file_prompt._search_text).
("inspection_parity", "f22"): (
None,
"legacy shows the PromptSearchList search text; each item is decided "
"at run time, so no positive is read (#548)",
),
}
@@ -158,24 +149,6 @@ class LegacyParityGateTest(unittest.TestCase):
self.assertEqual(text, expected)
self.assertNotEqual(text, legacy)
def test_the_search_list_query_is_never_stored_either(self):
"""#548: the scan and the monitor depart from legacy for f22 too."""
from utils.file_prompt import Policy, decide_file_prompt
graph, workflow = corpora()["inspection_parity"]["f22"]
self.assertEqual(legacy_positive(graph, workflow), "fox")
for policy in Policy:
with self.subTest(policy=policy):
decision = decide_file_prompt(
{"prompt": graph, "workflow": workflow},
file_name="f22.png",
file_size=None,
in_output=True,
lookup=lambda: None,
policy=policy,
)
self.assertEqual((decision.kind, decision.text), ("never", None))
def test_no_graph_leaves_the_legacy_agreement(self):
recorded = json.loads(AGREEMENT_PATH.read_text(encoding="utf-8"))
current = {name: agreeing(graphs) for name, graphs in corpora().items()}
+2
View File
@@ -143,6 +143,8 @@ class TestLibrary(unittest.TestCase):
self.db.get_library_positive(positive, variant_limit=201)
def test_sorts_keysets_survive_anchor_deletion_and_insertions_without_writes(self):
import sqlite3
a = self.save("Alpha", rating=2, tags=["tag"])
b = self.save("beta", rating=5)
c = self.save("charlie")
+1 -1
View File
@@ -297,7 +297,7 @@ class TestLibraryDeleteRoutes(LibraryDeleteRouteCase):
)
page = await response.json()
self.assertEqual(page["archived_total"], 3)
self.assertLessEqual(set(item["id"] for item in page["items"]), set(kept))
self.assertTrue(set(item["id"] for item in page["items"]) <= set(kept))
response = await self.client.request(
"GET",
"/prompt_manager/library?limit=2&shelf=archived"
-1
View File
@@ -135,7 +135,6 @@ class TestLogsAndCache(_EnvCase):
get_logger_manager()._setup_loggers()
except Exception:
# best effort restore in teardown; must not hide the test result
pass
def test_lora_image_cache_follows_the_data_folder(self):
+1 -1
View File
@@ -18,7 +18,7 @@ import tempfile
import threading
import types
import unittest
import unittest.mock as mock
from unittest import mock
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+1 -1
View File
@@ -22,7 +22,7 @@ import sqlite3
import sys
import threading
import unittest
import unittest.mock as mock
from unittest import mock
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+11 -11
View File
@@ -1112,10 +1112,10 @@ class LocalProjectionParityTests(unittest.TestCase):
self.assertEqual(_canonical_bytes(local), _canonical_bytes(server))
seen.update(server["record_formats"].values())
seen.add(server["workflow_graph"]["status"])
self.assertLessEqual(
{"reserialized-json", "original-text", "invalid", "limited"}, seen
self.assertTrue(
{"reserialized-json", "original-text", "invalid", "limited"} <= seen
)
self.assertLessEqual({"parsed", "malformed", "limited"}, seen)
self.assertTrue({"parsed", "malformed", "limited"} <= seen)
def _server_evidence(records, file_name=None):
@@ -1287,7 +1287,7 @@ class LocalResolverParityTests(unittest.TestCase):
def test_local_roles_match_selection_record_sets(self):
answers = self._compare(selection_cases.selection_cases())
reasons = {answer["resolution"]["reason"] for answer in answers.values()}
self.assertLessEqual(
self.assertTrue(
{
"Every sampler branch uses the same prompt pair",
"Multiple sampler branches",
@@ -1301,8 +1301,8 @@ class LocalResolverParityTests(unittest.TestCase):
"Negative prompt delimiter is absent",
"Parameters exceed the length limit",
"No A1111 parameters",
},
reasons,
}
<= reasons
)
# A stage reaching only previews is dropped: one sampler is named.
self.assertEqual(
@@ -1352,7 +1352,7 @@ class LocalResolverParityTests(unittest.TestCase):
def test_local_roles_match_golden_record_sets(self):
answers = self._compare(resolver_cases.golden_cases())
codes = {answer["resolution"]["reason_code"] for answer in answers.values()}
self.assertLessEqual(
self.assertTrue(
{
"malformed",
"unsupported-node",
@@ -1364,16 +1364,16 @@ class LocalResolverParityTests(unittest.TestCase):
"graph-ceiling",
"no-sampler",
None,
},
codes,
}
<= codes
)
sources = {
side["source"]
for answer in answers.values()
for side in answer["prompts"].values()
}
self.assertLessEqual(
{"execution", "graph-inferred", "zeroed-conditioning"}, sources
self.assertTrue(
{"execution", "graph-inferred", "zeroed-conditioning"} <= sources
)
def test_committed_resolver_vectors_are_current(self):

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