Compare commits

..
Author SHA1 Message Date
Vito Sansevero 38f5491da1 refactor(thumbnails): keep the after-scan queue's store rule and pin the write lock (#320)
A planned target keeps the plan's own containment rule (inside the
store, as the thumbnail job writes it); captures keep the serve rule.
The structural test also covers PREVIEW_FOLDER joins and the
repo-root modules; the write-lock test asserts the lock instead of
waiting on a timer; new tests cover nested roots in the after-scan
queue and a legacy upper-case preview served through the resolver.
2026-10-31 19:11:21 -07:00
Vito Sansevero c0328e993a refactor(thumbnails): one resolver for where previews live on disk (#320)
Add py/api/preview_store.py: the store of a source, a preview's path,
URL parts to disk, containment (owns), a write generation and the lock
around every preview's final rename. Every preview reader, writer and
remover now asks it instead of joining <source>/thumbnails by hand:
serve, on-demand, capture (its path resolved when the write starts),
the after-scan queue, the thumbnail plans and job payloads, clear, the
temp sweep, History covers, the auto-tag listing and the owned-preview
lookups.

Source mode only: every path is the one built before, so nothing
changes on disk or on the wire. A structural test keeps new
thumbnails joins out of the backend.
2026-10-31 18:51:18 -07:00
188 changed files with 1546 additions and 13590 deletions
-21
View File
@@ -1,21 +0,0 @@
# Left out of the Comfy Registry package (comfy node publish). Git clones still
# have everything. tests/test_package_contents.py holds these rules to what
# ComfyUI needs at run time; keep each one anchored to the root with a leading /.
# Development folders
/tests/
/tools/
/docs/
/.github/
# Screenshots used only by README-legacy.md on GitHub
/images/
# Development files
/TESTING.md
/Makefile
/requirements-dev.txt
/.coveragerc
/.flake8
/.pre-commit-config.yaml
/.gitignore
-5
View File
@@ -17,11 +17,6 @@ updates:
prefix: "deps(pip)"
include: "scope"
rebase-strategy: "auto"
# These floors follow ComfyUI's requirements.txt, never the newest release.
ignore:
- dependency-name: "Pillow"
- dependency-name: "aiohttp"
- dependency-name: "numpy"
# Optional: group non-breaking updates to reduce PR noise
groups:
python-minor-and-patch:
+1 -1
View File
@@ -80,7 +80,7 @@ jobs:
# pulled them from Ubuntu's apt mirror on every run, and three runs (#349, #378, #382) crawled at
# ~26 kB/s for 11-12 minutes until the job hit its limit after every test had passed.
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
image: mcr.microsoft.com/playwright:v1.64.1-noble
options: --user 1001
timeout-minutes: 24
steps:
+3 -3
View File
@@ -53,7 +53,7 @@ jobs:
# same pinned Playwright image as local runs (tag = @playwright/test in tools/vendor/package-lock.json;
# tests/vendor/browser-image.test.mjs checks it). Browsers and fonts come from the image.
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
image: mcr.microsoft.com/playwright:v1.64.1-noble
options: --user 1001
timeout-minutes: 20
# Public-repo ubuntu-latest runners have 4 vCPUs: three Playwright workers, each with its own test
@@ -121,7 +121,7 @@ jobs:
runs-on: ubuntu-latest
# Same image as the shards: the raw files name sources by the container's workspace path.
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
image: mcr.microsoft.com/playwright:v1.64.1-noble
options: --user 1001
timeout-minutes: 10
steps:
@@ -192,7 +192,7 @@ jobs:
# same pinned Playwright image as local runs (tag = @playwright/test in tools/vendor/package-lock.json;
# tests/vendor/browser-image.test.mjs checks it). Browsers and fonts come from the image.
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
image: mcr.microsoft.com/playwright:v1.64.1-noble
options: --user 1001
timeout-minutes: 20
# Public-repo ubuntu-latest runners have 4 vCPUs: three Playwright workers, each with its own test
+4 -4
View File
@@ -12,10 +12,10 @@ Prompt Manager saves your prompts as you generate, links each image back to the
## What's new: rebuilt from the ground up
- **A new UI.** One app, the workspace, replaces the separate dashboard, gallery and metadata pages.
- **A new UI.** One app, the workspace, replaces the separate dashboard, gallery and metadata pages. The old pages still work if you want them.
- **Classic and Nodes 2.0.** The nodes work in both ComfyUI renderers. CI starts a real ComfyUI and checks both.
- **Tested end to end.** Unit tests, browser tests in Chromium, Firefox, WebKit and touch emulation, and tests against a real ComfyUI.
- **Linux and Windows.** The Python suite runs on both, on Python 3.12, 3.13 and 3.14. macOS should work but is not tested.
- **Mac, Linux and Windows.** The Python suite runs on all three, on Python 3.12, 3.13 and 3.14.
## See it
@@ -212,9 +212,9 @@ Set `PROMPT_MANAGER_DATA_DIR` to an absolute folder to keep the library somewher
- **Older images:** a one-time notice offers a metadata scan that links them to prompts and negatives. Scans don't count as new runs.
- **Going back isn't supported.** Workflows are now stored once, and an older version shows new records without their workflow or prompt graph. Back up first in System > Backup & export. See [the old README's changelog](README-legacy.md#changelog) for the manual way back.
## The old pages will be removed
## The old pages still work
They still work at their URLs for now and will be removed in a future release. Everything they do is moving to the workspace.
They stay at their URLs, and System's Overview section links to them:
| Page | URL |
| --- | --- |
+20 -274
View File
@@ -77,7 +77,7 @@ PM_TEST_WORKERS=8 npm run --prefix tools/vendor test:coverage # whole
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 coverage:merge # report and baseline check of every raw file
npm run --prefix tools/vendor test:workflows -- --project webkit --shard 2/3
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
@@ -111,11 +111,8 @@ distributions, browser system dependencies may need manual installation. For a
quick browser run, use `test:workflows -- --project chromium`.
CI runs the browser jobs inside the pinned image
`mcr.microsoft.com/playwright:v<@playwright/test version>-noble@sha256:<digest>`
`mcr.microsoft.com/playwright:v<@playwright/test version>-noble`
(`tests/vendor/browser-image.test.mjs` keeps the tag in step with the lockfile).
The digest is part of the pin, because Microsoft re-pushes tags: `v1.64.1-noble`
moved from Node 24 to Node 26 on 2026-11-01 (#609). To bump it, change the tag and
digest together and check the new image's Node against the Node 24 guard.
Concept comparisons measure geometry, and geometry follows the fonts a browser
falls back to: the concept names Inter, which neither the image nor a stock
runner ships. On a plain `ubuntu-latest` runner the fallback is DejaVu Sans,
@@ -193,8 +190,8 @@ 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
`frontend coverage (merge)` to succeed on every event. The six
`page workflows (firefox|webkit, shard N/3)` jobs run only in the merge queue,
`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
@@ -668,10 +665,6 @@ Browser tests: `workspace-stale-source.spec.mjs` (`/__test__/sources/reverse` an
`/__test__/scope-key/rotate`, both undone by every reset); the manual-Refresh
recovery specs in `workspace-images.spec.mjs` answer every rescope 409.
## Registry package
`comfy node publish` zips the tracked files minus `.comfyignore`, so a new development file or folder at the root ships until it is listed there. `tests/test_package_contents.py` keeps each rule anchored to the root, checks that development paths are left out and that nothing ComfyUI needs at run time is. Git clones are unaffected.
## Windows portability preflight
Before changing paths, SQLite, streams, restore or cleanup:
@@ -1893,8 +1886,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.
@@ -2735,7 +2727,7 @@ the grid) and D43 (sorted chips, checked against the concept with its chips sort
D44 needs no mapping: the comparison runs with no Refine filter active, so the button
reads "Filters" as the concept's does. Nothing else follows the grid: the legacy
"Current prompt library" link is removed from the shelf; the legacy Library stays at
`/prompt_manager/admin` by direct URL (#525, #620)
`/prompt_manager/admin` by direct URL, linked only from System (#525, D165)
(`tests/browser/workspace.spec.mjs`). `library comparison detects
mutations` injects `.card-footer { padding: 2px }` and removes `.card-meta .chip.red`.
@@ -2986,8 +2978,7 @@ change: ComfyUI needs a restart.
A Pair save, by a run or the node's Save pair button, writes the category and tags to the positive prompt only when that
save creates the prompt row (maintainer ruling). An existing prompt never changes on a run: empty, cleared or set by a person, including one first seen as
another pair's negative. The negative stays empty, the saved pair keeps its own metadata, and `save_prompt_pair` writes
no prompt metadata unless called with `fill_new_positive`. No backfill on a run; the opt-in repair for prompts older
Pair runs saved is System › Maintenance › "Pair category and tags" (#550, "Pair category and tags repair" below).
no prompt metadata unless called with `fill_new_positive`. No backfill; optional repair is #550.
Tests: `tests/test_prompt_pair_node.py`, `tests/test_api_pairs.py`, `tests/comfyui/pair-node.spec.mjs`. A Python change: ComfyUI needs a restart.
### Delete… on the archived shelf (#492)
@@ -3243,7 +3234,7 @@ npm run --prefix tools/vendor test:workflows -- history-concept.spec.mjs \
`#/inspect` ("Inspect a file") is a native workspace route; it no longer hands off to
the legacy metadata page, which stays at its URL
(`/prompt_manager/gallery.html`) at its URL only (#525, #620). The page is the concept's `#metadata`
(`/prompt_manager/gallery.html`) linked only from System (#525, D165). The page is the concept's `#metadata`
(app.js `renderMetadata` a740–748, selection a1715–1729; style.css 709–722, 1180–1194,
1543–1545, 1559–1564) in the concept's own classes (`.surface`, `.file-drop`,
`.spaced-20`, `.local-file-preview`, all from the synced `concept.css`), with no
@@ -4316,12 +4307,6 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
primary stays the concept's plain button; the plan it opens has the primary.
`duplicates dialog matches the concept` and `duplicates comparison detects mutations`
(system-maintenance-concept.spec) pin the mapping.
- **D192** (Maintenance, #550, beyond the concept): Database adds "Pair category and
tags" after "Unused prompt components", a plain Review plan row (no danger mark: it
deletes nothing) with the line "Fill an empty category or tags on prompts older Pair
node runs saved, from their saved pairs. Only empty fields of never-edited prompts."
`system-maintenance-concept.spec.mjs` maps it (`mapPairRow`) as a copy of Optimize
database's row.
- **D102** (Maintenance, S7, Q5, #222): Metadata cache clears only the browser entries
Prompt Manager owns as a cache (`web/js/cache-ownership.js`). The inventory of every
storage writer has none (see "Browser cache ownership" below), so the row's line adds
@@ -4621,9 +4606,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
@@ -4645,74 +4630,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).
- **D205** (Prompt library, #620, beyond the concept): the sort menu appends Recently used, Most used and Name Z–A
after Oldest first. These are the old Dashboard's sorts, kept before its removal because the community asked for
them. A prompt never run sorts last in both use orders.
- **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":
@@ -4944,47 +4861,6 @@ replaced the database since the review; the plan is dropped). The job:
prompt ids only), logs the backup by its file name, and prunes safety backups to the
newest 10. With no backup the log and the status line say "No safety backup".
#### Pair category and tags repair (#550)
`pair_metadata` ("Pair category and tags", D192; `database/pair_metadata_repair.py`) fills
the empty category and the empty tag set of prompts that Pair node runs before #546
saved, from their saved pairs. It is reviewed and applied like the operations above and
follows the safety backup policy like them (no forced backup). Rules
(`docs/egm/post-3.4.1/550-design.md`):
- Considered: prompts the Pair node created as a positive: their earliest pair (lowest
id, either role, archived included) has them as the positive and its `created_at` is
within 2 s of theirs. A prompt first saved by the PromptManager node, or first as a
negative, is out of scope and not counted.
- Sources: the prompt's non-archived pairs where it is the positive; pairs with a blank
category or no tags are ignored for that field. Category values are trimmed and
case-sensitive, tag sets compared by tag id; a field fills when exactly one value is
left. A whitespace-only category counts as empty.
- Kept, per field, in this order: archived prompt, edited prompt (`updated_at` not within
2 s of `created_at`; NULL or unreadable counts as edited), pairs that disagree, a tag
set over 20 tags. A prompt can fill one field and keep the other.
- The plan lists protected rows first, then fills, then a "Record" row, within the 50
detail rows; at most 20,000 prompts per plan ("review again for the rest").
- The fingerprint covers each considered prompt's computed result (values, tag ids and
names, source pair ids), never a pair's title, notes, rating, run count or use
stamps, so a Pair run between review and apply still applies.
- The apply's writes are guarded (`category` still blank, no `prompt_tags` row) and a
guard that hits nothing rolls the whole transaction back as `plan-changed`. It never
writes `prompt_pairs`, `prompt_pair_tags`, `updated_at`, a tag or Library state.
- The job lists 200 changed rows ("Prompt 12: category “night”; tags a, b"); its
Download report lists every one (`truncated: false`, `Job.record_counted(complete=True)`).
Protected lists one row per kept prompt in the review's wording and order ("Prompt 7:
kept, pairs disagree on category: Adult, asian"), also complete in the report.
- Activity shows a row with no path as a record: its detail as plain text, never an open
or locate action, and the filter reads the detail. A maintenance job of records (every
operation but Missing-file links) says "No records in this group.", "No listed record
matches this filter." and "Filter records…" (`groupCopy`); file jobs keep "files".
Tests: `tests/test_maintenance_pair_metadata.py`, `tests/test_api_maintenance_pair_metadata.py`,
`tests/test_jobs.py`, `tests/js/workspace-system-maintenance.test.js`,
`tests/browser/workspace-system-maintenance.spec.mjs` (hook `/__test__/system/pair-metadata`).
A Python change: ComfyUI needs a restart.
The legacy restore (`POST /prompt_manager/restore`) takes a backup of any size the disks
have room for, so a real library's safety backup restores: the upload, its staged copy
and the swap (the upload twice in the temporary folder, once more plus the current
@@ -5339,16 +5215,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
@@ -5359,70 +5235,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`,
@@ -5909,73 +5723,6 @@ are closed before `os.replace`; renames stay inside one folder; folder identity
folder are refused. The browser test server closes its own log handlers before its temp
root (which now holds the logs) is removed.
### Thumbnail location contract (#320)
Previews live in each source's `thumbnails/` (the default) or in one folder, `<folder>/<source key>/…`. Every reader,
writer and remover asks `py/api/preview_store.py`; `tests/test_preview_store.py` fails on a `thumbnails` path join
anywhere else. The setting is `thumbnails.location` in config.json, and only the Move thumbnails job writes it: POST
`/settings` answers 409 `location-needs-move`.
- **Keys and marker.** A key is a slug plus a hash of the source's identity. The folder's
`.promptmanager-thumbnails.json` stores each key with its identity and install. Only a single `<slug>-<hex>` name
is ever joined onto the folder, and anything else in a marker is dropped. Every write and delete passes
`preview_store.may_write`: below a key folder, with no link, junction or reparse point on the way
(`tests/test_preview_store_folder.py`, including tampered markers).
- **Unavailable.** A folder is unavailable when it is missing, is a link, holds no marker or holds another
folder's marker. A probe checks it every 5 s, and a store call that fails or stalls marks it at once. While it is
unavailable:
- originals are served and nothing is written anywhere;
- generate, convert, clear and prune answer 409 `location-unavailable`;
- the folder is never made again except by Recreate folder.
The configured location is in force before any request. A bad value is `invalid`, never a fall-back to the
sources.
- **Move thumbnails.** The job is a `thumbnails` job with `scope.mode` `move` (`tests/test_thumbnail_move.py`). It
runs in this order:
1. The review answers `POST /images/thumbnails/plan`; not enough space answers 507.
2. A token starts the job.
3. Automatic previews are held.
4. Copies run on the job pool in chunks of 2 or 4 files, each with its size checked and its mtime rounded up to
2 s.
5. One config commit makes the switch and records the old stores under `cleanup`. The job cannot be cancelled
from here.
6. The old copies are removed.
If it fails or is cancelled before the switch, nothing changes. A crash leaves
`<data>/data/thumbnail-move.json`, which the next start undoes. Old copies still held open are removed by the
next start's "Move thumbnails (finishing)" job. Leaving a folder also removes the previews of this install's
sources that are no longer listed (Q320-8).
- **Remove thumbnails of removed sources** (`scope.mode` `prune`). It is offered in Thumbnail maintenance only in
folder mode, and it touches only this install's keys.
- **Page tests.** `tests/browser/workspace-thumbnail-location.spec.mjs` covers the row, both moves, the in-place
refusal, the confirmation, the notice with Recreate and with Use this folder. The legacy gallery runs once in
folder mode (`workflows.spec.mjs`). The hooks are `tests/browser/thumbnail_location_fixture.py`.
Beyond the concept, against the approved static mock `docs/egm/post-3.4.1/320-mock`:
- D196: the Thumbnail location row in System › Sources & scans, after Thumbnail generation.
- The status line is "Now: inside each source." or "Now: one folder, <path>.". It does not show the mock's
"(3 sources, 8,512 thumbnails)" counts, because counting means walking every preview on each view. It says
"Using the default folder next to your library." and names old copies that wait to be removed.
- D197: the Move thumbnails review uses the shared review.
- A grid value may be a lead sentence with a list (`[lead, items]`).
- Sources no longer listed appear under "Removed after the move", where the mock has a "Not moved" row
(maintainer answer to Q320-8: remove).
- The Free space line is "<size> free", without naming the disk.
- D198: Activity's view of `mode: move` and `mode: prune`.
- Tiles for a move: Moved, Already there, Left in place, Errors. Tiles for a prune: Removed, Left in place,
Errors.
- The step line under the bar is `#job-phase`.
- Cancel shows only until the switch.
- D199: the unavailable notice on Overview and Sources & scans.
- Recreate folder appears for a missing folder, after a confirmation.
- Use this folder appears for another folder's marker.
- Change location focuses the row; on Overview it opens Sources & scans.
- D200: Thumbnail maintenance's "Remove thumbnails of removed sources" scope, with the plan line and a grid of
key folders.
- D201: Move library's review adds "Thumbnails: Stay in <path>. Move them in System › Sources & scans." in folder
mode.
### Backup and restore contract (#249 System S8)
A restore is staged, then published (`database/restore_stage.py`). The upload is
@@ -7495,7 +7242,7 @@ Deploying this backend needs a ComfyUI restart (Python and route changes).
`#/tags` ("Tags & categories", page title "Tags") is a native workspace route; it no
longer hands off to the legacy tag manager. That page stays at
`/prompt_manager/admin#/tags` at its URL only (#525, #620); tag search and sort, the untagged
`/prompt_manager/admin#/tags` linked only from System (#525, D165); tag search and sort, the untagged
entry (D149) and multi-tag OR (the Library's Tag match, D150) moved to the workspace
(#444); bulk prompt delete stays on the legacy admin page (bulk tags: the Prompt
library's selection bar, D151). A prompt's category is changed in the Prompt library
@@ -7627,8 +7374,7 @@ The legacy pages (`/prompt_manager/admin`, `admin#/tags`, `gallery`, `gallery.ht
their URLs, and admin's Metadata and Gallery buttons still open their legacy pages (maintainer
ruling; pinned by `legacy-system-links.spec.mjs`).
- **D165 retired** (#620): the old pages show their removal notice since 3.5.1, so System no longer links them and the load failure below offers a reload and ComfyUI. The original ruling:
**D165** (beyond the concept, maintainer ruling 2026-10-30): System's Overview section ends
- **D165** (beyond the concept, maintainer ruling 2026-10-30): System's Overview section ends
with one small line, "Legacy pages: Dashboard · Tags · Gallery · Metadata viewer"
(`.legacy-pages`, new tabs), outside `.settings-layout`. The concept has no such line;
`mapOverviewConcept` adds it to the page tree.
+5 -21
View File
@@ -92,21 +92,10 @@ try:
routes = config.routes
api = PromptManagerAPI()
api.add_routes(routes)
# The thumbnail location's startup check (#320), then the temp files of
# preview writes a crash interrupted (#317), off the import path: the
# walk may cover a large thumbnails folder.
# #320: the configured thumbnail location is in force before any request
# (no I/O); the thread below checks it.
api.use_preview_location()
def _preview_startup():
api.start_preview_location()
api.recover_thumbnail_move() # an interrupted move, before the sweep
api.sweep_thumbnail_temps()
# Temp files of preview writes a crash interrupted (#317), off the
# import path: the walk may cover a large thumbnails folder.
threading.Thread(
target=_preview_startup,
target=api.sweep_thumbnail_temps,
name="PromptManagerPreviewTempSweep",
daemon=True,
).start()
@@ -119,8 +108,6 @@ try:
# Build each output root's Images listing once the server's loop runs,
# so the first open does not wait for the walk (#348).
host_app.on_startup.append(api.warm_gallery_listings)
# #320: old thumbnails a move left to remove, once it is checked.
host_app.on_startup.append(api.finish_thumbnail_move)
if host_app is not None and hasattr(host_app, "on_shutdown"):
# Open Needs review change streams end before the server waits on them.
host_app.on_shutdown.append(api.close_review_events)
@@ -461,11 +448,8 @@ except Exception as e:
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
# One startup line, printed at import on purpose: it is the package's banner on
# stdout, as ComfyUI's other custom nodes print theirs. Not a log record: the
# console log handler hides INFO by default, so a logger would drop it. ASCII
# only: a Windows console using a legacy code page cannot print emoji, and a
# UnicodeEncodeError here would abort loading the node.
# 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"
+7
View File
@@ -291,6 +291,13 @@ class AnnotationOperations:
conn.execute(_BUMP_INDEX)
return conn.execute(_REVISION, (byte_id,)).fetchone()[0]
def _current_set(self, conn, sha256, byte_id, revision) -> dict:
return {
"sha256": sha256,
"tags": self._byte_tags(conn, byte_id),
"tag_revision": revision,
}
def read_image_tags(self, locator: str, sha256: str) -> dict:
"""The stored set of these exact bytes, or an empty set at revision 0.
+25 -20
View File
@@ -16,17 +16,18 @@ check, and none of them carries a server path.
import datetime
import logging
import ntpath
import os
import sqlite3
from pathlib import PurePosixPath, PureWindowsPath
from urllib.parse import quote
try:
from ..utils.hashing import register_prompt_functions
from .models import normalize_image_path
from .sqlite_uri import readonly_uri
except ImportError:
from utils.hashing import register_prompt_functions
from database.models import normalize_image_path
from database.sqlite_uri import readonly_uri
logger = logging.getLogger("prompt_manager.database.health")
@@ -88,6 +89,25 @@ def _counted(lines):
return _result(count, detail)
def readonly_uri(db_path, *, windows=None):
"""The ``mode=ro`` SQLite URI of *db_path*, on this platform or the given one.
pathlib spells a UNC path ``\\\\server\\share\\x.db`` as
``file://server/share/x.db``, a remote authority SQLite refuses to open.
With an empty authority (``file:////server/share/x.db``) SQLite hands
Windows the ``//server/share`` path, which it opens as the UNC path.
"""
if windows is None:
windows = os.name == "nt"
db_path = os.path.abspath(db_path)
if not windows:
return PurePosixPath(db_path).as_uri() + "?mode=ro"
path = PureWindowsPath(db_path)
if ntpath.splitdrive(str(path))[0].startswith("\\\\"):
return "file:" + quote("//" + path.as_posix()) + "?mode=ro"
return path.as_uri() + "?mode=ro"
def _integrity(conn, _roots):
rows = [
row[0] for row in conn.execute(f"PRAGMA integrity_check({INTEGRITY_LIMIT})")
@@ -179,23 +199,6 @@ def link_states(rows, roots, readable):
check's ``missing_files`` and the maintenance missing-link plan both
judge links this way, so their counts agree.
"""
for row, owner, state in _placed_links(rows, roots, readable):
if state is None:
state = "present" if os.path.exists(row[1]) else "missing"
yield row, owner, state
def held_links(rows, roots, readable):
"""``(row, owner, state)`` for each link ``link_states`` holds back
(``unavailable`` or ``outside``), without looking at any file (#586)."""
for row, owner, state in _placed_links(rows, roots, readable):
if state is not None:
yield row, owner, state
def _placed_links(rows, roots, readable):
"""``link_states`` before any file is looked at: state None means the
link lies under a readable source."""
prefixes = [_root_prefix(root) for root in roots]
for row in rows:
owner = _image_owner(row[1], row[3], prefixes)
@@ -203,8 +206,10 @@ def _placed_links(rows, roots, readable):
yield row, None, "outside"
elif not readable[owner]:
yield row, owner, "unavailable"
elif os.path.exists(row[1]):
yield row, owner, "present"
else:
yield row, owner, None
yield row, owner, "missing"
def _unavailable_line(unavailable, *, partial):
-3
View File
@@ -97,9 +97,6 @@ _ORDERS = {
"newest": ("COALESCE(julianday(p.created_at),0)", "DESC"),
"oldest": ("COALESCE(julianday(p.created_at),0)", "ASC"),
"name": (_NAME, "ASC"),
"name-desc": (_NAME, "DESC"),
"most-used": ("COALESCE(p.run_count,0)", "DESC"),
"last-used": ("COALESCE(julianday(p.last_used_at),0)", "DESC"),
"rating": ("COALESCE(p.rating,0)", "DESC"),
}
# Pair archive scopes for variant and image reads, as conditions on alias pp.
+2 -6
View File
@@ -47,11 +47,11 @@ import sqlite3
import time
import types
import uuid
from pathlib import Path
from dataclasses import dataclass, field
from typing import Callable, Dict, List, Optional, Tuple
from .models import BUSY_TIMEOUT_SECONDS, PromptModel
from .sqlite_uri import readonly_uri
try:
from ..utils import library_location as ll
@@ -78,10 +78,6 @@ MESSAGES = {
),
"same-folder": "The new folder is the node folder itself.",
"target-invalid": "The new folder cannot hold the library.",
"data-dir-served": (
"The new folder is inside Prompt Manager's thumbnail folder. Nothing was "
"moved."
),
"target-has-library": (
"The new folder already holds a Prompt Manager library. Nothing was moved."
),
@@ -322,7 +318,7 @@ def _verify_copy(temp: str, counts, documents) -> None:
"verify-failed",
f"{MESSAGES['verify-failed']} {detail}" if detail else None,
)
uri = readonly_uri(temp)
uri = Path(os.path.abspath(temp)).as_uri() + "?mode=ro"
try:
with contextlib.closing(sqlite3.connect(uri, uri=True)) as conn:
copied = _describe(conn)
+24 -95
View File
@@ -2,8 +2,7 @@
Four operations, each the native owner of a legacy ``/maintenance`` one and
built on the same queries, so a preview counts what the legacy cleanup would
change (``workflow_storage`` and #550's ``pair_metadata``, which fills old
Pair prompts' empty category and tags, live in their own modules):
change:
- ``duplicate_prompts``: prompts whose text is equal ignoring case and outer
spaces (``DUPLICATE_PROMPTS_SQL``, legacy ``cleanup_duplicates``); each
@@ -43,7 +42,7 @@ from dataclasses import dataclass, field
from pathlib import Path
from .execution_contexts import LIVE_HINT_CTES
from .health import BATCH_ROWS, held_links, link_states, readable_roots, readonly_uri
from .health import BATCH_ROWS, link_states, readable_roots, readonly_uri
from .image_removal_schema import TABLE as MARKS_TABLE
from .image_removal_schema import clear_marks, has_marks_table
from .pairs import PairDependencyError
@@ -61,9 +60,6 @@ MAINTENANCE_OPERATIONS = (
"missing_links",
# Applied as its own batched job (database/workflow_storage.py).
"workflow_storage",
# #550: fill old Pair prompts' empty category and tags
# (database/pair_metadata_repair.py).
"pair_metadata",
)
MAX_DETAILS = 50 # detail rows a plan lists
MAX_RESULT_ROWS = 200 # changed records an apply describes (the job lists 200)
@@ -130,16 +126,6 @@ _UNLINKED_PROTECTIONS_SQL = (
+ "), 0) FROM prompts p WHERE NOT EXISTS "
"(SELECT 1 FROM generated_images gi WHERE gi.prompt_id = p.id)"
)
# Each prompt no image links to, with which protections keep it (#586); the
# ones with none are filtered out in Python, so each predicate runs once.
_UNLINKED_KEPT_SQL = (
"WITH "
+ LIVE_HINT_CTES
+ " SELECT p.id, "
+ ", ".join(f"({predicate})" for predicate, _many, _one in PROTECTIONS)
+ " FROM prompts p WHERE NOT EXISTS "
"(SELECT 1 FROM generated_images gi WHERE gi.prompt_id = p.id) ORDER BY p.id"
)
# Duplicate prompt groups, as the legacy cleanup finds them (case-insensitive
# text; GROUP_CONCAT order is sorted by created_at afterwards).
@@ -169,14 +155,6 @@ _PROMPT_STATE_SQL = (
"ORDER BY id",
)
# Why the missing-link cleanup holds a link back: (one, many) (#586).
HELD_LINK_REASONS = {
"unavailable": (
"link under an unavailable source",
"links under unavailable sources",
),
"outside": ("link outside the image sources", "links outside the image sources"),
}
NO_FILES_DELETED = "No image file is deleted."
LINKS_ONLY = "Database links only; no image file is deleted."
UNUSED_DETAIL = "No image, saved pair, Library state or recorded run uses it"
@@ -190,7 +168,6 @@ CONFLICT_REASONS = {
"groups have their own Library titles",
),
}
PAIRED_DETAIL = "Kept: belongs to a saved pair"
CONFLICT_DETAILS = {
"collision": "Kept: two copies link the same image",
"title": "Kept: a copy has its own Library title",
@@ -432,35 +409,6 @@ def _source_name(root, index):
return os.path.basename(str(root).rstrip("\\/")) or f"Source {index + 1}"
def _kept_duplicates(paired, conflicted):
"""``{prompt_id, reasons}`` for each prompt of a group kept whole (#586).
The reasons are the review's details without "Kept: ".
"""
held = [(ids, PAIRED_DETAIL) for ids in paired] + [
(ids, CONFLICT_DETAILS[reason]) for ids, reason in conflicted
]
return [
{"prompt_id": prompt_id, "reasons": [detail.removeprefix("Kept: ")]}
for ids, detail in held
for prompt_id in ids
]
def _kept_unused(conn):
"""``{prompt_id, reasons}`` for each prompt the unused prune keeps (#586).
The reasons are the review's, one per protection that applies, in the
order the plan names them.
"""
kept = []
for row in conn.execute(_UNLINKED_KEPT_SQL):
reasons = [one for flag, (_p, _m, one) in zip(row[1:], PROTECTIONS) if flag]
if reasons:
kept.append({"prompt_id": row[0], "reasons": reasons})
return kept
class MaintenanceOperations:
"""Mixin for PromptDatabase: maintenance previews and their apply."""
@@ -585,10 +533,6 @@ class MaintenanceOperations:
lambda conn: (self._link_rows(conn), _mark_rows(conn)),
)
return self._plan_missing_links(rows, roots, _stale_marks(marks, roots))
if operation == "pair_metadata":
from .pair_metadata_repair import plan_pair_metadata
return _read_snapshot(self.model.db_path, plan_pair_metadata)
reader = (
self._plan_duplicate_prompts
if operation == "duplicate_prompts"
@@ -640,7 +584,7 @@ class MaintenanceOperations:
+ [
{
"label": _excerpt(texts.get(group[0])),
"detail": PAIRED_DETAIL,
"detail": "Kept: belongs to a saved pair",
}
for group in paired
]
@@ -718,24 +662,6 @@ class MaintenanceOperations:
],
)
def kept_links(self, roots):
"""``{image_path, owner, reason}`` for each link the missing-link
cleanup holds back under ``roots`` (blocking, reads only, no file
opened); ``owner`` indexes ``roots`` (None outside every source).
``reason`` is the review's (``HELD_LINK_REASONS``), one link each.
"""
roots = [str(root) for root in roots]
rows = _read_snapshot(self.model.db_path, self._link_rows)
return [
{
"image_path": row[1],
"owner": owner,
"reason": HELD_LINK_REASONS[state][0],
}
for row, owner, state in held_links(rows, roots, readable_roots(roots))
]
@staticmethod
def _links_fingerprint(candidates, stale=()):
fingerprint = _Fingerprint("missing_links")
@@ -755,11 +681,23 @@ class MaintenanceOperations:
unavailable += 1
elif state == "outside":
outside += 1
reasons = [
_plural(count, *HELD_LINK_REASONS[state])
for state, count in (("unavailable", unavailable), ("outside", outside))
if count
]
reasons = []
if unavailable:
reasons.append(
_plural(
unavailable,
"link under an unavailable source",
"links under unavailable sources",
)
)
if outside:
reasons.append(
_plural(
outside,
"link outside the image sources",
"links outside the image sources",
)
)
count = len(candidates)
parts = []
if count:
@@ -862,11 +800,7 @@ class MaintenanceOperations:
records changed and up to ``MAX_RESULT_ROWS`` descriptions of them
(``{prompt_id[, kept_id]}`` or ``{image_path}``). ``marks`` are a
missing-links plan's stale removal marks; that apply also returns
``marks``, how many it forgot. ``pair_metadata`` returns every filled
prompt, uncapped (its job keeps the complete record, #550). The
prompt operations also return ``kept``: every prompt the plan keeps,
``{prompt_id, reasons}`` in the review's wording (#550, #586); the
links a missing-links plan keeps are read by ``kept_links``.
``marks``, how many it forgot.
"""
if operation not in MAINTENANCE_OPERATIONS or operation == "workflow_storage":
raise ValueError(f"Unknown maintenance operation {operation!r}")
@@ -881,10 +815,6 @@ class MaintenanceOperations:
"unused_components": self._apply_unused_components,
}
with self._pair_transaction() as conn:
if operation == "pair_metadata":
from .pair_metadata_repair import apply_pair_metadata
return apply_pair_metadata(conn, fingerprint, targets)
if operation == "missing_links":
return self._apply_missing_links(
conn, fingerprint, targets, roots, list(marks)
@@ -892,11 +822,10 @@ class MaintenanceOperations:
return appliers[operation](conn, fingerprint, targets, roots)
def _apply_duplicate_prompts(self, conn, fingerprint, targets, _roots):
groups, paired, conflicted, current = self._duplicates_fingerprint(conn)
groups, _paired, _conflicted, current = self._duplicates_fingerprint(conn)
if current != fingerprint or groups != targets:
raise PlanChanged("duplicate_prompts")
changed, rows = 0, []
kept = _kept_duplicates(paired, conflicted)
for sorted_ids in groups:
removed, _moved = self._merge_duplicate_group(conn, sorted_ids, strict=True)
changed += removed
@@ -904,7 +833,7 @@ class MaintenanceOperations:
{"prompt_id": duplicate_id, "kept_id": sorted_ids[0]}
for duplicate_id in sorted_ids[1:]
)
return {"changed": changed, "rows": rows[:MAX_RESULT_ROWS], "kept": kept}
return {"changed": changed, "rows": rows[:MAX_RESULT_ROWS]}
def _apply_unused_components(self, conn, fingerprint, targets, _roots):
ids, current = self._unused_fingerprint(conn)
@@ -916,7 +845,7 @@ class MaintenanceOperations:
{"prompt_id": prompt_id, "text": _excerpt(texts.get(prompt_id))}
for prompt_id in ids[:MAX_RESULT_ROWS]
]
return {"changed": changed, "rows": rows, "kept": _kept_unused(conn)}
return {"changed": changed, "rows": rows}
def _apply_missing_links(self, conn, fingerprint, targets, roots, marks):
rows = []
+4 -3
View File
@@ -14,7 +14,6 @@ from contextlib import closing
from pathlib import Path
from typing import Optional, Set, Tuple
from .sqlite_uri import readonly_uri
from .managed_connection import (
ManagedConnection,
await_closed,
@@ -1200,7 +1199,7 @@ class PromptModel:
if header != SQLITE_HEADER:
return False, "Not a SQLite database file"
uri = readonly_uri(path)
uri = Path(os.path.abspath(path)).as_uri() + "?mode=ro"
try:
conn = sqlite3.connect(uri, uri=True)
except sqlite3.Error as e:
@@ -1307,7 +1306,9 @@ class PromptModel:
replacement (for example, incompatible page sizes in WAL mode),
propagate the error rather than copy files under other open owners.
"""
source = sqlite3.connect(readonly_uri(Path(src_path).resolve()), uri=True)
source = sqlite3.connect(
Path(src_path).resolve().as_uri() + "?mode=ro", uri=True
)
try:
target = self.get_connection()
# The target is written under backup's step too: mark it in use
-452
View File
@@ -1,452 +0,0 @@
"""Pair category and tags repair (#550): the ``pair_metadata`` maintenance plan.
Before #546 a Pair node run saved its category and tags on the saved pair
only; the positive prompt it created stayed Uncategorized and untagged. This
plan fills the **empty** category and the **empty** tag set of those prompts
from their saved pairs (``docs/egm/post-3.4.1/550-design.md``):
- Considered: prompts the Pair node created as a positive (their earliest
pair, in either role, has them as the positive and its ``created_at`` is
within ``PAIR_CREATED_TOLERANCE`` of theirs) whose category is blank
and/or that have no tags.
- Sources: the prompt's non-archived pairs where it is the positive. Pairs
with a blank category or no tags are ignored for that field. A field fills
when exactly one value remains (category: trimmed, case-sensitive; tags:
the tag-id set).
- Kept (protected), in this order: archived prompt, edited prompt
(``updated_at`` not within the tolerance of ``created_at``), pairs that
disagree, a tag set over ``MAX_LIBRARY_TAGS``.
The apply runs in the caller's ``BEGIN IMMEDIATE`` transaction, recomputes
the fingerprint, then writes guarded statements (the field must still be
empty) that never touch a pair, a negative or ``updated_at``. A guard that
hits nothing raises ``PlanChanged`` so the whole transaction rolls back.
"""
import datetime
from . import maintenance as _maintenance
from .library_commands import MAX_LIBRARY_TAGS
from .maintenance import (
MAX_DETAILS,
MaintenanceDraft,
PlanChanged,
_excerpt,
_Fingerprint,
_marks,
_plural,
)
OPERATION = "pair_metadata"
# Seconds: the Pair save stamps its new prompt and its pair with separate
# utc_now_iso() calls in one transaction (microseconds apart).
PAIR_CREATED_TOLERANCE = 2.0
# Fill prompts one plan applies; the rest are applied by the next review.
# Bounds the complete record a job keeps in memory (about 100 bytes a row).
MAX_PLAN_FILLS = 20000
DETAIL_VALUES = 3
DETAIL_TAGS = 5
MEDIA_LABEL = "No image file or saved pair is changed."
EMPTY_LABEL = "No Pair prompts are missing a category or tags"
RECORD_NOTE = (
"The job's report lists every filled field. Download it to keep it: "
"Activity forgets jobs after a restart."
)
# Protected classes in precedence order: (key, detail, reason one, many).
EDITED_DETAIL = (
"Kept: edited after it was saved; its empty category or tags may be intentional"
)
PROTECTED = (
(
"archived",
"Kept: archived in the Library",
"archived in the Library",
"archived in the Library",
),
(
"edited",
EDITED_DETAIL,
"edited after it was saved",
"edited after they were saved",
),
("conflict", None, "where pairs disagree", "where pairs disagree"),
(
"too-many-tags",
f"Kept: its pair has more than {MAX_LIBRARY_TAGS} tags",
f"whose pair has more than {MAX_LIBRARY_TAGS} tags",
f"whose pair has more than {MAX_LIBRARY_TAGS} tags",
),
)
_PROTECTED_ORDER = [key for key, *_rest in PROTECTED]
# Detail rows: what the user can act on (a conflict) first.
DETAIL_ORDER = ("conflict", "edited", "archived", "too-many-tags")
_CANDIDATES_SQL = """
SELECT p.id, p.hash, p.text, p.created_at, p.updated_at,
COALESCE(TRIM(p.category), '') = '' AS blank_category,
NOT EXISTS (SELECT 1 FROM prompt_tags pt WHERE pt.prompt_id = p.id)
AS untagged,
EXISTS (SELECT 1 FROM prompt_archive pa WHERE pa.prompt_id = p.id)
AS archived,
(SELECT CASE WHEN pp.positive_prompt_id = p.id
THEN pp.created_at END
FROM prompt_pairs pp
WHERE pp.positive_prompt_id = p.id OR pp.negative_prompt_id = p.id
ORDER BY pp.id LIMIT 1) AS first_pair_at
FROM prompts p
WHERE EXISTS (SELECT 1 FROM prompt_pairs pp WHERE pp.positive_prompt_id = p.id)
AND (COALESCE(TRIM(p.category), '') = ''
OR NOT EXISTS (SELECT 1 FROM prompt_tags pt WHERE pt.prompt_id = p.id))
ORDER BY p.id
"""
_SOURCES_SQL = (
"SELECT id, positive_prompt_id, TRIM(COALESCE(category, '')) FROM prompt_pairs "
"WHERE archived = 0 AND positive_prompt_id IN ({marks}) ORDER BY id"
)
_PAIR_TAGS_SQL = (
"SELECT ppt.pair_id, t.id, t.name FROM prompt_pair_tags ppt "
"JOIN tags t ON t.id = ppt.tag_id WHERE ppt.pair_id IN ({marks}) "
"ORDER BY ppt.pair_id, t.id"
)
def parse_stamp(value):
"""A stored timestamp as an aware UTC datetime, or None when unreadable."""
if not isinstance(value, str) or not value.strip():
return None
try:
stamp = datetime.datetime.fromisoformat(value.strip())
except ValueError:
return None
if stamp.tzinfo is None:
stamp = stamp.replace(tzinfo=datetime.timezone.utc)
return stamp
def _close(first, second):
"""Whether two stored stamps parse and lie within the tolerance."""
a, b = parse_stamp(first), parse_stamp(second)
if a is None or b is None:
return False
return abs((a - b).total_seconds()) <= PAIR_CREATED_TOLERANCE
def _batches(ids):
size = _maintenance.ID_BATCH
for start in range(0, len(ids), size):
yield ids[start : start + size]
def _sources(conn, ids):
"""``{prompt_id: [(pair_id, category, ((tag_id, name), ...)), ...]}``."""
pairs = {}
for batch in _batches(ids):
for pair_id, prompt_id, category in conn.execute(
_SOURCES_SQL.format(marks=_marks(batch)), batch
):
pairs.setdefault(prompt_id, []).append([pair_id, category, []])
by_pair = {pair[0]: pair for group in pairs.values() for pair in group}
pair_ids = sorted(by_pair)
for batch in _batches(pair_ids):
for pair_id, tag_id, name in conn.execute(
_PAIR_TAGS_SQL.format(marks=_marks(batch)), batch
):
by_pair[pair_id][2].append((tag_id, name))
return {
prompt_id: [(pid, category, tuple(tags)) for pid, category, tags in group]
for prompt_id, group in pairs.items()
}
def _field(values, guard):
"""``(class, value)`` of one field from its distinct values, or None."""
if not values:
return None
if guard:
return (guard, None)
if len(values) > 1:
return ("conflict", sorted(values))
return ("fill", next(iter(values)))
def _classify(row, sources):
"""The result of one considered prompt, or None when nothing applies."""
guard = None
if row["archived"]:
guard = "archived"
elif not _close(row["updated_at"], row["created_at"]):
guard = "edited"
category = tags = None
tag_pair = None
if row["blank_category"]:
category = _field({c for _p, c, _t in sources if c}, guard)
if row["untagged"]:
sets = {}
for pair_id, _category, pair_tags in sources:
if pair_tags:
sets.setdefault(frozenset(pair_tags), pair_id)
tags = _field(set(sets), guard)
if tags and tags[0] == "fill":
tag_pair = sets[tags[1]]
if len(tags[1]) > MAX_LIBRARY_TAGS:
tags = ("too-many-tags", None)
else:
tags = ("fill", tuple(sorted(tags[1])))
elif tags and tags[0] == "conflict":
tags = ("conflict", [sorted(s) for s in sorted(sets, key=sorted)])
if category is None and tags is None:
return None
return {
"id": row["id"],
"hash": row["hash"],
"text": row["text"],
"category": category,
"tags": tags,
"tag_pair": tag_pair if tags and tags[0] == "fill" else None,
"pairs": [pair_id for pair_id, _c, _t in sources],
}
def _state(conn):
"""``(results, fingerprint)``: every considered prompt's result, by id."""
considered = []
for row in conn.execute(_CANDIDATES_SQL):
row = dict(
zip(
(
"id",
"hash",
"text",
"created_at",
"updated_at",
"blank_category",
"untagged",
"archived",
"first_pair_at",
),
tuple(row),
)
)
if _close(row["created_at"], row["first_pair_at"]):
considered.append(row)
sources = _sources(conn, [row["id"] for row in considered])
fingerprint = _Fingerprint(OPERATION)
results = []
for row in considered:
result = _classify(row, sources.get(row["id"], []))
if result is None:
continue
results.append(result)
fingerprint.add(
[
result["id"],
result["hash"],
result["category"],
result["tags"],
result["tag_pair"],
result["pairs"],
]
)
return results, fingerprint.hexdigest()
def _fills(result):
return (
bool(result["category"] and result["category"][0] == "fill"),
bool(result["tags"] and result["tags"][0] == "fill"),
)
def _targets(results):
targets = []
for result in results:
category, tags = _fills(result)
if category or tags:
targets.append([result["id"], category, tags])
return targets[:MAX_PLAN_FILLS]
def _protected_classes(result):
"""The protected classes of a result's fields, in precedence order."""
found = {
field[0]
for field in (result["category"], result["tags"])
if field and field[0] != "fill"
}
return [key for key in _PROTECTED_ORDER if key in found]
def _more(values, limit):
shown = ", ".join(values[:limit])
return shown + (", …" if len(values) > limit else "")
def _fill_detail(result):
parts = []
category, tags = _fills(result)
if category:
parts.append(f"category “{result['category'][1]}”")
if tags:
names = sorted(name for _id, name in result["tags"][1])
noun = "tag" if len(names) == 1 else "tags"
parts.append(f"{len(names)} {noun}: {_more(names, DETAIL_TAGS)}")
pairs = [str(pair_id) for pair_id in result["pairs"]]
label = "pair" if len(pairs) == 1 else "pairs"
return f"Fills {' and '.join(parts)} ({label} {_more(pairs, DETAIL_VALUES)})"
def _protected_detail(result, key):
if key != "conflict":
return next(detail for k, detail, *_r in PROTECTED if k == key)
if result["category"] and result["category"][0] == "conflict":
values = result["category"][1]
return f"Kept: pairs disagree on category: {_more(values, DETAIL_VALUES)}"
return "Kept: pairs disagree on tags"
def _kept(results):
"""One row per kept prompt, its review reasons without "Kept: " (#550)."""
rows = []
for result in results:
classes = set(_protected_classes(result))
if classes:
rows.append(
{
"prompt_id": result["id"],
"reasons": [
_protected_detail(result, key).removeprefix("Kept: ")
for key in DETAIL_ORDER
if key in classes
],
}
)
return rows
def _details(results, targets):
rows = []
for key in DETAIL_ORDER:
for result in results:
if key in _protected_classes(result):
rows.append(
{
"label": _excerpt(result["text"]),
"detail": _protected_detail(result, key),
}
)
planned = {target[0] for target in targets}
for result in results:
if result["id"] in planned:
rows.append(
{"label": _excerpt(result["text"]), "detail": _fill_detail(result)}
)
if not targets:
return rows[:MAX_DETAILS]
return rows[: MAX_DETAILS - 1] + [{"label": "Record", "detail": RECORD_NOTE}]
def _scope_label(targets, total):
if not targets:
return EMPTY_LABEL
categories = sum(1 for _id, category, _tags in targets if category)
tag_sets = sum(1 for _id, _category, tags in targets if tags)
label = (
f"{_plural(len(targets), 'prompt')} to fill: "
f"{_plural(categories, 'category', 'categories')}, "
f"{_plural(tag_sets, 'tag set')}"
)
if total > len(targets):
label += (
f" (the first {len(targets):,} of {total:,}; review again for the rest)"
)
return label
def plan_pair_metadata(conn):
"""The reviewed ``pair_metadata`` plan on ``conn``'s snapshot."""
results, fingerprint = _state(conn)
targets = _targets(results)
total = sum(1 for result in results if any(_fills(result)))
counts = {key: 0 for key in _PROTECTED_ORDER}
held = 0
for result in results:
classes = _protected_classes(result)
held += bool(classes)
for key in classes:
counts[key] += 1
reasons = [
f"{counts[key]:,} {one if counts[key] == 1 else many}"
for key, _detail, one, many in PROTECTED
if counts[key]
]
return MaintenanceDraft(
operation=OPERATION,
fingerprint=fingerprint,
targets=targets,
scope={"count": len(targets), "label": _scope_label(targets, total)},
protected={"count": held, "reasons": reasons},
media={"deletes_files": False, "label": MEDIA_LABEL},
details=_details(results, targets),
)
def _fill_categories(conn, fills):
"""Set each blank category; raises PlanChanged when one is no longer blank."""
cursor = conn.executemany(
"UPDATE prompts SET category = ? WHERE id = ? "
"AND COALESCE(TRIM(category), '') = ''",
fills,
)
if cursor.rowcount != len(fills):
raise PlanChanged(OPERATION)
def _fill_tags(conn, fills, expected):
"""Copy each source pair's tags onto its untagged prompt."""
cursor = conn.executemany(
"INSERT INTO prompt_tags(prompt_id, tag_id) "
"SELECT ?, ppt.tag_id FROM prompt_pair_tags ppt "
"JOIN tags t ON t.id = ppt.tag_id WHERE ppt.pair_id = ? "
"AND NOT EXISTS (SELECT 1 FROM prompt_tags WHERE prompt_id = ?)",
fills,
)
if cursor.rowcount != expected:
raise PlanChanged(OPERATION)
def apply_pair_metadata(conn, fingerprint, targets):
"""Apply the reviewed fills on ``conn`` (inside BEGIN IMMEDIATE).
Returns ``{changed, categories, tag_sets, rows, kept}``; ``rows`` lists
every filled prompt (``{prompt_id, category?, tags?}``) and ``kept`` every
prompt the plan keeps (``{prompt_id, reasons}``, in the review's wording
and order), both never capped.
"""
results, current = _state(conn)
if current != fingerprint or _targets(results) != targets:
raise PlanChanged(OPERATION)
by_id = {result["id"]: result for result in results}
categories, tags, rows = [], [], []
tag_count = 0
for prompt_id, fill_category, fill_tags in targets:
result = by_id[prompt_id]
row = {"prompt_id": prompt_id}
if fill_category:
row["category"] = result["category"][1]
categories.append((row["category"], prompt_id))
if fill_tags:
row["tags"] = sorted(name for _id, name in result["tags"][1])
tags.append((prompt_id, result["tag_pair"], prompt_id))
tag_count += len(result["tags"][1])
rows.append(row)
if categories:
_fill_categories(conn, categories)
if tags:
_fill_tags(conn, tags, tag_count)
return {
"changed": len(rows),
"categories": len(categories),
"tag_sets": len(tags),
"rows": rows,
"kept": _kept(results),
}
+1 -2
View File
@@ -42,7 +42,6 @@ from typing import Dict, Optional
from . import workflow_documents as wd
from .execution_history_schema import rotate_history_epoch
from .safety_backup import take_safety_backup
from .sqlite_uri import readonly_uri
logger = logging.getLogger("prompt_manager.database.restore_stage")
@@ -115,7 +114,7 @@ def file_digest(path: str) -> str:
def _readonly(path: str) -> sqlite3.Connection:
return sqlite3.connect(readonly_uri(Path(path).resolve()), uri=True)
return sqlite3.connect(Path(path).resolve().as_uri() + "?mode=ro", uri=True)
def _schema(path: str) -> list:
+1 -9
View File
@@ -31,15 +31,7 @@ MAX_STATE_BYTES = 4096
MAX_TAGS = 20
MAX_TAG_LENGTH = 50
_IMAGE_SORTS = ("newest", "oldest", "name", "name-desc", "rating")
_LIBRARY_SORTS = (
"newest",
"oldest",
"name",
"name-desc",
"most-used",
"last-used",
"rating",
)
_LIBRARY_SORTS = ("newest", "oldest", "name", "rating")
_MEDIA = ("all", "image", "video")
# How a Library view's Tags match (#444): every tag, any of them, or parents
# without tags ("untagged" never goes with tags; the listing refuses both).
-25
View File
@@ -1,25 +0,0 @@
"""The read-only SQLite URI of a path, safe for Windows network shares."""
import ntpath
import os
from pathlib import PurePosixPath, PureWindowsPath
from urllib.parse import quote
def readonly_uri(db_path, *, windows=None):
"""The ``mode=ro`` SQLite URI of *db_path*, on this platform or the given one.
pathlib spells a UNC path ``\\\\server\\share\\x.db`` as
``file://server/share/x.db``, a remote authority SQLite refuses to open.
With an empty authority (``file:////server/share/x.db``) SQLite hands
Windows the ``//server/share`` path, which it opens as the UNC path.
"""
if windows is None:
windows = os.name == "nt"
db_path = os.path.abspath(db_path)
if not windows:
return PurePosixPath(db_path).as_uri() + "?mode=ro"
path = PureWindowsPath(db_path)
if ntpath.splitdrive(str(path))[0].startswith("\\\\"):
return "file:" + quote("//" + path.as_posix()) + "?mode=ro"
return path.as_uri() + "?mode=ro"
+11 -16
View File
@@ -112,22 +112,20 @@ QUERY_PLAN_STATEMENTS = {
def _rewrite_row(conn, image_id, row, verified, checked):
"""Store the row's inline texts as documents; returns (changed, kept, size).
``kept`` names the columns whose value stays inline (not text).
"""Store the row's inline texts as documents; returns (changed, protected, size).
``verified`` maps document ids already read back in this batch to their
text, and ``checked`` holds the reused ids ``store_text`` already
checked, so a document shared by many rows is decompressed once.
"""
changes, replaced, kept = {}, [], []
size = 0
changes, replaced = {}, []
protected = size = 0
for index, (column, reference) in enumerate(TEXT_COLUMNS):
value, current = row[index], row[index + len(TEXT_COLUMNS)]
if value is None:
continue
if not storable(value):
kept.append(column)
protected += 1
continue
document_id = store_text(conn, value, checked)
if document_id not in verified:
@@ -145,7 +143,7 @@ def _rewrite_row(conn, image_id, row, verified, checked):
[*changes.values(), image_id],
)
release_documents(conn, replaced)
return bool(changes), kept, size
return bool(changes), protected, size
def migrate_batch(
@@ -162,10 +160,9 @@ def migrate_batch(
savepoint: a row that cannot be rewritten (``_row_error``) is rolled
back alone, stays inline and is listed in ``errors``; anything else rolls
the whole batch back and propagates. Returns ``{last_id, rows,
protected, kept, errors, done}``: the last row looked at, rows
rewritten, inline values kept (not text) and each of them as ``(image
id, column)`` (#586), the ids kept by an error and whether there was
nothing left to look at.
protected, errors, done}``: the last row looked at, rows rewritten,
inline values kept (not text), the ids kept by an error and whether
there was nothing left to look at.
"""
if conn.in_transaction:
raise RuntimeError("The migration needs its own transaction")
@@ -177,7 +174,6 @@ def migrate_batch(
"last_id": after_id,
"rows": 0,
"protected": 0,
"kept": [],
"errors": [],
"done": not ids,
}
@@ -186,7 +182,7 @@ def migrate_batch(
conn.execute("SAVEPOINT workflow_row")
try:
row = conn.execute(ROW_SQL, (image_id,)).fetchone()
changed, kept, row_size = _rewrite_row(
changed, protected, row_size = _rewrite_row(
conn, image_id, row, verified, checked
)
except Exception as error:
@@ -198,12 +194,11 @@ def migrate_batch(
checked.clear()
logger.warning(f"Image record {image_id} kept inline: {error}")
result["errors"].append(image_id)
changed, kept, row_size = False, [], 0
changed, protected, row_size = False, 0, 0
else:
conn.execute("RELEASE workflow_row")
result["rows"] += int(changed)
result["protected"] += len(kept)
result["kept"].extend((image_id, column) for column in kept)
result["protected"] += protected
result["last_id"] = image_id
size += row_size
if size >= max_bytes or _clock() - started >= max_seconds:
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

+14
View File
@@ -3,6 +3,7 @@ PromptManager: Main custom node implementation that extends CLIPTextEncode
with persistent prompt storage and search capabilities.
"""
import time
from typing import Any, Dict, Optional, Tuple
if __package__:
@@ -258,6 +259,19 @@ class PromptManager(PromptManagerBase, ComfyNodeABC):
if not save_to_library:
return result # #90: no metadata registration, no history text
# Register with ComfyUI integration for standard metadata compatibility
node_id = f"promptmanager_{int(time.time() * 1000)}"
self.comfyui_integration.register_prompt(
node_id,
encoding_text.strip(),
{
"category": category.strip() if category else None,
"tags": extended_tags,
"prompt_id": prompt_id,
"prepend_text": prepend_text.strip() if prepend_text else None,
"append_text": append_text.strip() if append_text else None,
},
)
record_runtime_context(
type(self).__name__, text, prepend_text, append_text, encoding_text
)
+22
View File
@@ -19,6 +19,7 @@ except ImportError:
try:
from .database.operations import PromptDatabase
from .utils.comfyui_integration import get_comfyui_integration
from .utils.image_monitor import get_image_monitor
from .utils.prompt_tracker import get_prompt_tracker
from .utils.hashing import generate_prompt_hash
@@ -37,6 +38,7 @@ except ImportError:
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from database.operations import PromptDatabase
from utils.comfyui_integration import get_comfyui_integration
from utils.image_monitor import get_image_monitor
from utils.prompt_tracker import get_prompt_tracker
from utils.hashing import generate_prompt_hash
@@ -84,6 +86,7 @@ class PromptManagerBase:
self.db = PromptDatabase()
self.prompt_tracker = get_prompt_tracker(self.db)
self.image_monitor = get_image_monitor(self.db, self.prompt_tracker)
self.comfyui_integration = get_comfyui_integration()
self._start_gallery_system()
self.logger.debug(f"{self.__class__.__name__} node initialization completed")
@@ -430,6 +433,18 @@ class PromptManagerBase:
self.logger.error(f"Error searching prompts: {e}")
return []
def search_prompts_api(self, search_text: str = "") -> List[Dict[str, Any]]:
"""API method for JavaScript UI to search prompts."""
return self._search_prompts(search_text=search_text)
def get_recent_prompts_api(self, limit: int = 20) -> List[Dict[str, Any]]:
"""API method for JavaScript UI to get recent prompts."""
try:
return self.db.get_recent_prompts(limit=limit)
except Exception as e:
self.logger.error(f"Error getting recent prompts: {e}")
return []
def _start_gallery_system(self):
"""Initialize and start the gallery monitoring system."""
try:
@@ -440,6 +455,13 @@ class PromptManagerBase:
self.logger.error(f"Failed to start gallery system: {e}")
self.logger.warning("Gallery features will be disabled")
def get_gallery_status(self) -> Dict[str, Any]:
"""Get status of the gallery system."""
return {
"image_monitor": self.image_monitor.get_status(),
"prompt_tracker": self.prompt_tracker.get_status(),
}
def cleanup_gallery_system(self):
"""Clean up gallery system resources.
+14
View File
@@ -2,6 +2,7 @@
import hashlib
import json
import time
from typing import Any, Dict, Optional, Tuple
if __package__:
@@ -172,6 +173,19 @@ class PromptManagerPair(PromptManagerFocused):
self.logger.warning(f"Could not save or track paired prompt: {error}")
if is_positive:
self._mark_untracked(prompt, unique_id)
try:
self.comfyui_integration.register_prompt(
f"promptmanager_pair_{time.time_ns()}",
positive,
{
"prompt_id": prompt_id,
"pair_id": pair_id if pair_used else None,
"negative_text": negative_text,
"category": category.strip() or None,
},
)
except Exception as error:
self.logger.warning(f"Could not register paired metadata: {error}")
record_runtime_context(
type(self).__name__,
text,
+14
View File
@@ -3,6 +3,7 @@ PromptManagerText: A text-only version of PromptManager that outputs STRING
without CLIP encoding, while maintaining all database and search features.
"""
import time
from typing import Any, Dict, Optional, Tuple
if __package__:
@@ -229,6 +230,19 @@ class PromptManagerText(PromptManagerBase, ComfyNodeABC):
if not save_to_library:
return result # #90: no metadata registration, no history text
# Register with ComfyUI integration for standard metadata compatibility
node_id = f"promptmanagertext_{int(time.time() * 1000)}"
self.comfyui_integration.register_prompt(
node_id,
final_text.strip(),
{
"category": category.strip() if category else None,
"tags": extended_tags,
"prompt_id": prompt_id,
"prepend_text": prepend_text.strip() if prepend_text else None,
"append_text": append_text.strip() if append_text else None,
},
)
record_runtime_context(
type(self).__name__, text, prepend_text, append_text, final_text
)
+60 -16
View File
@@ -11,7 +11,6 @@ Split into domain-specific mixins for maintainability:
- JobRoutesMixin: the session job registry behind Activity (py/api/jobs.py)
- ImageTagJobsMixin: bulk Add tags / Auto-tag as image-tags jobs
- ThumbnailJobsMixin: thumbnail plan, thumbnail job and the gallery stream on it
- ThumbnailLocationMixin: the thumbnail location check and payloads (#320)
- ThumbnailConvertMixin: Convert outdated thumbnails (#317), a thumbnail job mode
- AutoThumbnailMixin: previews made on first view, on capture and after a scan
- ReviewRoutesMixin: the Needs review queue, item detail and decisions
@@ -61,11 +60,6 @@ from .settings_routes import SettingsRoutesMixin
from .job_routes import JobRoutesMixin
from .image_tag_jobs import ImageTagJobsMixin
from .thumbnail_jobs import ThumbnailJobsMixin
from .thumbnail_location import ThumbnailLocationMixin
from .thumbnail_move import ThumbnailMoveMixin
from .thumbnail_move_plan import ThumbnailMovePlanMixin
from .thumbnail_move_recovery import ThumbnailMoveRecoveryMixin
from .thumbnail_prune import ThumbnailPruneMixin
from .thumbnail_convert import ThumbnailConvertMixin
from .thumbnail_auto import AutoThumbnailMixin
from .system import SystemRoutesMixin
@@ -89,6 +83,7 @@ from .source_rescope import SourceRescopeMixin
try:
from ...database.operations import PromptDatabase
from ...utils.file_prompt import parse_comfyui_prompt
from ...utils.logging_config import get_logger
from ...utils.native_memory import release_free_memory
from ...utils.png_text import png_text
@@ -100,6 +95,7 @@ except ImportError:
0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
)
from database.operations import PromptDatabase
from utils.file_prompt import parse_comfyui_prompt
from utils.logging_config import get_logger
from utils.native_memory import release_free_memory
from utils.png_text import png_text
@@ -252,6 +248,23 @@ async def _read_json_body(request, max_bytes=JSON_BODY_MAX_BYTES):
return data, None
class _LongJobGuard:
"""Async context manager handed out by ``_acquire_long_job``."""
def __init__(self, lock):
self._lock = lock
async def __aenter__(self):
# The caller checked ``locked()`` on this same loop iteration, so this
# acquire never waits.
await self._lock.acquire()
return self
async def __aexit__(self, exc_type, exc, tb):
self._lock.release()
return False
# ── Gzip compression middleware ────────────────────────────────────────
_GZIP_MIN_SIZE = 1024 # Only compress bodies larger than 1 KB
_GZIP_TYPES = frozenset(
@@ -325,11 +338,6 @@ class PromptManagerAPI(
JobRoutesMixin,
ImageTagJobsMixin,
ThumbnailJobsMixin,
ThumbnailLocationMixin,
ThumbnailMovePlanMixin,
ThumbnailMoveMixin,
ThumbnailMoveRecoveryMixin,
ThumbnailPruneMixin,
ThumbnailConvertMixin,
AutoThumbnailMixin,
SystemRoutesMixin,
@@ -407,6 +415,10 @@ class PromptManagerAPI(
self._workspace_card_cache = {}
# Favorite keys of each root's cached scan (images._favorite_listing).
self._favorite_listing_cache = {}
# Single-flight locks for long jobs, keyed by job family; see
# _acquire_long_job(). Created lazily so no lock binds to an event
# loop before the server's loop exists.
self._long_jobs: dict = {}
# This session's jobs (py/api/jobs.py): the metadata scan, and later
# every other long operation the Activity page tracks.
self._jobs = JobRegistry(self.logger)
@@ -463,6 +475,26 @@ class PromptManagerAPI(
_public_error = staticmethod(_public_error)
_read_json_body = staticmethod(_read_json_body)
def _acquire_long_job(self, name):
"""Claim the single-flight slot for a long job family, or None when busy.
Usage in a handler::
job = self._acquire_long_job("duplicates")
if job is None:
return self._long_job_busy_response("Duplicate scan")
async with job:
...
Families are free-form strings ("duplicates", "thumbnails",
"autotag", ...); the lock is released when the ``async with`` block
exits, including on exceptions. Must be called from the event loop.
"""
lock = self._long_jobs.setdefault(name, asyncio.Lock())
if lock.locked():
return None
return _LongJobGuard(lock)
@staticmethod
def _long_job_busy_response(label):
"""409 envelope for a long job that is already running."""
@@ -684,7 +716,6 @@ class PromptManagerAPI(
# ── Register domain-specific routes from mixins ───────────────
self._register_pair_routes(routes)
self._register_thumbnail_move_routes(routes)
self._register_library_routes(routes)
self._register_library_delete_routes(routes)
self._register_vocabulary_routes(routes)
@@ -821,11 +852,20 @@ class PromptManagerAPI(
f"{url_quote(rel_path.as_posix(), safe='/')}"
)
# Any preview the file owns, the current format first
# (#317); no store I/O here in folder mode (#320).
from .images import listing_thumbnail_url
# Any preview the file owns, the current format first (#317)
from . import thumbnail_settings
from .prompts import thumbnail_url_for
thumbnail_url = listing_thumbnail_url(output_path, rel_path)
existing = thumbnail_settings.existing_thumbnail(
output_path, rel_path
)
thumbnail_url = None
if existing is not None:
thumb_rel = (
thumbnail_settings.thumbnail_stem_rel(rel_path)
+ existing.suffix
)
thumbnail_url = thumbnail_url_for(thumb_rel, existing)
if thumbnail_url:
image["thumbnail_url"] = thumbnail_url
break # Found matching root, stop searching
@@ -1036,3 +1076,7 @@ class PromptManagerAPI(
f"Unreadable image {os.path.basename(str(image_path))}: {e}"
)
return {}
def _parse_comfyui_prompt(self, metadata):
"""Parse ComfyUI and A1111 prompt data from metadata."""
return parse_comfyui_prompt(metadata)
+1 -3
View File
@@ -30,10 +30,8 @@ from .jobs import (
)
try:
from ...database.sqlite_uri import readonly_uri
from ...utils.parallel import map_parallel
except ImportError:
from database.sqlite_uri import readonly_uri
from utils.parallel import map_parallel
try:
@@ -401,7 +399,7 @@ def _inspect_restore_upload(path):
Returns:
None when acceptable, otherwise a human-readable reason (blocking).
"""
uri = readonly_uri(path)
uri = Path(os.path.abspath(path)).as_uri() + "?mode=ro"
try:
conn = sqlite3.connect(uri, uri=True)
except sqlite3.Error as e:
+2 -6
View File
@@ -102,14 +102,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 +116,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)
+28 -131
View File
@@ -19,8 +19,8 @@ from .jobs import (
)
from .maintenance import disk_plan
from .images import listing_thumbnail_url
from . import preview_store
from .prompts import thumbnail_url_for
from . import preview_store, thumbnail_settings
from .. import model_gate
try:
@@ -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,
@@ -563,10 +539,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 +699,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.
@@ -943,6 +836,16 @@ class AutotagRoutesMixin:
return refusal.response
return web.json_response({"success": True, "job": job.summary()}, status=202)
def _gated_runner(self, owner):
"""Executor calls under the model gate *owner* holds (plain when None)."""
if owner is None:
return self._run_in_executor
async def run(func, *args, **kwargs):
return await model_gate.run(owner, func, *args, **kwargs)
return run
def _resolve_allowed_image_path(self, raw_path: str) -> Optional[str]:
"""Return the real path when it is a media file inside an output directory."""
if Path(str(raw_path)).suffix.lower() not in _MEDIA_EXTENSIONS:
@@ -1099,10 +1002,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(
@@ -1196,8 +1096,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
@@ -1214,8 +1112,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)
@@ -1255,7 +1151,6 @@ class AutotagRoutesMixin:
capture,
model_type,
options,
skip,
on_skipped=functools.partial(store.release_use, capture.token),
)
except MissingBackendPackages as exc:
@@ -1411,8 +1306,7 @@ class AutotagRoutesMixin:
)
model_type = service.get_loaded_model_type()
# Off the loop; a dropped request keeps the gate until it ends.
await model_gate.run(owner, service.unload_model)
service.unload_model()
finally:
model_gate.release(owner)
@@ -1477,21 +1371,24 @@ class AutotagRoutesMixin:
files = await self._run_in_executor(
self._without_removed, output_path, files
)
location = preview_store.current()
if location.mode == preview_store.FOLDER_MODE:
has_thumbnails = location.available # no store I/O here (#320)
else:
store = preview_store.store_dir(output_path)
has_thumbnails = store is not None and store.is_dir()
store = preview_store.store_dir(output_path)
has_thumbnails = store is not None and store.is_dir()
images = []
for image_path in files:
rel_path = image_path.relative_to(output_path)
thumbnail_url = None
if has_thumbnails:
# Any preview the file owns, the current format first
# (#317); in folder mode without store I/O (#320).
thumbnail_url = listing_thumbnail_url(output_path, rel_path)
# Any preview the file owns, the current format first (#317)
existing = thumbnail_settings.existing_thumbnail(
output_path, rel_path
)
if existing is not None:
thumb_rel = (
thumbnail_settings.thumbnail_stem_rel(rel_path)
+ existing.suffix
)
thumbnail_url = thumbnail_url_for(thumb_rel, existing)
images.append(
{
"filename": image_path.name,
+2 -3
View File
@@ -45,6 +45,7 @@ import os
import sqlite3
import tempfile
from contextlib import closing
from pathlib import Path
from aiohttp import web
@@ -55,7 +56,6 @@ from .maintenance_plans import PlanExpired, PlanUsed
from .restore_plans import RestorePlanStore
try:
from ...database.sqlite_uri import readonly_uri
from ...database import restore_stage
from ...database.operations import PromptDatabase
from ...database.safety_backup import (
@@ -66,7 +66,6 @@ try:
safety_temp,
)
except ImportError:
from database.sqlite_uri import readonly_uri
from database import restore_stage
from database.operations import PromptDatabase
from database.safety_backup import (
@@ -187,7 +186,7 @@ def snapshot_corruption(path):
with open(path, "rb") as handle:
if handle.read(len(SQLITE_HEADER)) != SQLITE_HEADER:
return "Not a SQLite database file"
uri = readonly_uri(path)
uri = Path(os.path.abspath(path)).as_uri() + "?mode=ro"
with closing(sqlite3.connect(uri, uri=True)) as conn:
row = conn.execute("PRAGMA integrity_check").fetchone()
except (OSError, sqlite3.Error) as error:
+1 -12
View File
@@ -65,7 +65,6 @@ from .duplicate_review import ( # noqa: F401 (CHANGED, OUTSIDE_SOURCE: public)
NOT_ATTEMPTED,
OUTSIDE_SOURCE,
RECORDS_FAILED,
REVIEW_KEPT_DETAILS,
ROW_DETAILS,
SAME_FILE,
SOURCES_CHANGED,
@@ -435,7 +434,6 @@ class DuplicateFilesMixin:
{
"targets": review["targets"],
"kept": review["kept"],
"held": review["held"],
"protected": review["public"]["protected"]["count"],
},
)
@@ -448,7 +446,7 @@ class DuplicateFilesMixin:
job hashes each file again right before it unlinks it.
"""
listed = self._listed_sources()
targets, kept, protected, held = [], [], {}, []
targets, kept, protected = [], [], {}
for group, keepers in selection.values():
removals = [r for r in group["files"] if r not in keepers]
for record in removals:
@@ -469,7 +467,6 @@ class DuplicateFilesMixin:
problem = _bound_problem(record, group, keeper)
if problem:
protected[problem] = protected.get(problem, 0) + 1
held.append(self._copy_row(record, REVIEW_KEPT_DETAILS[problem]))
continue
reviewed.append(self._target(group, record, keeper))
if reviewed:
@@ -491,7 +488,6 @@ class DuplicateFilesMixin:
return {
"targets": targets,
"kept": kept,
"held": held,
"fingerprint": _fingerprint(targets),
"public": _public_plan(targets, protected, records),
}
@@ -588,13 +584,6 @@ class DuplicateFilesMixin:
async def _remove_duplicates(self, job, db, plan, backup):
targets = plan.payload["targets"]
# #586: every copy the review protected, with its reason, and the
# whole group in the report.
held = plan.payload["held"]
job.record_counted("kept", len(held), held, complete=True)
if held:
copies = _plural(len(held), "copy", "copies")
job.log_line(f"Kept {copies} the review protected")
for row in plan.payload["kept"]:
job.record("kept", row, f"= {row['root']}/{row['path']} · {KEPT_COPY}")
job.update(total=len(targets), status_line="Removing duplicate files")
-12
View File
@@ -70,18 +70,6 @@ PROTECTED_REASONS = {
"files kept: their kept copy changed or is gone",
),
}
# The job's row for a copy the review protected (#586): the review's reason.
REVIEW_KEPT_DETAILS = {
MAYBE_SAME: "Kept: may be the same file reached through another mount or share",
NO_FILE_ID: "Kept: can't confirm this is a separate file on this drive",
SAME: "Kept: the same file as its kept copy",
OUTSIDE_SOURCE: "Kept: not inside a configured source",
LINK: "Kept: a link, not a regular file",
CHANGED_CONTENT: "Kept: changed since the search",
GONE: "Kept: no longer exists",
UNREADABLE: "Kept: could not be read",
KEEPER: "Kept: its kept copy changed or is gone",
}
# Job outcome rows (never an OS error text).
CHANGED = "The file changed since the review; it was kept."
KEEPER_CHANGED = "The kept copy changed or is gone; this file was kept."
+6 -29
View File
@@ -244,36 +244,19 @@ def resolve_current_candidate(
return _unavailable("temporary_unavailable", error=True)
def _target_authorized(target, namespace, scope, store_root=None) -> bool:
"""A preview in the thumbnail folder (#320, folder mode) is authorized by
the store of its source (``preview_store.owns``: inside the key folder,
no link); everything else by the output namespace and the roots."""
if (
store_root is not None
and preview_store.current().mode == preview_store.FOLDER_MODE
):
return preview_store.owns(store_root, target)
return _authorized(target, namespace, scope)
def _open_current_target(
target: Path,
namespace: Path,
scope: CurrentOutputScope,
preview: bool = False,
store_root: Path | None = None,
target: Path, namespace: Path, scope: CurrentOutputScope, preview: bool = False
) -> BinaryIO:
"""Open only the canonical target and verify authority against the actual handle.
A *preview* (a file under ``thumbnails/``) may also be JPEG XL (#317); in
folder mode it is authorized by the store of *store_root* (#320)."""
A *preview* (a file under ``thumbnails/``) may also be JPEG XL (#317)."""
if _canonical(target) != target:
raise CurrentLocationUnavailable("not_authorized", stale=True)
if in_trash_below_roots(target, [namespace]):
# Also at the open: the cover chooses its preview after the candidate
# check, and a preview can be a link into a trash folder (#543).
raise CurrentLocationUnavailable("missing")
if not _target_authorized(target, namespace, scope, store_root):
if not _authorized(target, namespace, scope):
raise CurrentLocationUnavailable("not_authorized")
suffix = target.suffix.lower()
if suffix not in SERVABLE_EXTENSIONS and not (
@@ -306,7 +289,7 @@ def _open_current_target(
if (
_canonical(target) != target
or (observed.st_dev, observed.st_ino) != (current.st_dev, current.st_ino)
or not _target_authorized(target, namespace, scope, store_root)
or not _authorized(target, namespace, scope)
):
raise CurrentLocationUnavailable("not_authorized", stale=True)
return os.fdopen(fd, "rb")
@@ -396,7 +379,7 @@ def _cover_target(resolved, ensure_preview, placeholder, accepts_jxl):
return _canonical(preview)
if preview is not None:
return _canonical(preview)
preview = media_preview_path(resolved.gallery_root, relative, create=True)
preview = media_preview_path(resolved.gallery_root, relative)
if (
ensure_preview is not None
and preview is not None
@@ -437,13 +420,7 @@ def _prepare_current_media(
return chosen
if chosen is not None:
target, is_preview = chosen, True
handle = _open_current_target(
target,
resolved.namespace,
scope,
is_preview,
resolved.gallery_root if is_preview else None,
)
handle = _open_current_target(target, resolved.namespace, scope, is_preview)
try:
return _OpenedCurrentMedia(
handle,
+4 -40
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,13 +529,7 @@ 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:
@@ -625,9 +598,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 +624,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 +633,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 +739,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 +894,6 @@ class ImageTagJobsMixin:
capture,
spec.model_type,
spec.options,
run.skip,
on_skipped=functools.partial(store.release_use, capture.token),
)
except Exception:
+10 -126
View File
@@ -35,7 +35,6 @@ from .gallery_listing import (
walk_root,
)
from . import preview_store, thumbnail_settings
from .thumbnail_location import location_refusal
from .thumbnail_encode import (
PREVIEW_HELD,
EncoderClosed,
@@ -1077,14 +1076,10 @@ def media_thumbnail_rel(rel_path, settings=None):
return thumbnail_settings.thumbnail_rel(rel_path, settings.extension)
def media_preview_path(output_path, rel_path, settings=None, *, create=False):
"""Where the preview of *rel_path* is written now (``preview_store``);
*create* for a writer (a new source gets its key). None when nothing
may be written (the thumbnail folder is unavailable)."""
def media_preview_path(output_path, rel_path, settings=None):
"""Where the preview of *rel_path* is written now (``preview_store``)."""
settings = settings or writer_settings()
return preview_store.preview_path(
output_path, rel_path, settings.extension, create=create
)
return preview_store.preview_path(output_path, rel_path, settings.extension)
def existing_media_thumbnail(output_path, rel_path):
@@ -1096,27 +1091,6 @@ def existing_media_thumbnail(output_path, rel_path):
)
def listing_thumbnail_url(output_path, rel_path):
"""The thumbnail URL of an older listing (``_enrich_images``, the
auto-tag scan), or None. These run on the event loop: in folder mode
(#320) the store is never touched there, so the URL names the preview
as it is written now and the serve route finds or makes it off the loop
(None while the thumbnail folder is unavailable)."""
from .prompts import thumbnail_url_for
location = preview_store.current()
if location.mode == preview_store.FOLDER_MODE:
if not location.available:
return None
quoted = urllib.parse.quote(media_thumbnail_rel(rel_path), safe="/")
return f"/prompt_manager/images/serve/{quoted}"
existing = thumbnail_settings.existing_thumbnail(output_path, rel_path)
if existing is None:
return None
thumb_rel = thumbnail_settings.thumbnail_stem_rel(rel_path) + existing.suffix
return thumbnail_url_for(thumb_rel, existing)
def media_thumbnail(output_path, rel_path, root_index, *, root_scope=None):
"""``(thumbnail_url, ready)`` for *rel_path* under *output_path* (a few
blocking stats). The Images listing, the Needs review queue cards and the
@@ -2839,31 +2813,7 @@ class ImageRoutesMixin:
if preview_store.is_preview_text(filepath)
else None
)
location = preview_store.current()
if location.mode == preview_store.FOLDER_MODE:
if preview_parts is None and await self._run_image(
self._in_source_thumbnails, output_dirs, filepath
):
# Previews live in the thumbnail folder: anything else
# under a source's thumbnails/ is a leftover (#320), in
# any case on a case-insensitive disk.
return _forbidden()
if preview_parts is not None:
# Store I/O never runs on the event loop (a share may
# stall): the lookup runs on the image pool.
found = await self._run_image(
self._find_preview, output_dirs, preview_parts, location
)
if found == "forbidden":
return _forbidden()
if found is not None:
return self._file_response(found)
for output_path in output_dirs:
if (
preview_parts is not None
and location.mode == preview_store.FOLDER_MODE
):
break # looked up above
if preview_parts is None:
image_path = output_path / filepath
else:
@@ -2906,48 +2856,6 @@ class ImageRoutesMixin:
{"success": False, "error": _safe_error(e)}, status=500
)
@staticmethod
def _in_source_thumbnails(output_dirs, filepath):
"""Whether *filepath* lies in a source's ``thumbnails`` folder: its
first part is ``thumbnails`` exactly, or another case of it that is
the same folder (a case-insensitive disk). ``Thumbnails/`` media on
a case-sensitive disk is another folder and stays served."""
first = filepath.split("/", 1)[0]
if first == "thumbnails":
return True
if first.casefold() != "thumbnails":
return False
for output_path in output_dirs:
spelled = output_path / first
cache = preview_store.source_folder(output_path)
try:
if spelled.is_dir() and cache.is_dir() and spelled.samefile(cache):
return True
except OSError:
continue
return False
@staticmethod
def _find_preview(output_dirs, parts, location):
"""The preview file *parts* names in folder mode, ``"forbidden"``
(it resolves out of its store or through a link) or None (blocking;
the first root whose store holds it, in ``_select_root`` order)."""
for output_path in output_dirs:
image_path = preview_store.request_path(output_path, parts, location)
if image_path is None:
continue
try:
with preview_store.store_io():
if not os.path.lexists(image_path):
continue
if not preview_store.owns(output_path, image_path, location):
return "forbidden"
if image_path.is_file():
return image_path
except OSError:
return None
return None
def _is_recorded_media(self, path) -> bool:
"""Whether the database records ``path`` as a linked image.
@@ -3074,9 +2982,6 @@ class ImageRoutesMixin:
data, error = await _json_body_or_error(request)
if error is not None:
return error
refused = location_refusal()
if refused is not None:
return refused
return await self._run_held(
THUMBNAILS_JOB_KIND,
THUMBNAIL_GENERATION_TITLE,
@@ -3166,9 +3071,9 @@ class ImageRoutesMixin:
if stop is not None and stop():
break
root = Path(root)
store = preview_store.ensure_store(root)
store = preview_store.store_dir(root)
if store is None:
continue # the thumbnail folder is unavailable
continue
plan = self._plan_thumbnails(
root,
store,
@@ -3210,8 +3115,6 @@ class ImageRoutesMixin:
"format": writer_settings().format,
# Files whose automatic preview failed this session (#249).
"failed": self._auto_thumbnail_failures(),
# #320: where previews live (no path here, D6A).
"location": self._thumbnail_location_payload(with_path=False),
"collection_id": hashlib.sha256(collection.encode()).hexdigest()[
:16
],
@@ -3260,8 +3163,6 @@ class ImageRoutesMixin:
thumbnails_dir = preview_store.given_store(output_path, thumbnails_dir)
plan = {key: 0 for key in _PLAN_COUNTS}
plan.update(targets=[], fresh_files=[])
if thumbnails_dir is None:
return plan # the thumbnail folder is unavailable
folders = {}
removed = self._removal_check_under(output_path)
for src in _iter_media_files(output_path, MAX_GALLERY_FILES):
@@ -3364,7 +3265,7 @@ class ImageRoutesMixin:
return {**result, "action": "error", "error": "File no longer exists"}
if not rebuild and _thumbnail_is_fresh(src, dst):
return {**result, "action": "skipped", "path": str(dst)}
preview_store.make_parent(dst)
dst.parent.mkdir(parents=True, exist_ok=True)
if is_video:
made = self._generate_video_thumbnail(
src,
@@ -3569,12 +3470,8 @@ class ImageRoutesMixin:
Invalid thumbnail directories return 400; unavailable gallery roots
return 404, and unexpected filesystem failures return 500. Holds the
registry as guarded work, like generation. Refused (409) while the
thumbnail folder is unavailable (#320).
registry as guarded work, like generation.
"""
refused = location_refusal()
if refused is not None:
return refused
return await self._run_held(
THUMBNAILS_JOB_KIND,
THUMBNAIL_CLEAR_TITLE,
@@ -3639,11 +3536,7 @@ class ImageRoutesMixin:
return 0
for output_path in output_dirs:
folder = preview_store.store_dir(output_path)
if (
folder is None
or preview_store.store_is_link(folder)
or not folder.is_dir()
):
if folder is None or folder.is_symlink() or not folder.is_dir():
continue
removed += sweep_stray_temps(folder)[0]
if removed:
@@ -3668,7 +3561,7 @@ class ImageRoutesMixin:
continue
container = preview_store.store_container(output_path)
if (
preview_store.store_is_link(thumbnails_dir)
thumbnails_dir.is_symlink()
or not _is_within(thumbnails_dir, container)
or _canonical(thumbnails_dir) == _canonical(container)
):
@@ -3685,15 +3578,7 @@ class ImageRoutesMixin:
temps, temp_bytes = sweep_stray_temps(thumbnails_dir, min_age=0)
cleared_count += temps
cleared_size += temp_bytes
folder_mode = preview_store.current().mode == preview_store.FOLDER_MODE
for root, dirs, files in os.walk(thumbnails_dir):
if folder_mode:
# Never into a junction or reparse point (#320).
dirs[:] = [
d
for d in dirs
if not preview_store.store_is_link(Path(root) / d)
]
for root, _dirs, files in os.walk(thumbnails_dir):
for name in files:
path = Path(root) / name
if (
@@ -3701,7 +3586,6 @@ class ImageRoutesMixin:
or path.suffix.lower()
not in thumbnail_settings.THUMBNAIL_EXTENSIONS
or path.is_symlink()
or not preview_store.may_write(path)
):
continue
try:
+25 -39
View File
@@ -204,8 +204,6 @@ class Job:
self.total = 0
self.counters = dict.fromkeys(groups, 0)
self.outcomes = {group: [] for group in groups}
# Groups whose report lists every row, beyond MAX_OUTCOME_ROWS (#550).
self.complete = {}
self.log = []
self._cancel_requested = False
self.last_event = None
@@ -288,48 +286,42 @@ class Job:
index = row.get("root_index")
if isinstance(index, bool) or not isinstance(index, int) or index < 0:
index = None
shaped = {
"path": path,
"root": str(row.get("root") or ""),
"root_index": index,
"detail": str(row.get("detail") or ""),
}
if group in self.complete: # a group kept whole for the report (#586)
self.complete[group].append(dict(shaped))
if len(rows) < MAX_OUTCOME_ROWS:
rows.append(shaped)
rows.append(
{
"path": path,
"root": str(row.get("root") or ""),
"root_index": index,
"detail": str(row.get("detail") or ""),
}
)
self.log_line(log_line)
def record_counted(self, group, count, rows=(), *, complete=False):
def record_counted(self, group, count, rows=()):
"""Count ``count`` outcomes in ``group`` at once, listing ``rows``.
For outcomes that need no log line each (a thumbnail job's already
fresh previews): the counter is exact, ``rows`` (``record``'s row
shape) are listed up to MAX_OUTCOME_ROWS, and nothing is logged.
``complete`` also keeps every row for the report (#550: a record of
each change that must not be cut short; the caller bounds it).
"""
if group not in self.counters:
raise ValueError(f"Unknown outcome group {group!r} for {self.kind}")
self.counters[group] += max(0, int(count))
listed = self.outcomes[group]
kept = self.complete.setdefault(group, []) if complete else None
for row in rows:
if kept is None and len(listed) >= MAX_OUTCOME_ROWS:
if len(listed) >= MAX_OUTCOME_ROWS:
break
index = row.get("root_index")
if isinstance(index, bool) or not isinstance(index, int) or index < 0:
index = None
shaped = {
"path": _relative_path(row.get("path")),
"root": str(row.get("root") or ""),
"root_index": index,
"detail": str(row.get("detail") or ""),
}
if kept is not None:
kept.append(shaped)
if len(listed) < MAX_OUTCOME_ROWS:
listed.append(dict(shaped))
listed.append(
{
"path": _relative_path(row.get("path")),
"root": str(row.get("root") or ""),
"root_index": index,
"detail": str(row.get("detail") or ""),
}
)
def finish(self, state="finished"):
"""End the job in ``state`` and release every subscriber.
@@ -414,9 +406,8 @@ class Job:
"""The downloadable report: the summary, every outcome group and the log.
Each group carries its exact count, its listed rows (at most
MAX_OUTCOME_ROWS, or every row of a ``complete`` group) and whether
the list is cut short. Rows and log lines already hold root-relative
paths only.
MAX_OUTCOME_ROWS) and whether the list is cut short. Rows and log lines
already hold root-relative paths only.
"""
summary = self.summary()
summary.pop("listed", None)
@@ -426,21 +417,16 @@ class Job:
"generated_at": self._clock(),
"job": summary,
"outcomes": {
group: self._report_group(group, rows)
group: {
"count": self.counters.get(group, len(rows)),
"rows": [dict(row) for row in rows],
"truncated": self.counters.get(group, len(rows)) > len(rows),
}
for group, rows in self.outcomes.items()
},
"log": list(self.log),
}
def _report_group(self, group, listed):
rows = self.complete.get(group, listed)
count = self.counters.get(group, len(rows))
return {
"count": count,
"rows": [dict(row) for row in rows],
"truncated": count > len(rows),
}
def report_filename(self):
"""The download name: prompt-manager-KIND-YYYYMMDD-HHMM.json (local time)."""
stamp = time.strftime("%Y%m%d-%H%M", time.localtime(self._clock()))
+1 -3
View File
@@ -377,9 +377,7 @@ class LibraryRoutesMixin:
[parent["id"] for parent in result["items"]]
)
result["next_cursor"] = _encode(
result.pop("next_key"),
scope,
name=options["sort"] in ("name", "name-desc"),
result.pop("next_key"), scope, name=options["sort"] == "name"
)
return result, records
-23
View File
@@ -50,7 +50,6 @@ _REFUSAL_STATUS = {
"not-movable": 400,
"same-folder": 400,
"target-invalid": 400,
"data-dir-served": 400,
"target-has-library": 409,
"target-conflict": 409,
"insufficient-space": 507,
@@ -75,24 +74,6 @@ def _failure(message, status, code):
)
def _thumbnail_folder():
"""The thumbnail folder in use, or None (previews inside each source)."""
from . import preview_store
setting = preview_store.setting()
return setting.path if setting.mode == preview_store.FOLDER_MODE else None
def _inside_thumbnail_folder(path):
"""Whether *path* lies in the thumbnail folder in use (#320)."""
from . import preview_store
setting = preview_store.setting()
if setting.mode != preview_store.FOLDER_MODE or not setting.path:
return False
return ll._inside_any(path, [setting.path])
def _plural(count, one, many=None):
return f"{count:,} {one if count == 1 else (many or one + 's')}"
@@ -244,8 +225,6 @@ class LibraryLocationRoutesMixin:
),
"problem": location.problem or self._node_folder_risk(location),
"size_bytes": size,
# #320 D201: thumbnails kept in one folder stay there (D8A).
"thumbnails_folder": _thumbnail_folder(),
}
# The node folder (tests point it at a temp tree).
@@ -293,8 +272,6 @@ class LibraryLocationRoutesMixin:
def _plan_library_move(self):
"""The checked plan (blocking); raises MoveFailed."""
plan = lm.plan_move(self._live_location())
if _inside_thumbnail_folder(plan.target_dir):
raise lm.MoveFailed("data-dir-served")
if lm.space_problem(plan) is not None:
raise lm.MoveFailed("insufficient-space")
return plan
+2 -23
View File
@@ -68,23 +68,6 @@ def _count(count, singular, plural):
return f"{count} {singular if count == 1 else plural}"
# Each kept entry's row in the job (#586), in the review's "Kept: ..." words.
KEPT_DETAILS = {
"active": "Kept: the active log",
"link": "Kept: a link",
"other": "Kept: another file",
}
def _kept_files(rows):
"""``{name, kind}`` of each entry the cleanup keeps, in folder order."""
return [
{"name": row["name"], "kind": row["kind"]}
for row in rows
if row["kind"] in KEPT_DETAILS
]
def _protected_reasons(rows):
"""The plan's Protected reasons: what the cleanup keeps, zero counts left out."""
active = sum(1 for row in rows if row["kind"] == "active")
@@ -146,7 +129,7 @@ class LogCleanupMixin:
token = store.issue(
ROTATED_LOGS,
json.dumps(files, sort_keys=True),
{"files": files, "protected": protected, "kept": _kept_files(rows)},
{"files": files, "protected": protected},
(),
)
except ScopeTooLarge:
@@ -215,11 +198,7 @@ class LogCleanupMixin:
job.record(
"errors", {"path": name, "detail": detail}, f"! {name} · {detail}"
)
kept = [
{"path": row["name"], "detail": KEPT_DETAILS[row["kind"]]}
for row in plan.payload["kept"]
]
job.record_counted("protected", len(kept), kept, complete=True)
job.record_counted("protected", plan.payload["protected"])
removed, failed = len(result["removed"]), len(result["failed"])
line = f"Deleted {_count(removed, 'rotated log copy', 'rotated log copies')}"
if failed:
+6 -95
View File
@@ -1,9 +1,8 @@
"""Maintenance routes: reviewed, read-only plans (#249 System S6).
``POST /prompt_manager/maintenance/preview {operation, options:{}}`` reviews
one operation (``duplicate_prompts``, ``optimize``, ``unused_components``,
``missing_links`` or ``pair_metadata``, #550; ``database/maintenance.py``;
``duplicate_files``,
one operation (``duplicate_prompts``, ``optimize``, ``unused_components`` or
``missing_links``; ``database/maintenance.py``; ``duplicate_files``,
``py/api/duplicate_files.py``; ``rotated_logs``, the rotated log copies,
``py/api/log_cleanup.py``, which has its own apply job) off the event loop and
answers a ``Plan``: ``{success, token, operation, expires_at, scope:{count,
@@ -98,11 +97,7 @@ MAINTENANCE_TITLES = {
"unused_components": "Unused prompt components",
"missing_links": "Missing-file links",
"workflow_storage": "Store each workflow once",
# #550, beyond the concept: fills old Pair prompts' empty category and tags.
"pair_metadata": "Pair category and tags",
}
# Operations whose job keeps every changed row for its report (#550).
COMPLETE_RECORD = frozenset({"pair_metadata"})
_OPERATION_ERROR = "operation must be one of: " + ", ".join(PREVIEW_OPERATIONS)
MAINTENANCE_GROUPS = ("changed", "protected", "errors")
MAX_TOKEN_LENGTH = 128
@@ -286,27 +281,9 @@ def short_space_message(short):
)
def _pair_metadata_line(result):
count = result["changed"]
noun = (
"prompt from its saved pairs"
if count == 1
else "prompts from their saved pairs"
)
categories = result.get("categories", 0)
tag_sets = result.get("tag_sets", 0)
return (
f"Filled {count:,} {noun} "
f"({categories:,} {'category' if categories == 1 else 'categories'}, "
f"{tag_sets:,} {'tag set' if tag_sets == 1 else 'tag sets'})"
)
def _changed_line(operation, count, marks=0, result=None):
def _changed_line(operation, count, marks=0):
if operation == "optimize":
return "Optimized the database"
if operation == "pair_metadata":
return _pair_metadata_line(result or {"changed": count})
if operation == "duplicate_prompts":
noun = "duplicate prompt" if count == 1 else "duplicate prompts"
return f"Merged {count:,} {noun}"
@@ -691,67 +668,13 @@ class MaintenanceRoutesMixin:
rows = await self._run_in_executor(
self._maintenance_rows, plan.operation, result["rows"]
)
job.record_counted(
"changed",
result["changed"],
rows,
complete=plan.operation in COMPLETE_RECORD,
)
kept = await self._run_in_executor(self._kept_rows, db, plan, result)
if kept is None:
job.record_counted("protected", payload["protected"]) # none kept
else:
# #550, #586: each kept record with its reason, like the changed
# ones, and every one of them in the report.
job.record_counted("protected", len(kept), kept, complete=True)
line = _changed_line(
plan.operation, result["changed"], result.get("marks", 0), result
)
job.record_counted("changed", result["changed"], rows)
job.record_counted("protected", payload["protected"])
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 _kept_rows(self, db, plan, result):
"""Outcome rows for the records the apply kept (blocking), or None.
Prompts are records (no path); a missing-links plan's kept links
name their file, relative to its output root, so Activity can open
and locate it (#586); one under a source that is gone keeps its path
under that source's label. None for an operation that keeps nothing
(Optimize).
"""
if plan.operation == "missing_links":
roots = _scan_roots(self._get_all_output_dirs())
judged = plan.payload["roots"]
rows = []
for item in db.kept_links(judged):
path, label, index = _scan_location(item["image_path"], roots)
if index is None and item["owner"] is not None:
# A source that is gone is not listed: name the file
# under it, with no listing index to open it by.
source = judged[item["owner"]]
where = [(os.path.abspath(source), self._public_path(source), None)]
path, label, index = _scan_location(item["image_path"], where)
rows.append(
{
"path": path,
"root": label,
"root_index": index,
"detail": f"Kept: {item['reason']}",
}
)
return rows
if "kept" not in result:
return None
return [
{
"path": "",
"detail": f"Prompt {item['prompt_id']}: kept, "
+ "; ".join(item["reasons"]),
}
for item in result["kept"]
]
def _maintenance_rows(self, operation, described):
"""Outcome rows for the changed records (root-relative paths only)."""
if operation == "missing_links":
@@ -768,8 +691,6 @@ class MaintenanceRoutesMixin:
}
)
return rows
if operation == "pair_metadata":
return [{"path": "", "detail": _filled_detail(item)} for item in described]
if operation == "duplicate_prompts":
return [
{
@@ -787,16 +708,6 @@ class MaintenanceRoutesMixin:
]
def _filled_detail(item):
"""``Prompt 12: category “portrait”; tags a, b``: one filled prompt (#550)."""
parts = []
if "category" in item:
parts.append(f"category “{item['category']}”")
if item.get("tags"):
parts.append("tags " + ", ".join(item["tags"]))
return f"Prompt {item['prompt_id']}: " + "; ".join(parts)
def backup_note(backup):
"""The status line's backup part: the file name, or that there is none."""
return f"Safety backup {backup}" if backup else NO_BACKUP
-728
View File
@@ -1,728 +0,0 @@
"""The thumbnail location setting (#320): parsing, validation, source keys
and the thumbnail folder's marker.
``preview_store`` holds the runtime state and maps paths; this module holds
the rules it applies. Nothing here keeps state.
Schema (config.json ``thumbnails.location``)::
{"mode": "folder", "path": "D:\\\\Thumbs", "id": "<uuid4>",
"install": "<uuid4>", "cleanup": []}
The default (and a missing key) is ``{"mode": "source"}``: previews inside
each source. An invalid value never falls back to the sources: it parses as
a folder location with problem ``invalid``, so nothing is written anywhere.
Layout in folder mode: ``<folder>/<source key>/<rel dir>/<stem>_thumb<ext>``.
A source key is a readable slug plus a short hash of the source's identity
(``output-3f9a2c1b``), assigned once and stored in the marker
``.promptmanager-thumbnails.json`` with the identity it belongs to.
"""
import errno
import hashlib
import json
import logging
import ntpath
import os
import posixpath
import re
import stat as stat_module
import threading
import time
import unicodedata
import uuid
from contextlib import contextmanager
from dataclasses import dataclass, field
try:
from ...utils import library_location
except ImportError: # tests import the package top-level
from utils import library_location
logger = logging.getLogger("prompt_manager.thumbnails")
SOURCE_MODE = "source"
FOLDER_MODE = "folder"
MARKER_NAME = ".promptmanager-thumbnails.json"
MARKER_VERSION = 1
# Beside the marker while one process re-reads, changes and replaces it.
MARKER_LOCK_NAME = ".promptmanager-thumbnails.lock"
MARKER_LOCK_TIMEOUT = 1.5 # a writer waits this long (under preview_store.IO_BUDGET)
MARKER_LOCK_STALE = 30.0 # a lock this old was left by a writer that died
MARKER_LOCK_POLL = 0.02
# A source key: one folder name, a slug and a 8-16 hex hash (``source_key``).
KEY_PATTERN = re.compile(r"^[a-z0-9_-]{1,24}-[0-9a-f]{8,16}$")
# A marker is a few keys; anything larger is not ours.
MAX_MARKER_BYTES = 4 * 2**20
# OS metadata files a folder may hold and still count as empty.
OS_METADATA = frozenset({"thumbs.db", "desktop.ini", ".ds_store"})
KEY_HASH_LENGTHS = (8, 12, 16)
SLUG_LENGTH = 24
# Folder + the longest key (24 + 1 + 16) + this margin must fit (Windows).
WINDOWS_FOLDER_LIMIT = 247
LONGEST_KEY = SLUG_LENGTH + 1 + KEY_HASH_LENGTHS[-1]
PATH_MARGIN = 40
# A validation stat on a share that does not answer in time is unavailable.
VALIDATION_TIMEOUT = 5.0
FILE_ATTRIBUTE_REPARSE_POINT = 0x400
_RESERVED = re.compile(r"^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$", re.IGNORECASE)
_BAD_CHARS = re.compile(r'[<>:"|?*\x00-\x1f]')
# Problem codes, their HTTP status and the text the UI shows (no path).
MESSAGES = {
"location-not-absolute": (
400,
"Enter a full folder path, such as D:\\Thumbnails or /mnt/fast/thumbnails.",
),
"location-device-path": (
400,
"Enter the folder as a drive letter or \\\\server\\share path, "
"without \\\\?\\.",
),
"location-invalid-name": (
400,
"This folder name can't be used on Windows. Choose another name.",
),
"location-too-broad": (
400,
"Choose a folder inside a drive or share, not the drive itself.",
),
"location-served": (
400,
"Choose a folder outside your image sources and ComfyUI's output, input "
"and temp folders.",
),
"location-node-folder": (
400,
"Choose a folder outside the Prompt Manager node folder.",
),
"location-contains-source": (
400,
"This folder contains an image source. Choose a folder beside it instead.",
),
"location-same": (400, "Thumbnails are already stored here."),
"location-inside-current": (
400,
"This folder is inside the thumbnail folder in use. Choose a folder "
"outside it.",
),
"location-parent-missing": (
400,
"The folder that would hold it doesn't exist. Check the path and that its "
"drive is connected.",
),
"location-unavailable": (
409,
"This folder can't be reached right now. Check that its drive or share is "
"connected.",
),
"location-not-empty": (
409,
"This folder already holds other files. Choose an empty folder or one "
"Prompt Manager used before.",
),
"location-not-writable": (
409,
"Prompt Manager can't write to this folder. Check its permissions.",
),
"location-path-too-long": (
400,
"This path is too long for Windows. Choose a shorter folder path.",
),
"insufficient-space": (
507,
"There is not enough space for the thumbnails. Free some space or choose "
"another folder.",
),
"location-needs-move": (
409,
"Use Move thumbnails in System › Sources & scans.",
),
}
HOLDS_THUMBNAILS_REASON = "This folder holds Prompt Manager's thumbnails."
def message(code):
"""``(status, text)`` of a problem code."""
return MESSAGES[code]
# ── parsing ──────────────────────────────────────────────────────────
@dataclass(frozen=True)
class ParsedLocation:
"""A ``thumbnails.location`` value, checked as data (no filesystem)."""
mode: str = SOURCE_MODE
path: str | None = None
id: str | None = None
install: str | None = None
cleanup: tuple = field(default_factory=tuple)
problem: str | None = None
def to_config(self):
"""The config.json form (``invalid`` values are never written)."""
if self.mode == SOURCE_MODE:
value = {"mode": SOURCE_MODE}
else:
value = {"mode": FOLDER_MODE, "path": self.path, "id": self.id}
if self.install:
value["install"] = self.install
value["cleanup"] = list(self.cleanup)
return value
def _cleanup(value):
if not isinstance(value, list):
return ()
return tuple(item for item in value if isinstance(item, dict))
def parse_location(value, *, flavor=os.path):
"""The location a ``thumbnails.location`` value names.
Missing: inside each source. Anything else that is not a well-formed
location (an unknown mode, a relative path, a missing id) becomes a
folder location with problem ``invalid``: never a silent fall-back.
"""
if value is None:
return ParsedLocation()
if not isinstance(value, dict):
return ParsedLocation(mode=FOLDER_MODE, problem="invalid")
mode = value.get("mode", SOURCE_MODE)
install = value.get("install") if isinstance(value.get("install"), str) else None
cleanup = _cleanup(value.get("cleanup"))
if mode == SOURCE_MODE:
return ParsedLocation(install=install, cleanup=cleanup)
if mode != FOLDER_MODE:
return ParsedLocation(mode=FOLDER_MODE, problem="invalid", cleanup=cleanup)
path, marker_id = value.get("path"), value.get("id")
if (
not isinstance(path, str)
or form_problem(path, flavor=flavor) is not None
or not isinstance(marker_id, str)
or not marker_id.strip()
):
return ParsedLocation(
mode=FOLDER_MODE,
path=path if isinstance(path, str) else None,
problem="invalid",
cleanup=cleanup,
)
return ParsedLocation(
mode=FOLDER_MODE,
path=flavor.abspath(path),
id=marker_id,
install=install,
cleanup=cleanup,
)
# ── form (text only) ─────────────────────────────────────────────────
def _is_windows(flavor):
return flavor is ntpath or getattr(flavor, "__name__", "") == "ntpath"
def form_problem(path, *, flavor=os.path):
"""The text-only problem of *path*, or None (no filesystem access).
``\\\\?\\`` and ``\\\\.\\`` prefixes, relative, drive-relative (``C:x``)
and root-relative (``\\x``) spellings, and names Windows cannot hold
(checked on every OS: the folder may be moved to Windows).
"""
if not isinstance(path, str) or not path.strip() or path != path.lstrip():
return "location-not-absolute"
if path.replace("/", "\\").startswith(("\\\\?\\", "\\\\.\\")):
return "location-device-path"
if _is_windows(flavor):
drive, rest = ntpath.splitdrive(path)
if not drive or not rest.startswith(("\\", "/")):
return "location-not-absolute"
elif not posixpath.isabs(path):
return "location-not-absolute"
drive, rest = flavor.splitdrive(path)
for part in re.split(r"[\\/]+", rest):
if not part:
continue
if (
_RESERVED.match(part)
or part.endswith((".", " "))
or _BAD_CHARS.search(part)
):
return "location-invalid-name"
return None
def is_too_broad(path, *, home=None, flavor=os.path):
"""A filesystem or share root, or the home folder itself."""
normalized = flavor.normpath(path)
drive, rest = flavor.splitdrive(normalized)
if rest in ("", "\\", "/"):
return True
home = home if home is not None else os.path.expanduser("~")
return bool(home) and flavor.normcase(normalized) == flavor.normcase(
flavor.normpath(home)
)
def too_long_for_windows(path, long_paths=None):
"""Folder + the longest key + a margin over the folder limit (Windows
without long paths; *long_paths* injects the registry answer)."""
if long_paths is None:
long_paths = long_paths_enabled()
if long_paths:
return False
return len(path) + 1 + LONGEST_KEY + PATH_MARGIN > WINDOWS_FOLDER_LIMIT
def long_paths_enabled():
"""``LongPathsEnabled`` on Windows (True on other systems)."""
if os.name != "nt":
return True
try:
import winreg
with winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE,
r"SYSTEM\CurrentControlSet\Control\FileSystem",
) as key:
return winreg.QueryValueEx(key, "LongPathsEnabled")[0] == 1
except OSError:
return False
# ── links ────────────────────────────────────────────────────────────
def is_link_like(path):
"""A symlink, a junction or any reparse point (one lstat), or missing.
Raises OSError other than "not found" (an unreachable share).
"""
try:
info = os.lstat(path)
except FileNotFoundError:
return False
if stat_module.S_ISLNK(info.st_mode):
return True
attributes = getattr(info, "st_file_attributes", 0)
if attributes & FILE_ATTRIBUTE_REPARSE_POINT:
return True
is_junction = getattr(os.path, "isjunction", None)
return bool(is_junction and is_junction(path))
# ── source keys ──────────────────────────────────────────────────────
def slug(name):
"""``NFKD`` to ASCII, lower case, runs outside ``[a-z0-9_]`` as ``-``,
trimmed and cut to 24 characters; empty becomes ``source``."""
text = unicodedata.normalize("NFKD", str(name))
text = text.encode("ascii", "ignore").decode("ascii").lower()
text = re.sub(r"[^a-z0-9_]+", "-", text).strip("-")
text = text[:SLUG_LENGTH].strip("-")
return text or "source"
def source_key(spelling, identity, taken):
"""The key of a source: ``slug(basename) + "-" + sha256(identity)[:n]``.
*taken* maps existing keys to their identity; a key taken by another
identity grows its hash to 12, then 16 characters.
"""
base = slug(ntpath.basename(str(spelling).rstrip("\\/")))
digest = hashlib.sha256(str(identity).encode("utf-8")).hexdigest()
for length in KEY_HASH_LENGTHS:
key = f"{base}-{digest[:length]}"
owner = taken.get(key)
if owner is None or owner == identity:
return key
raise ValueError("no free source key")
def file_identity(path):
"""``[st_dev, st_ino]`` of an existing folder, or None (no file ids)."""
try:
info = os.stat(path)
except (OSError, ValueError):
return None
if not info.st_ino:
return None
return [info.st_dev, info.st_ino]
def find_key(keys, spelling, identity, dev_ino):
"""The marker key of a source: the same configured spelling, then the
same identity text, then the same file id; or None."""
keys = {k: v for k, v in keys.items() if valid_key(k) and isinstance(v, dict)}
for key, entry in keys.items():
if entry.get("path") == spelling:
return key
for key, entry in keys.items():
if entry.get("identity") == identity:
return key
if dev_ino:
for key, entry in keys.items():
if entry.get("dev_ino") == list(dev_ino):
return key
return None
# ── the marker ───────────────────────────────────────────────────────
def marker_path(folder):
return os.path.join(folder, MARKER_NAME)
def read_marker(folder):
"""The marker in *folder* as a dict, or None (absent or unreadable as
ours). OSError other than "not found" propagates (an unreachable share)."""
try:
with open(marker_path(folder), "r", encoding="utf-8") as handle:
text = handle.read(MAX_MARKER_BYTES + 1)
except FileNotFoundError:
return None
except (ValueError, UnicodeDecodeError):
return None
if len(text) > MAX_MARKER_BYTES:
return None
try:
data = json.loads(text)
except ValueError:
return None
if not isinstance(data, dict) or not isinstance(data.get("id"), str):
return None
data["keys"] = valid_keys(data.get("keys"))
return data
def valid_key(key):
"""Whether *key* can name a source's folder: one folder name of the
``source_key`` form, never a separator, ``..`` or a drive."""
return isinstance(key, str) and KEY_PATTERN.fullmatch(key) is not None
def valid_keys(keys):
"""The marker's keys that are safe to join onto the folder; a hand-edited
or foreign entry that is not is dropped (and logged), never followed."""
if not isinstance(keys, dict):
return {}
kept = {}
for key, entry in keys.items():
if valid_key(key) and isinstance(entry, dict):
kept[key] = entry
else:
logger.warning(
"Ignoring a thumbnail folder marker entry that is not a source " "key"
)
return kept
@contextmanager
def _marker_lock(folder):
"""Hold the folder's marker lock: a file made with ``O_CREAT | O_EXCL``
(atomic on local disks and SMB/NFS shares, unlike ``flock``), so installs
sharing the folder change the marker one at a time. A lock older than
MARKER_LOCK_STALE was left by a writer that died and is taken over;
waiting longer than MARKER_LOCK_TIMEOUT raises TimeoutError."""
lock = os.path.join(folder, MARKER_LOCK_NAME)
deadline = time.monotonic() + MARKER_LOCK_TIMEOUT
while True:
try:
handle = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
except FileExistsError:
_break_stale_lock(lock)
if time.monotonic() >= deadline:
raise TimeoutError(
errno.ETIMEDOUT, "the thumbnail folder marker is locked", lock
)
time.sleep(MARKER_LOCK_POLL)
continue
try:
os.write(handle, f"{os.getpid()}\n".encode("ascii"))
finally:
os.close(handle) # closed before any unlink (Windows)
break
try:
yield
finally:
try:
os.unlink(lock)
except FileNotFoundError:
pass # taken over as stale by another writer: nothing to release
def _break_stale_lock(lock):
"""Remove *lock* when it is older than MARKER_LOCK_STALE (its writer
died); a lock that changed since it was looked at is left alone."""
try:
before = os.stat(lock)
except FileNotFoundError:
return
if time.time() - before.st_mtime < MARKER_LOCK_STALE:
return
logger.warning(f"Taking over a stale thumbnail folder lock: {lock}")
try:
after = os.stat(lock)
if (after.st_ino, after.st_mtime_ns) == (before.st_ino, before.st_mtime_ns):
os.unlink(lock)
except FileNotFoundError:
pass # another writer took it over first
def write_marker(folder, mutate):
"""Re-read the marker, apply ``mutate(marker)`` and write it atomically
(a temp sibling renamed over it), all under the folder's marker lock so a
second install sharing the folder never drops this one's change (and
the other way round). Callers hold ``preview_store.write_lock`` (threads
of this process). Returns the marker written; TimeoutError when another
writer holds the lock too long."""
with _marker_lock(folder):
return _write_marker_locked(folder, mutate)
def _write_marker_locked(folder, mutate):
current = read_marker(folder) or {}
marker = {
"version": MARKER_VERSION,
"id": current.get("id"),
"created_by_pm": bool(current.get("created_by_pm", False)),
"keys": dict(current.get("keys") or {}),
}
mutate(marker)
if not marker.get("id"):
raise ValueError("a marker needs an id")
target = marker_path(folder)
temp = os.path.join(
folder, f".{MARKER_NAME}.{os.getpid()}-{threading.get_ident()}.tmp"
)
try:
with open(temp, "w", encoding="utf-8") as handle:
json.dump(marker, handle, indent=2, sort_keys=True)
handle.flush()
os.fsync(handle.fileno())
os.replace(temp, target)
finally:
try:
os.unlink(temp)
except OSError:
pass # gone with the rename; a leftover is swept with the temps
return marker
def new_id():
return str(uuid.uuid4())
def init_folder(folder, *, created_by_pm=False):
"""The marker id of *folder*: an existing marker is adopted; a folder
without one gets a new marker (new id, no keys)."""
existing = read_marker(folder)
if existing is not None:
return existing["id"]
def create(marker):
if not marker.get("id"):
marker["id"] = new_id()
marker["created_by_pm"] = created_by_pm
return write_marker(folder, create)["id"]
def folder_state(path, expected_id):
"""``(available, problem, marker)`` of a configured thumbnail folder.
Available: the folder exists, is a directory and not a link, junction or
reparse point, and holds our marker with *expected_id*. Raises OSError
when the folder cannot be reached (an I/O error other than not found).
"""
try:
info = os.lstat(path)
except FileNotFoundError:
return False, "missing", None
except OSError as error:
if error.errno in (errno.ENOENT, errno.ENOTDIR):
return False, "missing", None
raise
if is_link_like(path):
return False, "invalid", None
if not stat_module.S_ISDIR(info.st_mode):
return False, "invalid", None
marker = read_marker(path)
if marker is None:
return False, "missing", None
if marker["id"] != expected_id:
return False, "marker-mismatch", marker
return True, None, marker
def holds_only_metadata(path):
"""Whether an existing folder is empty but for OS metadata files."""
with os.scandir(path) as entries:
for entry in entries:
name = entry.name.lower()
if name in OS_METADATA or name.startswith("._"):
continue
return False
return True
# ── validation ───────────────────────────────────────────────────────
@dataclass(frozen=True)
class Context:
"""What a location is checked against (gathered by the caller)."""
roots: tuple = () # every image source of every root list
contained: tuple = () # ComfyUI output/input/temp and the library folder
node_folder: str | None = None
default_path: str | None = None
current_path: str | None = None # the folder in use (folder mode)
home: str | None = None
flavor: object = os.path
long_paths: bool | None = None
environ: object = None
# ComfyUI's folder_paths module (None: none; unset: imported).
folder_paths: object = library_location._UNSET
def _timed(func, timeout):
"""``func()`` on a daemon thread; raises TimeoutError past *timeout*.
A stuck call is left to finish on its own thread."""
result = {}
done = threading.Event()
def run():
try:
result["value"] = func()
except BaseException as error: # _timed() re-raises it in the caller
result["error"] = error
finally:
done.set()
threading.Thread(target=run, name="pm-preview-stat", daemon=True).start()
if not done.wait(timeout):
raise TimeoutError("the folder did not answer in time")
if "error" in result:
raise result["error"]
return result.get("value")
def _inside(path, folders):
return library_location._inside_any(path, folders)
def _same(first, second):
return library_location.same_folder(first, second) or os.path.normcase(
os.path.realpath(first)
) == os.path.normcase(os.path.realpath(second))
def _served(path, context):
environ = os.environ if context.environ is None else context.environ
return (
library_location.data_dir_problem(
path,
folder_paths=context.folder_paths,
environ=environ,
gallery_roots=list(context.roots),
)
== "data-dir-served"
)
def _probe_write(path):
"""Create and delete a probe file in *path*; False when refused."""
probe = os.path.join(path, f".pm-probe-{uuid.uuid4().hex[:8]}.tmp")
try:
with open(probe, "wb") as handle:
handle.write(b"")
except OSError:
return False
try:
os.unlink(probe)
except OSError:
return False
return True
def location_problem(path, *, purpose, context, timeout=VALIDATION_TIMEOUT):
"""Why *path* cannot be the thumbnail folder, as a code, or None.
*purpose*: ``move`` (every rule), ``startup`` (no ``same``, ``empty``,
``writable`` or parent rules: availability covers them) or ``recreate``
(the folder is missing and is made again).
"""
flavor = context.flavor
code = form_problem(path, flavor=flavor)
if code is not None:
return code
path = flavor.abspath(path)
default = bool(context.default_path) and flavor.normcase(path) == flavor.normcase(
flavor.abspath(context.default_path)
)
if is_too_broad(path, home=context.home, flavor=flavor):
return "location-too-broad"
try:
return _timed(
lambda: _filesystem_problem(path, purpose, context, default), timeout
)
except (TimeoutError, OSError):
return "location-unavailable"
def _filesystem_problem(path, purpose, context, default):
if _served(path, context):
return "location-served"
if context.node_folder and not default and _inside(path, [context.node_folder]):
return "location-node-folder"
inner = list(context.roots) + list(context.contained)
if any(folder and _inside(folder, [path]) for folder in inner):
if not default:
return "location-contains-source"
if (
purpose == "move"
and context.current_path
and (
os.path.normcase(path) == os.path.normcase(context.current_path)
or (os.path.isdir(path) and _same(path, context.current_path))
)
):
return "location-same"
if (
purpose == "move"
and context.current_path
and _inside(path, [context.current_path])
):
# The old key folders are removed after the move, and with them a
# target nested inside one of them.
return "location-inside-current"
# Windows, or a test injecting its limit (``long_paths=False``).
if os.name == "nt" or context.long_paths is False:
if too_long_for_windows(path, context.long_paths):
return "location-path-too-long"
if purpose == "startup":
return None
if is_link_like(path):
# Never through a link: the folder itself must be a real folder.
return "location-not-empty"
exists = os.path.isdir(path)
if not exists:
if os.path.lexists(path):
return "location-not-empty"
if not default and not os.path.isdir(os.path.dirname(path)):
return "location-parent-missing"
return None
if read_marker(path) is None and not holds_only_metadata(path):
return "location-not-empty"
if not _probe_write(path):
return "location-not-writable"
return None
+18 -540
View File
@@ -23,31 +23,20 @@ no lock (the job registry's ``running()`` is a lock-free snapshot) and
All functions are blocking: call them off the event loop.
"""
import errno
import logging
import os
import threading
import time
from contextlib import contextmanager
from dataclasses import dataclass, replace
from dataclasses import dataclass
from pathlib import Path
from . import preview_location, thumbnail_settings
from . import thumbnail_settings
try:
from ...utils.media_folders import PREVIEW_FOLDER
except ImportError: # tests import the package top-level
from utils.media_folders import PREVIEW_FOLDER
logger = logging.getLogger("prompt_manager.thumbnails")
SOURCE_MODE = preview_location.SOURCE_MODE
FOLDER_MODE = preview_location.FOLDER_MODE
# The probe checks the folder this often, and a check (or a store call)
# taking longer than the budget marks it unreachable.
PROBE_INTERVAL = 5.0
IO_BUDGET = 2.0
PROBE_THREAD = "pm-preview-probe"
SOURCE_MODE = "source"
@dataclass(frozen=True)
@@ -63,36 +52,11 @@ class Location:
write_lock = threading.Lock()
_generation = 0
# The runtime location: the parsed setting, availability and the marker keys
# last read. Swapped as a whole; readers take one snapshot.
_state_lock = threading.Lock()
_parsed = preview_location.ParsedLocation()
_available = True
_problem = None
# The newest check whose answer was published.
_published_order = 0
_keys = {}
_key_cache = {}
_clock = time.monotonic
def current():
"""The thumbnail location now."""
with _state_lock:
parsed, available, problem, generation_now = (
_parsed,
_available,
_problem,
_generation,
)
if parsed.mode == SOURCE_MODE:
return Location(generation=generation_now)
return Location(FOLDER_MODE, parsed.path, available, problem, generation_now)
def setting():
"""The parsed setting in use (``preview_location.ParsedLocation``)."""
return _parsed
return Location(generation=_generation)
def generation():
@@ -116,480 +80,23 @@ def _within(path, folder):
return _canonical(path).is_relative_to(_canonical(folder))
# ── the setting and availability ─────────────────────────────────────
def configure(value, *, context=None, check=True):
"""Use the ``thumbnails.location`` *value* (config.json) from now on.
A folder location is validated against *context* (``startup`` rules: a
problem other than an unreachable folder makes it ``invalid``), then
checked once; the probe keeps checking it. Swapped under ``write_lock``
with a generation bump, so no write that started before lands.
"""
global _parsed, _available, _problem, _generation
parsed = preview_location.parse_location(value)
problem = parsed.problem
if parsed.mode == FOLDER_MODE and problem is None and context is not None:
code = preview_location.location_problem(
parsed.path, purpose="startup", context=context
)
if code == "location-unavailable":
problem = "unreachable"
elif code is not None:
problem = "invalid"
parsed = preview_location.ParsedLocation(
mode=FOLDER_MODE,
path=parsed.path,
id=parsed.id,
install=parsed.install,
cleanup=parsed.cleanup,
problem="invalid",
)
with write_lock:
with _state_lock:
_parsed = parsed
_available = parsed.mode == SOURCE_MODE
_problem = problem if parsed.mode == FOLDER_MODE else None
_keys.clear()
_key_cache.clear()
_generation += 1
if parsed.mode == FOLDER_MODE:
if check and parsed.problem is None:
refresh()
_probe.start()
else:
_probe.stop()
return current()
def refresh():
"""Check the folder now (bounded by IO_BUDGET); returns availability."""
parsed = _parsed
if parsed.mode != FOLDER_MODE or parsed.problem is not None:
return parsed.mode == SOURCE_MODE
try:
(available, problem, marker), order = _probe.ordered_check(
parsed.path, parsed.id
)
except (OSError, TimeoutError, RuntimeError):
available, problem, marker = False, "unreachable", None
order = _probe.latest_order()
_set_state(parsed, available, problem, marker, order)
return available
def _set_keys(parsed, marker):
"""Take the keys of a marker just written (availability unchanged)."""
with _state_lock:
if _parsed is not parsed:
return
keys = dict((marker or {}).get("keys") or {})
if keys != _keys:
_keys.clear()
_keys.update(keys)
_key_cache.clear()
def set_cleanup(entries):
"""The setting's ``cleanup`` after a removal was committed."""
global _parsed
with _state_lock:
_parsed = replace(_parsed, cleanup=tuple(entries))
def mark_invalid():
"""The setting cannot be used (a startup check that failed): folder
mode stays in force, unavailable, so nothing is written anywhere."""
global _parsed, _available, _problem
with _state_lock:
if _parsed.mode != FOLDER_MODE:
_parsed = preview_location.ParsedLocation(
mode=FOLDER_MODE, problem="invalid"
)
else:
_parsed = replace(_parsed, problem="invalid")
_available, _problem = False, "invalid"
def _set_state(parsed, available, problem, marker, order):
"""Publish a check's answer, unless a check that started later already
published (two callers can finish out of order, #611)."""
global _available, _problem, _published_order
with _state_lock:
if _parsed is not parsed:
return # the setting changed meanwhile
if order < _published_order:
return
_published_order = order
_available, _problem = available, problem
keys = dict((marker or {}).get("keys") or {})
if keys != _keys:
_keys.clear()
_keys.update(keys)
_key_cache.clear()
def note_failure():
"""A store call failed or stalled: unreachable until the probe finds the
folder again (requests do no store I/O meanwhile)."""
global _available, _problem
if _parsed.mode != FOLDER_MODE:
return
with _state_lock:
_available, _problem = False, "unreachable"
_probe.wake()
@contextmanager
def store_io():
"""Around a store call in folder mode: an I/O error (other than a file
that is not there) or a call over IO_BUDGET marks the folder
unreachable."""
if _parsed.mode != FOLDER_MODE:
yield
return
started = _clock()
try:
yield
except OSError as error:
if not isinstance(error, (FileNotFoundError, NotADirectoryError)):
note_failure()
raise
finally:
if _clock() - started > IO_BUDGET:
note_failure()
class _Probe:
"""The background check of the thumbnail folder (one thread, one check
at a time; a stuck check is left to finish on its own thread)."""
def __init__(self):
self._lock = threading.Lock()
self._wake = threading.Event()
self._stop = threading.Event()
self._thread = None
self._checking = None
self._order = 0
def check(self, path, expected_id):
"""``folder_state`` within IO_BUDGET; see :meth:`ordered_check`."""
return self.ordered_check(path, expected_id)[0]
def latest_order(self):
"""The order number of the check started last."""
with self._lock:
return self._order
def ordered_check(self, path, expected_id):
"""``(folder_state, order)`` within IO_BUDGET; raises TimeoutError.
``order`` grows with each check started, so answers can be published
in the order they looked. The answer comes from a check that started
after this call: one already running may have looked before what the
caller just did, so it is waited for (never run twice at once; a stuck
one times out here) and followed by a new one."""
key = (path, expected_id)
with self._lock:
earlier = self._running(key)
pending = None if earlier else self._start_check(path, expected_id)
if earlier:
self._wait(earlier)
with self._lock:
# One another caller started meanwhile is new enough to join.
pending = self._running(key) or self._start_check(path, expected_id)
self._wait(pending)
with self._lock:
if self._checking is pending:
self._checking = None
if "error" in pending[2]:
raise pending[2]["error"]
return pending[2]["value"], pending[2]["order"]
def _running(self, key):
"""The check of *key* still running (lock held), else None."""
pending = self._checking
if pending is None or pending[0] != key or pending[1].is_set():
return None
return pending
@staticmethod
def _wait(pending):
if not pending[1].wait(IO_BUDGET):
raise TimeoutError("the thumbnail folder did not answer in time")
def _start_check(self, path, expected_id):
"""Start one check on its own thread (lock held)."""
self._order += 1
result = {"order": self._order}
done = threading.Event()
def run():
try:
result["value"] = preview_location.folder_state(path, expected_id)
except BaseException as error: # check() re-raises it in the caller
result["error"] = error
finally:
done.set()
pending = ((path, expected_id), done, result)
self._checking = pending
threading.Thread(target=run, name=PROBE_THREAD + "-check", daemon=True).start()
return pending
def start(self):
with self._lock:
if self._thread is not None and self._thread.is_alive():
return
# Each thread has its own stop: a stopped one never resumes.
stop = self._stop = threading.Event()
thread = threading.Thread(
target=self._run, args=(stop,), name=PROBE_THREAD, daemon=True
)
try:
thread.start()
except RuntimeError:
return # refused at interpreter exit: no probe
self._thread = thread
def _run(self, stop):
while not stop.is_set():
self._wake.wait(PROBE_INTERVAL)
self._wake.clear()
if stop.is_set():
return
try:
refresh()
except Exception: # one failed check never stops the probe
logger.debug("Thumbnail folder check failed", exc_info=True)
def wake(self):
self._wake.set()
def stop(self, timeout=2.0):
"""Stop the probe; True when its thread ended."""
with self._lock:
thread, self._thread = self._thread, None
self._stop.set()
self._wake.set()
if thread is None or thread is threading.current_thread():
return True
thread.join(timeout)
return not thread.is_alive()
_probe = _Probe()
def stop_probe(timeout=2.0):
"""Stop the folder probe (the package teardown, tests)."""
return _probe.stop(timeout)
# ── stores ───────────────────────────────────────────────────────────
def source_folder(root):
"""``<source>/thumbnails``: the store in source mode, a leftover in
folder mode."""
return Path(root) / PREVIEW_FOLDER
def store_container(root, location=None):
"""The folder a store of *root* must stay inside: the source itself, or
the thumbnail folder in folder mode."""
location = location or current()
if location.mode == FOLDER_MODE:
return Path(location.path) if location.path else None
"""The folder a store of *root* must stay inside: the source itself."""
return Path(root)
def _identity(root):
return os.path.normcase(os.path.realpath(str(root)))
def _lookup_key(root):
spelling = str(root)
with _state_lock:
if spelling in _key_cache:
return _key_cache[spelling]
keys = dict(_keys)
key = preview_location.find_key(
keys,
spelling,
_identity(root),
preview_location.file_identity(root),
)
if key is not None:
with _state_lock:
_key_cache[spelling] = key
return key
def store_dir(root, location=None):
"""The store of *root*: ``<source>/thumbnails``, or in folder mode
``<folder>/<key>`` (None while the folder is unavailable or before the
source has a key: a reader then finds no preview)."""
location = location or current()
if location.mode == SOURCE_MODE:
return Path(root) / PREVIEW_FOLDER
if not location.available or not location.path:
return None
key = _lookup_key(root)
return None if key is None else _key_folder(location.path, key)
"""The store of *root* (``<source>/thumbnails``)."""
return Path(root) / PREVIEW_FOLDER
def _key_folder(folder, key):
"""``<folder>/<key>``, only for a key that is one folder name directly
below the thumbnail folder (a marker is data, never a path)."""
if not preview_location.valid_key(key):
return None
store = Path(folder) / key
if store.parent != Path(folder) or store.name != key:
return None
return store
def ensure_store(root, location=None, *, create=False):
"""``store_dir`` for a writer: in folder mode a source without a key gets
one (recorded in the marker); with *create* its key folder is made (never
the thumbnail folder itself). None while the folder is unavailable, and
for a key folder that is a link, junction or reparse point: nothing is
planned, written or removed through it."""
location = location or current()
store = store_dir(root, location)
if location.mode == SOURCE_MODE or (store is None and not location.available):
return store
if store is None:
store = _assign_key(root, location)
if store is None:
return None
try:
with store_io():
linked = preview_location.is_link_like(store)
except OSError:
return None
if linked:
logger.warning(f"Skipping the thumbnail folder {store}: it is a link")
return None
if create:
try:
with store_io():
os.mkdir(store)
except FileExistsError:
pass # already there: another writer made the key folder
except OSError:
return None
return store
def _assign_key(root, location):
parsed = _parsed
if location.path != parsed.path:
return None
spelling = str(root)
identity = _identity(root)
dev_ino = preview_location.file_identity(root)
assigned = {}
def add(marker):
if marker.get("id") != parsed.id:
raise ValueError("another folder's marker") # marker-mismatch
keys = marker["keys"]
key = preview_location.find_key(keys, spelling, identity, dev_ino)
if key is None:
taken = {k: v.get("identity") for k, v in keys.items()}
key = preview_location.source_key(spelling, identity, taken)
keys[key] = {
"path": spelling,
"identity": identity,
"dev_ino": dev_ino,
"install": parsed.install,
}
assigned["key"] = key
try:
with write_lock, store_io():
marker = preview_location.write_marker(location.path, add)
except (OSError, ValueError):
return None
_set_keys(parsed, marker)
return _key_folder(location.path, assigned["key"])
def may_write(path, location=None):
"""The one ownership check every preview write and delete passes.
Source mode: True (the callers' own root rules apply, as before). Folder
mode: *path* lies below a source key folder of the thumbnail folder,
lexically and resolved, and no link, junction or reparse point sits
anywhere from the key folder down to it (the file itself included).
"""
location = location or current()
if location.mode == SOURCE_MODE:
return True
if not location.path:
return False
folder = Path(os.path.abspath(location.path))
path = Path(os.path.abspath(path))
try:
relative = path.relative_to(folder)
except ValueError:
return False
if len(relative.parts) < 2 or ".." in relative.parts:
return False
store = _key_folder(folder, relative.parts[0])
if store is None or not _within(path, folder):
return False
try:
return _no_link_below(store, path)
except OSError:
note_failure()
return False
def make_parent(dst, location=None):
"""Make the folders a preview *dst* is written into. In folder mode only
folders below the thumbnail folder are made, never the folder itself,
and only while it holds this install's marker (an unplugged drive or an
unmounted mount point raises FileNotFoundError); a *dst* that does not
pass ``may_write`` raises PermissionError."""
location = location or current()
dst = Path(dst)
if location.mode == SOURCE_MODE or not location.path:
if location.mode != SOURCE_MODE:
raise FileNotFoundError(errno.ENOENT, "thumbnail folder unavailable")
dst.parent.mkdir(parents=True, exist_ok=True)
return
folder = Path(os.path.abspath(location.path))
with store_io():
if not location.available or not folder.is_dir():
raise FileNotFoundError(errno.ENOENT, "thumbnail folder unavailable")
marker = preview_location.read_marker(str(folder))
if marker is None or marker.get("id") != _parsed.id:
note_failure()
raise FileNotFoundError(errno.ENOENT, "thumbnail folder unavailable")
if not may_write(dst, location):
raise PermissionError(errno.EACCES, "outside the thumbnail folder")
current_folder = folder
for part in Path(os.path.abspath(dst.parent)).relative_to(folder).parts:
current_folder = current_folder / part
try:
os.mkdir(current_folder)
except FileExistsError:
pass # already there: another writer made this folder
if preview_location.is_link_like(current_folder):
raise PermissionError(errno.EACCES, "a link in the thumbnail folder")
def store_is_link(store, location=None):
"""Whether a store folder is a link (a junction or reparse point too in
folder mode): it is skipped by sweeps and refused by clears."""
location = location or current()
if location.mode == SOURCE_MODE:
return Path(store).is_symlink()
return preview_location.is_link_like(store)
def ensure_store(root, location=None):
"""``store_dir`` for a writer. Preview folders below it are made by the
write itself."""
return store_dir(root, location)
def given_store(root, store):
@@ -602,14 +109,10 @@ def given_store(root, store):
return store
def preview_path(root, media_rel, ext=None, location=None, *, create=False):
def preview_path(root, media_rel, ext=None, location=None):
"""Where the preview of *media_rel* (relative to *root*) is, or is to be
written (*create*: a writer, which gives a new source its key), in *ext*
(the extension written now when None)."""
if create:
store = ensure_store(root, location)
else:
store = store_dir(root, location)
written, in *ext* (the extension written now when None)."""
store = store_dir(root, location)
if store is None:
return None
if ext is None:
@@ -621,8 +124,8 @@ def preview_path(root, media_rel, ext=None, location=None, *, create=False):
def request_path(root, parts, location=None):
"""The disk path of the URL parts ``("thumbnails", ...)``, or None when
*parts* is not under an exact ``thumbnails`` first part or the store has
no place for it. The text is checked before (``is_preview_text``)."""
*parts* is not under an exact ``thumbnails`` first part. The text is
checked before (``is_preview_text``)."""
parts = tuple(parts)
if len(parts) < 2 or parts[0] != PREVIEW_FOLDER:
return None
@@ -632,38 +135,13 @@ def request_path(root, parts, location=None):
return store.joinpath(*parts[1:])
def _no_link_below(store, path):
"""No folder from *store* down to *path* (and *store* itself) is a link,
junction or reparse point (lexical walk, one lstat each)."""
store = Path(os.path.abspath(store))
path = Path(os.path.abspath(path))
try:
relative = path.relative_to(store)
except ValueError:
return False
current = store
for part in ("",) + relative.parts:
current = current / part if part else current
if preview_location.is_link_like(current):
return False
return True
def owns(root, path, location=None):
"""Whether *path* lies inside the store of *root*, resolved: the store
stays inside its container and no link takes *path* out of the store.
In folder mode no link, junction or reparse point may sit anywhere from
the store down to *path*."""
location = location or current()
stays inside its container and no link takes *path* out of the store."""
store = store_dir(root, location)
if store is None:
return False
container = store_container(root, location)
if not (_within(store, container) and _within(path, store)):
return False
if location.mode == SOURCE_MODE:
return True
return may_write(path, location)
return _within(store, store_container(root, location)) and _within(path, store)
# ── preview text ─────────────────────────────────────────────────────
+1 -9
View File
@@ -727,15 +727,7 @@ class PromptRoutesMixin:
async def save_prompt(self, request):
"""Save a new prompt with metadata and duplicate detection."""
try:
data, error = await self._read_json_body(request)
if error is not None:
return error
for field in ("text", "category", "notes"):
if data.get(field) is not None and not isinstance(data[field], str):
return web.json_response(
{"success": False, "error": f"{field} must be text"},
status=400,
)
data = await request.json()
text = (data.get("text") or "").strip()
if not text:
+14 -126
View File
@@ -30,7 +30,6 @@ from . import thumbnail_settings
from .admin import _current_image_monitor
from .job_routes import busy_response
from .jobs import THUMBNAILS_JOB_KIND, JobBusy
from .thumbnail_location import needs_move_response
try:
from ...utils.validators import validate_result_timeout, validate_worker_threads
@@ -55,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.
@@ -154,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")
@@ -179,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)
@@ -227,7 +176,7 @@ def _validate_autotag_settings(data):
MAX_SUGGESTION_LIMIT,
MODEL_TYPES,
validate_saved_threshold,
validate_skipped_tags,
validate_template,
)
updates = {}
@@ -256,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:
@@ -265,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
@@ -327,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
@@ -343,12 +264,6 @@ class SettingsRoutesMixin:
"sources": source_status,
"monitor": monitor,
"thumbnail_encoders": encoders,
# #320: the full path only here (D6A).
"thumbnail_location": await self._run_in_executor(
functools.partial(
self._thumbnail_location_payload, with_path=True
)
),
"settings": {
"monitoring_enabled": GalleryConfig.MONITORING_ENABLED,
"save_foreign_runs": GalleryConfig.SAVE_FOREIGN_RUNS,
@@ -366,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 "",
@@ -475,19 +377,12 @@ class SettingsRoutesMixin:
if error_response is not None:
return error_response
if "thumbnail_location" in data:
# #320: only the Move thumbnails job changes the location.
return needs_move_response()
expected_revision = data.get("expected_revision")
if expected_revision is not None and not isinstance(expected_revision, str):
return web.json_response(
{"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:
@@ -586,8 +481,7 @@ class SettingsRoutesMixin:
for key in _THUMBNAIL_SETTINGS
)
job = self._jobs.active(THUMBNAILS_JOB_KIND) if changed else None
# A move copies previews of the current settings (#320).
if job is None or job.scope.get("mode") not in ("convert", "move"):
if job is None or job.scope.get("mode") != "convert":
return None
return web.json_response(
{
@@ -617,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 -22
View File
@@ -30,8 +30,6 @@ import ntpath
import os
import re
from .preview_location import HOLDS_THUMBNAILS_REASON
logger = logging.getLogger("prompt_manager.api")
MAX_SOURCE_PATH = 4096 # characters
@@ -116,26 +114,7 @@ def source_problem(text):
code = source_form_error(text)
if code is not None:
return code, FORM_REASONS[code]
code, reason = GalleryConfig.gallery_root_problem(text)
if code is None and _holds_thumbnails(GalleryConfig.resolve_gallery_root(text)):
return "holds-thumbnails", HOLDS_THUMBNAILS_REASON
return code, reason
def _holds_thumbnails(path):
"""Whether *path* is inside or around the thumbnail folder in use (#320):
a source there would list Prompt Manager's own previews."""
from . import preview_store
try:
from ...utils.library_location import _inside_any
except ImportError: # tests import the package top-level
from utils.library_location import _inside_any
setting = preview_store.setting()
if setting.mode != preview_store.FOLDER_MODE or not setting.path:
return False
return _inside_any(path, [setting.path]) or _inside_any(setting.path, [path])
return GalleryConfig.gallery_root_problem(text)
def public_spelling(text):
+16 -12
View File
@@ -175,7 +175,7 @@ class DeferredPreview:
def resolve(self):
"""The preview's path now, or None when nothing may be written."""
dst = media_preview_path(self.root, self.rel, create=True)
dst = media_preview_path(self.root, self.rel)
if dst is None:
return None
if self.strict:
@@ -298,12 +298,8 @@ def _thumbnail_source(root, parts):
if in_trash_below_roots(found, [root]):
return None
rel = Path(*folder_parts, found.name)
dst = media_preview_path(root, rel, create=True)
if dst is None:
# Nowhere to write (the thumbnail folder is unavailable): the
# original is served and nothing is written.
return found, None, found.suffix.lower() in VIDEO_EXTENSIONS
if dst != _current_preview(root, parts):
dst = media_preview_path(root, rel)
if dst is None or dst != _current_preview(root, parts):
return None
if not preview_store.owns(root, dst):
dst = None
@@ -632,6 +628,10 @@ class AutoThumbnailer:
"""True once the worker has nothing queued or in hand."""
return self._wait_for(lambda: not self._busy, timeout)
def wait_batch(self, size, timeout=None):
"""True once the worker holds a batch of *size* files."""
return self._wait_for(lambda: self._batch == size, timeout)
class _PreviewGate:
"""Admission of view requests on the event loop (#249 I2).
@@ -745,8 +745,6 @@ class AutoThumbnailMixin:
pool.shutdown(wait=False, cancel_futures=True)
if final:
shutdown_encoders() # kill JPEG XL / ffmpeg encoders still running
# The thumbnail folder's probe (#320), bounded like the rest.
closed = preview_store.stop_probe() and closed
return closed
def _auto_thumbnail_failures(self):
@@ -771,9 +769,6 @@ class AutoThumbnailMixin:
return job
if job.kind == THUMBNAILS_JOB_KIND and job.title == THUMBNAIL_CLEAR_TITLE:
return job
# Move thumbnails holds automatic previews for its whole run (D2A).
if job.kind == THUMBNAILS_JOB_KIND and job.scope.get("mode") == "move":
return job
return None
# ── the view admission ────────────────────────────────────────────
@@ -963,6 +958,15 @@ class AutoThumbnailMixin:
return "queued"
return "skipped"
def _capture_target(self, image_path):
"""``(src, dst, is_video)`` of a new output's preview now, or None."""
located = self._capture_source(image_path)
if located is None:
return None
src, root, rel, is_video = located
dst = DeferredPreview(root, rel).resolve()
return None if dst is None else (src, dst, is_video)
def _capture_source(self, image_path):
"""``(src, root, rel, is_video)`` of a media file under a current
root (not a preview, a link or in a trash folder), or None."""
+1 -7
View File
@@ -44,7 +44,7 @@ import os
import time
from pathlib import Path
from . import preview_store, thumbnail_settings
from . import thumbnail_settings
from .thumbnail_encode import ThumbnailVerifyError, verify_thumbnail, writer_settings
from .admin import _plural, _scan_location, _scan_roots, _scan_status_line
from .images import _thumbnail_is_fresh, _worker_threads, _thumbnail_batch_size
@@ -393,12 +393,6 @@ class ThumbnailConvertMixin:
removed, with no bytes (another run or a sibling removed it)."""
removed, kept = 0, []
for path, size in old:
if not preview_store.may_write(path):
# Folder mode: never a file outside a key folder, or one
# reached through a link (checked at the moment of the delete).
self.logger.warning(f"Not removing {path}: outside the thumbnails")
kept.append(path)
continue
try:
os.remove(path)
except FileNotFoundError:
+6 -38
View File
@@ -46,7 +46,7 @@ from pathlib import Path
from PIL import Image
from . import preview_location, preview_store, thumbnail_settings
from . import preview_store, thumbnail_settings
try:
from ...utils.logging_config import get_logger
@@ -114,7 +114,6 @@ def replace_unless_held(tmp, dst, guard=None):
# ``temp_sibling``'s names: ``.<stem>.<tag>-<pid>-<thread id><ext>``.
_TEMP_NAME = re.compile(r"^\..+\.(?:part|src)-(\d+)-\d+\.[A-Za-z0-9]+$")
_MOVE_TEMP_NAME = re.compile(r"\.pm-move-[0-9a-f]{8}\.tmp$")
# A temp at least this old (seconds) outlived its write: no encode runs longer.
STRAY_TEMP_AGE = 4 * ENCODE_TIMEOUT
# When this process started (this module's import): a temp carrying its PID
@@ -126,10 +125,7 @@ def sweep_stray_temps(folder, min_age=None):
"""Remove the temp files a crashed write left under *folder* (a preview
folder, walked without following links); returns ``(count, bytes)``.
Only ``temp_sibling`` names are touched, never a link: a folder that is a
link, junction or reparse point is not walked into (Windows walks into a
junction even without ``followlinks``), and each file passes
``preview_store.may_write`` before it goes. With *min_age*
Only ``temp_sibling`` names are touched, never a link. With *min_age*
(seconds, the startup sweep) a temp younger than that, or one this
process wrote since it started (it may still be writing it), is left;
``min_age=0`` (the clear, which holds every preview write) removes them
@@ -139,39 +135,19 @@ def sweep_stray_temps(folder, min_age=None):
now = time.time()
own = str(os.getpid())
count = size = 0
try:
if preview_location.is_link_like(folder):
return count, size
except OSError as exc:
logger.debug(f"Not sweeping {folder}: {exc}")
return count, size
for root, dirs, files in os.walk(folder):
dirs[:] = [d for d in dirs if not _link_like(Path(root) / d)]
for root, _dirs, files in os.walk(folder):
for name in files:
match = _TEMP_NAME.match(name)
# A move's copy temp (#320): ``<name>.pm-move-<8 hex>.tmp``.
move_temp = match is None and _MOVE_TEMP_NAME.search(name)
if match is None and not move_temp:
if match is None:
continue
path = Path(root) / name
try:
stat = path.lstat()
if (
path.is_symlink()
or not path.is_file()
or _link_like(path)
or not preview_store.may_write(path)
):
if path.is_symlink() or not path.is_file():
continue
if min_age and (
now - stat.st_mtime < min_age
or move_temp
and stat.st_mtime >= PROCESS_STARTED
or (
match is not None
and match.group(1) == own
and stat.st_mtime >= PROCESS_STARTED
)
or (match.group(1) == own and stat.st_mtime >= PROCESS_STARTED)
):
continue
path.unlink()
@@ -183,14 +159,6 @@ def sweep_stray_temps(folder, min_age=None):
return count, size
def _link_like(path):
"""``is_link_like``, with a path that cannot be checked counted as one."""
try:
return preview_location.is_link_like(path)
except OSError:
return True
def _unlink_quietly(path):
try:
Path(path).unlink()
+6 -42
View File
@@ -49,7 +49,6 @@ from .images import (
_worker_threads,
)
from .job_routes import busy_response
from .thumbnail_location import location_refusal
from .jobs import THUMBNAIL_GROUP_NAMES, THUMBNAILS_JOB_KIND, JobBusy
from .thumbnail_convert import CONVERT_JOB_TITLE
from .thumbnail_encode import writer_settings
@@ -90,7 +89,7 @@ class ThumbnailOptions:
}
def thumbnail_options(raw):
def thumbnail_options(raw, *, from_query=False):
"""``(ThumbnailOptions, None)`` from a body or query, or ``(None, message)``.
An absent mode is ``missing``; the settings are the server's now.
@@ -132,11 +131,8 @@ class ThumbnailJobsMixin:
"""The thumbnail plan, the thumbnail job and the legacy stream on it."""
async def get_thumbnail_plan(self, request):
"""What a thumbnail run would do (read-only; capped like a run).
``mode=prune`` lists the removed sources' thumbnails (#320)."""
if request.query.get("mode") == "prune":
return await self.get_prune_plan()
options, error = thumbnail_options(request.query)
"""What a thumbnail run would do (read-only; capped like a run)."""
options, error = thumbnail_options(request.query, from_query=True)
if error:
return _invalid(error)
try:
@@ -183,39 +179,15 @@ class ThumbnailJobsMixin:
)
except (ValueError, UnicodeDecodeError):
return _invalid(_OPTIONS_ERROR)
if isinstance(body, dict) and body.get("mode") in ("move", "prune"):
return await self._start_location_job(body)
options, error = thumbnail_options(body)
if error:
return _invalid(error)
refused = location_refusal()
if refused is not None:
return refused
try:
job = self._start_thumbnail_job(options)
except JobBusy as busy:
return busy_response(busy)
return web.json_response({"success": True, "job": job.summary()}, status=202)
async def _start_location_job(self, body):
"""Move thumbnails or Remove thumbnails of removed sources (#320)."""
if body["mode"] == "prune":
refused = location_refusal()
if refused is not None:
return refused
try:
if body["mode"] == "move":
started = self.start_thumbnail_move(body)
else:
started = self.start_thumbnail_prune()
except JobBusy as busy:
return busy_response(busy)
if isinstance(started, web.Response):
return started
return web.json_response(
{"success": True, "job": started.summary()}, status=202
)
def _start_thumbnail_job(self, options):
"""Register the thumbnail job synchronously; raises JobBusy.
@@ -487,12 +459,8 @@ class ThumbnailJobsMixin:
or attaches to the running thumbnail job, and streams its frames. A
legacy hold (the blocking generate or clear) or a record-changing
job refuses it with the stream's error frame. The job goes on when
the client goes away. Refused (409) while the thumbnail folder is
unavailable (#320).
the client goes away.
"""
refused = location_refusal()
if refused is not None:
return refused
response = web.StreamResponse(
status=200,
headers={
@@ -539,12 +507,8 @@ class ThumbnailJobsMixin:
the way.
"""
running = self._jobs.active(THUMBNAILS_JOB_KIND)
if running is not None and running.scope.get("mode") in (
"convert",
"move",
"prune",
):
return None, False, running # no legacy frames to follow (#317, #320)
if running is not None and running.scope.get("mode") == "convert":
return None, False, running # no legacy frames to follow (#317)
if running is not None and running.cancellable:
return running, False, None
busy = self._jobs.blocker(THUMBNAILS_JOB_KIND, admission="guarded")
-162
View File
@@ -1,162 +0,0 @@
"""The thumbnail location on the API (#320): startup, the settings payload
and the refusals while the thumbnail folder is unavailable.
The setting itself is config.json ``thumbnails.location``
(``preview_location``); the runtime state is ``preview_store``. POST
``/settings`` never changes it (409 ``location-needs-move``): the Move
thumbnails job is its only writer.
"""
import os
from aiohttp import web
from . import preview_location, preview_store
try:
from ...utils import library_location
except ImportError: # tests import the package top-level
from utils import library_location
# The node folder (its web/ is served and a reinstall wipes it).
NODE_FOLDER = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
)
def default_path():
"""``<data folder>/data/thumbnails`` (D7B), or None when unknown."""
try:
return library_location.cache_dir("thumbnails")
except Exception: # no data folder can be decided: no default
return None
def location_refusal(location=None):
"""The 409 ``location-unavailable`` response while the thumbnail folder
is unavailable, else None (thumbnail generate, convert, clear, prune)."""
location = location or preview_store.current()
if location.mode != preview_store.FOLDER_MODE or location.available:
return None
status, text = preview_location.message("location-unavailable")
return web.json_response(
{"success": False, "code": "location-unavailable", "error": text},
status=status,
)
def needs_move_response():
status, text = preview_location.message("location-needs-move")
return web.json_response(
{"success": False, "code": "location-needs-move", "error": text},
status=status,
)
class ThumbnailLocationMixin:
"""Startup check and payloads of the thumbnail location."""
def _all_source_roots(self):
"""Every image source of every root list: the Images roots, the
configured sources and the extra gallery roots (as strings)."""
from ..config import GalleryConfig, _extra_gallery_roots
roots = [str(root) for root in self._get_all_output_dirs()]
roots += [str(root) for root in GalleryConfig.MONITORING_DIRECTORIES]
roots += list(_extra_gallery_roots())
return tuple(dict.fromkeys(root for root in roots if root))
def _thumbnail_location_context(self):
"""What a thumbnail folder is validated against (blocking)."""
contained = []
try:
import folder_paths
for name in (
"get_output_directory",
"get_input_directory",
"get_temp_directory",
):
getter = getattr(folder_paths, name, None)
folder = getter() if getter is not None else None
if folder:
contained.append(str(folder))
except ImportError:
pass # folder_paths exists only inside ComfyUI (tests run without it)
try:
contained.append(library_location.config_location()[1])
except Exception: # no data folder can be decided
self.logger.debug("No library folder for the thumbnail check")
setting = preview_store.setting()
return preview_location.Context(
roots=self._all_source_roots(),
contained=tuple(contained),
node_folder=NODE_FOLDER,
default_path=default_path(),
current_path=(
setting.path if setting.mode == preview_store.FOLDER_MODE else None
),
)
def use_preview_location(self):
"""Put the configured location in force at once, unchecked (the
package's import, before any request): folder mode is unavailable
until ``start_preview_location`` has checked it, so nothing is ever
written to the sources meanwhile. No filesystem access."""
from ..config import PromptManagerConfig
try:
return preview_store.configure(
PromptManagerConfig.THUMBNAIL_LOCATION, check=False
)
except Exception:
self.logger.warning("The thumbnail location is unusable", exc_info=True)
preview_store.mark_invalid()
return preview_store.current()
def start_preview_location(self):
"""Parse and check the configured location once (startup thread);
the probe takes over. Never raises; a check that fails leaves a
folder location unavailable (``invalid``), never the sources."""
from ..config import PromptManagerConfig
try:
value = PromptManagerConfig.THUMBNAIL_LOCATION
parsed = preview_location.parse_location(value)
context = None
if parsed.mode == preview_store.FOLDER_MODE:
context = self._thumbnail_location_context()
location = preview_store.configure(value, context=context)
except Exception:
self.logger.warning("The thumbnail location check failed", exc_info=True)
if (
preview_location.parse_location(
PromptManagerConfig.THUMBNAIL_LOCATION
).mode
== preview_store.FOLDER_MODE
):
preview_store.mark_invalid()
return preview_store.current()
if location.mode == preview_store.FOLDER_MODE and not location.available:
self.logger.warning(
"The thumbnail folder is unavailable "
f"({location.problem or 'checking'}): originals are shown and "
"no thumbnails are written until it is back"
)
return location
def _thumbnail_location_payload(self, *, with_path):
"""``{mode, available, problem}`` plus, for the settings GET only,
``path`` and ``default_path`` (D6A)."""
location = preview_store.current()
payload = {
"mode": location.mode,
"available": location.available,
"problem": location.problem,
}
if with_path:
payload["path"] = location.path
payload["default_path"] = default_path()
# Old copies a move still has to remove (#320 Part 3).
payload["cleanup_pending"] = len(preview_store.setting().cleanup)
return payload
-579
View File
@@ -1,579 +0,0 @@
"""Move thumbnails (#320 Part 3): the reviewed job that changes where previews
live, the removal of old copies after it, recovery at startup and the
"Remove thumbnails of removed sources" scope (Part 5).
Kind ``thumbnails`` with ``mode: "move"`` (D1A): admission ``guarded``, so
scans, deletes, tagging, source changes and every other thumbnail job wait
for it, while the monitor's Needs review recording runs beside it.
Automatic previews are held for the whole job (D2A, ``_auto_thumbnail_blocker``).
Phases: review (``POST /images/thumbnails/plan``, no writes but the
validation probe), start (a plan token), hold (generation bump, journal,
target folder), copy (cancellable at every file boundary, on the job pool
in small chunks, D3A), switch (one config commit sets the location and
records the old stores under ``cleanup``; not cancellable from here),
remove old copies, end. Per-file errors switch anyway (D10A); a folder-wide
error or a cancel before the switch removes what the job wrote and changes
nothing. The move is the only writer of the setting (D4B).
Moving away from a thumbnail folder also removes the previews of this
install's sources that are no longer listed (Q320-8: remove).
"""
import functools
import os
import time
from pathlib import Path
from aiohttp import web
from . import preview_location, preview_store
from . import thumbnail_move_files as files
from .jobs import THUMBNAILS_JOB_KIND
from .thumbnail_move_plan import (
MOVE_GROUPS,
MOVE_TITLE,
PLAN_TTL,
_number,
_plural,
_remove,
_roots_revision,
_volume,
_is_network,
_write_json,
)
from . import thumbnail_move_plan as move_plan
try:
from ...utils import exit_signal
from ...utils.parallel import map_parallel
except ImportError: # tests import the package top-level
from utils import exit_signal
from utils.parallel import map_parallel
class ThumbnailMoveMixin:
"""Start and run the move job; the removal of old copies."""
# ── start ─────────────────────────────────────────────────────────
def _take_move_plan(self, body):
"""The plan a start names, or ``(status, code)``."""
token = body.get("token")
plan = (self._move_plans or {}).get(token) if isinstance(token, str) else None
if plan is None or time.monotonic() - plan.created > PLAN_TTL:
return 409, "plan-stale"
roots = [str(root) for root in self._get_all_output_dirs()]
if _roots_revision(roots) != plan.revision:
return 409, "plan-stale"
if preview_store.setting() != plan.old:
return 409, "plan-stale"
if plan.confirm_required and body.get("confirmed") is not True:
return 400, "confirmation-required"
return plan
def start_thumbnail_move(self, body):
"""Start the move job (sync, on the loop); raises JobBusy. Returns
the job or a refusal response."""
if not self._move_recovered:
blocker = self._jobs.active(THUMBNAILS_JOB_KIND)
return web.json_response(
{
"success": False,
"code": "busy",
"error": "Prompt Manager is still checking an earlier move. "
"Try again in a moment.",
"running": None if blocker is None else {"id": blocker.id},
},
status=409,
)
taken = self._take_move_plan(body)
if isinstance(taken, tuple):
status, code = taken
text = {
"plan-stale": "The review is out of date. Review the move again.",
"confirmation-required": "Confirm that Prompt Manager may "
"create its folders there.",
}[code]
return web.json_response(
{"success": False, "code": code, "error": text}, status=status
)
plan = taken
self._move_plans.pop(plan.token, None)
return self._jobs.start(
THUMBNAILS_JOB_KIND,
MOVE_TITLE,
admission="guarded",
cancellable=True,
scope={
"mode": "move",
"from": plan.old.mode,
"to": plan.target["mode"],
"to_path": plan.target["path"],
},
groups=MOVE_GROUPS,
runner=functools.partial(self._run_thumbnail_move, plan=plan),
)
# ── the job ───────────────────────────────────────────────────────
async def _run_thumbnail_move(self, job, *, plan):
state = {"written": [], "created": [], "created_folder": False}
try:
await self._run_in_executor(self._move_hold, job, plan, state)
except (OSError, ValueError) as error:
await self._run_in_executor(self._move_rollback, plan, state)
self._end_move(
job, "failed", "The new folder could not be made; nothing changed."
)
self.logger.warning(f"Move thumbnails could not start: {error}")
return
left = [
{
"path": "/".join(item),
"root": s.label,
"detail": "Not a thumbnail; left in place",
}
for s in plan.sources
if s.scan is not None
for item in s.scan.left
]
if left:
job.record_counted("left", len(left), left)
items = [
(source, item)
for source in plan.sources
if source.scan is not None
for item in source.scan.copy
]
total = len(items)
job.update(total=total, processed=0, progress=0)
chunk = await self._run_in_executor(self._move_chunk, plan)
workers = self._move_workers()
done = 0
for start in range(0, total, chunk):
if job.cancelled:
await self._move_cancelled(job, plan, state)
return
batch = items[start : start + chunk]
try:
results = await self._run_in_executor(
map_parallel,
functools.partial(self._move_copy_one, state=state),
batch,
workers,
)
except exit_signal.ExitRequested:
return # exiting: the next start undoes the copies (journal)
except files.FolderGone as error:
self.logger.warning(f"Move thumbnails stopped: {error}")
await self._run_in_executor(self._move_rollback, plan, state)
self._end_move(
job,
"failed",
"The new folder could not be reached or is full; nothing changed.",
)
return
except Exception:
# Anything else before the switch: undo, never half a move.
self.logger.exception("Move thumbnails failed while copying")
await self._run_in_executor(self._move_rollback, plan, state)
self._end_move(job, "failed", "The copy failed; nothing changed.")
return
for (source, item), (outcome, detail) in zip(batch, results):
row = {
"path": "/".join(item[0]),
"root": source.label,
"detail": detail,
}
job.record(
"moved" if outcome == "moved" else outcome,
row,
f"{outcome}: {source.label} · {'/'.join(item[0])}",
)
done += len(batch)
label = batch[-1][0].label
job.update(
processed=done,
progress=int(done * 90 / total) if total else 90,
status_line=(
f"Copying {_number(done)} of {_number(total)} previews · {label}"
),
)
job.publish({"type": "progress", "summary": job.summary()})
if job.cancelled:
await self._move_cancelled(job, plan, state)
return
# The switch: not cancellable from here (an exit still stops between
# files and leaves the cleanup to the next start).
job.cancellable = False
job.update(status_line="Switching to the new location", progress=92)
job.publish({"type": "progress", "summary": job.summary()})
try:
await self._run_in_executor(self._move_switch, plan)
except Exception:
self.logger.exception("Move thumbnails could not switch")
await self._run_in_executor(self._move_rollback, plan, state)
self._end_move(
job, "failed", "The setting could not be saved; nothing changed."
)
return
await self._remove_old_copies(job, plan)
moved = job.counters.get("moved", 0) + job.counters.get("skipped", 0)
errors = job.counters.get("errors", 0)
where = plan.target["path"] or "inside each source"
parts = [f"{_number(moved)} moved"]
if errors:
parts.append(_plural(errors, "error"))
parts.append(
"old copies removed"
if not job.scope.get("cleanup_left")
else "some old copies are removed at the next start"
)
self._end_move(
job, "finished", f"Thumbnails moved to {where}: " + " · ".join(parts) + "."
)
def _end_move(self, job, state, line):
job.update(status_line=line, progress=100)
job.log_line(line)
job.publish({"type": "complete", "summary": job.summary()})
job.finish(state)
def _move_workers(self):
from .images import _worker_threads
return max(1, int(_worker_threads() or 1))
def _move_chunk(self, plan):
"""2 files at a time across volumes or to/from a share, else 4, never
more than Worker threads (D3A: the pool keeps its size)."""
sides = [plan.target["path"] or ""] + [s.old_store or "" for s in plan.sources]
sides += [s.new_store for s in plan.sources]
network = any(_is_network(side) for side in sides if side)
volumes = {_volume(side) for side in sides if side}
size = 2 if network or len(volumes) > 1 else 4
return max(1, min(size, self._move_workers()))
def _move_hold(self, job, plan, state):
"""Phase 3: bump the generation, write the journal, make the target."""
preview_store.bump_generation()
if plan.to_folder:
folder = plan.target["path"]
if not os.path.isdir(folder):
if plan.target_default:
os.makedirs(folder, exist_ok=True)
else:
os.mkdir(folder)
state["created_folder"] = True
plan.target_id = preview_location.init_folder(
folder, created_by_pm=state["created_folder"]
)
install = plan.install
def add_keys(marker):
for source in plan.sources:
if source.key and source.key not in marker["keys"]:
marker["keys"][source.key] = {
"path": source.root,
"identity": os.path.normcase(os.path.realpath(source.root)),
"dev_ino": preview_location.file_identity(source.root),
"install": install,
}
with preview_store.write_lock:
preview_location.write_marker(folder, add_keys)
_write_json(
move_plan.journal_path(),
{
"state": "copying",
"from": plan.old.to_config(),
"to": plan.target,
"stores": [s.new_store for s in plan.sources],
# Stores that do not exist yet are made by this job only:
# after a crash everything in them goes (B4).
"created_dirs": [
s.new_store for s in plan.sources if not os.path.isdir(s.new_store)
],
"created_folder": state["created_folder"],
"install": plan.install,
},
)
job.log_line(
f"Move thumbnails started: {plan.old.path or 'inside each source'} → "
f"{plan.target['path'] or 'inside each source'}"
)
def _move_copy_one(self, pair, *, state):
"""Copy one preview (a pool thread); ``(group, detail)``."""
source, (relative, size, mtime_ns) = pair
destination = Path(source.new_store).joinpath(*relative)
try:
if not os.path.isdir(destination.parent):
base = Path(source.new_store)
if not base.is_dir():
if plan_parent_missing(base):
raise files.FolderGone("the new folder is gone")
created = []
files._make_folders(base.parent, (base.name,), created)
state["created"].extend(created)
created = []
files._make_folders(base, relative[:-1], created)
state["created"].extend(created)
# Also when the folders already exist (Codex P1 on #592).
files.refuse_links(source.new_store, relative[:-1])
outcome = files.copy_one(
Path(source.old_store).joinpath(*relative),
destination,
size,
mtime_ns,
state["created"],
)
except files.CopyFailed as error:
return "errors", str(error)
if outcome == "moved":
state["written"].append(str(destination))
return "moved", "Copied and checked"
return "skipped", "Already there"
async def _move_cancelled(self, job, plan, state):
if exit_signal.requested():
return # the next start removes the partial copies (journal)
await self._run_in_executor(self._move_rollback, plan, state)
self._end_move(job, "cancelled", "Move cancelled: nothing changed.")
def _move_rollback(self, plan, state):
"""Remove what this job wrote (files, folders it made, a folder and
marker it created) and the journal: nothing changed."""
for path in state["written"]:
files.remove_file(path)
for folder in reversed(state["created"]):
files.remove_empty_folders(folder)
if plan.to_folder and state.get("created_folder"):
folder = plan.target["path"]
try:
names = os.listdir(folder)
except OSError:
names = None
if names is not None and set(names) <= {preview_location.MARKER_NAME}:
_remove(preview_location.marker_path(folder))
files.remove_empty_folders(folder)
_remove(move_plan.journal_path())
def _move_switch(self, plan):
"""Phase 5: one config commit sets the location and records the old
stores under ``cleanup``; the runtime follows under the write lock."""
from ..config import PromptManagerConfig
from ..config_store import get_config_store
entry = {
"from": plan.old.mode,
"folder": plan.old.path if plan.from_folder else None,
"stores": [s.old_store for s in plan.sources if s.old_store],
"drop_keys": self._old_keys_to_drop(plan),
"install": plan.install,
}
# An older entry never removes a store the move just filled (a move
# back while old copies still waited).
filled = {os.path.normcase(s.new_store) for s in plan.sources}
cleanup = []
for old_entry in plan.old.cleanup:
stores = [
store
for store in old_entry.get("stores") or []
if isinstance(store, str) and os.path.normcase(store) not in filled
]
folder = old_entry.get("folder")
drop = [
key
for key in old_entry.get("drop_keys") or []
if not folder
or os.path.normcase(os.path.join(folder, key)) not in filled
]
cleanup.append({**old_entry, "stores": stores, "drop_keys": drop})
cleanup.append(entry)
if plan.to_folder:
value = {
"mode": preview_store.FOLDER_MODE,
"path": plan.target["path"],
"id": plan.target_id,
"install": plan.install,
"cleanup": cleanup,
}
else:
value = {
"mode": preview_store.SOURCE_MODE,
"install": plan.install,
"cleanup": cleanup,
}
def mutate(config):
section = config.get("thumbnails")
if not isinstance(section, dict):
section = {}
section["location"] = value
config["thumbnails"] = section
return config
def publish(_config):
PromptManagerConfig.THUMBNAIL_LOCATION = value
preview_store.configure(value, check=False)
get_config_store().commit(mutate, expected_revision=None, publish=publish)
# The switch is made: what follows never undoes it.
try:
preview_store.refresh()
_remove(move_plan.journal_path())
except OSError as error:
self.logger.warning(
f"The thumbnail move journal stays until the next start: {error}"
)
def _old_keys_to_drop(self, plan):
if not plan.from_folder:
return []
keys = [s.old_store and os.path.basename(s.old_store) for s in plan.sources]
keys = [key for key in keys if key]
if plan.to_folder and os.path.normcase(plan.old.path or "") == os.path.normcase(
plan.target["path"] or ""
):
return []
return keys + [key for key, _store, _scan in plan.unlisted]
async def _remove_old_copies(self, job, plan):
"""Phase 6: run the cleanup the switch recorded (and earlier ones)."""
job.update(status_line="Removing old copies", progress=95)
job.publish({"type": "progress", "summary": job.summary()})
left = await self._run_in_executor(self._run_cleanup, job)
if left:
job.scope["cleanup_left"] = True
def _run_cleanup(self, job=None):
"""Remove every recorded old store's previews, temps and empty
folders; keep in ``cleanup`` what could not go. Returns how many
entries are left (blocking)."""
setting = preview_store.setting()
remaining = []
for entry in setting.cleanup:
if not self._cleanup_entry(entry, job):
remaining.append(entry)
if list(setting.cleanup) != remaining:
self._commit_cleanup(remaining)
return len(remaining)
def _cleanup_entry(self, entry, job):
"""One recorded removal; True when it is complete."""
folder = entry.get("folder")
stores = [
store for store in entry.get("stores") or [] if isinstance(store, str)
]
if folder:
if not os.path.isdir(folder):
return False # the old folder is not reachable: kept for later
folder_marker = preview_location.read_marker(folder) or {}
keys = folder_marker.get("keys") or {}
stores += [
os.path.join(folder, key)
for key in entry.get("drop_keys") or []
if preview_location.valid_key(key) and key in keys
]
complete = True
for store in dict.fromkeys(stores):
if not self._remove_store(store, folder, job):
complete = False
if folder and complete:
drop = set(entry.get("drop_keys") or [])
def forget(marker):
for key in drop:
marker["keys"].pop(key, None)
try:
with preview_store.write_lock:
marker = preview_location.write_marker(folder, forget)
except (OSError, ValueError):
return False
if not marker["keys"] and marker.get("created_by_pm"):
names = set(os.listdir(folder))
rest = {n for n in names if not files.is_os_metadata(n)}
if rest <= {preview_location.MARKER_NAME}:
_remove(preview_location.marker_path(folder))
files.remove_empty_folders(folder)
return complete
def _remove_store(self, store, folder, job):
"""Remove the previews and temps of one old store; True when done."""
store = Path(store)
if folder:
# A key folder directly in the old thumbnail folder, nothing else.
if not preview_location.valid_key(store.name) or os.path.normcase(
str(store.parent)
) != os.path.normcase(str(folder)):
return True
elif store.name != "thumbnails":
return True # a hand-edited entry never names another folder
if not store.exists():
return True
if preview_location.is_link_like(store):
return True # never through a link; nothing of ours there
complete = True
removed = 0
for current, dirs, names in os.walk(store):
dirs[:] = [
d for d in dirs if not preview_location.is_link_like(Path(current) / d)
]
for name in names:
path = Path(current) / name
if files.is_temp_name(name) or files._preview_parts(name):
if preview_location.is_link_like(path):
continue
if exit_signal.requested():
return False # exiting: the next start finishes it
if files.remove_file(path):
removed += 1
else:
complete = False
relative = path.relative_to(store).as_posix()
self.logger.warning(
f"Could not remove the old thumbnail {path}; it is "
"removed at the next start"
)
if job is not None:
# D10A: a removal that failed is an error the job lists.
job.record(
"errors",
{
"path": relative,
"root": store.name,
"detail": "In use by another program; removed "
"at the next start",
},
f"In use by another program; removed at the next "
f"start: {store.name}/{relative}",
)
files.remove_empty_folders(store)
if job is not None and removed:
job.log_line(f"Removed {_number(removed)} old files from {store.name}")
return complete
def _commit_cleanup(self, remaining):
from ..config import PromptManagerConfig
from ..config_store import get_config_store
def mutate(config):
section = config.get("thumbnails")
if isinstance(section, dict) and isinstance(section.get("location"), dict):
section["location"]["cleanup"] = remaining
return config
def publish(config):
value = (config.get("thumbnails") or {}).get("location")
PromptManagerConfig.THUMBNAIL_LOCATION = value
preview_store.set_cleanup(remaining)
get_config_store().commit(mutate, expected_revision=None, publish=publish)
def plan_parent_missing(store):
"""A key folder's parent (the thumbnail folder) is gone mid-copy."""
return not Path(store).parent.is_dir()
-348
View File
@@ -1,348 +0,0 @@
"""The file side of Move thumbnails (#320 Part 3): what is copied, how a
copy is made and checked, and how old copies are removed.
No job, HTTP or config code lives here; ``thumbnail_move`` drives it.
Copy set (B5): each old store is walked with ``os.scandir``, never through a
symlink, junction or reparse point (counted as "skipped link"). A regular
file is
- copied when it has the preview grammar (``<name>_thumb<ext>``, any written
or legacy extension) and its original still exists in the source;
- removed with the old copies, not copied, when it is an orphan (no
original), lies under a trash folder, or is a temp sibling of a write
(``.<stem>.part-…``) or of a move (``….pm-move-<8 hex>.tmp``);
- left in place otherwise ("Left in place"). OS metadata files (``Thumbs.db``,
``desktop.ini``, ``.DS_Store``, ``._*``) are removed only when they alone
keep a preview folder from being removed.
A copy goes to ``<name>.pm-move-<8 hex>.tmp`` beside its destination, its
size is compared, its mtime is set to the source preview's rounded up to the
next 2-second step (FAT/exFAT keep it fresh, a stale preview stays stale),
then it is renamed into place. Every handle is closed before a rename or an
unlink (Windows).
"""
import errno
import logging
import os
import re
import secrets
import shutil
import stat as stat_module
import time
from dataclasses import dataclass, field
from pathlib import Path
from . import preview_location, thumbnail_settings
from .thumbnail_encode import _TEMP_NAME
try:
from ...utils.media_folders import in_trash_folder
except ImportError: # tests import the package top-level
from utils.media_folders import in_trash_folder
logger = logging.getLogger("prompt_manager.thumbnails")
MOVE_TEMP = re.compile(r"\.pm-move-[0-9a-f]{8}\.tmp$")
MOVE_TEMP_SUFFIX = ".pm-move-{token}.tmp"
# The temp name adds this much to a destination path (".pm-move-xxxxxxxx.tmp").
TEMP_EXTRA = 21
WINDOWS_FILE_LIMIT = 259
MTIME_STEP_NS = 2_000_000_000
# A file another program holds open (Windows) is retried this often.
REMOVE_RETRIES = (0.5, 1.0, 2.0)
_WINDOWS_IN_USE = {32, 33} # ERROR_SHARING_VIOLATION, ERROR_LOCK_VIOLATION
def is_os_metadata(name):
folded = name.lower()
return folded in preview_location.OS_METADATA or folded.startswith("._")
def is_temp_name(name):
return bool(_TEMP_NAME.match(name) or MOVE_TEMP.search(name))
def _preview_parts(name):
"""``(base, extension)`` of a preview file name, or None."""
stem, extension = os.path.splitext(name)
suffix = thumbnail_settings.THUMB_SUFFIX
if not stem.endswith(suffix) or len(stem) <= len(suffix):
return None
if extension.lower() not in thumbnail_settings.THUMBNAIL_EXTENSIONS:
return None
return stem[: -len(suffix)], extension
def _has_original(root, rel_dir, base, extension):
"""Whether a media file in ``<root>/<rel_dir>`` owns this preview."""
folder = Path(root).joinpath(*rel_dir)
try:
with os.scandir(folder) as entries:
for entry in entries:
stem, source_ext = os.path.splitext(entry.name)
if stem != base or not source_ext:
continue
if not thumbnail_settings.owns(extension, source_ext):
continue
if entry.is_file(follow_symlinks=False):
return True
except OSError:
return False
return False
@dataclass
class StoreScan:
"""One old store's files, sorted into what the move does with them."""
store: Path
copy: list = field(default_factory=list) # (relative parts, size, mtime_ns)
orphans: list = field(default_factory=list) # relative parts
temps: list = field(default_factory=list)
left: list = field(default_factory=list)
links: int = 0
bytes: int = 0
@property
def removable(self):
return [item[0] for item in self.copy] + self.orphans + self.temps
def scan_store(store, root, *, stop=None):
"""Sort the files of *store* (the previews of the source *root*).
A store that does not exist, or is itself a link, scans empty (a link
counts once).
"""
store = Path(store)
scan = StoreScan(store)
try:
if preview_location.is_link_like(store):
scan.links += 1
return scan
if not store.is_dir():
return scan
except OSError:
return scan
_walk(scan, store, (), root, stop)
return scan
def _walk(scan, folder, rel_dir, root, stop):
try:
with os.scandir(folder) as iterator:
entries = list(iterator)
except OSError:
return
for entry in entries:
if stop is not None and stop():
return
relative = rel_dir + (entry.name,)
path = Path(entry.path)
try:
if preview_location.is_link_like(path):
scan.links += 1
continue
info = entry.stat(follow_symlinks=False)
except OSError:
continue
if stat_module.S_ISDIR(info.st_mode):
_walk(scan, path, relative, root, stop)
continue
if not stat_module.S_ISREG(info.st_mode):
scan.left.append(relative)
continue
if is_temp_name(entry.name):
scan.temps.append(relative)
continue
if is_os_metadata(entry.name):
continue # removed only when it alone keeps a folder (rmdir)
parts = _preview_parts(entry.name)
if parts is None:
scan.left.append(relative)
continue
if in_trash_folder("/".join(relative)) or not _has_original(
root, rel_dir, *parts
):
scan.orphans.append(relative)
continue
scan.copy.append((relative, info.st_size, info.st_mtime_ns))
scan.bytes += info.st_size
# ── copying ──────────────────────────────────────────────────────────
def rounded_mtime_ns(mtime_ns):
"""*mtime_ns* rounded up to the next 2-second step (FAT/exFAT)."""
return -(-mtime_ns // MTIME_STEP_NS) * MTIME_STEP_NS
def too_long(path, long_paths=None):
"""A destination (with its temp name) over Windows' file limit."""
if os.name != "nt" and long_paths is not False:
return False
if long_paths is None:
long_paths = preview_location.long_paths_enabled()
if long_paths:
return False
return len(str(path)) + TEMP_EXTRA > WINDOWS_FILE_LIMIT
class CopyFailed(Exception):
"""One preview could not be copied (a per-file error, D10A)."""
class FolderGone(Exception):
"""The destination cannot take writes any more (abort the move)."""
def _make_folders(base, relative_dir, created):
"""Make ``base/relative_dir`` one folder at a time (*base* must exist);
every folder made is added to *created*."""
current = Path(base)
for part in relative_dir:
current = current / part
try:
os.mkdir(current)
created.append(str(current))
except FileExistsError:
pass # already there: made by an earlier copy into this folder
except OSError as error:
if error.errno in (errno.ENOSPC, errno.EDQUOT, errno.EROFS):
raise FolderGone(str(error)) from error
raise CopyFailed(
"Its folder could not be made; made again on view"
) from error
if preview_location.is_link_like(current):
raise CopyFailed("a link in the destination")
def refuse_links(base, relative_dir):
"""Raise CopyFailed when *base* or an existing folder on the way to
``base/relative_dir`` is a link (never copy through one)."""
current = Path(base)
for part in (None, *relative_dir):
if part is not None:
current = current / part
if preview_location.is_link_like(current):
raise CopyFailed("a link in the destination")
def copy_one(source, destination, size, mtime_ns, created, *, long_paths=None):
"""Copy one preview; returns ``"moved"`` or ``"skipped"`` (already
there). Raises CopyFailed (this file) or FolderGone (the destination)."""
destination = Path(destination)
if too_long(destination, long_paths):
raise CopyFailed("Path too long for Windows; the original is shown")
wanted = rounded_mtime_ns(mtime_ns)
try:
existing = os.stat(destination, follow_symlinks=False)
except FileNotFoundError:
existing = None
except OSError as error:
raise FolderGone(str(error)) from error
if (
existing is not None
and stat_module.S_ISREG(existing.st_mode)
and existing.st_size == size
and existing.st_mtime_ns == wanted
):
return "skipped"
temp = destination.with_name(
destination.name + MOVE_TEMP_SUFFIX.format(token=secrets.token_hex(4))
)
try:
shutil.copyfile(source, temp)
if os.stat(temp).st_size != size:
raise CopyFailed("The copy did not match; made again on view")
os.utime(temp, ns=(wanted, wanted))
os.replace(temp, destination)
except CopyFailed:
_unlink(temp)
raise
except FileNotFoundError as error:
_unlink(temp)
if not Path(source).exists():
raise CopyFailed("Could not be read; made again on view") from error
raise FolderGone(str(error)) from error
except OSError as error:
_unlink(temp)
if error.errno in (errno.ENOSPC, errno.EDQUOT, errno.EROFS):
raise FolderGone(str(error)) from error
raise CopyFailed("Could not be copied; made again on view") from error
return "moved"
def _unlink(path):
"""Remove a copy's temp file after a failed copy; True when it is gone.
One that cannot go (a Windows lock, permissions) is logged with its path;
the next start's temp sweep removes it (``.pm-move-*.tmp``)."""
try:
os.unlink(path)
return True
except FileNotFoundError:
return True # never written, or already renamed into place
except OSError as error:
logger.warning(f"Could not remove the move's temp file {path}: {error}")
return False
# ── removing ─────────────────────────────────────────────────────────
def _in_use(error):
return getattr(error, "winerror", None) in _WINDOWS_IN_USE
def remove_file(path, *, sleep=time.sleep):
"""Remove one file; a file another program holds open (Windows) is
retried 3 times. Returns True when it is gone."""
for wait in (0.0,) + REMOVE_RETRIES:
if wait:
sleep(wait)
try:
os.unlink(path)
return True
except FileNotFoundError:
return True
except OSError as error:
if not _in_use(error):
return False
return False
def remove_empty_folders(top, *, keep_top=False):
"""``rmdir`` the empty folders below *top* (and *top*), bottom-up, never
through a link. A folder holding only OS metadata files loses them first.
Returns the folders that could not be removed."""
top = Path(top)
left = []
if not top.is_dir() or preview_location.is_link_like(top):
return left
# Top-down, pruning every link, junction or reparse point, then bottom-up:
# never into a folder outside *top* (Windows junctions included).
folders = []
for folder, dirs, _files in os.walk(top):
folder = Path(folder)
dirs[:] = [d for d in dirs if not preview_location.is_link_like(folder / d)]
folders.append(folder)
for folder in reversed(folders):
if folder == top and keep_top:
continue
try:
names = os.listdir(folder)
except OSError:
left.append(folder)
continue
if names and all(is_os_metadata(name) for name in names):
for name in names:
remove_file(folder / name)
try:
os.rmdir(folder)
except OSError:
left.append(folder)
return left
-492
View File
@@ -1,492 +0,0 @@
"""Move thumbnails, the review (#320 Part 3 phase 1): what a move would copy,
remove and leave, checked before anything is written."""
import hashlib
import json
import os
import secrets
import shutil
import time
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from aiohttp import web
from . import preview_location, preview_store
from . import thumbnail_move_files as files
from .job_routes import busy_response
from .jobs import THUMBNAILS_JOB_KIND, JobBusy
from .thumbnail_location import default_path
try:
from ...utils import library_location
except ImportError: # tests import the package top-level
from utils import library_location
MOVE_TITLE = "Move thumbnails"
FINISHING_TITLE = "Move thumbnails (finishing)"
PRUNE_TITLE = "Remove thumbnails of removed sources"
MOVE_GROUPS = ("moved", "skipped", "left", "errors")
PRUNE_GROUPS = ("removed", "left", "errors")
PLAN_TTL = 600.0
JOURNAL_NAME = "thumbnail-move.json"
SPACE_MARGIN = 64 * 2**20
SPACE_SHARE = 0.05
LISTED = 5
SENTENCE = (
"Every thumbnail is copied and checked before Prompt Manager switches. "
"Scans, deletes and tagging wait until it finishes; closing this page does "
"not stop it."
)
def _number(value):
return f"{value:,}"
def _plural(count, one, many=None):
return f"{_number(count)} {one if count == 1 else (many or one + 's')}"
def _size(value):
size = float(value)
for unit in ("B", "KB", "MB", "GB"):
if size < 1024 or unit == "GB":
return f"{size:.1f} {unit}" if unit != "B" else f"{int(size)} B"
size /= 1024
return f"{size:.1f} GB"
def _refusal(code, status=None, **extra):
known_status, text = preview_location.MESSAGES.get(code, (400, code))
return web.json_response(
{"success": False, "code": code, "error": text, **extra},
status=status or known_status,
)
def journal_path():
"""``<data folder>/data/thumbnail-move.json`` (only while a move copies)."""
return library_location.cache_dir(JOURNAL_NAME)
def _read_json(path):
try:
with open(path, "r", encoding="utf-8") as handle:
data = json.load(handle)
except (OSError, ValueError):
return None
return data if isinstance(data, dict) else None
def _write_json(path, data):
os.makedirs(os.path.dirname(path), exist_ok=True)
temp = f"{path}.{os.getpid()}.tmp"
with open(temp, "w", encoding="utf-8") as handle:
json.dump(data, handle, indent=2)
handle.flush()
os.fsync(handle.fileno())
os.replace(temp, path)
def _remove(path):
try:
os.unlink(path)
except FileNotFoundError:
pass # already gone: removing it is the goal
def _is_network(path):
text = str(path)
if text.startswith(("\\\\", "//")):
return True
if os.name == "nt":
try:
import ctypes
drive = os.path.splitdrive(os.path.abspath(text))[0] + "\\"
return ctypes.windll.kernel32.GetDriveTypeW(drive) == 4 # DRIVE_REMOTE
except (AttributeError, OSError):
return False
return False
def _volume(path):
current = Path(os.path.abspath(path))
while not current.exists() and current.parent != current:
current = current.parent
try:
return os.stat(current).st_dev
except OSError:
return None
def _free(path):
current = Path(os.path.abspath(path))
while not current.exists() and current.parent != current:
current = current.parent
try:
return shutil.disk_usage(current).free
except OSError:
return None
# ── the plan ─────────────────────────────────────────────────────────
@dataclass
class SourcePlan:
"""One listed source: its old store, its new store and what moves."""
root: str
label: str
key: str | None
old_store: str | None
new_store: str
scan: files.StoreScan | None
available: bool = True
too_long: list = field(default_factory=list)
@dataclass
class MovePlan:
"""A reviewed move: from, to, the sources and the removals."""
token: str
created: float
revision: str
old: preview_location.ParsedLocation
target: dict # {"mode", "path"}
target_id: str | None
target_exists: bool
target_default: bool
install: str
sources: list
unlisted: list # (key, store path, scan)
needs_bytes: int
confirm_required: bool
@property
def to_folder(self):
return self.target["mode"] == preview_store.FOLDER_MODE
@property
def from_folder(self):
return self.old.mode == preview_store.FOLDER_MODE
def _roots_revision(roots):
text = json.dumps(sorted(str(root) for root in roots))
return hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
class ThumbnailMovePlanMixin:
"""The move's review and its routes."""
_move_plans = None
# ── routes ────────────────────────────────────────────────────────
def _register_thumbnail_move_routes(self, routes):
@routes.post("/prompt_manager/images/thumbnails/plan")
async def thumbnail_move_plan_route(request):
return await self.plan_thumbnail_move(request)
@routes.post("/prompt_manager/images/thumbnails/location/recreate")
async def thumbnail_location_recreate_route(request):
return await self.recreate_thumbnail_folder(request)
@routes.post("/prompt_manager/images/thumbnails/location/adopt")
async def thumbnail_location_adopt_route(request):
return await self.adopt_thumbnail_folder(request)
# ── review ────────────────────────────────────────────────────────
async def plan_thumbnail_move(self, request):
try:
body = await request.json()
except (ValueError, UnicodeDecodeError):
body = None
if not isinstance(body, dict) or body.get("mode") != "move":
return web.json_response(
{
"success": False,
"code": "invalid-request",
"error": "mode must be move",
},
status=400,
)
target = body.get("location")
if not isinstance(target, dict) or target.get("mode") not in (
preview_store.SOURCE_MODE,
preview_store.FOLDER_MODE,
):
return web.json_response(
{
"success": False,
"code": "invalid-request",
"error": "location must name a mode",
},
status=400,
)
blocker = self._jobs.blocker(THUMBNAILS_JOB_KIND, admission="guarded")
if blocker is not None:
return busy_response(JobBusy(blocker))
try:
outcome = await self._run_in_executor(self._build_move_plan, target)
except Exception:
self.logger.exception("The thumbnail move could not be planned")
return web.json_response(
{
"success": False,
"code": None,
"error": "The move could not be planned",
},
status=500,
)
if isinstance(outcome, str):
return _refusal(outcome)
plan, payload = outcome
if payload.get("insufficient"):
return _refusal(
"insufficient-space",
status=507,
error_detail=payload["insufficient"],
plan=payload,
)
self._store_move_plan(plan)
return web.json_response({"success": True, **payload})
def _store_move_plan(self, plan):
if self._move_plans is None:
self._move_plans = {}
now = time.monotonic()
self._move_plans = {
token: kept
for token, kept in self._move_plans.items()
if now - kept.created < PLAN_TTL
}
self._move_plans[plan.token] = plan
def _build_move_plan(self, target):
"""``(plan, payload)`` or a refusal code (blocking)."""
old = preview_store.setting()
location = preview_store.current()
install = old.install or str(uuid.uuid4())
target_path = None
default = default_path()
if target["mode"] == preview_store.FOLDER_MODE:
raw = target.get("path")
if not isinstance(raw, str):
return "location-not-absolute"
context = self._thumbnail_location_context()
code = preview_location.location_problem(
raw, purpose="move", context=context
)
if code is not None:
return code
target_path = os.path.abspath(raw)
elif old.mode == preview_store.SOURCE_MODE and not old.problem:
return "location-same"
target_default = bool(default) and os.path.normcase(
target_path or ""
) == os.path.normcase(os.path.abspath(default))
target_exists = bool(target_path) and os.path.isdir(target_path)
target_marker = (
preview_location.read_marker(target_path) if target_exists else None
)
roots = [str(root) for root in self._get_all_output_dirs()]
labels = self._source_labels(roots)
old_keys = {}
if old.mode == preview_store.FOLDER_MODE and location.available:
marker = preview_location.read_marker(old.path) or {}
old_keys = marker.get("keys") or {}
new_keys = dict((target_marker or {}).get("keys") or {})
taken = {key: entry.get("identity") for key, entry in new_keys.items()}
sources = []
for root in roots:
identity = os.path.normcase(os.path.realpath(root))
dev_ino = preview_location.file_identity(root)
old_key = None
old_store = None
available = True
if old.mode == preview_store.SOURCE_MODE:
old_store = str(preview_store.source_folder(root))
elif location.available:
old_key = preview_location.find_key(old_keys, root, identity, dev_ino)
if old_key:
old_store = os.path.join(old.path, old_key)
else:
available = False
key = None
if target_path:
key = old_key or preview_location.find_key(
new_keys, root, identity, dev_ino
)
if key is None or taken.get(key, identity) != identity:
key = preview_location.source_key(root, identity, taken)
taken[key] = identity
new_store = os.path.join(target_path, key)
else:
new_store = str(preview_store.source_folder(root))
scan = None
if old_store:
scan = files.scan_store(old_store, root)
plan = SourcePlan(
root, labels.get(root, root), key, old_store, new_store, scan, available
)
if scan is not None and target_path:
plan.too_long = [
item[0]
for item in scan.copy
if files.too_long(Path(new_store).joinpath(*item[0]))
]
sources.append(plan)
unlisted = self._unlisted_keys(old, location, old_keys, install)
needs = sum(source.scan.bytes for source in sources if source.scan is not None)
plan = MovePlan(
token=secrets.token_urlsafe(16),
created=time.monotonic(),
revision=_roots_revision(roots),
old=old,
target={"mode": target["mode"], "path": target_path},
target_id=(target_marker or {}).get("id"),
target_exists=target_exists,
target_default=target_default,
install=install,
sources=sources,
unlisted=unlisted,
needs_bytes=needs,
confirm_required=bool(target_path) and not target_default,
)
return plan, self._plan_payload(plan, location)
def _unlisted_keys(self, old, location, keys, install):
"""This install's keys whose source no longer is in any root list
(``(key, store, scan)``), in the folder in use."""
if old.mode != preview_store.FOLDER_MODE or not location.available:
return []
listed = {
os.path.normcase(os.path.realpath(root))
for root in self._all_source_roots()
}
found = []
for key, entry in sorted(keys.items()):
if not preview_location.valid_key(key) or not isinstance(entry, dict):
continue # a marker is data: never a path out of the folder
if entry.get("install") not in (install, None):
continue
if entry.get("identity") in listed:
continue
store = os.path.join(old.path, key)
found.append(
(key, store, files.scan_store(store, entry.get("path") or store))
)
return found
def _source_labels(self, roots):
from . import sources
labels = sources._comfyui_labels()
return {root: sources.source_label(root, labels) for root in roots}
def _plan_payload(self, plan, location):
sources = plan.sources
copies = sum(len(s.scan.copy) for s in sources if s.scan)
orphans = sum(len(s.scan.orphans) for s in sources if s.scan)
temps = sum(len(s.scan.temps) for s in sources if s.scan)
links = sum(s.scan.links for s in sources if s.scan)
left = [
f"{s.label} · {'/'.join(item)}"
for s in sources
if s.scan
for item in s.scan.left
]
too_long = [
f"{s.label} · {'/'.join(item)}" for s in sources for item in s.too_long
]
unlisted_count = sum(
len(scan.copy) + len(scan.orphans) for _k, _s, scan in plan.unlisted
)
free = None
insufficient = None
if plan.needs_bytes:
volumes = {}
for source in sources:
if source.scan is None or not source.scan.bytes:
continue
volume = _volume(source.new_store)
entry = volumes.setdefault(volume, [source.new_store, 0])
entry[1] += source.scan.bytes
for where, needed in volumes.values():
available = _free(where)
if available is None:
continue
free = available if free is None else min(free, available)
margin = max(SPACE_MARGIN, int(needed * SPACE_SHARE))
if needed + margin > available:
insufficient = {
"needed": needed + margin,
"free": available,
"text": (
f"There is not enough space for the thumbnails: "
f"{_size(available)} free, {_size(needed + margin)} "
"needed. Free some space or choose another folder."
),
}
payload = {
"mode": "move",
"from": {
"mode": plan.old.mode,
"path": plan.old.path,
"available": location.available,
},
"to": {
"mode": plan.target["mode"],
"path": plan.target["path"],
"exists": plan.target_exists,
"will_create": bool(plan.target["path"]) and not plan.target_exists,
"default": plan.target_default,
},
"sources": [
{
"label": s.label,
"key": s.key,
"files": len(s.scan.copy) if s.scan else 0,
"bytes": s.scan.bytes if s.scan else 0,
"too_long": len(s.too_long),
"links": s.scan.links if s.scan else 0,
"orphans": len(s.scan.orphans) if s.scan else 0,
"left_in_place": len(s.scan.left) if s.scan else 0,
"available": s.available,
}
for s in sources
],
"totals": {
"sources": len(sources),
"files": copies,
"bytes": plan.needs_bytes,
"orphans": orphans,
"temps": temps,
"links": links,
"left_in_place": len(left),
"too_long": len(too_long),
"unlisted_sources": len(plan.unlisted),
"unlisted_files": unlisted_count,
},
"too_long_listed": too_long[:LISTED],
"left_listed": left[:LISTED],
"unlisted": [
{"key": key, "files": len(scan.copy) + len(scan.orphans)}
for key, _store, scan in plan.unlisted
],
"needs_bytes": plan.needs_bytes,
"free_bytes": free,
"confirm_required": plan.confirm_required,
"token": plan.token,
"sentence": SENTENCE,
}
if insufficient:
payload["insufficient"] = insufficient
return payload
-246
View File
@@ -1,246 +0,0 @@
"""Move thumbnails after a restart and the unavailable folder's actions
(#320 Parts 3 and 4): undo an interrupted copy, finish a removal that
waited, Recreate folder and Use this folder."""
import os
import threading
from pathlib import Path
from aiohttp import web
from . import preview_location, preview_store
from . import thumbnail_move_files as files
from .job_routes import busy_response
from .jobs import THUMBNAILS_JOB_KIND, JobBusy
from .thumbnail_move_plan import (
FINISHING_TITLE,
MOVE_GROUPS,
_read_json,
_refusal,
_remove,
)
from . import thumbnail_move_plan as move_plan
class ThumbnailMoveRecoveryMixin:
"""Startup recovery, the finishing job, Recreate and Use this folder."""
_move_recover_lock = threading.Lock()
# Per API: set once recover_thumbnail_move has run (a move waits for it).
_move_recovered = False
# ── startup ───────────────────────────────────────────────────────
def recover_thumbnail_move(self):
"""Startup (B4): undo an interrupted copy, or forget a journal the
switch already made true. Idempotent; the move start waits for it."""
with self._move_recover_lock:
if self._move_recovered:
return
try:
self._recover_journal()
except Exception:
self.logger.warning(
"The interrupted thumbnail move check failed", exc_info=True
)
self._move_recovered = True
def _recover_journal(self):
path = move_plan.journal_path()
journal = _read_json(path)
if journal is None:
return
setting = preview_store.setting()
target = journal.get("to") or {}
now = setting.path if setting.mode == preview_store.FOLDER_MODE else None
if setting.mode == target.get("mode") and (
now is None
or os.path.normcase(now) == os.path.normcase(target.get("path") or "")
):
_remove(path) # the switch happened; only the journal was left
return
for store in journal.get("stores") or []:
if not isinstance(store, str) or not os.path.isdir(store):
continue
for current, dirs, names in os.walk(store):
dirs[:] = [
d
for d in dirs
if not preview_location.is_link_like(Path(current) / d)
]
for name in names:
if files.MOVE_TEMP.search(name):
files.remove_file(Path(current) / name)
for folder in reversed(journal.get("created_dirs") or []):
if not isinstance(folder, str) or not os.path.isdir(folder):
continue
for current, dirs, names in os.walk(folder):
dirs[:] = [
d
for d in dirs
if not preview_location.is_link_like(Path(current) / d)
]
for name in names:
if files._preview_parts(name) or files.is_temp_name(name):
files.remove_file(Path(current) / name)
files.remove_empty_folders(folder)
folder = target.get("path")
if journal.get("created_folder") and folder and os.path.isdir(folder):
names = set(os.listdir(folder))
if names <= {preview_location.MARKER_NAME}:
_remove(preview_location.marker_path(folder))
files.remove_empty_folders(folder)
_remove(path)
self.logger.info("An interrupted thumbnail move was undone; nothing changed")
def _cleanup_reachable(self, entry):
folder = entry.get("folder")
if folder:
return os.path.isdir(folder)
return True
async def finish_thumbnail_move(self, _app=None):
"""Startup: recover, then start "Move thumbnails (finishing)" when old
copies wait and every one is reachable (on the server's loop)."""
await self._run_in_executor(self.recover_thumbnail_move)
cleanup = list(preview_store.setting().cleanup)
if not cleanup:
return None
reachable = await self._run_in_executor(
lambda: all(self._cleanup_reachable(entry) for entry in cleanup)
)
if not reachable:
return None
try:
return self._jobs.start(
THUMBNAILS_JOB_KIND,
FINISHING_TITLE,
admission="guarded",
cancellable=False,
scope={"mode": "move", "phase": "remove"},
groups=MOVE_GROUPS,
runner=self._run_finishing,
)
except JobBusy:
return None
async def _run_finishing(self, job):
await self._remove_old_copies(job, None)
left = len(preview_store.setting().cleanup)
self._end_move(
job,
"finished",
(
"Old thumbnails removed."
if not left
else (
"Some old thumbnails are still in use; they are removed at "
"the next start."
)
),
)
# ── Recreate and Use this folder (Part 4) ─────────────────────────
async def recreate_thumbnail_folder(self, _request):
blocker = self._jobs.blocker(THUMBNAILS_JOB_KIND, admission="guarded")
if blocker is not None:
return busy_response(JobBusy(blocker))
code = await self._run_in_executor(self._recreate_folder)
if code is not None:
status = (
409 if code in ("location-has-marker", "location-not-empty") else 400
)
if code == "location-has-marker":
return web.json_response(
{
"success": False,
"code": code,
"error": "The folder is back with its thumbnails. Nothing "
"was recreated.",
},
status=status,
)
return _refusal(code, status=status)
return web.json_response(
{
"success": True,
"location": self._thumbnail_location_payload(with_path=True),
}
)
def _recreate_folder(self):
setting = preview_store.setting()
if setting.mode != preview_store.FOLDER_MODE or not setting.path:
return "location-same"
folder = setting.path
if os.path.isdir(folder):
if preview_location.read_marker(folder) is not None:
return "location-has-marker"
if not preview_location.holds_only_metadata(folder):
return "location-not-empty"
elif not os.path.isdir(os.path.dirname(folder)):
return "location-parent-missing"
else:
try:
os.mkdir(folder)
except OSError:
return "location-not-writable"
try:
marker_id = preview_location.init_folder(folder, created_by_pm=True)
except OSError:
return "location-not-writable"
return self._commit_location_id(marker_id)
async def adopt_thumbnail_folder(self, _request):
blocker = self._jobs.blocker(THUMBNAILS_JOB_KIND, admission="guarded")
if blocker is not None:
return busy_response(JobBusy(blocker))
code = await self._run_in_executor(self._adopt_folder)
if code is not None:
return _refusal(code)
return web.json_response(
{
"success": True,
"location": self._thumbnail_location_payload(with_path=True),
}
)
def _adopt_folder(self):
setting = preview_store.setting()
if setting.mode != preview_store.FOLDER_MODE or not setting.path:
return "location-same"
try:
marker = preview_location.read_marker(setting.path)
except OSError:
return "location-unavailable"
if marker is None:
return "location-unavailable"
return self._commit_location_id(marker["id"])
def _commit_location_id(self, marker_id):
from ..config import PromptManagerConfig
from ..config_store import get_config_store
value = dict(preview_store.setting().to_config())
value["id"] = marker_id
def mutate(config):
section = config.get("thumbnails")
if not isinstance(section, dict):
section = {}
section["location"] = value
config["thumbnails"] = section
return config
def publish(_config):
PromptManagerConfig.THUMBNAIL_LOCATION = value
preview_store.configure(value, check=False)
try:
get_config_store().commit(mutate, expected_revision=None, publish=publish)
except Exception:
self.logger.exception("The thumbnail folder could not be saved")
return "location-not-writable"
preview_store.refresh()
return None
-175
View File
@@ -1,175 +0,0 @@
"""Remove thumbnails of removed sources (#320 Part 5, D200): a Thumbnail
maintenance scope, offered only in folder mode (31A).
*Removed* means a key of this install in the thumbnail folder's marker whose
identity matches no root in any root list. The job removes the
preview-grammar files and temps of those key folders, ``rmdir``s the empty
folders, drops the keys from the marker and lists anything else as "Left in
place". Kind ``thumbnails``, ``mode: "prune"``.
"""
import functools
import os
from pathlib import Path
from aiohttp import web
from . import preview_location, preview_store
from . import thumbnail_move_files as files
from .jobs import THUMBNAILS_JOB_KIND
from .thumbnail_location import location_refusal
from .thumbnail_move_plan import _number, _plural, _size
PRUNE_TITLE = "Remove thumbnails of removed sources"
PRUNE_GROUPS = ("removed", "left", "errors")
def _prune_line(count, files_count, size):
if not count:
return "No sources were removed: nothing to remove."
return (
f"{_plural(count, 'source')} no longer listed: "
f"{_plural(files_count, 'thumbnail')} ({_size(size)}) are removed."
)
class ThumbnailPruneMixin:
"""The prune plan and job."""
def _prune_targets(self):
"""``[(key, store, scan)]`` of this install's unlisted keys (blocking)."""
setting = preview_store.setting()
location = preview_store.current()
if setting.mode != preview_store.FOLDER_MODE or not location.available:
return []
marker = preview_location.read_marker(setting.path) or {}
install = setting.install
return self._unlisted_keys(setting, location, marker.get("keys") or {}, install)
def _prune_payload(self, targets):
files_count = sum(
len(scan.copy) + len(scan.orphans) for _k, _s, scan in targets
)
size = sum(scan.bytes for _k, _s, scan in targets)
return {
"success": True,
"mode": "prune",
"sources": [
{
"key": key,
"files": len(scan.copy) + len(scan.orphans),
"bytes": scan.bytes,
"left_in_place": len(scan.left),
}
for key, _store, scan in targets
],
"targets": files_count,
"bytes": size,
"line": _prune_line(len(targets), files_count, size),
"folder": preview_store.setting().path,
}
async def get_prune_plan(self):
refused = location_refusal()
if refused is not None:
return refused
if preview_store.current().mode != preview_store.FOLDER_MODE:
return web.json_response(
{
"success": False,
"code": "invalid-request",
"error": "Thumbnails are kept inside each source: no removed "
"source has thumbnails here.",
},
status=400,
)
targets = await self._run_in_executor(self._prune_targets)
return web.json_response(
self._prune_payload(targets), headers={"Cache-Control": "no-store"}
)
def start_thumbnail_prune(self):
"""Start the prune job (sync, on the loop); raises JobBusy."""
return self._jobs.start(
THUMBNAILS_JOB_KIND,
PRUNE_TITLE,
admission="guarded",
cancellable=True,
scope={"mode": "prune"},
groups=PRUNE_GROUPS,
runner=self._run_thumbnail_prune,
)
async def _run_thumbnail_prune(self, job):
targets = await self._run_in_executor(self._prune_targets)
job.update(total=len(targets), processed=0)
setting = preview_store.setting()
for done, (key, store, scan) in enumerate(targets, 1):
if job.cancelled:
break
removed, failed = await self._run_in_executor(
functools.partial(self._prune_store, store)
)
job.record_counted(
"removed", removed, [{"path": key, "detail": "Thumbnails removed"}]
)
if failed:
job.record_counted("errors", failed)
if scan.left:
job.record_counted(
"left",
len(scan.left),
[{"path": f"{key}/{'/'.join(item)}"} for item in scan.left],
)
await self._run_in_executor(
functools.partial(self._forget_key, setting.path, key, store)
)
job.update(
processed=done,
progress=int(done * 100 / len(targets)),
status_line=f"Removing thumbnails of {_number(done)} of "
f"{_number(len(targets))} removed sources",
)
job.publish({"type": "progress", "summary": job.summary()})
line = f"{_number(job.counters.get('removed', 0))} thumbnails removed"
if job.counters.get("errors"):
line += f" · {_plural(job.counters['errors'], 'error')}"
job.update(status_line=line, progress=100)
job.log_line(line)
job.finish("cancelled" if job.cancelled else "finished")
@staticmethod
def _prune_store(store):
"""Remove the previews and temps of one key folder; ``(removed, failed)``."""
removed = failed = 0
store = Path(store)
if not store.is_dir() or preview_location.is_link_like(store):
return 0, 0
for current, dirs, names in os.walk(store):
dirs[:] = [
d for d in dirs if not preview_location.is_link_like(Path(current) / d)
]
for name in names:
path = Path(current) / name
if not (files.is_temp_name(name) or files._preview_parts(name)):
continue
if preview_location.is_link_like(path):
continue
if files.remove_file(path):
removed += 1
else:
failed += 1
files.remove_empty_folders(store)
return removed, failed
@staticmethod
def _forget_key(folder, key, store):
"""Drop *key* from the marker once its folder is gone."""
if os.path.exists(store):
return
def forget(marker):
marker["keys"].pop(key, None)
with preview_store.write_lock:
preview_location.write_marker(folder, forget)
+1 -7
View File
@@ -221,13 +221,7 @@ def _owned(root, rel_path, preferred):
if store is None:
return iter(())
stem = store / preview_stem(rel_path)
found = owned_in_folder(stem.parent, stem.name, rel_path.suffix, preferred)
if preview_store.current().mode == preview_store.SOURCE_MODE:
return found
# Folder mode: timed, so a stalled share marks the folder unreachable,
# and only previews no link takes out of the key folder (deletes use it).
with preview_store.store_io():
return iter([path for path in found if preview_store.may_write(path)])
return owned_in_folder(stem.parent, stem.name, rel_path.suffix, preferred)
def owned_thumbnails(root, rel_path, preferred=None):
+1 -16
View File
@@ -59,9 +59,6 @@ REWRITE_FAILED = (
)
RECLAIM_FAILED = "Reclaim space failed; the database is unchanged."
ROW_KEPT = "could not be rewritten; it keeps its inline text and reads as before"
# A protected value's row (#586), in the review's "not text; kept as is".
VALUE_KEPT = "stored value not text; kept as is"
KEPT_COLUMNS = {"workflow_data": "workflow", "prompt_metadata": "prompt graph"}
NOTHING_LEFT = "Every image record already stores its workflow once"
@@ -180,19 +177,7 @@ class WorkflowStorageRoutesMixin:
protected += batch["protected"]
errors += len(batch["errors"])
job.record_counted("changed", batch["rows"])
job.record_counted(
"protected",
batch["protected"],
[
{
"path": "",
"detail": f"Image record {image_id}: "
f"{KEPT_COLUMNS.get(column, column)}, {VALUE_KEPT}",
}
for image_id, column in batch["kept"]
],
complete=True,
)
job.record_counted("protected", batch["protected"])
for image_id in batch["errors"]:
job.record(
"errors",
+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
+66 -196
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
@@ -78,6 +77,27 @@ def _canonical_path(path: str) -> str:
return os.path.normcase(os.path.realpath(path))
def write_private_json(path: str, data: Any) -> None:
"""Write ``data`` as JSON to ``path`` readable by the current user only.
config.json can hold an API key (integrations.lora_manager), so it is
created with mode 0600 and, on POSIX, an existing more open file is
tightened too. Windows has no POSIX modes; there the chmod is a best
effort that only affects the read-only bit. Parent directories are
created as needed. Raises OSError when the file cannot be written.
"""
import json
parent_dir = os.path.dirname(path)
if parent_dir:
os.makedirs(parent_dir, exist_ok=True)
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
if os.name == "posix":
os.chmod(path, 0o600)
def _lexical_path(path: str) -> str:
"""``path`` normalised for comparison without touching the filesystem.
@@ -567,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
@@ -587,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,
)
@@ -611,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}")
@@ -656,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 = {
@@ -683,155 +654,35 @@ 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
def suggestion_limit(cls) -> int:
return cls.SUGGESTION_LIMIT
@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")
@staticmethod
def valid_suggestion_limit(value: Any) -> bool:
return (
type(value) is int and 1 <= value <= MAX_SUGGESTION_LIMIT
) # bool, float and str are rejected
@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):
@@ -847,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:
@@ -913,8 +767,6 @@ class PromptManagerConfig:
THUMBNAIL_QUALITY = 80
THUMBNAIL_EDGE = 512
THUMBNAIL_EDGE_RANGE = (128, 2048)
# #320 thumbnail location (config.json ``thumbnails.location``), as read.
THUMBNAIL_LOCATION = None
@classmethod
def max_worker_threads(cls) -> int:
@@ -976,12 +828,6 @@ class PromptManagerConfig:
"format": cls.THUMBNAIL_FORMAT,
"quality": cls.THUMBNAIL_QUALITY,
"edge": cls.THUMBNAIL_EDGE,
# #320: as read; only the Move thumbnails job writes it.
**(
{"location": cls.THUMBNAIL_LOCATION}
if cls.THUMBNAIL_LOCATION is not None
else {}
),
},
"gallery": GalleryConfig.get_config(),
"integrations": IntegrationConfig.get_config(),
@@ -1036,6 +882,35 @@ class PromptManagerConfig:
else:
config_logger.info(f"Config file not found: {config_path}, using defaults")
@classmethod
def save_to_file(cls, config_path: Optional[str] = None):
"""Save the current configuration to a JSON file.
Serializes the complete configuration (including gallery settings) to
a JSON file. Creates the directory structure if it doesn't exist.
Args:
config_path (str): Path where the JSON configuration file should be saved.
Parent directories will be created if they don't exist.
Defaults to :meth:`get_config_path`.
Raises:
The method handles all exceptions internally and logs errors rather
than propagating them.
Example:
PromptManagerConfig.save_to_file('backup_config.json')
PromptManagerConfig.save_to_file('/etc/comfyui/prompt_manager.json')
"""
if config_path is None:
config_path = cls.get_config_path()
try:
write_private_json(config_path, cls.get_config())
config_logger.info(f"Saved configuration to {config_path}")
except Exception as e:
config_logger.error(f"Error saving config to {config_path}: {e}")
@classmethod
def update_config(cls, new_config: Dict[str, Any]):
"""Update configuration attributes from a dictionary.
@@ -1127,11 +1002,6 @@ class PromptManagerConfig:
low, high = cls.THUMBNAIL_EDGE_RANGE
if type(edge) is int and low <= edge <= high:
cls.THUMBNAIL_EDGE = edge
# #320: kept as read (``preview_location.parse_location`` checks it;
# a bad value becomes an unavailable ``invalid`` location, never a
# silent fall-back to the sources).
if "location" in section:
cls.THUMBNAIL_LOCATION = section["location"]
# Load configuration on import
+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}
+2 -3
View File
@@ -1,11 +1,10 @@
[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.6.0"
version = "3.4.1"
license = {file = "LICENSE"}
requires-python = ">=3.12"
dependencies = ["watchdog>=2.1.0", "Pillow>=10.1.0"]
classifiers = ["Operating System :: OS Independent"]
dependencies = ["watchdog>=2.1.0", "Pillow>=8.0.0"]
[build-system]
requires = ["setuptools>=84.2.1"]
+7 -7
View File
@@ -1,18 +1,18 @@
# Runtime dependencies (Pillow, aiohttp and numpy floors match ComfyUI requirements.txt)
# Runtime dependencies
watchdog>=6.0.0
Pillow>=10.1.0
aiohttp>=3.11.8
Pillow>=12.3.0
aiohttp>=3.14.5
# Tests and coverage
coverage>=7.17.0
numpy>=1.25.0 # WD14 engine tests
coverage>=7.16.2
numpy>=2.2.6 # WD14 engine tests
# Code quality
black==26.10.1
flake8==7.5.0
flake8==7.4.1
# Security scanning
bandit[toml]>=1.9.4
# Pre-commit hooks
pre-commit>=4.7.0
pre-commit>=4.6.2
+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
Pillow>=10.1.0 # Image metadata extraction and thumbnails
Pillow>=8.0.0 # Image metadata extraction and thumbnails
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

@@ -1,47 +0,0 @@
// #615: each old page says it will be removed and links to where its job lives now. The page keeps working, and the
// notice takes its own room: nothing is pushed off a page whose layout fills the window.
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
const PAGES = Object.freeze([
{ path: '/prompt_manager/admin', section: 'library', heading: 'Prompt library' },
{ path: '/prompt_manager/gallery', section: 'images', heading: 'Images' },
{ path: '/prompt_manager/gallery.html', section: 'inspect', heading: 'Inspect a file' },
]);
test.beforeEach(async ({ page, request, browserName }) => {
await startCoverage(page, browserName);
await expect(await request.post('/__test__/reset', {})).toBeOK();
await page.addInitScript(() => {
localStorage.setItem('pm_update_notice_dismissed', '3.2.0');
localStorage.setItem('gallerySettings', JSON.stringify({ checkThumbnailsAtStartup: false }));
});
});
test.afterEach(async ({ page, browserName }) => {
await saveCoverage(page, browserName);
});
for (const { path, section, heading } of PAGES) {
test(`${path} says it will be removed and links to ${section}`, async ({ page }) => {
await page.goto(path);
const notice = page.locator('#legacy-removal-notice');
await expect(notice).toBeVisible();
await expect(notice).toContainText('This page will be removed in a future release.');
await notice.getByRole('link', { name: 'Open the workspace' }).click();
await expect(page).toHaveURL(new RegExp(`/prompt_manager/workspace#/${section}$`));
await expect(page.locator('#workspace-main h1')).toHaveText(heading);
});
}
test('the notice leaves the metadata page filling the window', async ({ page }) => {
await page.goto('/prompt_manager/gallery.html');
await expect(page.locator('#legacy-removal-notice')).toBeVisible();
const { bottom, height, scroll } = await page.locator('#image-area').evaluate(area => ({
bottom: area.getBoundingClientRect().bottom,
height: window.innerHeight,
scroll: document.documentElement.scrollHeight,
}));
expect(bottom).toBeLessThanOrEqual(height);
expect(scroll).toBeLessThanOrEqual(height);
});
+2 -3
View File
@@ -21,9 +21,8 @@ export function mapLibraryConcept(reference) {
// Save view is the concept's own (saved views, #249 D16 retired).
const toolbar={...library.toolbar};
toolbar.items=toolbar.items.map(item=>{
// D14: the Library appends its Oldest first after the concept's sorts; D153 (#447) drops "Highest rated"; D205
// (#620) appends the old Dashboard's three sorts.
if(item.id==='sort')return {...item,options:[...item.options.filter(option=>option!=='Highest rated'),'Oldest first','Recently used','Most used','Name Z–A']};
// D14: the Library appends its Oldest first after the concept's sorts; D153 (#447) drops "Highest rated".
if(item.id==='sort')return {...item,options:[...item.options.filter(option=>option!=='Highest rated'),'Oldest first']};
// D36: the category list is the Library's real list, sorted alphabetically; the concept lists prompt order.
if(item.id==='category-filter')return {...item,options:[item.options[0],...item.options.slice(1).sort(nocase)]};
return item;
+10 -26
View File
@@ -22,9 +22,6 @@ from PIL.PngImagePlugin import PngInfo
from tests.browser.folder_trash import close_trash, replace_folder
from tests.browser.malloc_tuning import pin_mmap_threshold
from tests.browser.request_hold import register_request_holds
from tests.browser.thumbnail_location_fixture import (
register_thumbnail_location_commands,
)
from tests.browser import slow_reset_watch
from tests.browser.fast_reset import SEED_ON_LIVE_ENV, ScratchSeeder
from tests.browser.workspace_activity_fixture import register_activity_commands
@@ -88,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
@@ -121,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")
@@ -244,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)
@@ -252,7 +240,6 @@ def create_app(root: Path) -> web.Application:
library_editor_commands = register_library_editor_commands(routes, api, root)
register_vocabulary_commands(routes, api, root)
restore_sources = register_source_change_commands(routes, api)
reset_thumbnail_location = register_thumbnail_location_commands(routes, api, root)
register_tagging_commands(routes, api, root, model, on_reset=restore_sources)
register_review_commands(routes, api, root)
register_collections_commands(routes, api, root)
@@ -464,8 +451,6 @@ def create_app(root: Path) -> web.Application:
restore_sources()
# No preview queued by the last test may land in the new fixture.
await api._run_in_executor(api.close_auto_thumbnails)
# Previews inside each source again (#320).
await api._run_in_executor(reset_thumbnail_location)
library_editor_commands.reset()
await api._run_in_executor(reset_logs)
data = await request.json() if request.can_read_body else {}
@@ -610,7 +595,6 @@ def create_app(root: Path) -> web.Application:
history_commands.reset()
library_editor_commands.reset()
reveal_stub.reset()
await api._run_in_executor(reset_thumbnail_location)
history_fixture = None
manifest = await api._run_in_executor(
seeder.run,
+5 -4
View File
@@ -167,6 +167,8 @@ 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) {
// 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) };
const rows = {
@@ -263,7 +265,7 @@ const UNMAPPED_PATHS = [
'columns.surfaces.0.rows.length', 'columns.surfaces.0.tree.4', 'columns.surfaces.0.tree.length',
'columns.surfaces.1.rows.0.description', 'columns.surfaces.1.rows.1.title', 'columns.surfaces.1.rows.2.description',
'heading.actions.0', 'heading.actions.length', 'metrics.items.3.value', 'nav.label',
'save.button.cursor', 'save.button.opacity', 'save.status.text',
'save.button.cursor', 'save.button.opacity', 'save.status.text', 'tree.3', 'tree.length',
].sort();
test('system comparison detects mutations', async ({ page, request }) => {
@@ -654,10 +656,9 @@ function mapSourcesConcept(reference, { settings, sources }) {
// D113: the range ends at the server's core count (the concept's fixed 16) and shows the real worker count.
return [{ ...row, control: { ...row.control, max: String(settings.cpu_count), value: String(settings.worker_threads) } }];
}),
// D158: the inserted row after the first setting row; D196 (#320): the Thumbnail location block after
// "Thumbnail generation"; D98: "Refresh files" closes the surface.
// D158: the inserted row after the first setting row; D98: "Refresh files" closes the surface.
surface: { ...reference.surface, tree: [...reference.surface.tree.slice(0, rowAt + 1), 'div.setting-row',
...reference.surface.tree.slice(rowAt + 1), 'div.setting-block', 'div.operation'] },
...reference.surface.tree.slice(rowAt + 1), 'div.operation'] },
operations: [...reference.operations, { ...reference.operations[0], title: REFRESH_FILES.title,
description: REFRESH_FILES.description, actions: [{ ...reference.operations[0].actions[0], text: REFRESH_FILES.label }] }],
};
@@ -64,31 +64,11 @@ function measureMaintenance(mainSelector) {
};
}
/**
* D192 (#550, beyond the concept): Database adds "Pair category and tags" after "Unused prompt components", shaped like
* the concept's plain Review plan row (Optimize database's).
*/
function mapPairRow(reference) {
return {
...reference,
surfaces: reference.surfaces.map(surface => {
const plain = surface.rows.find(row => row.title === 'Optimize database');
const at = surface.rows.findIndex(row => row.title === 'Unused prompt components');
if (!plain || at < 0) return surface;
return { ...surface, tree: [...surface.tree, 'div.operation'], rows: [...surface.rows.slice(0, at + 1),
{ ...plain, title: 'Pair category and tags', description: 'Fill an empty category or tags on prompts older ' +
'Pair node runs saved, from their saved pairs. Only empty fields of never-edited prompts.' },
...surface.rows.slice(at + 1)] };
}),
};
}
/** D102: the Metadata cache row says why its review is unavailable and its button is aria-disabled (D93's dimmed style). */
function mapMaintenanceConcept(reference) {
const mapped = mapPairRow(reference);
return {
...mapped,
surfaces: mapped.surfaces.map(surface => ({ ...surface, rows: surface.rows.map(row => row.title !== 'Metadata cache' ? row : {
...reference,
surfaces: reference.surfaces.map(surface => ({ ...surface, rows: surface.rows.map(row => row.title !== 'Metadata cache' ? row : {
...row, description: `${row.description} Nothing is cached in this browser.`,
actions: row.actions.map(action => ({ ...action, opacity: '0.55', cursor: 'not-allowed' })) }) })),
};
@@ -229,8 +209,8 @@ test('maintenance comparison detects mutations', async ({ page, request }) => {
const expected = mapMaintenanceConcept(concept);
const production = await productionMaintenance(page, 'dark');
expect(diffInventory(expected, production)).toEqual([]);
// Mapped only for the D192 row, the section differs only on the D102 row.
expect(diffInventory(mapPairRow(concept), production).map(item => item.path).sort()).toEqual([
// Unmapped, the section differs only on the D102 row.
expect(diffInventory(concept, production).map(item => item.path).sort()).toEqual([
'surfaces.1.rows.3.actions.0.cursor', 'surfaces.1.rows.3.actions.0.opacity', 'surfaces.1.rows.3.description']);
const measure = () => page.evaluate(measureMaintenance, '#workspace-main');
const paths = async () => diffInventory(expected, await measure()).map(item => item.path);
+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}));
-170
View File
@@ -1,170 +0,0 @@
"""The thumbnail location on the test server (#320), undone by every reset.
Folder mode is reachable from config.json and tests only until the Move
thumbnails job lands, so page tests set it here, inside the server's root:
- ``POST /__test__/thumbnail-location`` ``{"mode": "folder"}`` (or
``"source"``): previews live in ``<root>/thumbs/<key>`` from now on (a new
folder with Prompt Manager's marker).
- ``GET /__test__/thumbnail-files``: the preview files in the thumbnail
folder and whether any source grew a ``thumbnails/`` folder.
- ``POST /__test__/thumbnail-folder/remove``: the thumbnail folder goes, as
when its drive is unplugged; the server notices it on its next check.
"""
from __future__ import annotations
import shutil
from pathlib import Path
from typing import TYPE_CHECKING, Callable
from aiohttp import web
if TYPE_CHECKING:
from py.api import PromptManagerAPI
FOLDER_NAME = "thumbs"
def register_thumbnail_location_commands(
routes: web.RouteTableDef, api: PromptManagerAPI, root: Path
) -> Callable[[], None]:
"""Register the commands; returns the reset every reset runs."""
from py.api import preview_location, preview_store
from py.api import thumbnail_location, thumbnail_move_plan
from py.config import PromptManagerConfig
from py.config_store import get_config_store
folder = root / FOLDER_NAME
default = root / "default-thumbs"
custom = root / "custom-thumbs" # a folder a page types in
journal = root / "data" / "thumbnail-move.json"
# The move's journal and default folder live in this server's root, never
# in the checkout's data folder.
thumbnail_move_plan.journal_path = lambda: str(journal)
thumbnail_move_plan.default_path = lambda: str(default)
thumbnail_location.default_path = lambda: str(default)
api.recover_thumbnail_move()
def forget_config(config: dict) -> dict:
section = config.get("thumbnails")
if isinstance(section, dict):
section.pop("location", None)
return config
real_free = thumbnail_move_plan._free
def reset() -> None:
thumbnail_move_plan._free = real_free
preview_store.configure(None)
PromptManagerConfig.THUMBNAIL_LOCATION = None
store = get_config_store()
config, _revision = store.read()
if (
isinstance(config.get("thumbnails"), dict)
and "location" in config["thumbnails"]
):
store.commit(forget_config, expected_revision=None)
for path in (folder, default, custom):
shutil.rmtree(path, ignore_errors=True)
journal.unlink(missing_ok=True)
def use(mode: str) -> dict:
reset()
if mode == "source":
return {"mode": "source"}
folder.mkdir()
marker_id = preview_location.init_folder(str(folder), created_by_pm=True)
location = preview_store.configure(
{
"mode": "folder",
"path": str(folder),
"id": marker_id,
"install": "browser-tests",
}
)
return {"mode": location.mode, "available": location.available}
@routes.post("/__test__/thumbnail-location")
async def set_location(request: web.Request) -> web.Response:
data = await request.json()
mode = data.get("mode") if isinstance(data, dict) else None
if mode not in ("source", "folder"):
raise web.HTTPBadRequest()
return web.json_response(await api._run_in_executor(use, mode))
def files() -> dict:
setting = preview_store.setting()
where = Path(setting.path) if setting.path else folder
previews = sorted(
path.relative_to(where).as_posix()
for path in where.rglob("*_thumb.*")
if path.is_file()
)
sources = [str(path) for path in api._get_all_output_dirs()]
return {
"folder": previews,
"folder_exists": folder.exists() or default.exists() or custom.exists(),
"custom_path": str(custom),
"source_thumbnails": any(
(Path(source) / "thumbnails").exists() for source in sources
),
}
@routes.get("/__test__/thumbnail-files")
async def thumbnail_files(_request: web.Request) -> web.Response:
return web.json_response(await api._run_in_executor(files))
def remove() -> bool:
setting = preview_store.setting()
shutil.rmtree(setting.path or folder, ignore_errors=True)
return preview_store.refresh()
@routes.post("/__test__/thumbnail-folder/remove")
async def remove_folder(_request: web.Request) -> web.Response:
available = await api._run_in_executor(remove)
return web.json_response({"available": available})
def foreign() -> bool:
"""Another install recreated the folder: its marker has another id."""
setting = preview_store.setting()
def other(marker: dict) -> None:
marker["id"] = "another-install"
preview_location.write_marker(setting.path, other)
return preview_store.refresh()
@routes.post("/__test__/thumbnail-folder/foreign")
async def foreign_folder(_request: web.Request) -> web.Response:
return web.json_response({"available": await api._run_in_executor(foreign)})
def unlisted() -> None:
"""A source this install no longer lists left its thumbnails here."""
setting = preview_store.setting()
def add(marker: dict) -> None:
marker["keys"]["gone-0123abcd"] = {
"path": "/gone",
"identity": "/gone",
"install": setting.install,
}
preview_location.write_marker(setting.path, add)
gone = Path(setting.path) / "gone-0123abcd"
gone.mkdir()
(gone / "x_thumb.webp").write_bytes(b"preview")
preview_store.refresh()
@routes.post("/__test__/thumbnail-folder/unlisted")
async def unlisted_source(_request: web.Request) -> web.Response:
await api._run_in_executor(unlisted)
return web.json_response({"success": True})
@routes.post("/__test__/thumbnail-space/full")
async def space_full(_request: web.Request) -> web.Response:
"""Every target disk reports 1 byte free until the next reset."""
thumbnail_move_plan._free = lambda _path: 1
return web.json_response({"success": True})
return reset
-17
View File
@@ -261,20 +261,3 @@ test('prompt deletion requires confirmation and can retry after a network failur
await expect(await request.get(url)).toBeOK();
}
});
// #320: with previews in one folder, the legacy gallery still shows them, made
// and served from that folder (the fixture's own previews inside the sources
// are never served then).
test('the legacy gallery shows thumbnails kept in one folder', async ({ page, request }) => {
await expect(await request.post('/__test__/thumbnail-location', { data: { mode: 'folder' } })).toBeOK();
const served = page.waitForResponse(response =>
response.url().includes('/prompt_manager/images/serve/thumbnails/') && response.status() === 200);
await page.goto('/prompt_manager/gallery');
await expect(page.locator('#galleryGrid .image-item')).toHaveCount(2);
const response = await served; // made in the folder before it was served
const name = decodeURIComponent(new URL(response.url()).pathname.split('/serve/thumbnails/')[1]);
const files = await (await request.get('/__test__/thumbnail-files')).json();
const stem = name.replace(/\.[^.]+$/, '');
expect(files.folder.some(file => /^[a-z0-9_-]+-[0-9a-f]{8}\//.test(file)
&& file.slice(file.indexOf('/') + 1).replace(/\.[^.]+$/, '') === stem)).toBe(true);
});
+1 -1
View File
@@ -191,7 +191,7 @@ test('library toolbar matches the concept and tags live in Refine this view',asy
expect(await toolbar.evaluate(node=>[...node.children].map(child=>child.tagName.toLowerCase()+(child.id?`#${child.id}`:'')+(child.className?`.${child.className.split(' ').filter(name=>!name.startsWith('ca-')).join('.')}`:'')))).toEqual(['label.search-field','select#category-filter','select#sort','button','button']);
await expect(toolbar.locator('#search')).toHaveAttribute('placeholder','Search prompts, tags, filenames…');
// D153 (#447): no "Highest rated".
await expect(toolbar.locator('#sort option')).toHaveText(['Recently added','Name A–Z','Oldest first','Recently used','Most used','Name Z–A']);
await expect(toolbar.locator('#sort option')).toHaveText(['Recently added','Name A–Z','Oldest first']);
await expect(toolbar.getByRole('button')).toHaveText(['Save view','Filters']);
await expect(page.getByRole('button',{name:'Refresh library',exact:true})).toHaveCount(0);
await expect(page.getByRole('textbox',{name:'Tags',exact:true})).toHaveCount(0);
+1 -1
View File
@@ -205,7 +205,7 @@ test('a view stored under older rules still lists and opens with its usable valu
return (await response.json()).id;
};
const imagesId = await raw({ page: 'images', name: 'Old cats', state: JSON.stringify({ q: 'cat', sort: 'shuffle', layout: 'grid' }) });
await raw({ page: 'library', name: '', state: JSON.stringify({ q: 'dog', sort: 'shuffle' }) });
await raw({ page: 'library', name: '', state: JSON.stringify({ q: 'dog', sort: 'name-desc' }) });
expect(await listed(request)).toEqual([
{ id: imagesId, page: 'images', name: 'Old cats', state: { ...IMAGE_DEFAULTS, q: 'cat' } },
expect.objectContaining({ page: 'library', name: 'Saved view', state: { q: 'dog', category: '', sort: 'newest', tags: [], tag_mode: 'all', folder: '' } }),
@@ -64,9 +64,7 @@ test('the section shows the concept rows; Metadata cache follows Q5 (D102)', asy
item.querySelector('.row-actions > button').textContent, item.querySelector('.row-actions > button').className]),
]))).toEqual([
['Database', [['Integrity & relationship checks', 'Check', ''], ['Duplicate prompt text', 'Review plan', ''],
['Optimize database', 'Review plan', ''], ['Unused prompt components', 'Review plan', 'danger'],
// D192 (#550): beyond the concept.
['Pair category and tags', 'Review plan', '']]],
['Optimize database', 'Review plan', ''], ['Unused prompt components', 'Review plan', 'danger']]],
['Images & cache', [['Missing-file links', 'Review plan', 'danger'], ['Duplicate image files', 'Find duplicates', ''],
['Thumbnail cache', 'Review options', ''], ['Metadata cache', 'Review plan', '']]],
]);
@@ -179,70 +177,6 @@ test("plan dialog shows the server's scope, protected, backup policy and media l
expect(await serverJson(request, '/__test__/system/digest')).toEqual(before);
});
test('D192: Pair category and tags reviews old Pair prompts, keeps an edited one and fills the other (#550)',
async ({ page, request }) => {
await expect(await request.post('/__test__/system/pair-metadata')).toBeOK();
const answers = [];
page.on('response', response => { if (response.url().endsWith(PREVIEW_URL)) answers.push(response.json()); });
await openMaintenance(page);
const pairRow = row(page, 'Pair category and tags');
await expect(pairRow.locator('p')).toHaveText('Fill an empty category or tags on prompts older Pair node runs saved, ' +
'from their saved pairs. Only empty fields of never-edited prompts.');
await pairRow.getByRole('button', { name: 'Review plan', exact: true }).click();
const plan = dialog(page);
await expect(plan.locator('#modal-body > h3')).toHaveText('Pair category and tags');
const answer = await answers[0];
expect(answer.operation).toBe('pair_metadata');
expect(await gridOf(page)).toEqual([
['Scope', '1 prompt to fill: 1 category, 1 tag set'],
['Protected', '1 edited after it was saved'],
['Backup', `Create and verify before changes (${answer.backup.destination_label})`],
['Media on disk', 'No image file or saved pair is changed.'],
]);
await expect(plan.locator('details > summary')).toHaveText('Listed records (3)');
await plan.locator('details > summary').click();
// Protected rows come first, the Record row last.
const lines = (await plan.locator('details > pre').textContent()).split('\n');
expect(lines).toHaveLength(3);
expect(lines[0]).toBe('S550 harbor · Kept: edited after it was saved; its empty category or tags may be intentional');
expect(lines[1]).toMatch(/^S550 lantern · Fills category “night” and 2 tags: s550-glow, s550-warm \(pair \d+\)$/);
expect(lines[2]).toMatch(/^Record · The job's report lists every filled field\. Download it to keep it/);
await plan.getByRole('button', { name: 'Apply reviewed operation', exact: true }).click();
await expect(dialog(page)).toHaveCount(0);
await expect(page).toHaveURL(/#\/activity\/[0-9a-f]{32}$/);
const job = await ended(request, page.url().split('/').pop());
expect([job.title, job.state, job.counters.changed, job.counters.protected])
.toEqual(['Pair category and tags', 'finished', 1, 1]);
const main = page.locator('#workspace-main');
await expect(main.locator('h1')).toHaveText('Pair category and tags');
await expect(main.getByRole('button', { name: 'Download report', exact: true })).toBeVisible();
// #550 live test: the Changed group lists the path-less record rows as plain text, never an open action.
const results = main.getByRole('region', { name: 'Results', exact: true });
await results.locator('.outcome-list > button').filter({ hasText: 'Changed' }).click();
const records = results.locator('.outcome-files > .file-row');
await expect(records).toHaveCount(1);
await expect(records.first()).toHaveText(/^Prompt \d+: category “night”; tags s550-glow, s550-warm$/);
expect(await records.first().evaluate(node => node.localName)).toBe('div');
await expect(results.locator('.outcome-files button')).toHaveCount(0);
await expect(results.getByRole('searchbox', { name: 'Filter results' })).toHaveAttribute('placeholder', 'Filter records…');
// Protected lists each kept prompt with the review's reason (#550 live re-check).
await results.locator('.outcome-list > button').filter({ hasText: 'Protected' }).click();
await expect(records).toHaveCount(1);
await expect(records.first()).toHaveText(
/^Prompt \d+: kept, edited after it was saved; its empty category or tags may be intentional$/);
// An empty group says records, not files.
await results.locator('.outcome-list > button').filter({ hasText: 'Errors' }).click();
await expect(results.locator('.outcome-files > p.empty-state')).toHaveText('No records in this group.');
const library = await serverJson(request, '/prompt_manager/library');
const lantern = library.items.find(item => item.text === 'S550 lantern');
const harbor = library.items.find(item => item.text === 'S550 harbor');
const filled = (await serverJson(request, `/prompt_manager/library/${lantern.id}?archive=all`)).positive;
expect([filled.category, [...filled.tags].sort()]).toEqual(['night', ['s550-glow', 's550-warm']]);
expect((await serverJson(request, `/prompt_manager/library/${harbor.id}?archive=all`)).positive.category).toBeFalsy();
await page.goto(`${workspace}#/library`);
await expect(page.locator(`article[data-prompt-id="${lantern.id}"] .card-title > .chip`)).toHaveText('night');
});
test('a plan with nothing to do shows only Close, never Apply', async ({ page, request }) => {
const applies = [];
page.on('request', item => { if (item.url().endsWith(APPLY_URL)) applies.push(item.url()); });
+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' });
});
@@ -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);
});
@@ -1,213 +0,0 @@
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
import { test, expect } from './fixtures.mjs';
import { seedConcept } from './concept-inventory.mjs';
// #320 Thumbnail location (D196–D201, the approved 320 mock) on the concept-shaped real fixture: the row in System ›
// Sources & scans reviews and runs Move thumbnails both ways, the review refuses a folder in place, and an unplugged
// thumbnail folder shows the notice whose Recreate folder brings it back. The hooks are thumbnail_location_fixture.py's.
const workspace = '/prompt_manager/workspace';
const NOTICE_KEY = 'pm_update_notice_dismissed';
test.beforeEach(async ({ page, request, browserName }) => {
await startCoverage(page, browserName);
await seedConcept(request);
await expect(await request.post('/__test__/system/reset')).toBeOK();
await expect(await request.post('/__test__/thumbnail-location', { data: { mode: 'source' } })).toBeOK();
page.locationErrors = [];
page.on('pageerror', error => page.locationErrors.push(error.message));
await page.addInitScript(key => { if (location.protocol.startsWith('http')) localStorage.setItem(key, '3.2.0'); }, NOTICE_KEY);
});
test.afterEach(async ({ page, request, browserName }) => {
await saveCoverage(page, browserName);
await request.post('/__test__/thumbnail-location', { data: { mode: 'source' } });
expect(page.locationErrors).toEqual([]);
});
const block = page => page.locator('#location-block');
const dialog = page => page.locator('dialog#modal[open]');
async function files(request) {
const response = await request.get('/__test__/thumbnail-files');
await expect(response).toBeOK();
return response.json();
}
async function openSources(page) {
await page.goto(`${workspace}#/system/sources`);
await expect(block(page).locator('#location-status')).not.toHaveText('');
}
/** Review the row's choice and start the move; resolves once its job view says it finished. */
async function moveFromRow(page) {
const planned = page.waitForResponse(response => response.url().endsWith('/images/thumbnails/plan')
&& response.request().method() === 'POST');
await block(page).getByRole('button', { name: 'Review move…' }).click();
expect((await planned).status()).toBe(200);
await expect(dialog(page).locator('h2')).toHaveText('Move thumbnails');
await dialog(page).getByRole('button', { name: 'Move thumbnails' }).click();
await expect(page.locator('#workspace-main h1')).toHaveText('Move thumbnails');
await expect(page.locator('#workspace-main')).toContainText('Thumbnails moved to');
}
test('the row moves thumbnails into one folder and back', async ({ page, request }) => {
await openSources(page);
const row = block(page);
await expect(row.getByRole('radio', { name: /Inside each source/ })).toBeChecked();
await expect(row.locator('#location-status')).toHaveText('Now: inside each source.');
await expect(row.getByRole('button', { name: 'Review move…' })).toHaveAttribute('aria-disabled', 'true');
await expect(row.locator('#location-path')).toBeDisabled();
await row.getByRole('radio', { name: /One folder/ }).check();
await expect(row.locator('#location-path')).toBeEnabled();
await expect(row.locator('#location-path')).toHaveValue(/default-thumbs$/);
await expect(row.locator('#location-status')).toHaveText(/^Now: inside each source\. The suggested folder is next to your library/);
await expect(row.getByRole('button', { name: 'Review move…' })).toHaveAttribute('aria-disabled', 'false');
await moveFromRow(page);
await expect(page.locator('#job-phase')).toHaveText(/^Thumbnails are now read from the new location/);
const moved = await files(request);
expect(moved.folder.length).toBeGreaterThan(0);
expect(moved.folder.every(name => /^[a-z0-9_-]+-[0-9a-f]{8}\//.test(name))).toBe(true);
expect(moved.source_thumbnails).toBe(false);
// Images shows thumbnails served from the folder.
const served = page.waitForResponse(response => response.url().includes('/images/serve/thumbnails/')
&& response.status() === 200);
await page.goto(`${workspace}#/images`);
await served;
await openSources(page);
await expect(block(page).locator('#location-status')).toHaveText(/^Now: one folder, .*default-thumbs\. Using the default folder/);
await block(page).getByRole('radio', { name: /Inside each source/ }).check();
await moveFromRow(page);
const back = await files(request);
expect(back.source_thumbnails).toBe(true);
expect(back.folder_exists).toBe(false);
});
test('a folder the server refuses is explained under the field', async ({ page }) => {
await openSources(page);
const row = block(page);
await row.getByRole('radio', { name: /One folder/ }).check();
await row.locator('#location-path').fill('thumbnails');
await row.getByRole('button', { name: 'Review move…' }).click();
await expect(row.locator('#location-problem')).toHaveText(
'Enter a full folder path, such as D:\\Thumbnails or /mnt/fast/thumbnails.');
await expect(row.locator('#location-path')).toHaveAttribute('aria-invalid', 'true');
await expect(row.locator('#location-path')).toHaveAttribute('aria-describedby', 'location-problem');
await expect(dialog(page)).toHaveCount(0);
await row.locator('#location-path').fill('/somewhere/else');
await expect(row.locator('#location-problem')).toHaveText('');
});
test('an unplugged folder shows the notice and Recreate folder brings it back', async ({ page, request }) => {
await expect(await request.post('/__test__/thumbnail-location', { data: { mode: 'folder' } })).toBeOK();
const removed = await request.post('/__test__/thumbnail-folder/remove');
expect((await removed.json()).available).toBe(false);
await openSources(page);
const notice = page.locator('#unavailable-notice');
await expect(notice.locator('h2')).toHaveText('The thumbnail folder is unavailable');
await expect(notice).toContainText('is missing. Originals are shown and no thumbnails are written until it is back.');
await expect(block(page).locator('#location-status')).toHaveText(/· unavailable\. Originals are shown\.$/);
await notice.getByRole('button', { name: 'Recreate folder' }).click();
await expect(dialog(page).locator('h2')).toHaveText('Recreate the thumbnail folder?');
const recreated = page.waitForResponse(response => response.url().endsWith('/location/recreate'));
await dialog(page).getByRole('button', { name: 'Recreate folder' }).click();
expect((await recreated).status()).toBe(200);
await expect(notice).toHaveCount(0);
await expect(block(page).locator('#location-status')).toHaveText(/^Now: one folder, .*thumbs\.$/);
});
test('a folder of your own needs the confirmation before it moves', async ({ page, request }) => {
const custom = (await files(request)).custom_path;
await openSources(page);
const row = block(page);
await row.getByRole('radio', { name: /One folder/ }).check();
await row.locator('#location-path').fill(custom);
await row.getByRole('button', { name: 'Review move…' }).click();
const review = dialog(page);
await expect(review.locator('dt')).toContainText(['From', 'To', 'Thumbnails', 'Free space', 'Removed after the move']);
await expect(review.locator('dd').nth(1)).toHaveText(`${custom} (new folder)`);
const move = review.getByRole('button', { name: 'Move thumbnails' });
await expect(move).toHaveAttribute('aria-disabled', 'true');
await review.getByRole('checkbox', { name: /may create its own folders/ }).check();
await expect(move).toHaveAttribute('aria-disabled', 'false');
await move.click();
await expect(page.locator('#workspace-main')).toContainText('Thumbnails moved to');
expect((await files(request)).folder.length).toBeGreaterThan(0);
});
test('another folder’s marker offers Use this folder', async ({ page, request }) => {
await expect(await request.post('/__test__/thumbnail-location', { data: { mode: 'folder' } })).toBeOK();
await expect(await request.post('/__test__/thumbnail-folder/foreign')).toBeOK();
await openSources(page);
const notice = page.locator('#unavailable-notice');
await expect(notice).toContainText('holds another thumbnail folder');
await notice.getByRole('button', { name: 'Use this folder' }).click();
await expect(notice).toHaveCount(0);
await expect(block(page).locator('#location-status')).toHaveText(/^Now: one folder, .*thumbs\.$/);
});
test('Overview shows the notice and Change location opens the row', async ({ page, request }) => {
await expect(await request.post('/__test__/thumbnail-location', { data: { mode: 'folder' } })).toBeOK();
const removed = await request.post('/__test__/thumbnail-folder/remove');
expect((await removed.json()).available).toBe(false);
await page.goto(`${workspace}#/system/overview`);
const notice = page.locator('#unavailable-notice');
await expect(notice.locator('h2')).toHaveText('The thumbnail folder is unavailable');
await notice.getByRole('button', { name: 'Change location' }).click();
await expect(page).toHaveURL(/#\/system\/sources$/);
const sourcesNotice = page.locator('#unavailable-notice');
await sourcesNotice.getByRole('button', { name: 'Change location' }).click();
await expect(block(page).getByRole('radio', { name: /One folder/ })).toBeChecked();
await expect(block(page).locator('#location-path')).toBeFocused();
});
test('Overview’s Use this folder takes the folder and the notice goes', async ({ page, request }) => {
await expect(await request.post('/__test__/thumbnail-location', { data: { mode: 'folder' } })).toBeOK();
await expect(await request.post('/__test__/thumbnail-folder/foreign')).toBeOK();
await page.goto(`${workspace}#/system/overview`);
const notice = page.locator('#unavailable-notice');
await expect(notice).toContainText('holds another thumbnail folder');
const adopted = page.waitForResponse(response => response.url().endsWith('/location/adopt'));
await notice.getByRole('button', { name: 'Use this folder' }).click();
expect((await adopted).status()).toBe(200);
await expect(notice).toHaveCount(0);
});
test('Thumbnail maintenance removes the thumbnails of removed sources in folder mode', async ({ page, request }) => {
await expect(await request.post('/__test__/thumbnail-location', { data: { mode: 'folder' } })).toBeOK();
await expect(await request.post('/__test__/thumbnail-folder/unlisted')).toBeOK();
await openSources(page);
const generation = page.locator('#workspace-main .operation').filter({ hasText: 'Thumbnail generation' });
await generation.getByRole('button', { name: 'Review options' }).click();
const scope = dialog(page).locator('#thumb-mode');
await expect(scope.locator('option')).toHaveText(['Missing thumbnails only', 'Rebuild all (explicit opt-in)',
'Convert outdated (removes older formats)', 'Remove thumbnails of removed sources']);
await scope.selectOption('prune');
await expect(dialog(page).locator('[role=status]')).toHaveText(
/^1 source no longer listed: 1 thumbnail \(.+\) are removed\.$/);
await expect(dialog(page).locator('dl.meta-grid dt')).toHaveText(['gone-0123abcd']);
await expect(dialog(page)).toContainText('If a source comes back, its thumbnails are made again as you browse.');
await scope.selectOption('missing');
await expect(dialog(page).locator('dl.meta-grid')).toBeHidden();
await scope.selectOption('prune');
await dialog(page).getByRole('button', { name: 'Start' }).click();
await expect(page.locator('#workspace-main h1')).toHaveText('Remove thumbnails of removed sources');
await expect(page.locator('#workspace-main')).toContainText('1 thumbnails removed');
});
test('a disk without the space shows the review but cannot move', async ({ page, request }) => {
await expect(await request.post('/__test__/thumbnail-space/full')).toBeOK();
await openSources(page);
const row = block(page);
await row.getByRole('radio', { name: /One folder/ }).check();
const planned = page.waitForResponse(response => response.url().endsWith('/images/thumbnails/plan'));
await row.getByRole('button', { name: 'Review move…' }).click();
expect((await planned).status()).toBe(507);
const review = dialog(page);
await expect(review.locator('.review-refusal')).toHaveText(/^There is not enough space for the thumbnails: 1 B free/);
await expect(review.getByRole('button', { name: 'Move thumbnails' })).toHaveAttribute('aria-disabled', 'true');
await review.getByRole('button', { name: 'Cancel' }).click();
await expect(dialog(page)).toHaveCount(0);
});
+36 -11
View File
@@ -135,11 +135,34 @@ test('/prompt_manager/web lands on the workspace Library and Back does not bounc
await expect(main.getByRole('heading', { level: 1, name: 'Overview', exact: true })).toBeVisible();
});
// #620: the old pages are on their way out (notice since 3.5.1), so the workspace no longer links them.
test('System no longer links the old pages (#620)', async ({ page }) => {
// D165 (#525, maintainer ruling): a discreet line under System is the workspace's only visible way to the legacy pages.
test('System Overview links the legacy pages discreetly, and each one loads (#525)', async ({ page, context }) => {
await page.goto(`${workspace}#/system/overview`);
await expect(page.locator('#workspace-main h1')).toHaveText('System');
await expect(page.locator('#workspace-main a[href*="/admin"], #workspace-main a[href*="/gallery"]')).toHaveCount(0);
const line = page.locator('#workspace-main .legacy-pages');
await expect(line).toBeVisible();
await expect(line).toContainText('Legacy pages');
const links = line.getByRole('link');
await expect(links).toHaveCount(4);
for (const [name, url, ready] of [
['Dashboard', '/prompt_manager/admin', '#addPromptBtn'],
['Tags', '/prompt_manager/admin#/tags', '#addPromptBtn'],
['Gallery', '/prompt_manager/gallery', 'body'],
['Metadata viewer', '/prompt_manager/gallery.html', 'body'],
]) {
const opened = context.waitForEvent('page');
await line.getByRole('link', { name, exact: true }).click();
const tab = await opened;
await tab.waitForLoadState('domcontentloaded');
const target = new URL(tab.url());
expect(target.pathname + target.hash).toBe(url);
await expect(tab.locator(ready).first()).toBeAttached();
await tab.close();
}
// Only on System's Overview section, and gone with the page.
await page.locator('#workspace-main .section-nav').getByRole('button', { name: 'Appearance', exact: true }).click();
await expect(line).toBeHidden();
await page.goto(`${workspace}#/library`);
await expect(page.locator('#workspace-main .legacy-pages')).toHaveCount(0);
});
const bootFailure = page => page.locator('#workspace-boot-failure');
@@ -152,7 +175,7 @@ test('a healthy workspace never shows the load failure message (#525)', async ({
});
for (const [label, pattern] of [['the entry module', '**/js/workspace/index.mjs*'], ['a dependency', '**/js/workspace/state.mjs*']]) {
test(`when ${label} fails to load the page offers a reload and ComfyUI (#525, #620)`, async ({ page }) => {
test(`when ${label} fails to load the page still offers the legacy pages (#525)`, async ({ page }) => {
const pageErrors = [];
page.on('pageerror', error => pageErrors.push(error.message));
await page.route(pattern, route => route.abort());
@@ -160,17 +183,19 @@ for (const [label, pattern] of [['the entry module', '**/js/workspace/index.mjs*
const failure = bootFailure(page);
await expect(failure).toBeVisible();
await expect(failure).toHaveAttribute('role', 'alert');
await expect(failure.getByRole('link', { name: 'Reload the page', exact: true })).toHaveAttribute('href', 'workspace');
await expect(failure.getByRole('link', { name: 'ComfyUI', exact: true })).toHaveAttribute('href', '../');
await expect(failure.getByRole('link')).toHaveCount(2);
const dashboard = failure.getByRole('link', { name: 'Dashboard', exact: true });
await expect(dashboard).toBeVisible();
await expect(failure.getByRole('link')).toHaveCount(4);
await dashboard.click();
await expect(page).toHaveURL(/\/prompt_manager\/admin$/);
expect(pageErrors).toEqual([]);
});
}
test('when a module throws while starting the page still offers a reload (#525, #620)', async ({ page }) => {
test('when a module throws while starting the page still offers the legacy pages (#525)', async ({ page }) => {
await page.route('**/js/workspace/state.mjs*', route => route.fulfill({ contentType: 'text/javascript', body: "throw new Error('boot failure');" }));
await page.goto(`${workspace}#/library`);
await expect(bootFailure(page).getByRole('link', { name: 'Reload the page', exact: true })).toBeVisible();
await expect(bootFailure(page).getByRole('link', { name: 'Dashboard', exact: true })).toBeVisible();
// The broken module's own error is this test's premise, not a failure of the page.
page.workspaceErrors.length = 0;
});
@@ -544,7 +569,7 @@ test('System is native: the concept shell, section navigation, sections and the
// D92: no "Sample configuration" chip.
await expect(main.locator('.heading .chip')).toHaveCount(0);
expect(await main.evaluate(node => [...node.children].map(child => `${child.localName}.${child.className}`)))
.toEqual(['div.heading', 'div.section-nav', 'div.settings-layout']);
.toEqual(['div.heading', 'div.section-nav', 'div.settings-layout', 'p.small legacy-pages']);
const nav = main.getByRole('group', { name: 'System sections', exact: true });
await expect(nav.getByRole('button')).toHaveText(SYSTEM_SECTION_LABELS);
await expect(nav.getByRole('button', { name: 'Overview', exact: true })).toHaveAttribute('aria-pressed', 'true');
-24
View File
@@ -41,11 +41,6 @@ read. These per-server hooks only change what a System test needs on top:
- ``POST /__test__/system/duplicate-prompts`` (S7) adds two prompts whose
text differs only in case and spacing (a duplicate prompt text group);
``{"again": true}`` adds a third, so a reviewed plan no longer matches;
- ``POST /__test__/system/pair-metadata`` (#550) saves two saved pairs the
way the Pair node did before #546 (the pair holds the category and tags,
its new positive prompt none): ``S550 lantern`` (category ``night``, tags
``s550-glow``, ``s550-warm``) fills; ``S550 harbor`` was edited after it was
saved, so the plan keeps it;
- ``POST /__test__/system/duplicate-files`` (S7) writes one PNG's bytes three
times: ``output/s7-dupes/same.png``, ``input/s7-dupes/same.png`` (the same
relative path in the other source) and ``output/s7-dupes/copy.png``;
@@ -349,25 +344,6 @@ def register_system_commands(app, routes, api, root: Path):
conn.commit()
return web.json_response({"success": True})
@routes.post("/__test__/system/pair-metadata")
async def pair_metadata(request):
def seed():
api.db.save_prompt_pair(
"S550 lantern",
"S550 blurry",
category="night",
tags=["s550-glow", "s550-warm"],
)
api.db.save_prompt_pair("S550 harbor", category="night")
with api.db.model.get_connection() as conn:
conn.execute(
"UPDATE prompts SET updated_at='2099-01-01T00:00:00+00:00' "
"WHERE text='S550 harbor'"
)
await api._run_in_executor(seed)
return web.json_response({"success": True})
@routes.post("/__test__/system/duplicate-files")
async def duplicate_files(request):
body = await request.json() if request.can_read_body else {}
+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
+1 -16
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",
@@ -126,9 +125,6 @@
"POST /prompt_manager/images/removed/return",
"POST /prompt_manager/images/rescope",
"POST /prompt_manager/images/reveal",
"POST /prompt_manager/images/thumbnails/location/adopt",
"POST /prompt_manager/images/thumbnails/location/recreate",
"POST /prompt_manager/images/thumbnails/plan",
"POST /prompt_manager/jobs/autotag",
"POST /prompt_manager/jobs/duplicates",
"POST /prompt_manager/jobs/image-tags",
@@ -188,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",
@@ -220,13 +212,6 @@
"thumbnail_encoders",
"thumbnail_encoders.jpeg",
"thumbnail_encoders.jxl",
"thumbnail_encoders.webp",
"thumbnail_location",
"thumbnail_location.available",
"thumbnail_location.cleanup_pending",
"thumbnail_location.default_path",
"thumbnail_location.mode",
"thumbnail_location.path",
"thumbnail_location.problem"
"thumbnail_encoders.webp"
]
}
-10
View File
@@ -371,16 +371,6 @@ test("ApiError keeps the job that applied a used plan (plan-used, System S7)", (
}
});
test("ApiError keeps a move refused for space with its review (#320)", () => {
const plan = { totals: { files: 3 }, insufficient: { text: "Not enough space" } };
assert.equal(new ApiError(507, "no space", "insufficient-space", null, { plan }).plan, plan);
for (const bad of [null, "x", [1], undefined]) {
assert.equal(new ApiError(507, "no space", "insufficient-space", null, { plan: bad }).plan, null);
}
assert.equal(new ApiError(409, "busy", "busy", null, { plan }).plan, null);
assert.equal(new ApiError(507, "no space", "insufficient-space").plan, null);
});
test("ApiError keeps a validated running job on busy and a validated source on invalid-source", () => {
const body = { running: { id: "a".repeat(32), title: "Scan for metadata" }, source: "output/gone" };
const busy = new ApiError(409, "busy", "busy", null, body);
+1 -47
View File
@@ -255,8 +255,7 @@ test('All is offered only with two or more eligible nodes and is never selected
test('Apply to All previews every node first, then sends one after another in node id order',async()=>{
const {createSendSession,ALL_NODES}=await source;const s=await allSession(createSendSession);
s.session.selectTarget(ALL_NODES);await s.session.apply();
// Each node takes a fresh ticket, the first too: the host keeps only its latest 100 (#593).
assert.deepEqual(order(s.calls),['preview 2','preview 9','preview 10','preview 2','apply 2','preview 9','apply 9','preview 10','apply 10']);
assert.deepEqual(order(s.calls),['preview 2','preview 9','preview 10','apply 2','preview 9','apply 9','preview 10','apply 10']);
assert.equal(s.session.state.phase,'success');assert.equal(s.session.state.message,'Sent to 3 of 3 nodes in Flow - ComfyUI.');
await s.session.apply();assert.equal(s.calls.filter(call=>call[0]==='apply').length,3);s.session.dispose();
});
@@ -364,48 +363,3 @@ test('a changed prepend or append after the review refuses that node',async()=>{
assert.deepEqual(s.calls.filter(call=>call[0]==='apply').map(call=>call[1]),['2','10']);
assert.match(s.session.state.message,/^Sent to 2 of 3 nodes in Flow - ComfyUI\. #9 Node 9: Its prompt changed since the preview\./);s.session.dispose();
});
// #593: Send to All through the real Toolkit bridge and the real adapter, as the review bot reproduced it.
function realWindow(count) {
const listeners=new Set();
const channelFactory=()=>{const own=new Set();return{
addEventListener(type,listener){if(type==='message'){own.add(listener);listeners.add(listener);}},removeEventListener(type,listener){listeners.delete(listener);},
postMessage(data){for(const listener of [...listeners])if(!own.has(listener))queueMicrotask(()=>listener({data:structuredClone(data),origin:'https://comfy.example'}));},close(){},
};};
// Like LiteGraph, a widget write moves the version of the graph it is in.
const graph=()=>{const made={_version:1,setDirtyCanvas(){made._version++;}};made._nodes=Array.from({length:count},(_,index)=>{
const widgets=['text','prepend_text','append_text'].map(name=>({name,value:name==='text'?'old':'',inputEl:{value:name==='text'?'old':''}}));
return {id:index+1,type:'PromptManager',title:`Node ${index+1}`,widgets,widgets_values:widgets.map(widget=>widget.value),inputs:[],setDirtyCanvas(){}};
});return made;};
return {channelFactory,graph};
}
async function realSend(count,{afterFirstApply}={}) {
const {createSendSession,ALL_NODES}=await source;
const {createBridgeHost,createBridgeClient}=await import('../../web/js/shared/toolkit/prompt-bridge.js');
const {createPromptAdapter}=await import('../../web/js/prompt-bridge-adapter.js');
const win=realWindow(count);const app={graph:win.graph(),canvas:{}};
const adapter=createPromptAdapter(app,{runtime:{}});let applied=0;
const watched={...adapter,apply(snapshot){const result=adapter.apply(snapshot);if(++applied===1)afterFirstApply?.(app,win);return result;}};
const options={namespace:'/prompt_manager/',origin:'https://comfy.example',channelFactory:win.channelFactory};
const host=createBridgeHost({...options,label:'Flow - ComfyUI',adapter:watched});
const client=createBridgeClient(options);
const session=createSendSession({client,roles:{positive:'new'},onChange:()=>{}});
try {
await session.discover();session.selectTarget(ALL_NODES);await session.apply();
return {state:session.state,app};
} finally { session.dispose();host.dispose(); }
}
test('All refuses the later nodes when another workflow is loaded after the first send (#593)',async()=>{
let first;
const {state,app}=await realSend(3,{afterFirstApply:(target,win)=>{first=target.graph;target.graph=win.graph();target.graph._version=first._version;}});
// The new workflow has the same ids, text and composition, and even the same graph version: only node identity differs.
assert.equal(first._nodes[0].widgets[0].value,'new');
assert.deepEqual(app.graph._nodes.map(node=>node.widgets[0].value),['old','old','old']);
assert.equal(state.phase,'error');
assert.match(state.message,/^Sent to 1 of 3 nodes in Flow - ComfyUI\. #2 Node 2: The workflow changed since this window was found\./);
});
test('All sends to every node of a window with more than 100 of them (#593)',async()=>{
const {state,app}=await realSend(101);
assert.equal(state.message,'Sent to 101 of 101 nodes in Flow - ComfyUI.');
assert.ok(app.graph._nodes.every(node=>node.widgets[0].value==='new'));
});
+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 -1
View File
@@ -143,7 +143,7 @@ test('the shelf no longer sorts by rating (D153)',async()=>{
const {createLibraryStream}=await source;const s=createLibraryStream({api:{get:async()=>page([])}});
await assert.rejects(s.reset({sort:'rating'}),/Invalid library sort/);
assert.throws(()=>createLibraryStream({api:{get:async()=>page([])},restore:{filters:{sort:'rating'},state:{}}}),/Invalid library sort/);
for(const sort of ['newest','oldest','name','name-desc','most-used','last-used']) assert.equal(await s.reset({sort}),0);s.dispose();
for(const sort of ['newest','oldest','name']) assert.equal(await s.reset({sort}),0);s.dispose();
});
test('Refine › Folder nests each folder under its exact parent, by path segment (#444 live B1)',async()=>{
const {libraryFolderOptions}=await source;assert.equal(typeof libraryFolderOptions,'function');
+2 -4
View File
@@ -32,7 +32,7 @@ test('a saved state always holds every page key and keeps only values its page a
{ ...full, q: '', folder: '', tags: [] });
assert.deepEqual(normalizeViewState('images', { ...full, category: 'bad\u0007', tags: [' '] }), { ...full, category: '', tags: [] });
// Library has no source or media, and no Name Z–A; its folder (#444) is the Library's own.
assert.deepEqual(normalizeViewState('library', { ...full, sort: 'rating' }), { q: 'castle', category: 'Portraits', sort: 'newest', tags: ['moody', 'fog'], tag_mode: 'all', folder: 'a/b' });
assert.deepEqual(normalizeViewState('library', { ...full, sort: 'name-desc' }), { q: 'castle', category: 'Portraits', sort: 'newest', tags: ['moody', 'fog'], tag_mode: 'all', folder: 'a/b' });
assert.deepEqual(normalizeViewState('library', { sort: 'oldest' }), { ...LIBRARY_DEFAULTS, sort: 'oldest' });
// The returned tags are a copy.
const tags = ['one'];
@@ -244,7 +244,5 @@ test('a view saved under the retired Highest rated sort opens on Recently added
assert.equal(imagesFilters({ sort: 'rating' }).sort, 'newest');
assert.equal(libraryFilters({ sort: 'rating' }).sort, 'newest');
assert.equal(normalizeViewState('images', { sort: 'name-desc' }).sort, 'name-desc');
for (const sort of ['oldest', 'name-desc', 'most-used', 'last-used']) {
assert.equal(normalizeViewState('library', { sort }).sort, sort);
}
assert.equal(normalizeViewState('library', { sort: 'oldest' }).sort, 'oldest');
});
+7
View File
@@ -93,6 +93,13 @@ test('every Overview card opens its own native page, never a legacy one (#525)',
}
});
test('the legacy pages System links are the four that stay at their URLs (#525 D165)', async () => {
const { LEGACY_PAGES } = await source;
assert.deepEqual(LEGACY_PAGES.map(page => page.href), ['/prompt_manager/admin', '/prompt_manager/admin#/tags',
'/prompt_manager/gallery', '/prompt_manager/gallery.html']);
for (const page of LEGACY_PAGES) assert.ok(page.label);
});
test('a job of the Activity page is its own deep link', async () => {
const { resolveRoute, routeTitle, isHandoff } = await source;
const id = '0123456789abcdef'.repeat(2);
@@ -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 -54
View File
@@ -21,23 +21,8 @@ test('the section lists the concept rows in its order, labels and danger marks',
assert.deepEqual(MAINTENANCE_SURFACES.map(([heading]) => heading), surfaces.map(text => text.replace('&amp;', '&')));
const production = MAINTENANCE_SURFACES.flatMap(([, rows]) => rows.map(([title, description, label, , danger = false]) =>
[title, description, label, danger]));
// D192 (#550, beyond the concept): "Pair category and tags" follows "Unused prompt components" in Database.
const expected = concept.map(([title, ...rest]) => [title.replace('&amp;', '&'), ...rest]);
expected.splice(expected.findIndex(([title]) => title === 'Unused prompt components') + 1, 0, PAIR_ROW);
assert.deepEqual(production, expected);
assert.equal(production.length, 9);
});
const PAIR_ROW = Object.freeze(['Pair category and tags', 'Fill an empty category or tags on prompts older Pair node runs ' +
'saved, from their saved pairs. Only empty fields of never-edited prompts.', 'Review plan', false]);
test('D192: the Pair category and tags row reviews the pair_metadata plan under its title (#550)', async () => {
const { MAINTENANCE_SURFACES } = await maintenance;
const { OPERATION_TITLES } = await review;
const [, database] = MAINTENANCE_SURFACES[0];
const row = database.find(([title]) => title === PAIR_ROW[0]);
assert.deepEqual(row, [PAIR_ROW[0], PAIR_ROW[1], PAIR_ROW[2], 'pair_metadata']);
assert.equal(OPERATION_TITLES.pair_metadata, 'Pair category and tags');
assert.deepEqual(production, concept.map(([title, ...rest]) => [title.replace('&amp;', '&'), ...rest]));
assert.equal(production.length, 8);
});
const PLAN = Object.freeze({
@@ -225,40 +210,3 @@ test('a rotated-log cleanup the log overtook offers Review again (S12)', async (
assert.deepEqual(reviewAgainOf({ kind: 'maintenance', state: 'failed', scope: { operation: 'rotated_logs', failure: 'plan-changed' } }),
{ operation: 'rotated_logs' });
});
// #550 live test: a records operation's rows have no path, only a detail ("Prompt 12: category …").
const RECORD_JOB = Object.freeze({
id: 'a'.repeat(32), kind: 'maintenance', title: 'Pair category and tags', state: 'finished',
admission: 'records', cancellable: false, progress: 100, processed: 0, total: 0, status_line: 'Filled 2 prompts',
scope: { operation: 'pair_metadata' }, counters: { changed: 2, protected: 0, errors: 0 },
started_at: 1, finished_at: 2,
});
test('#550: path-less record rows are kept with their detail; file rows are unchanged', async () => {
const { parseDetail, filterRows } = await jobView;
const parsed = parseDetail({ ...RECORD_JOB, outcomes: { changed: [
{ path: '', root: '', root_index: null, detail: 'Prompt 2325: category “asian”; tags asian' },
{ path: '', root: '', root_index: null, detail: '' },
{ path: 'a/b.png', root: 'output', root_index: 0, detail: 'Link removed' },
] }, log: [] });
assert.deepEqual(parsed.outcomes.changed, [
{ path: '', root: '', root_index: null, detail: 'Prompt 2325: category “asian”; tags asian', record: true },
{ path: 'a/b.png', root: 'output', root_index: 0, detail: 'Link removed', record: false },
]);
// The filter reads a record's detail and a file's path.
assert.deepEqual(filterRows(parsed.outcomes.changed, 'asian').map(row => row.detail),
['Prompt 2325: category “asian”; tags asian']);
assert.deepEqual(filterRows(parsed.outcomes.changed, 'a/b').map(row => row.path), ['a/b.png']);
});
test('#550: a records operation says records, not files, for an empty group and a filter', async () => {
const { groupCopy, EMPTY_GROUP, NO_MATCH } = await jobView;
assert.deepEqual(groupCopy(RECORD_JOB), { empty: 'No records in this group.', noMatch: 'No listed record matches this filter.',
placeholder: 'Filter records…' });
for (const operation of ['duplicate_prompts', 'unused_components']) {
assert.equal(groupCopy({ ...RECORD_JOB, scope: { operation } }).empty, 'No records in this group.');
}
const files = { empty: EMPTY_GROUP, noMatch: NO_MATCH, placeholder: 'Filter by file path…' };
assert.deepEqual(groupCopy({ ...RECORD_JOB, scope: { operation: 'missing_links' } }), files);
assert.deepEqual(groupCopy({ ...RECORD_JOB, kind: 'scan', scope: {} }), files);
});
@@ -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,170 +0,0 @@
// Thumbnail location (#320, D196–D201): the row's state and status line, the move review's rows, the unavailable
// notice's text, the Activity views of a move and a prune, the prune scope's plan line and the Move library row.
const test = require('node:test');
const assert = require('node:assert/strict');
const locationModule = import('../../web/js/workspace/system-thumbnail-location.mjs');
const kindsModule = import('../../web/js/workspace/activity-kinds.mjs');
const reviewModule = import('../../web/js/workspace/thumbnail-review.mjs');
const libraryModule = import('../../web/js/workspace/library-location.mjs');
const SOURCE = Object.freeze({ mode: 'source', path: null, default_path: '/data/thumbnails', available: true,
problem: null, cleanup_pending: 0 });
const FOLDER = Object.freeze({ mode: 'folder', path: 'E:\\Thumbnails', default_path: '/data/thumbnails',
available: true, problem: null, cleanup_pending: 0 });
const PLAN = Object.freeze({
from: { mode: 'source', path: null, available: true },
to: { mode: 'folder', path: 'E:\\Thumbnails', exists: true, will_create: false, default: false },
totals: { sources: 3, files: 8512, bytes: 1.2 * 1024 ** 3, orphans: 41, temps: 2, links: 1, left_in_place: 1,
too_long: 32, unlisted_sources: 0, unlisted_files: 0 },
too_long_listed: ['Archive 2025 · a_thumb.webp', 'Archive 2025 · b_thumb.webp'],
left_listed: ['Archive 2025 · notes.txt'],
unlisted: [],
free_bytes: 1.6 * 1024 ** 4,
confirm_required: true,
token: 't',
});
test('the status line says where previews are now', async () => {
const { statusText } = await locationModule;
assert.equal(statusText(SOURCE), 'Now: inside each source.');
assert.equal(statusText(FOLDER), 'Now: one folder, E:\\Thumbnails.');
assert.equal(statusText({ ...FOLDER, path: '/data/thumbnails' }),
'Now: one folder, /data/thumbnails. Using the default folder next to your library.');
assert.equal(statusText({ ...FOLDER, available: false, problem: 'missing' }),
'Now: one folder, E:\\Thumbnails · unavailable. Originals are shown.');
assert.match(statusText({ ...SOURCE, cleanup_pending: 1 }), /Old thumbnails in 1 place wait to be removed/);
assert.equal(statusText(undefined), '');
assert.match(statusText(SOURCE, { mode: 'folder', path: '/data/thumbnails' }),
/^Now: inside each source\. The suggested folder is next to your library/);
assert.equal(statusText(SOURCE, { mode: 'folder', path: '/other' }), 'Now: inside each source.');
});
test('Review move… is offered only for another location', async () => {
const { choiceChanged } = await locationModule;
assert.equal(choiceChanged(SOURCE, { mode: 'source' }), false);
assert.equal(choiceChanged(SOURCE, { mode: 'folder', path: ' ' }), false);
assert.equal(choiceChanged(SOURCE, { mode: 'folder', path: '/data/thumbnails' }), true);
assert.equal(choiceChanged(FOLDER, { mode: 'folder', path: 'E:\\Thumbnails' }), false);
assert.equal(choiceChanged(FOLDER, { mode: 'folder', path: 'F:\\Thumbs' }), true);
assert.equal(choiceChanged(FOLDER, { mode: 'source' }), true);
assert.equal(choiceChanged({ ...SOURCE, problem: 'invalid' }, { mode: 'source' }), true);
assert.equal(choiceChanged(undefined, { mode: 'source' }), false);
});
test('the review lists what moves, what is removed and what stays', async () => {
const { reviewRows } = await locationModule;
const rows = Object.fromEntries(reviewRows(PLAN));
assert.equal(rows.From, 'Inside each source (3 sources)');
assert.equal(rows.To, 'E:\\Thumbnails (empty folder)');
assert.equal(rows.Thumbnails, '8,512 in 3 sources · 1.2 GB');
assert.equal(rows['Free space'], '1638.4 GB free');
assert.match(rows['Too long for Windows'][0], /^32 thumbnails cannot be stored there/);
assert.deepEqual(rows['Too long for Windows'][1], [...PLAN.too_long_listed, 'and 30 more']);
assert.deepEqual(rows['Removed after the move'][1],
['41 thumbnails whose original is gone', '2 unfinished temporary files']);
assert.equal(rows['Left in place'][0], '1 file that is not a thumbnail:');
assert.equal(rows['Links skipped'], '1 link inside a thumbnails folder is not followed');
});
test('the review of a move back names the folder and the removed sources', async () => {
const { reviewRows } = await locationModule;
const back = reviewRows({ ...PLAN, from: { mode: 'folder', path: '/data/thumbnails' },
to: { mode: 'source', path: null }, unlisted: [{ key: 'old-1c22e0a4', files: 240 }],
totals: { sources: 1, files: 1, bytes: 10 }, free_bytes: undefined });
const rows = Object.fromEntries(back);
assert.equal(rows.From, '/data/thumbnails');
assert.equal(rows.To, 'Inside each source (1 source)');
assert.equal(rows['Free space'], undefined);
assert.match(rows['Removed after the move'][0], /The folder itself is removed once empty/);
assert.deepEqual(rows['Removed after the move'][1],
['1 source no longer listed: 240 thumbnails are removed; they are made again if it returns']);
// Nothing else removed: one full stop, never "created it.." (live test of #592).
const plain = Object.fromEntries(reviewRows({ ...PLAN, from: { mode: 'folder', path: '/data/thumbnails' },
to: { mode: 'source', path: null }, unlisted: [], totals: { sources: 1, files: 1, bytes: 10 } }));
assert.match(plain['Removed after the move'][0], /Prompt Manager created it\.$/);
const fresh = Object.fromEntries(reviewRows({ ...PLAN, to: { mode: 'folder', path: '/d', default: true, will_create: true } }));
assert.equal(fresh.To, '/d (new, next to your library)');
});
test('the notice says why the folder is unavailable', async () => {
const { noticeText } = await locationModule;
assert.match(noticeText({ ...FOLDER, problem: 'missing' }), /^E:\\Thumbnails is missing\. Originals are shown/);
assert.match(noticeText({ ...FOLDER, problem: 'unreachable' }), /can’t be reached/);
assert.match(noticeText({ ...FOLDER, problem: 'marker-mismatch' }), /holds another thumbnail folder/);
assert.match(noticeText({ ...FOLDER, problem: 'invalid' }), /config\.json can’t be used/);
assert.match(noticeText({ ...FOLDER, problem: null }), /is being checked/);
});
test('sizes are written as the server writes them', async () => {
const { sizeText } = await locationModule;
assert.equal(sizeText(512), '512 B');
assert.equal(sizeText(85.2 * 1024 * 1024), '85.2 MB');
});
test('a move and a prune show their own tiles in Activity', async () => {
const { viewOf, movePhase, finishToast } = await kindsModule;
const move = { kind: 'thumbnails', state: 'running', cancellable: true, scope: { mode: 'move', from: 'source' },
counters: { moved: 3, skipped: 0, left: 1, errors: 2 }, status_line: '' };
assert.deepEqual(viewOf(move).tiles.map(([group, label]) => label), ['Moved', 'Already there', 'Left in place', 'Errors']);
assert.equal(viewOf(move).cancelLabel, 'Cancel move');
assert.match(movePhase(move), /^Step 1 of 3: copying\. Thumbnails are still read from inside each source/);
assert.match(movePhase({ ...move, cancellable: false }), /can no longer be cancelled/);
assert.match(movePhase({ ...move, scope: { mode: 'move', phase: 'remove' } }), /Removing the old copies/);
assert.match(movePhase({ ...move, state: 'finished' }), /1 file that is not a thumbnail was left in place/);
assert.match(movePhase({ ...move, state: 'cancelled' }), /^Nothing changed/);
assert.match(movePhase({ ...move, state: 'finished', counters: { moved: 3, left: 2 } }),
/2 files that are not thumbnails were left in place\.$/);
assert.equal(movePhase({ ...move, state: 'finished', counters: undefined }), 'Thumbnails are now read from the new location.');
assert.match(movePhase({ ...move, scope: { mode: 'move', from: 'folder' } }), /read from the old folder until the switch/);
const done = { ...move, state: 'finished', status_line: 'Thumbnails moved to E:\\T: 3 moved · old copies removed.' };
assert.equal(finishToast(done), done.status_line);
const prune = { kind: 'thumbnails', state: 'running', cancellable: true, scope: { mode: 'prune' },
counters: { removed: 1, left: 0, errors: 0 } };
assert.deepEqual(viewOf(prune).tiles.map(([, label]) => label), ['Removed', 'Left in place', 'Errors']);
assert.equal(viewOf(move).line(move), '');
assert.equal(viewOf(done).line(done), done.status_line);
assert.equal(viewOf(move).kept(move), '3 moved');
assert.equal(finishToast({ ...move, state: 'cancelled', status_line: '' }), viewOf(move).finishToast({ ...move,
state: 'cancelled', status_line: '' }));
const pruned = { ...prune, state: 'finished', status_line: '1 thumbnails removed' };
assert.equal(viewOf(prune).line(prune), '');
assert.equal(viewOf(pruned).line(pruned), '1 thumbnails removed');
assert.equal(viewOf(pruned).line({ ...pruned, status_line: null }), '');
assert.equal(viewOf(done).line({ ...done, status_line: null }), '');
assert.equal(finishToast(pruned), '1 thumbnails removed');
assert.equal(viewOf(prune).kept(prune), '1 removed');
assert.match(finishToast({ ...prune, state: 'finished', status_line: '', title: 'Remove thumbnails of removed sources' }),
/Remove thumbnails of removed sources/);
const generate = { kind: 'thumbnails', state: 'running', cancellable: true, scope: { mode: 'missing' },
counters: { generated: 0, skipped: 0, errors: 0 } };
assert.deepEqual(viewOf(generate).tiles.map(([, label]) => label), ['Generated', 'Skipped', 'Errors']);
});
test('the prune scope reads its plan line from the server', async () => {
const { planText, PLAN_FAILED, PRUNE_OPTION } = await reviewModule;
assert.equal(PRUNE_OPTION[1], 'Remove thumbnails of removed sources');
assert.equal(planText({ mode: 'prune', line: '2 sources no longer listed: 1,240 thumbnails (85.2 MB) are removed.' }),
'2 sources no longer listed: 1,240 thumbnails (85.2 MB) are removed.');
assert.equal(planText({ mode: 'prune' }), PLAN_FAILED);
});
test('the prune review names the folder and each removed source', async () => {
const { pruneText, pruneEntries } = await reviewModule;
assert.match(pruneText({ folder: 'E:\\Thumbs' }), /^Only Prompt Manager’s thumbnails in their folders inside E:\\Thumbs are /);
assert.match(pruneText({}), /inside the thumbnail folder are removed\. If a source comes back/);
assert.deepEqual(pruneEntries({ sources: [{ key: 'gone-0123abcd', files: 1240, bytes: 85.2 * 1024 * 1024 },
{ key: 'one-0123abcd', files: 1, bytes: 512 }] }),
[['gone-0123abcd', '1,240 thumbnails · 85.2 MB'], ['one-0123abcd', '1 thumbnail · 512 B']]);
assert.deepEqual(pruneEntries(null), []);
assert.deepEqual(pruneEntries({ sources: 'x' }), []);
});
test('Move library says where thumbnails kept in one folder stay', async () => {
const { reviewGrid } = await libraryModule;
const base = { path: 'p', target: 't', size_bytes: 0, config_moves: true };
assert.equal(reviewGrid(base).some(([term]) => term === 'Thumbnails'), false);
const rows = Object.fromEntries(reviewGrid({ ...base, thumbnails_folder: '/data/thumbnails' }));
assert.equal(rows.Thumbnails, 'Stay in /data/thumbnails. Move them in System › Sources & scans.');
});
+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",
+3 -58
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]]
@@ -732,32 +729,6 @@ class TestAutotagErrorBodiesHideServerPaths(AutotagAPITestCase):
class TestUnloadAutotag(AutotagAPITestCase):
async def test_unload_runs_off_the_loop_and_holds_the_gate_until_done(self):
started = threading.Event()
release = threading.Event()
self.addCleanup(release.set)
threads = []
def unload():
threads.append(threading.get_ident())
started.set()
release.wait(5)
self.service._loaded_type = None
self.service._loaded_type = "gguf"
self.service.unload_model = unload
first = asyncio.ensure_future(
self.client.request("POST", "/prompt_manager/autotag/unload")
)
self.assertTrue(await asyncio.to_thread(started.wait, 5))
self.assertNotEqual(threads, [threading.get_ident()])
second = await self.client.request("POST", "/prompt_manager/autotag/unload")
self.assertEqual(second.status, 409)
release.set()
data = await (await first).json()
self.assertEqual(data["message"], "GGUF model unloaded successfully")
self.assertIsNone(model_gate.holder())
async def test_nothing_loaded(self):
resp = await self.client.request("POST", "/prompt_manager/autotag/unload")
data = await resp.json()
@@ -1562,8 +1533,7 @@ class TestSavedTaggingDefaults(AutotagAPITestCase):
"EXISTING",
"GENERAL_THRESHOLD",
"CHARACTER_THRESHOLD",
"INSTRUCTIONS",
"DEFAULT_INSTRUCTIONS",
"TEMPLATE",
"SUGGESTION_LIMIT",
):
self.addCleanup(setattr, AutotagConfig, attr, getattr(AutotagConfig, attr))
@@ -1584,11 +1554,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(
@@ -1612,27 +1578,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)

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