Compare commits

..
Author SHA1 Message Date
Vito Sansevero 259ad65b3f fix(tagging): refuse non-text default ids and keep one listener per list (#431)
A default instructions id that is not text is a 400 on save and falls back
with a warning on load; a damaged list warns once and migrates the old
template afresh. Option clicks use one listener on the list, the reset
note clears once the defaults are saved, and a duplicated long name is cut
to fit.
2026-10-31 18:28:55 -07:00
Vito Sansevero f2f40b80e8 feat(tagging): saved instructions with a default per engine (#431)
System > Auto-tagging gets Saved instructions (D203): Built-in first and
read-only, saved entries with New, Duplicate and Delete, and a default for
GGUF and for HuggingFace JoyCaption. A template saved by an older version
becomes "Custom template", the default for both engines, in memory at
load; the next save that touches either key writes the list. Every such
save also mirrors the GGUF default into the old template key (deleted when
it is Built-in) so a downgrade keeps working. The batch review names the
engine's default (D205). The legacy blank-prompt fallbacks and
default_prompt read the engine's default.
2026-10-31 18:19:09 -07:00
Vito Sansevero 90c5848b13 test(tagging): classify the Skipped tags preview route as reading no media (#431) 2026-10-31 17:25:20 -07:00
Vito Sansevero 5981e1d9e3 fix(tagging): skip a damaged WD14 label list in the preview (#431) 2026-10-31 17:16:23 -07:00
Vito Sansevero 66f8071198 test(tagging): count the Skipped tags block as one concept difference (#431) 2026-10-31 17:07:16 -07:00
Vito Sansevero a20e59f674 feat(tagging): skipped tags hide matching suggestions (#431)
A Skipped tags list in System > Auto-tagging (D202) hides every suggested
tag whose words contain an entry's words, on every suggestion path
(Tag image, Auto-tag selected, the System batch, the legacy single and
batch routes), before the limit. Jobs snapshot the list at their start.
A read-only preview route counts what a list hides among the library's
tags and the downloaded WD14 label lists. The draft store compares list
and object drafts by value (sameDraft).
2026-10-31 16:43:15 -07:00
81 changed files with 4084 additions and 5951 deletions
+125 -69
View File
@@ -349,12 +349,9 @@ and the time left. The steps, in order:
observer join get what is left, which can be 0 s. `stop()` joins watchdog's
emitters without a limit, so it runs on a daemon thread and is waited for
only until the deadline; a later stop waits for that same call (#561).
3. `close_auto_thumbnails(final=True)` stops the preview worker (30 s),
kills ffmpeg and shuts the interactive image pool down.
4. `shutdown_job_pool` (`utils/parallel.py`) cancels the long-lived job pool's
queued items and joins its threads (5 s; part `workers`, only when a job
used the pool, #523).
5. `PromptTracker.shutdown` stops the prompt cleanup thread (2 s).
3. `close_auto_thumbnails(final=True)` stops the preview worker (30 s) and
kills ffmpeg.
4. `PromptTracker.shutdown` stops the prompt cleanup thread (2 s).
A failing step, or a second Ctrl+C, is logged with its traceback and does not
skip the later steps. A clean run logs one INFO line to PromptManager's log:
@@ -459,38 +456,6 @@ A hot SQLite read that returns many rows aggregates them into one step
(`json_group_array`) instead of stepping once per row, so it does not wait for
the GIL once per row while ComfyUI generates (#354, `get_image_link_matches`).
A metadata read never decodes pixels (#523). Pillow's `PngImageFile.text` loads
every pixel to reach text chunks after IDAT; the scan (`_extract_comfyui_metadata`)
and the image monitor (`ComfyUIMetadataExtractor.extract_metadata`) read PNG text
through `utils/png_text.py`, which runs Pillow's own chunk handlers after the
first IDAT and seeks over pixel data. The legacy `.text` read is the floor:
`tests/test_png_text.py` compares both readers on every image under
`tests/fixtures`, a curated corpus (text after IDAT, zTXt, iTXt, split IDAT, bad
checksums, truncations, oversized text, APNG) and a seeded fuzz, and asserts that
neither reader decodes pixels by patching `ImageFile.load`. The only reads that
differ are files whose pixel data is undecodable or cut short: legacy found
nothing, the walk keeps the text before the pixels. Animated PNGs still use
`.text`.
Image work runs on PromptManager's own threads (#523). glibc keeps each thread's
malloc arena at its high-water mark, so image buffers spread over the default
executor's threads stay resident. Interactive image work (view previews behind their
gate, workspace card facts, both inspector routes) runs on the API's fixed
`pm-image` pool (`IMAGE_POOL_SIZE` 3, `_run_image`, `_map_image`; card facts already
cached answer without a trip; while the gate's 3 preview writes hold every thread, a
card or inspector read waits for one of them, by design of the merged pool; after the
package teardown the pool is not recreated and late calls use the default executor). Jobs (scan metadata and file facts, thumbnail and
convert jobs, the legacy gallery generation, capture previews) run through
`map_parallel` on one long-lived `pm-worker` pool of Worker threads, reused between
jobs; a new Worker threads value replaces it (the teardown also joins a replaced pool
still finishing), and a call made from a job thread runs inline. After a scan, thumbnail or convert job, or the legacy gallery generation,
`release_free_memory` (`utils/native_memory.py`) calls glibc's `malloc_trim(0)` once;
it does nothing on Windows, macOS and musl and never raises. No allocator setting
changes. `tests/test_image_pools.py` asserts the thread of each read and write and the
trim; `tests/test_utils_parallel.py` the pool's reuse, resize, nesting and shutdown.
`tools/perf/memory_drive.py` (manual, Linux, never in CI) measures RSS, heap, threads
and arenas under the #523 load against the disposable browser test server.
The `perf budget` job in `.github/workflows/test.yml` is the reference
measurement: `ubuntu-latest`, one Python, no coverage, only the wall-clock tests.
It runs on every push to `main` and on `workflow_dispatch`, never on pull
@@ -1886,7 +1851,8 @@ 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 server's `default_prompt`.
from the server's `default_prompt` (since #431 the saved engine's default
instructions, GGUF's for a WD14 engine).
- **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.
@@ -3893,8 +3859,8 @@ workspace never asks; previews are a cache the server keeps:
- `GET /images/serve/thumbnails/<rel>_thumb<ext>` for a missing preview is refused as
text first (traversal, a backslash, a drive or stream colon, empty or dot segments, so
`D:/x`, `\\server\share` and a leading `/` never reach a joined path), then admitted
on the event loop (`_PreviewGate`): 3 previews are written at once on the API's
interactive `pm-image` pool (never the default executor; #523), one flight runs per file (the history
on the event loop (`_PreviewGate`): 3 previews are written at once on the API's own
`pm-preview` pool (never the default executor), one flight runs per file (the history
route shares the flight key and the admission), and at most 64 requests wait; one
more gets its fallback at once without listing a folder. Inside the admission the
preview maps back to its media file under the same root (exact names, then one folder
@@ -3927,7 +3893,7 @@ workspace never asks; previews are a cache the server keeps:
`exclusive` job runs (`_auto_thumbnail_blocker`), checked when a write starts (after
its slot) and again just before its rename.
- Shutdown is bounded and logged: the worker stops at a file boundary, ffmpeg is
killed, an idle worker leaves (the next output starts a fresh one), the image pool
killed, an idle worker leaves (the next output starts a fresh one), the view pool
shuts down, and after the app's cleanup a late monitor event
starts nothing. A package reload stops the workers the previous import left.
- Cards and strips load previews only: Images in every layout and size, Library cards,
@@ -4404,23 +4370,6 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
choice. The host's checks still refuse a linked field or a changed workflow, and the
sender also refuses if the destination's `revision` (node object plus graph version)
moved since discovery; it is undefined on an older tab, which skips the comparison. A host error reads as a refusal, not "may have applied".
- **D191** (#542, beyond the concept; maintainer 2026-10-31, Q542-1 A to 5 A): a window with two or more
eligible nodes adds a last Destination node option "All N eligible nodes" (`ALL_NODES` in
`prompt-sender.mjs`), never selected for you (D173). Eligible is D173's filter; the nodes it leaves out
stay counted in the discovery sentence. One window only. Preview stays optional (D175): Preview changes
shows one `section.prompt-sender-node` per node, headed "#<id> <title>", in send order (node id,
numeric). Apply previews every node first and writes nothing if any refuses ("#<id> <title>: <reason>
Nothing was sent."). Then it sends one node after another. Only the first node of a pass taken in that
click keeps its ticket; every other node, and every node of a held Preview (host tickets expire after
30 s), takes a fresh ticket that is refused unless its Before texts and composition equal the reviewed
ones. That fresh preview checks the discovered revision until something has been posted, and not after,
since our own write may move the graph version. Refusals are best effort: "Sent to 2 of 3 nodes in
<window>. #12 <title>: <host's refusal>."; Apply stays available and a retry sends to every node again
(rewriting the same text). A lost reply stops the run, names that node as unknown and lists the nodes
not sent. Closing the dialog stops after the current node. The review sections are labelled by their
`h4` node heading. Unit tests in `tests/js/prompt-sender.test.js`, a fake three-node host in
`tests/browser/prompt-inspector.spec.mjs`, real Classic and Nodes 2.0 widgets in
`tests/comfyui/prompt-sender.spec.mjs`.
- **D106** (Integrations, S10, Q8): the send bridge always asks for the ComfyUI window
and node, and the nodes always follow ComfyUI's own renderer, so "Default send
destination" offers only "Ask each time" and "Host compatibility" only "Follow host
@@ -4606,9 +4555,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>" 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
("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
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
@@ -4630,6 +4579,51 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
Cancel-only review matches the concept review shell` (system-tagging-concept.spec) and
the batch tests of workspace-system-tagging.spec; the mutation check renames each grid
label in turn.
- **D202** (Auto-tagging, #431, beyond the concept): a "Skipped tags" block
(`.setting-block`, `data-setting="tag-skipped"`, `data-key="autotag_skipped_tags"`)
follows "Suggestions per image": the label and description in the concept's
setting-row tokens ("Suggested tags that contain one of these words are hidden.
Words are separated by spaces, _ or -; other characters such as ( ) belong to the
word."), an "Add a word or words" input (`#tag-skipped-input`) with Add (Enter adds
too), the entries as removable chips in the Tag image dialog's D83 chip markup (each
with its count, title "Hides N tags", or muted "none" with "Hides no known tag"), and
a `role="status"` preview line: "**Hides N tags from suggestions**: <up to 12>[ and K
more]. Tags already on images are not changed." ("Checked against library tags only;
no WD14 label list is downloaded." before the last sentence without a label list;
"No tags are skipped." for an empty list; "Checking…" while waiting; "The preview
could not be read." on failure). The client refuses an entry with "Enter a word.",
"No commas or line breaks.", "At most 50 characters.", "Already in the list." or "The
list is full (200)." under the input. The Tag image dialog's chip hint adds "N skipped
tags were not suggested." when the server hid any (the static mock's copy). Pinned by
the comparison's mapping (system-tagging-concept.spec: the block's tokens are the
concept row's), `skipped tags add, preview, remove, save and reload (D202)`, `skipped
tags preview checks library tags only …` (workspace-system-tagging.spec) and
`suggestions omit skipped tags and say how many were hidden` (workspace-tagging.spec).
- **D203** (Auto-tagging, #431, beyond the concept): "Saved instructions" replaces the
concept's "LLM tag template" field (the mock's state 2): a `.setting-block`
(`data-setting="tag-instructions"`, `data-key="autotag_instructions"`) with the label,
"Instructions for the language-model engines. Built-in can’t be changed; duplicate it
to make your own. N of 20 saved · Built‑in is not counted.", a `role="listbox"` list
(Built-in first with a "Read-only" chip, then the saved entries; "Default for GGUF" /
"Default for HuggingFace" chips), "+ New" ("New instructions", "(2)" if taken),
"Duplicate" ("<name> copy"), "Delete" (`.danger`, disabled for Built-in), "At most 20
saved instructions." at the cap, and the editor beside the list (under it below 700px):
"Name" ("N of 60") and "Text" ("N of 4,000 characters"), read-only for Built-in with
"Built-in instructions can’t be changed. Duplicate them to make your own.", else the
entry's problem ("The name “X” is already used.", "The text must not be empty."…).
Then two concept select rows, "Default for GGUF JoyCaption" and "Default for
HuggingFace JoyCaption" ("The Tag image dialog and batches start from this.",
`data-key="autotag_default_instructions"`), and the note a deleted default leaves
("GGUF now uses Built-in."; nothing is written until Save preferences). Pinned by the
comparison's mapping (system-tagging-concept.spec: the block in the concept row's
tokens, the two rows on the concept's select row) and `saved instructions: new, edit,
per-engine default, delete, save and reload (D203)`, `a refused instructions save names
the entry and highlights the block (D203)` (workspace-system-tagging.spec).
- **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":
@@ -5215,16 +5209,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),
`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
`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
/prompt_manager/settings` with `expected_revision`; a bad value is refused with a 400
naming the setting by its row label ("General-tag confidence must be between 0 and
1."), nothing is written, the Save row shows that sentence (`invalid-setting`, set by
`settingRefusal` in system.mjs only for a save that carried `autotag_*` keys; every
other section's plain 400 keeps the generic status) and the control is highlighted.
The template's limit counts code points, as Python's `len` does. A hand-edited bad value
Instruction text limits count 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
@@ -5235,8 +5229,70 @@ 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`), so the Tag image and Auto-tag selected
dialogs start from them; a preset (the bulk review, Preview tagging review) wins.
`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`.
`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`,
+7 -30
View File
@@ -92,20 +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.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()
@@ -290,11 +280,11 @@ def _teardown_logger():
def _stop_background_work():
"""Stop the image monitor, the preview worker, the job worker threads
and the prompt cleanup thread against one TEARDOWN_SECONDS deadline, once.
"""Stop the image monitor, the preview worker and the prompt cleanup
thread against one TEARDOWN_SECONDS deadline, once.
Returns ``{"monitor", "thumbnails", "workers", "tracker"}`` booleans
(True: it stopped), leaving out parts that never started. Later calls return a
Returns ``{"monitor", "thumbnails", "tracker"}`` booleans (True: it
stopped), leaving out parts that never started. Later calls return a
copy of the first result. A Ctrl+C inside a step is logged and the
step is False; one between steps is re-raised after the result (parts
not reached are False) is stored and the ``atexit`` entry removed.
@@ -308,14 +298,11 @@ def _stop_background_work():
api,
_global_prompt_tracker,
)
from .utils import parallel
# A part whose global is set has started; False until its step says
# otherwise, so an interrupt between steps leaves an honest result.
started = (
("monitor", monitor),
("thumbnails", thumbnails),
("workers", parallel.job_pool_started() or None),
("tracker", tracker),
)
result = {name: False for name, part in started if part is not None}
@@ -358,16 +345,6 @@ def _stop_background_work():
result["thumbnails"] = _teardown_step(
"the preview worker", close_previews, logger
)
if "workers" in result:
def stop_workers():
from .utils.parallel import JOB_POOL_JOIN_SECONDS
return parallel.shutdown_job_pool(left(JOB_POOL_JOIN_SECONDS))
result["workers"] = _teardown_step(
"the job worker threads", stop_workers, logger
)
if tracker is not None:
def stop_tracker():
-4
View File
@@ -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."
),
+14 -22
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,7 +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_convert import ThumbnailConvertMixin
from .thumbnail_auto import AutoThumbnailMixin
from .system import SystemRoutesMixin
@@ -87,8 +85,6 @@ 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
from ...utils.video_metadata import is_video_path, read_video_metadata
except ImportError:
import sys
@@ -99,8 +95,6 @@ except ImportError:
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
from utils.video_metadata import is_video_path, read_video_metadata
@@ -340,7 +334,6 @@ class PromptManagerAPI(
JobRoutesMixin,
ImageTagJobsMixin,
ThumbnailJobsMixin,
ThumbnailLocationMixin,
ThumbnailConvertMixin,
AutoThumbnailMixin,
SystemRoutesMixin,
@@ -533,14 +526,6 @@ class PromptManagerAPI(
return await loop.run_in_executor(None, call)
return await loop.run_in_executor(None, func, *args)
async def _release_job_memory(self):
"""After a scan or thumbnail job: glibc's free heap pages go back to
the OS once (``malloc_trim(0)``; nothing elsewhere, #523)."""
try:
await self._run_in_executor(release_free_memory)
except Exception: # best effort: the job's result already stands
self.logger.debug("Releasing free memory failed", exc_info=True)
def _root_listing(self, root) -> RootListing:
"""The shared listing record of one output root (created empty)."""
return self._root_listings.setdefault(str(root), RootListing())
@@ -855,11 +840,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
@@ -1054,8 +1048,6 @@ class PromptManagerAPI(
def _extract_comfyui_metadata(self, image_path):
"""Embedded ComfyUI metadata of an image (PNG text chunks) or a video.
PNG text is read without decoding the pixels (``utils.png_text``, #523).
Videos go through ffprobe (see utils/video_metadata). Unreadable files
are a warning, not an error: a scan over a large output tree meets
truncated and foreign files, and it carries on past them.
@@ -1064,7 +1056,7 @@ class PromptManagerAPI(
return read_video_metadata(image_path)
try:
with Image.open(image_path) as img:
return png_text(img)
return dict(getattr(img, "text", None) or {})
except Exception as e:
self.logger.warning(
f"Unreadable image {os.path.basename(str(image_path))}: {e}"
+3 -5
View File
@@ -1454,7 +1454,6 @@ class AdminRoutesMixin:
verb = "cancelled" if state == "cancelled" else "finished"
job.log_line(f"Scan {verb} · {_plural(job.processed, 'file')} processed")
job.finish(state)
await self._release_job_memory()
if state == "finished":
# After the scan, so the guarded thumbnail job is admitted (#249).
await self._hand_off_scan_thumbnails(job, output_dirs)
@@ -1669,10 +1668,9 @@ class AdminRoutesMixin:
def _extract_batch_metadata_sync(self, file_batch):
"""Extract metadata from a batch of files in one executor call.
Files are read on the long-lived job pool of
``PromptManagerConfig.WORKER_THREADS`` threads (#523).
PNG text is read chunk by chunk without decoding pixels (#523); file
reads and ffprobe release the GIL.
Files are read on ``PromptManagerConfig.WORKER_THREADS`` threads:
Pillow decodes the whole image to reach trailing text chunks and
releases the GIL while doing so.
"""
from ..config import PromptManagerConfig
+6 -2
View File
@@ -102,11 +102,14 @@ 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):
def run_suggestions(
service, capture_path: str, model_type: str, options: dict, skip=None
):
"""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).
downloaded (``load_model`` refuses a model that is not on disk). ``skip``
is the request's or job's compiled Skipped tags (#431).
"""
if not service.is_model_loaded() or service.get_loaded_model_type() != model_type:
service.load_model(model_type, options["use_gpu"])
@@ -116,6 +119,7 @@ def run_suggestions(service, capture_path: str, model_type: str, options: dict):
prompt=options.get("instructions"),
general_threshold=options.get("general_threshold"),
character_threshold=options.get("character_threshold"),
skip=skip,
)
+8 -2
View File
@@ -35,6 +35,7 @@ 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 (
@@ -640,6 +641,8 @@ 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.
@@ -653,6 +656,7 @@ class AnnotationRoutesMixin:
capture,
model_type,
options,
skip,
on_skipped=functools.partial(store.release_use, token),
)
except MissingBackendPackages as exc:
@@ -677,6 +681,8 @@ 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"},
)
@@ -732,10 +738,10 @@ def _byte_range(request: web.Request, size: int):
return start, stop
def _pinned_suggestions(store, token, service, capture, model_type, options):
def _pinned_suggestions(store, token, service, capture, model_type, options, skip=None):
"""Worker thread: infer on the capture, then end its pin (even on error)."""
try:
return run_suggestions(service, capture.path, model_type, options)
return run_suggestions(service, capture.path, model_type, options, skip)
finally:
store.release_use(token)
+131 -14
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 thumbnail_settings
from .. import model_gate
try:
@@ -40,6 +40,12 @@ 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."
@@ -72,6 +78,12 @@ 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
@@ -247,6 +259,10 @@ 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)
@@ -284,7 +300,15 @@ class AutotagRoutesMixin:
"success": True,
"models": public_models(status, loaded_type),
# The saved tagging defaults (#328, System › Auto-tagging).
"default_prompt": AutotagConfig.TEMPLATE,
# 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),
"model_loaded": loaded,
"loaded_model_type": loaded_type,
"wd14_general_threshold": AutotagConfig.GENERAL_THRESHOLD,
@@ -539,7 +563,10 @@ class AutotagRoutesMixin:
else:
prompt = params.get("prompt")
template = prompt if isinstance(prompt, str) and prompt.strip() else None
options = {"instructions": template or AutotagConfig.TEMPLATE}
options = {
"instructions": template
or AutotagConfig.default_instructions_text(model_type)
}
try:
_model, options = validate_options(
model_type,
@@ -699,6 +726,86 @@ 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.
@@ -1002,7 +1109,10 @@ class AutotagRoutesMixin:
else:
prompt = data.get("prompt")
template = prompt if isinstance(prompt, str) and prompt.strip() else None
options = {"instructions": template or AutotagConfig.TEMPLATE}
options = {
"instructions": template
or AutotagConfig.default_instructions_text(model_type)
}
use_gpu = data.get("use_gpu", True)
options["use_gpu"] = use_gpu if isinstance(use_gpu, bool) else True
return validate_options(
@@ -1096,6 +1206,8 @@ 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
@@ -1112,6 +1224,8 @@ 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)
@@ -1151,6 +1265,7 @@ class AutotagRoutesMixin:
capture,
model_type,
options,
skip,
on_skipped=functools.partial(store.release_use, capture.token),
)
except MissingBackendPackages as exc:
@@ -1371,21 +1486,23 @@ 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()
has_thumbnails = (output_path / "thumbnails").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,
+1 -4
View File
@@ -843,10 +843,7 @@ class HistoryRoutesMixin:
raise
async def execute():
# An inspection reads the file's header and text: the
# interactive image pool (#523); media stays on the default.
run = self._run_image if kind == "inspection" else self._run_in_executor
pending = asyncio.create_task(run(project))
pending = asyncio.create_task(self._run_in_executor(project))
try:
return await asyncio.shield(pending)
except asyncio.CancelledError:
+15 -40
View File
@@ -15,10 +15,10 @@ from typing import BinaryIO
from aiohttp import web
from . import preview_store, thumbnail_settings
from . import thumbnail_settings
from .images import _canonical, _public_path, SERVABLE_EXTENSIONS, VIDEO_EXTENSIONS
from .images import in_trash_below_roots
from .images import existing_media_thumbnail, media_preview_path
from .images import existing_media_thumbnail, media_thumbnail_rel
try:
from ...utils.execution_outputs import (
@@ -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")
@@ -343,10 +326,12 @@ class _OpenedCurrentMedia:
def _preview_writable(root: Path, preview: Path) -> bool:
"""A preview may be written: the store of *root* and every link segment
of the preview's folder resolve inside it (the serve route's rule, #249;
``preview_store.owns``)."""
return preview_store.owns(root, preview)
"""A preview may be written: ``thumbnails`` and every link segment of the
preview's folder resolve inside *root* (the serve route's rule, #249)."""
folder = root / "thumbnails"
return _canonical(folder).is_relative_to(_canonical(root)) and _canonical(
preview
).is_relative_to(_canonical(folder))
def _is_jxl(path) -> bool:
@@ -396,12 +381,8 @@ 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)
if (
ensure_preview is not None
and preview is not None
and _preview_writable(resolved.gallery_root, preview)
):
preview = resolved.gallery_root / media_thumbnail_rel(relative)
if ensure_preview is not None and _preview_writable(resolved.gallery_root, preview):
ensure_preview(target, preview, is_video)
if is_video and placeholder is not None:
return _placeholder_media(placeholder)
@@ -437,13 +418,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,
+40 -4
View File
@@ -39,6 +39,7 @@ 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,
@@ -170,6 +171,8 @@ 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)
@@ -183,6 +186,9 @@ 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:
@@ -238,7 +244,11 @@ def _parse_auto(targets, data):
)
except OptionError as exc:
return None, bad_request(str(exc))
return _Spec("auto", targets, model_type=model_type, options=options), None
skip = tuple(AutotagConfig.SKIPPED_TAGS)
return (
_Spec("auto", targets, model_type=model_type, options=options, skip=skip),
None,
)
def parse_image_tag_request(data: dict):
@@ -329,6 +339,8 @@ 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)
@@ -400,6 +412,14 @@ 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
@@ -507,6 +527,7 @@ 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)
@@ -529,7 +550,13 @@ 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
model_type,
existing,
options,
keep_in_memory,
backup_on,
owner,
skip=tuple(AutotagConfig.SKIPPED_TAGS),
)
admitted = False
try:
@@ -598,7 +625,9 @@ 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)
spec = _Spec(
"auto", (), model_type=run.model_type, options=run.options, skip=run.skip
)
service = await self._prepare_tagging_model(job, spec, run.owner)
if service is None:
return "failed"
@@ -624,6 +653,7 @@ 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")
@@ -633,7 +663,9 @@ class ImageTagJobsMixin:
spec = dataclasses.replace(
spec, targets=tuple(item.target for item in remaining)
)
loop_run = _Run(job, spec, epoch, run.owner, service)
loop_run = _Run(
job, spec, epoch, run.owner, service, skip=SkipMatcher(spec.skip)
)
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}")
@@ -739,6 +771,9 @@ 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)
@@ -894,6 +929,7 @@ class ImageTagJobsMixin:
capture,
spec.model_type,
spec.options,
run.skip,
on_skipped=functools.partial(store.release_use, capture.token),
)
except Exception:
+31 -204
View File
@@ -34,8 +34,7 @@ from .gallery_listing import (
WalkOutcome,
walk_root,
)
from . import preview_store, thumbnail_settings
from .thumbnail_location import location_refusal
from . import thumbnail_settings
from .thumbnail_encode import (
PREVIEW_HELD,
EncoderClosed,
@@ -1069,24 +1068,13 @@ def _iter_media_files(output_path, max_files):
def media_thumbnail_rel(rel_path, settings=None):
"""``thumbnails/<rel>_thumb<ext>`` (POSIX URL text) for the media file
*rel_path*: the name the preview gets when it is written now (#317),
images and videos alike (``writer_settings``' extension). Where it is on
disk is ``media_preview_path``."""
"""``thumbnails/<rel>_thumb<ext>`` (POSIX) for the media file *rel_path*:
the name the preview gets when it is written now (#317), images and
videos alike (``writer_settings``' extension)."""
settings = settings or writer_settings()
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)."""
settings = settings or writer_settings()
return preview_store.preview_path(
output_path, rel_path, settings.extension, create=create
)
def existing_media_thumbnail(output_path, rel_path):
"""The existing preview *rel_path* owns that a listing or cover shows
(the format written now first, then older ones), or None. The Images
@@ -1096,27 +1084,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
@@ -2831,56 +2798,13 @@ class ImageRoutesMixin:
output_dirs[0], filepath, int(root_param), len(outputs)
)
# Preview text is mapped by the resolver (#320); any other text,
# ``Thumbnails/...`` media on a case-sensitive disk included,
# keeps the plain join.
preview_parts = (
tuple(filepath.split("/"))
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:
image_path = preview_store.request_path(output_path, preview_parts)
if image_path is None:
continue
image_path = output_path / filepath
if not image_path.exists():
continue
# A symlink that points outside the root (a preview: outside
# the folder holding its store) is a hard deny, not a
# fall-through to the next root.
container = (
output_path
if preview_parts is None
else preview_store.store_container(output_path)
)
if not _is_within(image_path, container):
# A symlink that points outside the root is a hard deny, not
# a fall-through to the next root.
if not _is_within(image_path, output_path):
return _forbidden()
if in_trash_below_roots(image_path, output_dirs):
return _file_not_found()
@@ -2906,48 +2830,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 +2956,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,
@@ -3096,7 +2975,6 @@ class ImageRoutesMixin:
result = await self._run_in_executor(
self._generate_gallery_thumbnails_sync, output_dirs
)
await self._release_job_memory()
return web.json_response(result)
except Exception as e:
@@ -3109,7 +2987,6 @@ class ImageRoutesMixin:
"""Blocking thumbnail generation loop (run in executor)."""
settings = writer_settings(settings)
start = _time.monotonic()
thumbnails_dir = preview_store.given_store(output_path, thumbnails_dir)
plan = self._plan_thumbnails(output_path, thumbnails_dir, settings=settings)
payload = self._execute_thumbnail_plan(plan, settings, start)
payload["thumbnails_path"] = _public_path(thumbnails_dir)
@@ -3122,8 +2999,7 @@ class ImageRoutesMixin:
start = _time.monotonic()
plan = self._plan_gallery_thumbnails(output_dirs, settings=settings)
payload = self._execute_thumbnail_plan(plan, settings, start)
store = preview_store.store_dir(output_dirs[0])
payload["thumbnails_path"] = None if store is None else _public_path(store)
payload["thumbnails_path"] = _public_path(output_dirs[0] / "thumbnails")
return payload
def _execute_thumbnail_plan(self, plan: dict, settings, start: float) -> dict:
@@ -3166,12 +3042,9 @@ class ImageRoutesMixin:
if stop is not None and stop():
break
root = Path(root)
store = preview_store.ensure_store(root)
if store is None:
continue # the thumbnail folder is unavailable
plan = self._plan_thumbnails(
root,
store,
root / "thumbnails",
mode=mode,
stop=stop,
settings=settings,
@@ -3210,8 +3083,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
],
@@ -3257,11 +3128,9 @@ class ImageRoutesMixin:
"""
settings = writer_settings(settings)
output_path = Path(output_path)
thumbnails_dir = preview_store.given_store(output_path, thumbnails_dir)
thumbnails_dir = 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):
@@ -3351,9 +3220,6 @@ class ImageRoutesMixin:
"dir": src.parent.name,
"type": "video" if is_video else "image",
}
# Taken when the write starts: a thumbnail location switched since
# then drops the write at its rename (#320).
guard = preview_store.write_guard(guard)
dst = Path(dst)
if dst.suffix != settings.extension:
dst = dst.with_suffix(settings.extension)
@@ -3364,7 +3230,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 +3435,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,
@@ -3638,12 +3500,8 @@ class ImageRoutesMixin:
self.logger.debug(f"No roots to sweep for preview temp files: {exc}")
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()
):
folder = Path(output_path) / "thumbnails"
if folder.is_symlink() or not folder.is_dir():
continue
removed += sweep_stray_temps(folder)[0]
if removed:
@@ -3661,16 +3519,13 @@ class ImageRoutesMixin:
directories = []
for output_path in output_dirs:
output_path = Path(output_path)
thumbnails_dir = preview_store.store_dir(output_path)
if thumbnails_dir is None:
continue
thumbnails_dir = output_path / "thumbnails"
if not thumbnails_dir.exists() and not thumbnails_dir.is_symlink():
continue
container = preview_store.store_container(output_path)
if (
preview_store.store_is_link(thumbnails_dir)
or not _is_within(thumbnails_dir, container)
or _canonical(thumbnails_dir) == _canonical(container)
thumbnails_dir.is_symlink()
or not _is_within(thumbnails_dir, output_path)
or _canonical(thumbnails_dir) == _canonical(output_path)
):
raise ValueError(
"Safety check failed: invalid thumbnails directory path"
@@ -3685,15 +3540,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 +3548,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:
@@ -4151,8 +3997,7 @@ class ImageRoutesMixin:
**observed,
)
# Header and text reads: the interactive image pool (#523).
body = await self._run_image(inspect)
body = await self._run_in_executor(inspect)
if body is None:
return web.json_response(
{
@@ -4365,22 +4210,6 @@ class ImageRoutesMixin:
# (which Windows refuses on open files) is not held up by it.
return _CardFacts(width, height, *self._card_parse(path, records))
def _cached_card_facts(self, entry: tuple) -> _CardFacts | None:
path, mtime, root, index = entry[:4]
return self._workspace_card_cache.get((index, str(root)), {}).get((path, mtime))
def _card_facts_of(self, entries: list, *, parse: bool) -> list[_CardFacts]:
"""Card facts of *entries*, in order; files not cached yet are read on
the interactive image pool (#523), cached ones answer here."""
found = [self._cached_card_facts(entry) for entry in entries]
missing = [i for i, facts in enumerate(found) if facts is None]
read = self._map_image(
lambda i: self._card_facts(entries[i], parse=parse), missing
)
for i, facts in zip(missing, read):
found[i] = facts
return found
def _card_facts(self, entry: tuple, *, parse: bool) -> _CardFacts:
"""One listed image's card facts, read once per ``(path, mtime)``.
@@ -4389,11 +4218,11 @@ class ImageRoutesMixin:
and drops the rest, and a roots change drops them all. Only files of
the current scan are stored, so the cache is bounded by the listing.
"""
cached = self._cached_card_facts(entry)
if cached is not None:
return cached
path, mtime, root, index = entry[:4]
root_key, key = (index, str(root)), (path, mtime)
cached = self._workspace_card_cache.get(root_key, {}).get(key)
if cached is not None:
return cached
facts = self._read_card_facts(path, parse=parse)
if facts is None:
# Not stored: a locked or half-written file may read next time.
@@ -4419,17 +4248,15 @@ class ImageRoutesMixin:
if linked
else {}
)
# Videos: the card reads no video metadata. Library previews show
# no card facts, so they skip the parse (``parse=linked``).
stills = [image for image in images if not image["is_video"]]
read = self._card_facts_of(
[entries[(image["root_index"], image["path"])] for image in stills],
parse=linked,
)
facts_of = {id(image): facts for image, facts in zip(stills, read)}
for image in images:
entry = entries[(image["root_index"], image["path"])]
facts = facts_of.get(id(image), _NO_CARD_FACTS)
# Videos: the card reads no video metadata. Library previews show
# no card facts, so they skip the parse (``parse=linked``).
facts = (
_NO_CARD_FACTS
if image["is_video"]
else self._card_facts(entry, parse=linked)
)
image["width"], image["height"] = facts.width, facts.height
if not linked:
continue
-13
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,16 +74,6 @@ def _failure(message, status, code):
)
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')}"
@@ -283,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
-643
View File
@@ -1,643 +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 unicodedata
import uuid
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
# 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-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.",
),
"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
def write_marker(folder, mutate):
"""Re-read the marker, apply ``mutate(marker)`` and write it atomically
(a temp sibling renamed over it). Callers hold ``preview_store.write_lock``.
Returns the marker written."""
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: # handed to 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"
# 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
-671
View File
@@ -1,671 +0,0 @@
"""Where previews live on disk (#320): the one resolver.
Every mapping between an image source (a "root"), one of its media files and
that file's preview on disk goes through this module. URL text stays in
``thumbnail_settings`` (``thumbnail_rel``: ``thumbnails/<rel>_thumb<ext>``,
the shape the serve route, ``inspection-state.mjs`` and ``admin.js`` share);
this module answers where such a preview is on disk.
The store of a source is where its previews live. With the default location
(mode ``source``, "Inside each source") it is ``<source>/thumbnails``, exactly
the folder every caller joined by hand before, so nothing moves.
Writes and the switch: every preview writer takes ``write_guard`` when it
starts (it records the location's ``generation``); the final rename runs
under ``write_lock`` and only when the generation is unchanged and no hold
applies (``thumbnail_encode.replace_unless_held``). Changing the location
bumps the generation under the same lock, so no write that began before can
land in the old store afterwards. Nothing reads or commits config while
``write_lock`` is held. The hold check runs under it too, so hold checks take
no lock (the job registry's ``running()`` is a lock-free snapshot) and
``bump_generation`` is never called with a registry or config lock held.
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 pathlib import Path
from . import preview_location, 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"
@dataclass(frozen=True)
class Location:
"""One snapshot of the thumbnail location (take one per request or job)."""
mode: str = SOURCE_MODE
path: str | None = None
available: bool = True
problem: str | None = None
generation: int = 0
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
_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
def generation():
return _generation
def bump_generation():
"""Invalidate every write that started before (under ``write_lock``)."""
global _generation
with write_lock:
_generation += 1
return _generation
def _canonical(path):
"""Resolved and case-folded, so paths compare on every OS."""
return Path(os.path.normcase(os.path.realpath(str(path))))
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 = _probe.check(parsed.path, parsed.id)
except (OSError, TimeoutError, RuntimeError):
available, problem, marker = False, "unreachable", None
_set_state(parsed, available, problem, marker)
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 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):
global _available, _problem
with _state_lock:
if _parsed is not parsed:
return # the setting changed meanwhile
_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
def check(self, path, expected_id):
"""``folder_state`` within IO_BUDGET; raises TimeoutError. A check
already running (the probe's, or a stuck one) is joined, never run
twice at once."""
with self._lock:
pending = self._checking
if pending is None or pending[0] != (path, expected_id):
pending = self._start_check(path, expected_id)
key, done, result = pending
if not done.wait(IO_BUDGET):
raise TimeoutError("the thumbnail folder did not answer in time")
with self._lock:
if self._checking is pending:
self._checking = None
if "error" in result:
raise result["error"]
return result["value"]
def _start_check(self, path, expected_id):
"""Start one check on its own thread (lock held)."""
result = {}
done = threading.Event()
def run():
try:
result["value"] = preview_location.folder_state(path, expected_id)
except BaseException as error: # handed to 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
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)
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."""
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
if create:
try:
with store_io():
os.mkdir(store)
except FileExistsError:
pass
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
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 given_store(root, store):
"""A caller's explicit store argument: the default spelling
(``<root>/thumbnails``) means the resolver's store; another folder (only
tests pass one) is used as given."""
store = Path(store)
if store == Path(root) / PREVIEW_FOLDER:
return store_dir(root)
return store
def preview_path(root, media_rel, ext=None, location=None, *, create=False):
"""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)
if store is None:
return None
if ext is None:
from .thumbnail_encode import writer_settings
ext = writer_settings().extension
return store / (thumbnail_settings.preview_stem(media_rel) + ext)
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 = tuple(parts)
if len(parts) < 2 or parts[0] != PREVIEW_FOLDER:
return None
store = store_dir(root, location)
if store is None:
return 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()
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)
# ── preview text ─────────────────────────────────────────────────────
def is_preview_text(filepath):
"""URL text naming a preview: ``thumbnails/<...>/<name>_thumb<ext>``.
Text only, before any join: no backslash, drive or stream colon, and no
empty, ``.`` or ``..`` segment; ``thumbnails`` exactly as the first part
and nowhere else among the folders; the extension, in any case, one a
preview may carry (``X_thumb.JPG`` of an older release included).
"""
if not filepath or "\\" in filepath or ":" in filepath:
return False
parts = filepath.split("/")
if len(parts) < 2 or parts[0] != PREVIEW_FOLDER:
return False
if any(part in ("", ".", "..") for part in parts):
return False
if PREVIEW_FOLDER in parts[1:-1]:
return False
stem, extension = os.path.splitext(parts[-1])
return (
stem.endswith(thumbnail_settings.THUMB_SUFFIX)
and len(stem) > len(thumbnail_settings.THUMB_SUFFIX)
and extension.lower() in thumbnail_settings.THUMBNAIL_EXTENSIONS
)
# ── writes ───────────────────────────────────────────────────────────
class _WriteGuard:
"""``guard()`` for one preview write: true while the location has not
changed since the write started and *hold* (if any) allows it."""
__slots__ = ("generation", "_hold")
def __init__(self, hold=None):
self.generation = _generation
self._hold = hold
def __call__(self):
if self.generation != _generation:
return False
return self._hold is None or bool(self._hold())
def write_guard(hold=None):
"""Take when a preview write starts; hand to its final rename."""
if isinstance(hold, _WriteGuard):
return hold
return _WriteGuard(hold)
@contextmanager
def committed(guard=None):
"""Around a preview's final rename: holds ``write_lock`` and yields
whether the write may land (``guard()``, re-checked under the lock)."""
with write_lock:
yield guard is None or bool(guard())
+114 -23
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,9 +54,25 @@ _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.
@@ -138,13 +153,20 @@ 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.
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.
"""
def mutate(config):
for name, value in updates.items():
*sections, key = _SETTINGS_FILE_KEYS[name]
_set_nested(config, sections, key, value)
_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)
if new_roots is not None:
gallery = _section(config, "gallery")
monitoring = _section(gallery, "monitoring")
@@ -156,10 +178,37 @@ 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."""
"""``config[s1][s2]...[key] = value``, each section a fresh dict;
``_DELETE`` removes the key."""
if not sections:
config[key] = value
if value is _DELETE:
config.pop(key, None)
else:
config[key] = value
return
merged = _section(config, sections[0])
_set_nested(merged, sections[1:], key, value)
@@ -177,7 +226,7 @@ def _validate_autotag_settings(data):
MAX_SUGGESTION_LIMIT,
MODEL_TYPES,
validate_saved_threshold,
validate_template,
validate_skipped_tags,
)
updates = {}
@@ -206,12 +255,7 @@ def _validate_autotag_settings(data):
raise ValueError(f"{error}.") from None
updates[key.upper()] = value
if "autotag_template" in data:
try:
updates["AUTOTAG_TEMPLATE"] = validate_template(
data["autotag_template"], "LLM tag template"
)
except ValueError as error:
raise ValueError(f"{error}.") from None
raise ValueError(TEMPLATE_REPLACED_ERROR)
if "autotag_suggestion_limit" in data:
value = data["autotag_suggestion_limit"]
if type(value) is not int or not 1 <= value <= MAX_SUGGESTION_LIMIT:
@@ -220,9 +264,41 @@ 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
@@ -250,6 +326,7 @@ 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
@@ -265,12 +342,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,
@@ -288,8 +359,21 @@ class SettingsRoutesMixin:
"autotag_character_threshold": (
AutotagConfig.CHARACTER_THRESHOLD
),
"autotag_template": AutotagConfig.TEMPLATE,
# 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_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 "",
@@ -384,15 +468,16 @@ 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:
@@ -521,6 +606,12 @@ 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):
+48 -188
View File
@@ -8,8 +8,7 @@ previews behind those URLs are made here, in three ways:
- **On first view.** ``GET /prompt_manager/images/serve/thumbnails/<rel>_thumb
<ext>`` for a preview that does not exist yet is admitted on the event loop
(``_PreviewGate``): at most ``ON_DEMAND_LIMIT`` previews are written at once,
on this API's interactive image pool (``IMAGE_POOL_SIZE`` threads, shared
with card facts and inspector reads; never the default executor), one flight runs
on this API's own small pool (never the default executor), one flight runs
per file, and the requests waiting are capped (``MAX_WAITING``); past the
cap a request gets its fallback at once. Inside the admission the preview is
mapped back to its media file under the same root (the request text is
@@ -25,8 +24,7 @@ previews behind those URLs are made here, in three ways:
- **On capture.** The image monitor hands every new output it settles to
``queue_capture_thumbnail``, which queues its preview for one background
worker thread (``AutoThumbnailer``). Each wake takes at most Worker threads
(``WORKER_THREADS``) files and writes them on the long-lived job pool
(``utils.parallel``, shared with scans and thumbnail jobs); enqueuing
(``WORKER_THREADS``) files and writes them on that many threads; enqueuing
is O(1), so the monitor and ComfyUI's queue never wait. A file already
queued is not queued again, the queue is bounded (``MAX_QUEUED``) and a
preview left out is made on its first view instead.
@@ -51,7 +49,7 @@ write starts (after its slot) and again just before its rename.
Shutdown: ``close_auto_thumbnails`` stops the worker at a file boundary,
kills a running ffmpeg, waits a bounded time (and logs when the wait ran
out) and shuts the image pool down; after the app's cleanup
out) and shuts the view pool down; after the app's cleanup
(``final=True``) nothing starts a worker again. Importing this module again
(a package reload) stops the workers the previous import left.
"""
@@ -62,12 +60,12 @@ import functools
import io
import os
import threading
from concurrent.futures import CancelledError, ThreadPoolExecutor
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from aiohttp import web
from . import preview_store, thumbnail_settings
from . import thumbnail_settings
from .images import (
in_trash_below_roots,
MEDIA_EXTENSIONS,
@@ -78,7 +76,7 @@ from .images import (
_thumbnail_is_fresh,
_video_placeholder_image,
_worker_threads,
media_preview_path,
media_thumbnail_rel,
)
from .jobs import (
DUPLICATE_CLEANUP_JOB_KIND,
@@ -106,11 +104,8 @@ PLACEHOLDER_SIZE = (thumbnail_settings.DEFAULT_EDGE, thumbnail_settings.DEFAULT_
PLACEHOLDER_QUALITY = 85
# Source extensions in the order a shared preview name looks for them (R2).
_SOURCE_ORDER = thumbnail_settings.IMAGE_SOURCE_ORDER + tuple(sorted(VIDEO_EXTENSIONS))
# Threads of the interactive image pool (#523): view previews, card facts and
# inspector reads. Fixed, so image buffers stay in a few threads' arenas.
IMAGE_POOL_SIZE = 3
# Previews views may write at once (the whole image pool).
ON_DEMAND_LIMIT = IMAGE_POOL_SIZE
# Previews views may write at once (the size of the view pool).
ON_DEMAND_LIMIT = 3
# View requests that may wait on the gate; one more gets its fallback at once.
MAX_WAITING = 64
# How long a pool thread waits for the capture worker's flight of the same file.
@@ -129,7 +124,7 @@ AUTO_REFUSED_KINDS = frozenset(
{RESTORE_JOB_KIND, DUPLICATE_CLEANUP_JOB_KIND, SOURCE_CHANGE_JOB_KIND}
)
WORKER_NAME = "PromptManagerThumbnailWorker"
POOL_PREFIX = "pm-image"
POOL_PREFIX = "pm-preview"
# Identifies this import's workers: a reload stops the ones it did not start.
_MODULE_TOKEN = object()
@@ -158,36 +153,6 @@ def _dst_key(dst):
return flight_key(Path(dst).parent, (Path(dst).name,))
class DeferredPreview:
"""The preview of *rel* under *root*, resolved when its write starts
(#320): a queued capture follows the thumbnail location at write time,
not the one when it was queued."""
__slots__ = ("root", "rel", "strict")
def __init__(self, root, rel, *, strict=True):
self.root = Path(root)
self.rel = Path(rel)
# A capture refuses a store linked out of its container (the serve
# route's rule); a planned target keeps the plan's rule: inside the
# store, as the thumbnail job writes it.
self.strict = strict
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)
if dst is None:
return None
if self.strict:
return dst if preview_store.owns(self.root, dst) else None
store = preview_store.store_dir(self.root)
return dst if _is_within(dst, store) else None
def _resolved(dst):
return dst.resolve() if isinstance(dst, DeferredPreview) else Path(dst)
@functools.lru_cache(maxsize=1)
def video_placeholder_jpeg():
"""The placeholder frame as JPEG bytes (a video whose preview is missing)."""
@@ -245,19 +210,10 @@ def _candidates(parts):
def _current_preview(root, parts):
"""The preview *parts* names, in the format written now (#317): a request
for an older name (``_thumb.png`` before a conversion) is answered with
the current file. None when the store of *root* has no place for it."""
the current file."""
_folder, base, _wanted = _candidates(parts)
name = base + thumbnail_settings.THUMB_SUFFIX + writer_settings().extension
return preview_store.request_path(root, (*parts[:-1], name))
def _request_key(root, parts):
"""The flight key of the preview *parts* under *root*: the key of its
disk path (so the serve and history routes share one flight)."""
path = preview_store.request_path(root, parts)
if path is None:
return flight_key(root, parts)
return _dst_key(path)
return Path(root).joinpath(*parts[:-1], name)
def _quick_source(root, parts):
@@ -298,14 +254,12 @@ 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
dst = root / media_thumbnail_rel(rel)
if dst != _current_preview(root, parts):
return None
if not preview_store.owns(root, dst):
if not _is_within(root / "thumbnails", root) or not _is_within(
dst, root / "thumbnails"
):
dst = None
return found, dst, found.suffix.lower() in VIDEO_EXTENSIONS
@@ -325,21 +279,6 @@ def _scan_folder(folder, base, extensions):
return None
def _deferred(roots, src, dst):
"""The ``DeferredPreview`` of a planned ``(src, dst)``: the root whose
store the plan wrote *dst* into (nested roots plan one each); *dst* as
planned when none matches."""
src = Path(src)
for root in roots:
root = Path(root)
if not src.is_relative_to(root):
continue
rel = src.relative_to(root)
if media_preview_path(root, rel) == Path(dst):
return DeferredPreview(root, rel, strict=False)
return dst
def _stop_stale_workers():
"""Stop capture workers a previous import of this module started."""
for thread in threading.enumerate():
@@ -355,7 +294,7 @@ class AutoThumbnailer:
``generate(src, dst, is_video, *, stop, guard, track)`` writes one
preview and returns ``_generate_one``'s result dict; ``may_write()``
says whether automatic previews may be written now. Views call
``ensure`` on the image pool, the monitor calls ``enqueue`` from its
``ensure`` on the view pool, the monitor calls ``enqueue`` from its
thread.
"""
@@ -481,23 +420,16 @@ class AutoThumbnailer:
def enqueue(self, src, dst, is_video):
"""Queue *dst*'s preview for the worker; False when already queued,
full, closed, nowhere to write it or its worker is refused at
interpreter exit. Never blocks on a write. *dst* may be a
``DeferredPreview``: its path is resolved again when the write
starts."""
now = _resolved(dst)
if now is None:
return False
key = _dst_key(now)
if not isinstance(dst, DeferredPreview):
dst = now
full, closed or its worker is refused at interpreter exit. Never
blocks on a write."""
key = _dst_key(dst)
with self._changed:
if self._closed or key in self._queued:
return False
if len(self._queue) >= self._max_queued:
return False
self._queued.add(key)
self._queue.append((key, Path(src), dst, bool(is_video)))
self._queue.append((key, Path(src), Path(dst), bool(is_video)))
self._busy = True
started = True
if self._start_worker and not self._worker_running:
@@ -559,15 +491,12 @@ class AutoThumbnailer:
self._worker_running = False
self._changed.notify_all()
return
workers = max(1, int(self._workers() or 1))
take = min(len(self._queue), workers)
take = min(len(self._queue), max(1, int(self._workers() or 1)))
batch = [self._queue.popleft() for _ in range(take)]
self._batch = len(batch)
self._changed.notify_all()
try:
# The job pool's size is the setting; a smaller batch uses
# fewer of its threads (#523: one long-lived pool).
map_parallel(self._write_queued, batch, workers)
map_parallel(self._write_queued, batch, len(batch))
finally:
with self._changed:
self._batch = 0
@@ -577,9 +506,6 @@ class AutoThumbnailer:
def _write_queued(self, item):
_key, src, dst, is_video = item
try:
dst = _resolved(dst)
if dst is None:
return # nowhere to write it now: made on its first view
self.ensure(src, dst, is_video)
except Exception: # one file never stops the worker
self._logger.warning(
@@ -641,7 +567,7 @@ class _PreviewGate:
"""Admission of view requests on the event loop (#249 I2).
Waiters are coroutines: at most ``limit`` previews are written at once
on the image pool, one flight runs per key, and past ``MAX_WAITING``
on the view pool, one flight runs per key, and past ``MAX_WAITING``
waiting requests ``run`` answers ``OVER`` at once.
"""
@@ -696,17 +622,13 @@ class _PreviewGate:
flight.set_result(result)
def _on_image_thread():
return threading.current_thread().name.startswith(POOL_PREFIX + "_")
class AutoThumbnailMixin:
"""Automatic previews on the API: views, the monitor's queue, scans."""
_auto_thumbnailer = None
_auto_thumbnails_shut = False
_preview_gate_obj = None
_image_pool_obj = None
_preview_pool_obj = None
_auto_thumbnailer_lock = threading.Lock()
def _auto_thumbnails(self):
@@ -729,7 +651,7 @@ class AutoThumbnailMixin:
self._auto_thumbnails_shut = True
def close_auto_thumbnails(self, final=False, timeout=None):
"""Stop the capture worker and the image pool, and forget them.
"""Stop the capture worker and the view pool, and forget them.
Bounded by ``timeout`` (see AutoThumbnailer.close). ``final`` (the
package teardown) also keeps a late monitor event from starting a
@@ -740,7 +662,7 @@ class AutoThumbnailMixin:
if final:
self._auto_thumbnails_shut = True
thumbnailer, self._auto_thumbnailer = self._auto_thumbnailer, None
pool, self._image_pool_obj = self._image_pool_obj, None
pool, self._preview_pool_obj = self._preview_pool_obj, None
self._preview_gate_obj = None
closed = True
if thumbnailer is not None:
@@ -749,8 +671,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):
@@ -786,67 +706,16 @@ class AutoThumbnailMixin:
gate = self._preview_gate_obj = _PreviewGate(ON_DEMAND_LIMIT, self.logger)
return gate
def _image_pool(self):
"""The interactive image pool (#523): IMAGE_POOL_SIZE threads for view
previews (behind their gate), card facts and inspector reads, so
image buffers never spread over the default executor's threads.
None after the package teardown (``close_auto_thumbnails(final=True)``):
a late request then runs on the default executor (``run_in_executor``
with None) and starts no thread the teardown no longer stops.
"""
def _preview_pool(self):
with AutoThumbnailMixin._auto_thumbnailer_lock:
if self._image_pool_obj is None and not self._auto_thumbnails_shut:
self._image_pool_obj = ThreadPoolExecutor(
IMAGE_POOL_SIZE, thread_name_prefix=POOL_PREFIX
if self._preview_pool_obj is None:
self._preview_pool_obj = ThreadPoolExecutor(
ON_DEMAND_LIMIT, thread_name_prefix=POOL_PREFIX
)
return self._image_pool_obj
return self._preview_pool_obj
async def _run_image(self, func, *args, **kwargs):
"""``_run_in_executor`` for interactive image work: the image pool.
After the teardown, or with the pool shut down in between (a test
reset), the call runs on the default executor as before.
"""
call = functools.partial(func, *args, **kwargs)
loop = asyncio.get_running_loop()
try:
future = loop.run_in_executor(self._image_pool(), call)
except RuntimeError: # submit refused: the pool was shut down
future = loop.run_in_executor(None, call)
return await future
def _map_image(self, func, items):
"""``func`` over *items* on the image pool, in order (blocking).
Called from an image pool thread, after the teardown, or with the
pool shut down in between (its queued items are cancelled), the
items it did not run run on the calling thread.
"""
items = list(items)
pool = None if not items or _on_image_thread() else self._image_pool()
if pool is None:
return [func(item) for item in items]
futures = []
try:
for item in items:
futures.append(pool.submit(func, item))
except RuntimeError:
pass # shut down in between: the rest run here
results = []
for index, item in enumerate(items):
future = futures[index] if index < len(futures) else None
if future is None or future.cancelled():
results.append(func(item))
continue
try:
results.append(future.result())
except CancelledError: # cancelled while this caller waited
results.append(func(item))
return results
def _image_pool_threads(self):
pool = self._image_pool_obj
def _preview_pool_threads(self):
pool = self._preview_pool_obj
return [] if pool is None else list(getattr(pool, "_threads", ()))
def _video_placeholder_bytes(self):
@@ -861,7 +730,7 @@ class AutoThumbnailMixin:
return await self._preview_gate().run(
flight_key(Path(dst).parent, (Path(dst).name,)),
functools.partial(self._ensure_view, src, dst, is_video),
self._image_pool(),
self._preview_pool(),
)
def _ensure_view(self, src, dst, is_video):
@@ -905,17 +774,17 @@ class AutoThumbnailMixin:
The flight answers only the outcome (a history flight may be the one
joined); the file it previews is derived here: the preview path is
the request's own, and a fallback looks its source up again on the
image pool (bounded by it, never the default executor).
view pool (bounded by it, never the default executor).
"""
if _has_traversal(filepath) or not _is_preview_request(filepath):
return None
parts = tuple(filepath.split("/"))
gate = self._preview_gate()
pool = self._image_pool()
pool = self._preview_pool()
for root in output_dirs:
root = Path(root)
outcome = await gate.run(
_request_key(root, parts),
flight_key(root, parts),
functools.partial(self._preview_on_demand, root, parts),
pool,
)
@@ -928,7 +797,7 @@ class AutoThumbnailMixin:
continue
return self._preview_response(BUSY, quick[0], None, quick[1])
dst = _current_preview(root, parts)
if outcome == READY and dst is not None:
if outcome == READY:
return self._file_response(dst)
located = await gate.loop.run_in_executor(
pool, _thumbnail_source, root, parts
@@ -951,31 +820,17 @@ class AutoThumbnailMixin:
"""
if self._auto_thumbnails_shut:
return "skipped"
located = self._capture_source(image_path)
located = self._capture_target(image_path)
if located is None:
return "skipped"
src, root, rel, is_video = located
dst = DeferredPreview(root, rel).resolve()
if dst is None:
return "skipped"
src, dst, is_video = located
if _thumbnail_is_fresh(src, dst):
return "fresh"
if self._auto_thumbnails().enqueue(src, DeferredPreview(root, rel), is_video):
if self._auto_thumbnails().enqueue(src, dst, is_video):
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."""
path = Path(os.path.abspath(str(image_path)))
if path.suffix.lower() not in MEDIA_EXTENSIONS or not _regular_file(path):
return None
@@ -988,7 +843,12 @@ class AutoThumbnailMixin:
rel = Path(os.path.relpath(path, root))
if "thumbnails" in rel.parts[:-1] or in_trash_below_roots(path, [root]):
return None
return path, root, rel, path.suffix.lower() in VIDEO_EXTENSIONS
dst = root / media_thumbnail_rel(rel)
if not _is_within(root / "thumbnails", root) or not _is_within(
dst, root / "thumbnails"
):
return None
return path, dst, path.suffix.lower() in VIDEO_EXTENSIONS
return None
# ── after a scan ──────────────────────────────────────────────────
@@ -1030,7 +890,7 @@ class AutoThumbnailMixin:
return "none"
thumbnailer = self._auto_thumbnails()
for src, dst, is_video in plan["targets"]:
thumbnailer.enqueue(src, _deferred(output_dirs, src, dst), is_video)
thumbnailer.enqueue(src, dst, is_video)
self.logger.info(f"{missing:,} previews missing after the scan; queued")
return "queued"
-1
View File
@@ -153,7 +153,6 @@ class ThumbnailConvertMixin:
job.log_line("Conversion failed · internal error")
state = "failed"
job.finish(state)
await self._release_job_memory()
async def _convert_job_body(self, job, options):
output_dirs = await self._run_in_executor(self._get_all_output_dirs)
+5 -10
View File
@@ -46,7 +46,7 @@ from pathlib import Path
from PIL import Image
from . import preview_store, thumbnail_settings
from . import thumbnail_settings
try:
from ...utils.logging_config import get_logger
@@ -101,15 +101,10 @@ def temp_sibling(dst, tag="part"):
def replace_unless_held(tmp, dst, guard=None):
"""Rename *tmp* over *dst*, unless ``guard()`` is false (PreviewWriteHeld).
The check and the rename run under ``preview_store.write_lock`` (#320): a
guard from ``preview_store.write_guard`` also refuses a write that started
before the thumbnail location changed."""
with preview_store.committed(guard) as allowed:
if not allowed:
raise PreviewWriteHeld(PREVIEW_HELD)
os.replace(tmp, dst)
"""Rename *tmp* over *dst*, unless ``guard()`` is false (PreviewWriteHeld)."""
if guard is not None and not guard():
raise PreviewWriteHeld(PREVIEW_HELD)
os.replace(tmp, dst)
# ``temp_sibling``'s names: ``.<stem>.<tag>-<pid>-<thread id><ext>``.
+1 -10
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
@@ -183,9 +182,6 @@ class ThumbnailJobsMixin:
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:
@@ -230,7 +226,6 @@ class ThumbnailJobsMixin:
)
state = "failed"
job.finish(state)
await self._release_job_memory()
async def _thumbnail_job_body(self, job, options):
"""Plan, then generate in worker batches; returns the final state.
@@ -463,12 +458,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={
-160
View File
@@ -1,160 +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
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()
return payload
+4 -22
View File
@@ -114,16 +114,9 @@ def validate_edge(value):
# ── names ────────────────────────────────────────────────────────────
def preview_stem(rel_path):
"""``<rel without extension>_thumb`` (POSIX): a preview's name inside the
store of its source (``preview_store``)."""
return f"{Path(rel_path).with_suffix('').as_posix()}{THUMB_SUFFIX}"
def thumbnail_stem_rel(rel_path):
"""``thumbnails/<rel without extension>_thumb`` (POSIX): URL text only.
Where the preview is on disk is ``preview_store``'s answer."""
return f"thumbnails/{preview_stem(rel_path)}"
"""``thumbnails/<rel without extension>_thumb`` (POSIX)."""
return f"thumbnails/{Path(rel_path).with_suffix('').as_posix()}{THUMB_SUFFIX}"
def thumbnail_rel(rel_path, extension=None):
@@ -214,20 +207,9 @@ def owned_in_folder(folder, stem, source_extension, preferred=None):
def _owned(root, rel_path, preferred):
from . import preview_store
rel_path = Path(rel_path)
store = preview_store.store_dir(root)
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)])
stem = Path(root) / thumbnail_stem_rel(rel_path)
return owned_in_folder(stem.parent, stem.name, rel_path.suffix, preferred)
def owned_thumbnails(root, rel_path, preferred=None):
+10 -10
View File
@@ -30,9 +30,9 @@ except ImportError:
# The tag template and thresholds live with the saved tagging defaults
# (#328), so config.py never imports PIL.
try:
from .tagging_defaults import DEFAULT_TAG_TEMPLATE
from .tagging_defaults import DEFAULT_TAG_TEMPLATE, read_wd14_label_rows
except ImportError:
from py.tagging_defaults import DEFAULT_TAG_TEMPLATE
from py.tagging_defaults import DEFAULT_TAG_TEMPLATE, read_wd14_label_rows
try:
from ..utils import exit_signal
@@ -559,7 +559,6 @@ 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"]
@@ -576,12 +575,8 @@ class AutoTagService:
session = ort.InferenceSession(str(model_path), providers=providers)
# 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)
# Read tag list from CSV (the reader the Skipped tags preview shares)
tags_list = read_wd14_label_rows(tags_path)
self.logger.info(
f"WD14 model loaded ({len(tags_list)} tags, "
@@ -992,6 +987,7 @@ 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.
@@ -1000,7 +996,9 @@ 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. ``prompt`` is used for this call only;
``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;
``custom_prompt`` is never changed.
"""
if type(limit) is not int or not 1 <= limit <= MAX_SUGGESTION_LIMIT:
@@ -1017,6 +1015,8 @@ 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
+184 -24
View File
@@ -30,6 +30,7 @@ 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
@@ -587,8 +588,15 @@ 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
@@ -600,8 +608,15 @@ 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,
)
@@ -617,6 +632,31 @@ 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}")
@@ -637,8 +677,18 @@ 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 = {
@@ -654,12 +704,13 @@ 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
@@ -672,17 +723,142 @@ class AutotagConfig:
type(value) is int and 1 <= value <= MAX_SUGGESTION_LIMIT
) # bool, float and str are rejected
@classmethod
def all_instructions(cls) -> List[Dict[str, str]]:
"""Built-in first, then the saved entries (copies)."""
return [builtin_instructions()] + [dict(item) for item in cls.INSTRUCTIONS]
@classmethod
def default_instructions_text(cls, engine: str) -> str:
"""The text of ``engine``'s (``gguf`` or ``hf``) default instructions."""
if engine not in INSTRUCTION_ENGINES:
raise ValueError(f"{engine} takes no instructions")
wanted = cls.DEFAULT_INSTRUCTIONS.get(engine, BUILTIN_INSTRUCTIONS_ID)
for item in cls.INSTRUCTIONS:
if item["id"] == wanted:
return item["text"]
return DEFAULT_TAG_TEMPLATE
@classmethod
def mirror_template(cls) -> Optional[str]:
"""What an older build reads as its one template (#431 B1.1): the
GGUF default's text, or None (no key) when that default is Built-in."""
if cls.DEFAULT_INSTRUCTIONS.get("gguf") == BUILTIN_INSTRUCTIONS_ID:
return None
return cls.default_instructions_text("gguf")
@classmethod
def get_config(cls) -> Dict[str, Any]:
"""The saved defaults; the template only when it is not the built-in
one, so a later built-in template still reaches who never set one."""
"""The saved defaults; ``template`` only as the GGUF default's mirror."""
defaults = {
key: getattr(cls, attr) for key, (attr, _v, _d) in cls._KEYS.items()
}
if defaults["template"] == DEFAULT_TAG_TEMPLATE:
del defaults["template"]
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
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):
@@ -698,12 +874,9 @@ class AutotagConfig:
try:
setattr(cls, attr, validate(defaults[key]))
except ValueError as error:
config_logger.warning(
f"autotag.defaults.{key} {error}; using {default!r}"
if key != "template"
else f"autotag.defaults.template {error}; using the built-in one"
)
config_logger.warning(_fallback_warning(key, error, default))
setattr(cls, attr, default)
cls._load_instructions(defaults)
class PromptManagerConfig:
@@ -767,8 +940,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:
@@ -830,12 +1001,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(),
@@ -1010,11 +1175,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
+233 -1
View File
@@ -4,8 +4,13 @@ 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
from typing import Any
import os
import re
import threading
import unicodedata
from typing import Any, Dict, Iterable, List, Set, Tuple
# The tagging engines (``py/autotag.py`` MODELS) and the options they take.
WD14_MODEL_TYPES = frozenset({"wd14-swinv2", "wd14-vit"})
@@ -88,3 +93,230 @@ 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}
-41
View File
@@ -320,44 +320,3 @@ test('copy toast and fallback dialog',async({page})=>{
await expect(fallback).toContainText('Automatic clipboard access requires a secure browser context.');
await page.keyboard.press('Escape');await expect(fallback).toBeHidden();await expect(copy).toBeFocused();
});
// #542 D191: "All N eligible nodes" previews every node, sends one after another and names each refusal.
test('All eligible nodes previews each node, sends in node id order and names a refusal',async({page,context},testInfo)=>{
const host=await context.newPage();await host.goto('/prompt_manager/workspace#/overview');
await host.evaluate(async()=>{
const {createBridgeHost}=await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');
const targets=['12','3','7'].map(id=>({id,label:id==='7'?'Edited in ComfyUI':'Prompt Manager (+/-)',type:'PromptManagerPair',roles:['positive','negative']}));
window.hostValues=Object.fromEntries(targets.map(target=>[target.id,{positive:`Existing ${target.id}`,negative:'Existing negative'}]));window.applied=[];
window.fixtureHost=createBridgeHost({origin:location.origin,namespace:'/',label:'Test ComfyUI',adapter:{
listTargets:()=>targets,
preview:(id,roles)=>{const target=targets.find(item=>item.id===id);return{preview:{target,fields:Object.entries(roles).map(([role,after])=>({role,before:window.hostValues[id][role],after}))},snapshot:{id,roles}};},
apply:({id,roles})=>{if(id==='7')throw new Error('Workflow or prompt changed. Preview again.');Object.assign(window.hostValues[id],roles);window.applied.push(id);return{targetId:id,values:roles};},
}});
});
try {
await page.goto(workspace);await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:/^Send prompt pair to ComfyUI: /}).click();
const sender=dialog(page);const nodes=sender.getByRole('combobox',{name:'Destination node',exact:true});
await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'Test ComfyUI'});
await expect(nodes.locator('option')).toHaveText(['Choose a destination node','#12 Prompt Manager (+/-)','#3 Prompt Manager (+/-)','#7 Edited in ComfyUI','All 3 eligible nodes']);
await expect(nodes).toHaveValue('');
await nodes.selectOption({label:'All 3 eligible nodes'});
await expect(sender.getByRole('status')).toHaveText('Sending to all 3 eligible nodes in Test ComfyUI. Apply to send it, or preview the changes first.');
await page.screenshot({path:testInfo.outputPath('send-all-option.png')});
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
const reviews=sender.locator('section.prompt-sender-node');
await expect(reviews.locator('> h4')).toHaveText(['#3 Prompt Manager (+/-)','#7 Edited in ComfyUI','#12 Prompt Manager (+/-)']);
await expect(sender.getByRole('region',{name:'#12 Prompt Manager (+/-)',exact:true})).toContainText('Existing 12');
expect(await host.evaluate(()=>window.applied)).toEqual([]);
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();
await expect(sender.getByRole('alert')).toHaveText('Sent to 2 of 3 nodes in Test ComfyUI. #7 Edited in ComfyUI: Workflow or prompt changed. Preview again.');
expect(await host.evaluate(()=>window.applied)).toEqual(['3','12']);
expect(await host.evaluate(()=>window.hostValues['7'].positive)).toBe('Existing 7');
await page.screenshot({path:testInfo.outputPath('send-all-result.png')});
await page.setViewportSize({width:390,height:844});
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();await expect(reviews).toHaveCount(3);
await reviews.last().scrollIntoViewIfNeeded();
expect(await page.evaluate(()=>document.querySelector('dialog.prompt-sender').scrollWidth<=document.querySelector('dialog.prompt-sender').clientWidth+1)).toBe(true);
await page.screenshot({path:testInfo.outputPath('send-all-mobile.png')});
const apply=sender.getByRole('button',{name:'Apply to ComfyUI',exact:true});await apply.focus();await expect(apply).toBeFocused();
} finally {await host.evaluate(()=>window.fixtureHost.dispose());await host.close();}
});
+19 -17
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,6 +85,9 @@ class TestModel:
suggestions: list | None = None
# The engine's model table (``py/autotag.py`` MODELS), set at startup.
models_config: dict = {}
# Where the WD14 label lists live (the Skipped tags preview, #431), set at
# startup; the tagging fixture writes or removes a small list there.
models_dir = None
def get_models_status(self) -> dict:
from py.autotag import package_fields
@@ -118,21 +118,26 @@ class TestModel:
return ["generated", Path(image_path).parent.parent.name]
def generate_tag_suggestions(
self, image_path: str, *, limit: int, **kwargs: object
self, image_path: str, *, limit: int, skip=None, **kwargs: object
) -> list[tuple[str, float | None]]:
"""Fixed scored suggestions (WD14) or unscored ones (language models)."""
"""Fixed scored suggestions (WD14) or unscored ones (language models),
without the ones ``skip`` hides before the limit, as the engine does
(#431)."""
scored = self.loaded is not None and self.loaded.startswith("wd14")
if self.suggestions is not None:
return [
pairs = [
(name, confidence if scored else None)
for name, confidence in self.suggestions[:limit]
for name, confidence in self.suggestions
]
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])
]
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]
def download_model(self, model_type: str, progress_callback=None) -> bool:
raise AssertionError("browser tests never download a model")
@@ -236,6 +241,7 @@ 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)
@@ -243,7 +249,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)
@@ -455,8 +460,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 {}
@@ -601,7 +604,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,
+95 -18
View File
@@ -33,6 +33,10 @@ 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. */
@@ -62,7 +66,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: { tree: [...field.children].map(name), label: [text(field.querySelector('label')), field.querySelector('label').htmlFor],
field: 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') },
@@ -70,6 +74,31 @@ 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]) })),
@@ -103,13 +132,21 @@ 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, { template, phone }) {
function mapTaggingConcept(reference, { builtin, saved, 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; D134 adds the operation after the actions.
tree: [...reference.tree.slice(0, 6), 'div.setting-row', ...reference.tree.slice(6), 'div.operation'],
// 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'],
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]],
@@ -120,16 +157,49 @@ function mapTaggingConcept(reference, { template, 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'),
],
// 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 } },
// 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,
// 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.map(() => true),
selectsFit: [...reference.selectsFit, true, true].map(() => true),
};
}
@@ -153,7 +223,9 @@ async function productionTagging(page, theme) {
async function savedTemplate(request, page) {
const response = await request.get('/prompt_manager/settings');
await expect(response).toBeOK();
return { template: (await response.json()).settings.autotag_template, phone: page.viewportSize().width <= 700 };
const { settings } = await response.json();
return { builtin: settings.autotag_builtin_instructions, saved: settings.autotag_instructions,
phone: page.viewportSize().width <= 700 };
}
for (const theme of THEMES) for (const [width, height] of SIZES) {
@@ -177,14 +249,15 @@ 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');
await expect(selects).toHaveCount(2);
for (let index = 0; index < 2; index += 1) {
// D203 adds the two per-engine default selects.
await expect(selects).toHaveCount(4);
for (let index = 0; index < 4; 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]);
expect(measured.selectsFit, `${await select.getAttribute('id')}=${value}`).toEqual([true, true, true, true]);
expect(measured.pageScroll, value).toBe(false);
}
}
@@ -198,12 +271,16 @@ 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 and D134 are recorded (and the saved template's text).
expect(diffInventory(concept, production).map(item => item.path.split('.').slice(0, 3).join('.'))
// 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('.')))
.filter((path, index, all) => all.indexOf(path) === index).sort()).toEqual([
'actions.buttons.1', 'actions.buttons.2', 'field.textarea.value', 'operations.0.actions', 'operations.0.description',
'operations.0.title', 'operations.0.tree', 'operations.length', 'rows.0.control', 'rows.0.text', 'rows.4.control', 'rows.4.controlBelow',
'rows.4.flexWrap', 'rows.4.text', 'rows.4.tree', 'rows.length', 'tree.6', 'tree.7', 'tree.8', 'tree.9', 'tree.length'].sort());
'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());
const measure = () => page.evaluate(measureTagging, '#workspace-main');
const paths = async () => diffInventory(expected, await measure()).map(item => item.path);
// A removed row.
@@ -254,8 +331,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']);
expect(production.selectsFit).toEqual([true, true]);
expect(production.rows.map(row => row.flexWrap)).toEqual(['wrap', 'nowrap', 'nowrap', 'nowrap', 'nowrap', 'nowrap', 'nowrap']);
expect(production.selectsFit).toEqual([true, true, true, true]);
});
/** Runs in the page: the open review dialog: its title, body and the concept's tokens. */
@@ -1,92 +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
folder = root / FOLDER_NAME
def reset() -> None:
preview_store.configure(None)
shutil.rmtree(folder, ignore_errors=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:
previews = sorted(
path.relative_to(folder).as_posix()
for path in folder.rglob("*_thumb.*")
if path.is_file()
)
sources = [str(path) for path in api._get_all_output_dirs()]
return {
"folder": previews,
"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:
shutil.rmtree(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})
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);
});
@@ -284,23 +284,6 @@ test('a restore seen finishing elsewhere closes an open restore review and disca
expect((await serverJson(request, '/__test__/system/backups')).stages).toBe(0);
});
// #583: in WebKit, while a page.route is armed, a file part of a FormData body is read only when the request goes on;
// a garbage collection in between dropped the form once nothing referenced it, and the server got an empty file
// ("Uploaded file is empty"). The page keeps the form until its upload is answered. The route holds the upload
// through a collection; without the fix WebKit lost the file every time (0/10 in the pinned image). Chromium and
// Firefox keep the body anyway, so there it only checks that a held upload still opens the review.
test('a review upload survives a garbage collection while it is in flight', async ({ page, request }) => {
const bytes = await backupBytes(request);
await page.route(`**${PREVIEW_URL}`, async route => {
await page.requestGC();
await route.continue();
});
await openBackups(page);
expect((await chooseBackup(page, bytes)).status()).toBe(200);
await expect(dialog(page).locator('#modal-title')).toHaveText('Restore preview');
expect((await serverJson(request, '/__test__/system/backups')).stages).toBe(1);
});
test('invalid file shows the validator message; nothing is staged', async ({ page, request }) => {
const before = await serverJson(request, '/__test__/system/digest');
await openBackups(page);
+157 -3
View File
@@ -112,13 +112,14 @@ 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']);
'General-tag confidence', 'Character-tag confidence', 'Suggestions per image', 'Default for GGUF JoyCaption',
'Default for HuggingFace JoyCaption']);
await expect(page.locator('#tag-existing')).toHaveValue('skip');
await expect(page.locator('#threshold-general')).toHaveValue('0.35');
await expect(page.locator('#threshold-character')).toHaveValue('0.85');
await expect(page.locator('#tag-suggestions')).toHaveValue('10');
const settings = await serverJson(request, SETTINGS_URL);
await expect(page.locator('#tag-template')).toHaveValue(settings.settings.autotag_template);
// D203 (#431): Saved instructions replace the template field.
await expect(page.locator('#tag-template')).toHaveCount(0);
// Unload is offered only while a model is loaded (none is).
await expect(actionOf(page, 'Unload')).toBeDisabled();
});
@@ -418,3 +419,156 @@ 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' });
});
+12
View File
@@ -849,3 +849,15 @@ 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.`);
});
+74 -6
View File
@@ -226,6 +226,7 @@ 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:
@@ -244,6 +245,7 @@ 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)
@@ -353,13 +355,22 @@ 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. Reset
restores the standard 0.35 / 0.85 / 10.
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.
"""
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")
@@ -380,10 +391,15 @@ 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 and template (the fake model's
short instructions, which the concept comparisons show)."""
puts back the saved model, batch policy, skipped tags and instructions
(the fake model's short instructions, which the concept comparisons
show)."""
from py.config import AutotagConfig
general, character, limit = values
@@ -393,7 +409,48 @@ def _set_defaults(model, values) -> None:
if values == DEFAULTS:
AutotagConfig.MODEL_TYPE = "wd14-swinv2"
AutotagConfig.EXISTING = "skip"
AutotagConfig.TEMPLATE = model.default_prompt
# #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")
def _valid_defaults(data: object) -> bool:
@@ -402,9 +459,20 @@ 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 set(data) == {"general", "character", "limit"}
and {"general", "character", "limit"} <= set(data)
and set(data) <= {"general", "character", "limit"} | optional
and unit(data["general"])
and unit(data["character"])
and type(data["limit"]) is int
+2 -31
View File
@@ -131,40 +131,11 @@ test('native image inspector previews and sends a pair into real visible widgets
const twinId=await page.evaluate(async()=>{const {app}=await import('/scripts/app.js');const node=LiteGraph.createNode('PromptManagerPair');app.graph.add(node);return node.id;});
await find0.click();
const targets=sender.getByRole('combobox',{name:'Destination node',exact:true});
await expect(targets.locator('option')).toHaveCount(4);
await expect(targets.locator('option')).toHaveCount(3);
await expect(targets).toHaveValue('');
await expect(apply).toBeDisabled();
await expect(targets.locator('option').nth(2)).toHaveText(new RegExp(`^#${twinId} `));
// #542 D191: "All 2 eligible nodes" writes both real nodes, one after another; a linked third node is not offered.
const linkedId=await page.evaluate(async()=>{
const {app}=await import('/scripts/app.js');const {convertToInput}=await import('/extensions/core/widgetInputs.js');
const node=LiteGraph.createNode('PromptManagerPair');app.graph.add(node);
const widget=node.widgets.find(widget=>widget.name==='text');
if(!node.inputs.some(input=>input.name==='text'))convertToInput(node,widget);
const source=LiteGraph.createNode('PrimitiveString');app.graph.add(source);
source.connect(0,node,node.inputs.findIndex(input=>input.name==='text'));
for(const each of app.graph._nodes.filter(item=>item.type==='PromptManagerPair')){const text=each.widgets.find(widget=>widget.name==='text');if(text)text.value=`Before all ${each.id}`;}
return node.id;
});
await find0.click();
await expect(targets.locator('option')).toHaveText(['Choose a destination node',new RegExp(`^#${id} `),new RegExp(`^#${twinId} `),'All 2 eligible nodes']);
await expect(sender.getByRole('status')).toContainText('1 other Prompt Manager node cannot receive this prompt.');
await targets.selectOption({label:'All 2 eligible nodes'});
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
await expect(sender.locator('section.prompt-sender-node > h4')).toHaveText([new RegExp(`^#${id} `),new RegExp(`^#${twinId} `)]);
await apply.click();
// The tab title gains ComfyUI's unsaved mark once the workflow changed, so the window is matched loosely.
await expect(sender.getByRole('status')).toHaveText(/^Sent to 2 of 2 nodes in .*ComfyUI\.$/);
await expect(apply).toBeDisabled();
const all=await page.evaluate(async ids=>{
const {app}=await import('/scripts/app.js');const output=(await app.graphToPrompt()).output;
return ids.map(nodeId=>{const node=app.graph.getNodeById(nodeId);const serialized=node.serialize();const index=node.widgets.findIndex(widget=>widget.name==='negative_text');
return{prompt:output[String(nodeId)]?.inputs.negative_text,serialized:serialized.widgets_values[index],text:node.widgets.find(widget=>widget.name==='text')?.value};});
},[id,twinId,linkedId]);
expect(all.slice(0,2)).toEqual([{prompt:negative,serialized:negative,text:positive},{prompt:negative,serialized:negative,text:positive}]);
expect(all[2].text).toBe(`Before all ${linkedId}`);
expect(await page.evaluate(()=>window.senderEffects.queue-window.senderSendBaseline.queue)).toBe(0);
await page.evaluate(async ids=>{const {app}=await import('/scripts/app.js');for(const nodeId of ids)app.graph.remove(app.graph.getNodeById(nodeId));},[twinId,linkedId]);
await page.evaluate(async twinId=>{const {app}=await import('/scripts/app.js');app.graph.remove(app.graph.getNodeById(twinId));},twinId);
// #535: the status line says why a window cannot receive.
const find=sender.getByRole('button',{name:'Find ComfyUI windows',exact:true});
const preview=sender.getByRole('button',{name:'Preview changes',exact:true});
+6 -7
View File
@@ -105,6 +105,7 @@
"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",
@@ -184,10 +185,14 @@
"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",
@@ -212,12 +217,6 @@
"thumbnail_encoders",
"thumbnail_encoders.jpeg",
"thumbnail_encoders.jxl",
"thumbnail_encoders.webp",
"thumbnail_location",
"thumbnail_location.available",
"thumbnail_location.default_path",
"thumbnail_location.mode",
"thumbnail_location.path",
"thumbnail_location.problem"
"thumbnail_encoders.webp"
]
}
-138
View File
@@ -225,141 +225,3 @@ test('acknowledgement cannot claim an extra role beyond approved changes',async(
const {createSendSession}=await source;const s=setup(createSendSession,{apply:async()=>({targetId:'one',values:{positive:'new',negative:'also changed'}})});
await s.session.discover();s.session.selectHost('host');s.session.selectTarget('one');await s.session.preview();await s.session.apply();assert.equal(s.session.state.phase,'error');assert.match(s.session.state.message,/unexpected acknowledgement/);s.session.dispose();
});
// #542: "All N eligible nodes" sends to every eligible node of one window, previewing every node first (Q542-2 C).
function allHost({nodes=['10','9','2'],revision,preview,apply,discover}={}) {
const calls=[];const sent=new Set();
const targets=nodes.map(id=>pm(id,['positive'],revision?{revision}:{}));
const client={
discover:discover ?? (async()=>[window1(targets)]),
preview:async(host,id,roles)=>{calls.push(['preview',id]);if(preview)return preview(id,calls,sent);return{ticket:`t-${id}-${calls.length}`,revision,target:{id,label:`Node ${id}`},fields:[{role:'positive',before:sent.has(id)?'new':'old',after:roles.positive}]};},
apply:async(host,ticket)=>{const id=ticket.split('-')[1];calls.push(['apply',id,ticket]);if(apply)return apply(id,calls);sent.add(id);return{targetId:id,values:{positive:'new'}};},
dispose:()=>calls.push(['dispose']),
};
return {client,calls,sent};
}
async function allSession(createSendSession,options) {
const host=allHost(options);const session=createSendSession({client:host.client,roles:{positive:'new'},onChange:()=>{}});
await session.discover();return {...host,session};
}
const order=calls=>calls.filter(call=>call[0]!=='dispose').map(call=>`${call[0]} ${call[1]}`);
test('All is offered only with two or more eligible nodes and is never selected for you',async()=>{
const {createSendSession,ALL_NODES}=await source;
const one=await allSession(createSendSession,{nodes:['1']});
one.session.selectTarget(ALL_NODES);assert.equal(one.session.state.targetId,'');assert.equal(one.calls.length,0);one.session.dispose();
const two=await allSession(createSendSession,{nodes:['1','2']});
assert.equal(two.session.state.targetId,'');
two.session.selectTarget(ALL_NODES);assert.equal(two.session.state.targetId,ALL_NODES);
assert.match(two.session.state.message,/all 2 eligible nodes in Flow - ComfyUI/);
await two.session.discover();assert.equal(two.session.state.targetId,'');two.session.dispose();
});
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();
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();
});
test('a double click on Apply to All sends to each node once',async()=>{
const {createSendSession,ALL_NODES}=await source;const s=await allSession(createSendSession,{nodes:['1','2']});
s.session.selectTarget(ALL_NODES);await Promise.all([s.session.apply(),s.session.apply()]);
assert.deepEqual(s.calls.filter(call=>call[0]==='apply').map(call=>call[1]),['1','2']);s.session.dispose();
});
test('a node refusing its preview stops All before anything is written and names the node',async()=>{
const {createSendSession,ALL_NODES}=await source;
const s=await allSession(createSendSession,{preview:id=>{if(id==='9')throw Error('positive is connected to an upstream input.');return{ticket:`t-${id}`,target:{id},fields:[{role:'positive',before:'old',after:'new'}]};}});
s.session.selectTarget(ALL_NODES);await s.session.apply();
assert.equal(s.calls.filter(call=>call[0]==='apply').length,0);assert.equal(s.session.state.phase,'error');
assert.match(s.session.state.message,/^#9 Node 9: positive is connected to an upstream input\. Nothing was sent\./);
assert.equal(s.session.state.outcomeUnknown,false);s.session.dispose();
});
test('a refusal while sending to All is reported by node and the other nodes are still sent',async()=>{
const {createSendSession,ALL_NODES}=await source;
const s=await allSession(createSendSession,{apply:id=>{if(id==='9')throw Error('Workflow or prompt changed. Preview again.');return{targetId:id,values:{positive:'new'}};}});
s.session.selectTarget(ALL_NODES);await s.session.apply();
assert.deepEqual(s.calls.filter(call=>call[0]==='apply').map(call=>call[1]),['2','9','10']);
assert.equal(s.session.state.phase,'error');assert.equal(s.session.state.outcomeUnknown,false);
assert.equal(s.session.state.message,'Sent to 2 of 3 nodes in Flow - ComfyUI. #9 Node 9: Workflow or prompt changed. Preview again.');s.session.dispose();
});
test('a lost reply on one node of All leaves the later nodes unsent and that outcome unknown',async()=>{
const {createSendSession,ALL_NODES}=await source;
const s=await allSession(createSendSession,{apply:id=>{if(id==='9')throw Error('Bridge request timed out');return{targetId:id,values:{positive:'new'}};}});
s.session.selectTarget(ALL_NODES);await s.session.apply();
assert.deepEqual(s.calls.filter(call=>call[0]==='apply').map(call=>call[1]),['2','9']);
assert.equal(s.session.state.outcomeUnknown,true);
assert.match(s.session.state.message,/^Sent to 1 of 3 nodes in Flow - ComfyUI\. No confirmation received from #9 Node 9; the update may have applied\. Not sent: #10 Node 10\. Check ComfyUI, then preview again\. Bridge request timed out$/);
await s.session.apply();assert.equal(s.calls.filter(call=>call[0]==='apply').length,2);s.session.dispose();
});
test('closing the dialog during All stops after the current node',async()=>{
const {createSendSession,ALL_NODES}=await source;let release;
const s=await allSession(createSendSession,{apply:id=>id==='2'?new Promise(done=>release=()=>done({targetId:id,values:{positive:'new'}})):{targetId:id,values:{positive:'new'}}});
s.session.selectTarget(ALL_NODES);const pending=s.session.apply();
for(let i=0;i<50 && !release;i++)await tick();assert.ok(release,'the first node was sent');
s.session.dispose();release();await pending;
assert.deepEqual(s.calls.filter(call=>call[0]==='apply').map(call=>call[1]),['2']);
});
test('All accepts the revision our own write moved, but not a workflow changed before the run',async()=>{
const {createSendSession,ALL_NODES}=await source;
// The host moves the graph version on every write: later nodes preview with the new revision.
let version=4;
const bumped=await allSession(createSendSession,{revision:'1:4',preview:id=>({ticket:`t-${id}`,revision:`1:${version}`,target:{id},fields:[{role:'positive',before:'old',after:'new'}]}),apply:id=>{version++;return{targetId:id,values:{positive:'new'}};}});
bumped.session.selectTarget(ALL_NODES);await bumped.session.apply();
assert.equal(bumped.session.state.message,'Sent to 3 of 3 nodes in Flow - ComfyUI.');bumped.session.dispose();
const moved=await allSession(createSendSession,{revision:'1:4',preview:id=>({ticket:`t-${id}`,revision:'1:5',target:{id},fields:[{role:'positive',before:'old',after:'new'}]})});
moved.session.selectTarget(ALL_NODES);await moved.session.apply();
assert.match(moved.session.state.message,/workflow changed since this window was found/i);assert.equal(moved.calls.filter(call=>call[0]==='apply').length,0);moved.session.dispose();
});
test('a node whose text changed after the preview pass is refused, not overwritten',async()=>{
const {createSendSession,ALL_NODES}=await source;let edited=false;
const s=await allSession(createSendSession,{preview:id=>({ticket:`t-${id}`,target:{id},fields:[{role:'positive',before:id==='10'&&edited?'typed in ComfyUI':'old',after:'new'}]}),apply:id=>{edited=true;return{targetId:id,values:{positive:'new'}};}});
s.session.selectTarget(ALL_NODES);await s.session.apply();
assert.deepEqual(s.calls.filter(call=>call[0]==='apply').map(call=>call[1]),['2','9']);
assert.match(s.session.state.message,/^Sent to 2 of 3 nodes in Flow - ComfyUI\. #10 Node 10: Its prompt changed since the preview\./);s.session.dispose();
});
test('Preview with All shows every node in order, and Apply sends fresh tickets that match the review',async()=>{
const {createSendSession,ALL_NODES}=await source;const s=await allSession(createSendSession);
s.session.selectTarget(ALL_NODES);await s.session.preview();
assert.equal(s.session.state.phase,'ready');assert.equal(s.session.state.preview,null);
assert.deepEqual(s.session.state.previews.map(item=>item.label),['#2 Node 2','#9 Node 9','#10 Node 10']);
assert.match(s.session.state.message,/Sending to all 3 eligible nodes in Flow - ComfyUI\. Review the complete changes, then apply\./);
const first=s.session.state.previews[0].preview.ticket;
await s.session.apply();
// A held review may have outlived the host's 30 s tickets, so even the first node takes a fresh one.
assert.deepEqual(order(s.calls),['preview 2','preview 9','preview 10','preview 2','apply 2','preview 9','apply 9','preview 10','apply 10']);
assert.notEqual(s.calls.find(call=>call[0]==='apply')[2],first);assert.equal(s.session.state.phase,'success');
s.session.selectTarget('2');assert.deepEqual(s.session.state.previews,[]);s.session.dispose();
});
test('the dialog offers All for two or more nodes and reviews each node under its name',async()=>{
const {createPromptSender,ALL_NODES}=await source;const {createScope}=await import('../../web/js/shared/toolkit/core.js');
const dom=fakeDOM();const scope=createScope();
const {client}=allHost({nodes:['1','2']});
try {
createPromptSender({scope,clientFactory:()=>client}).open({positive:'new'});await tick();
const dialog=dom.body.children.at(-1);const target=dom.find(dialog,node=>node.id==='prompt-sender-target');
assert.deepEqual(target.children.map(option=>[option.value,option.textContent]),[['','Choose a destination node'],['1','#1 Node 1'],['2','#2 Node 2'],[ALL_NODES,'All 2 eligible nodes']]);
assert.equal(target.value,'');target.value=ALL_NODES;target.dispatchEvent(new Event('change'));
dom.find(dialog,node=>node.textContent==='Preview changes').dispatchEvent(new Event('click'));for(let i=0;i<5;i++)await tick();
const sections=[];(function walk(node){if(node.className==='prompt-sender-node')sections.push(node);node.children.forEach(walk);})(dialog);
assert.deepEqual(sections.map(section=>section.children[0].textContent),['#1 Node 1','#2 Node 2']);
dom.find(dialog,node=>node.textContent==='Apply to ComfyUI').dispatchEvent(new Event('click'));for(let i=0;i<10;i++)await tick();
assert.ok(dom.find(dialog,node=>node.textContent==='Sent to 2 of 2 nodes in Flow - ComfyUI.'));
} finally {scope.dispose();dom.restore();}
});
test('an expired review still sends every node, and a held review checks the discovered revision first',async()=>{
const {createSendSession,ALL_NODES}=await source;let reviewing=true;
// The host refuses every ticket taken during the review, as after its 30 s expiry.
const s=await allSession(createSendSession,{preview:(id,calls)=>({ticket:`t-${id}-${reviewing?'old':calls.length}`,target:{id},fields:[{role:'positive',before:'old',after:'new'}]}),apply:id=>({targetId:id,values:{positive:'new'}})});
const realApply=s.client.apply;s.client.apply=async(host,ticket,options)=>{if(ticket.endsWith('-old'))throw Error('Preview expired or unavailable; preview again');return realApply(host,ticket,options);};
s.session.selectTarget(ALL_NODES);await s.session.preview();reviewing=false;await s.session.apply();
assert.equal(s.session.state.message,'Sent to 3 of 3 nodes in Flow - ComfyUI.');s.session.dispose();
const moved=await allSession(createSendSession,{revision:'1:4',preview:(id,calls)=>({ticket:`t-${id}`,revision:calls.length>3?'1:5':'1:4',target:{id},fields:[{role:'positive',before:'old',after:'new'}]})});
moved.session.selectTarget(ALL_NODES);await moved.session.preview();assert.equal(moved.session.state.phase,'ready');await moved.session.apply();
assert.match(moved.session.state.message,/^Sent to 0 of 3 nodes in Flow - ComfyUI\. #2 Node 2: The workflow changed since this window was found/);
assert.equal(moved.calls.filter(call=>call[0]==='apply').length,0);moved.session.dispose();
});
test('a changed prepend or append after the review refuses that node',async()=>{
const {createSendSession,ALL_NODES}=await source;let edited=false;
const s=await allSession(createSendSession,{preview:id=>({ticket:`t-${id}`,target:{id},fields:[{role:'positive',before:'old',after:'new'}],composition:{prepend:id==='9'&&edited?'typed':'trigger',append:'',before:'trigger old',after:'trigger new'}}),apply:id=>{edited=true;return{targetId:id,values:{positive:'new'}};}});
s.session.selectTarget(ALL_NODES);await s.session.apply();
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();
});
@@ -936,3 +936,17 @@ 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));
}
});
-18
View File
@@ -128,24 +128,6 @@ test('invalid file shows the validator message; a refusal names its job; a lost
assert.deepEqual(await uploadRestore(file, { ApiError, fetchUrl: async () => response(200, PREVIEW) }), PREVIEW);
});
// #583: WebKit reads a FormData's file part by reference, possibly after a garbage collection; the page holds the form
// until the upload is answered, then lets it go, whether the server staged it, refused it or the request failed.
test('a review upload keeps its form referenced until the upload is answered', async () => {
const { uploadRestore, uploadsInFlight } = await backups;
const file = new File(['bytes'], 'b.db');
for (const answer of [async () => response(200, PREVIEW), async () => response(400, { success: false, error: 'x' }),
async () => { throw new TypeError('offline'); }]) {
let held;
const upload = uploadRestore(file, { ApiError, fetchUrl: async (url, init) => {
held = uploadsInFlight.has(init.body) && init.body.get('database_file').size === file.size;
return answer();
} }).catch(() => {});
await upload;
assert.equal(held, true);
assert.equal(uploadsInFlight.size, 0);
}
});
test('Cancel discards the stage through the api client and never throws', async () => {
const { discardRestore, PREVIEW_URL } = await backups;
const calls = [];
@@ -0,0 +1,257 @@
// 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(); }
});
@@ -0,0 +1,247 @@
// 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();
});
+39 -2
View File
@@ -402,11 +402,12 @@ 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;
drafts.set('host', 'gallery_root_paths', ['a']);
// (#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']);
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'] });
assert.deepEqual(domains.calls.at(-1)[1], { gallery_root_paths: ['a', 'b'] });
// 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);
@@ -429,3 +430,39 @@ 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);
});
+32 -21
View File
@@ -42,6 +42,8 @@ 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; }
@@ -65,7 +67,9 @@ 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_template: 'Describe the image as tags.' };
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' } };
const entry = (label, size, extra = {}) => ({ name: label, label, size_label: size, downloaded: true, loaded: false, ...extra });
const MODELS = {
success: true,
@@ -124,7 +128,8 @@ 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.'));
assert.ok(body.includes('LLM tag template') && SOURCE.includes("'LLM tag template'"));
// 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('"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);
@@ -167,27 +172,25 @@ 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-template': 'autotag_template' });
'tag-suggestions': 'autotag_suggestion_limit' });
assert.deepEqual(['0.4', ' 1.5 ', '15', '', 'abc', '1e-1'].map(coerceNumber), [0.4, 1.5, 15, '', 'abc', 0.1]);
assert.equal(draftValue('autotag_suggestion_limit', '15'), 15);
assert.equal(draftValue('autotag_template', ' 12 '), ' 12 ');
assert.equal(draftValue('autotag_existing', ' 12 '), ' 12 ');
assert.equal(draftValue('autotag_existing', undefined), '');
const { dom, drafts, scope, byId } = await mounted();
try {
assert.equal(byId('tag-model').value, 'wd14-swinv2');
assert.equal(byId('threshold-general').value, '0.35');
assert.equal(byId('tag-template').value, HOST.autotag_template);
assert.deepEqual([byId('threshold-general').min, byId('threshold-general').max, byId('threshold-general').step], ['0', '1', '0.05']);
assert.deepEqual([byId('tag-suggestions').min, byId('tag-suggestions').max], ['1', '100']);
byId('tag-existing').value = 'merge'; await byId('tag-existing').dispatch('change');
byId('threshold-general').value = '0.4'; await byId('threshold-general').dispatch('change');
byId('tag-suggestions').value = '15'; await byId('tag-suggestions').dispatch('change');
byId('tag-template').value = 'Tags only.'; await byId('tag-template').dispatch('input');
assert.deepEqual(drafts.changes('host'), { autotag_existing: 'merge', autotag_general_threshold: 0.4,
autotag_suggestion_limit: 15, autotag_template: 'Tags only.' });
autotag_suggestion_limit: 15 });
// Every control names its stored key, so a refused save highlights it (D92).
for (const id of Object.keys(SETTING_KEYS)) assert.equal(byId(id).dataset.key, SETTING_KEYS[id]);
assert.equal(byId('tag-template').dataset.setting, 'tag-template');
assert.equal(byId('tag-template'), undefined);
} finally { scope.dispose(); assert.equal(dom.listenerCount(), 0); dom.restore(); }
});
@@ -201,7 +204,8 @@ 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', 'field', 'row-actions', 'operation']);
['', '', 'setting-row', 'setting-row', 'setting-row', 'setting-row', 'setting-row', 'setting-block', 'setting-block', 'setting-row',
'setting-row', 'small instruction-reset', 'row-actions', 'operation']);
} finally { scope.dispose(); dom.restore(); }
const failed = await mounted({ modelsFail: true });
try {
@@ -302,7 +306,9 @@ 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: HOST.autotag_template, suggestion_limit: 10 } });
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.');
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.' });
@@ -319,15 +325,17 @@ 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);
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.' });
// #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.' });
// Characters are code points, as Python's len counts them: 4000 emoji (8000 UTF-16 units) are allowed.
assert.equal(draftProblem({ ...HOST, autotag_model_type: 'hf', autotag_template: '🏜'.repeat(4000) }), null);
assert.equal(draftProblem({ ...HOST, autotag_model_type: 'hf', autotag_template: '🏜'.repeat(4001) }).key, 'autotag_template');
// A WD14 run never reads the template, nor a language model the thresholds.
assert.equal(draftProblem({ ...HOST, autotag_template: '' }), null);
assert.equal(draftProblem(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: 'gguf', autotag_general_threshold: 7 }), null);
const none = await mounted({ image: null });
@@ -372,9 +380,12 @@ 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(-3),
estimate_device: null, suggestion_limit: 4 }, llm).slice(-4),
[['Time', 'Language-model engines take longer per image. Activity shows the time left once tagging starts.'],
['Settings', 'Template of 27 characters · up to 4 tags per image'], ['Backup', 'No safety backup (the policy is off)']]);
['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']);
assert.equal(batchGrid({}, HOST)[2][1], '0');
assert.equal(batchGrid({}, HOST)[6][1], 'General 0.35 · Character 0.85');
assert.equal(existingLabel('odd'), 'odd');
@@ -382,7 +393,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: HOST.autotag_template } });
assert.deepEqual(batchBody(llm), { model_type: 'gguf', existing: 'skip', options: { instructions: 'Describe the image as tags.' } });
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.');
+7 -2
View File
@@ -73,6 +73,7 @@ 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
@@ -115,8 +116,10 @@ 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(
@@ -132,9 +135,11 @@ 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(SCORED[:limit])
return [(name, None) for name, _ in SCORED[:limit]]
return list(kept[:limit])
return [(name, None) for name, _ in kept[:limit]]
class SuggestTestCase(AnnotationAPITestCase):
+2
View File
@@ -1144,8 +1144,10 @@ 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",
+32 -3
View File
@@ -129,8 +129,10 @@ class FakeAutotagService:
prompt=None,
general_threshold=None,
character_threshold=None,
skip=None,
):
"""The suggestion entry point (#379): the fixed tags, at most ``limit``."""
"""The suggestion entry point (#379): the fixed tags, at most ``limit``,
without the ones ``skip`` hides (#431)."""
self.suggest_calls += 1
tags = self.generate_tags(
image_path,
@@ -138,6 +140,7 @@ 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]]
@@ -1533,7 +1536,8 @@ class TestSavedTaggingDefaults(AutotagAPITestCase):
"EXISTING",
"GENERAL_THRESHOLD",
"CHARACTER_THRESHOLD",
"TEMPLATE",
"INSTRUCTIONS",
"DEFAULT_INSTRUCTIONS",
"SUGGESTION_LIMIT",
):
self.addCleanup(setattr, AutotagConfig, attr, getattr(AutotagConfig, attr))
@@ -1554,7 +1558,11 @@ class TestSavedTaggingDefaults(AutotagAPITestCase):
self.config.EXISTING = "replace_after_review"
self.config.GENERAL_THRESHOLD = 0.4
self.config.CHARACTER_THRESHOLD = 0.7
self.config.TEMPLATE = "Saved template"
# #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.SUGGESTION_LIMIT = 15
data = await (await self.client.get("/prompt_manager/autotag/models")).json()
self.assertEqual(
@@ -1578,6 +1586,27 @@ 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)
-415
View File
@@ -1,415 +0,0 @@
"""The thumbnail location setting (#320 PR 2): parsing, validation, source
keys, the marker, the settings payloads and reverse containment.
Spelling rules are tested on both path flavors (``ntpath`` and
``posixpath``) as text; filesystem rules run on the native one.
"""
import json
import ntpath
import os
import posixpath
import re
import sys
import tempfile
import threading
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.modules.setdefault("server", MagicMock())
from py.api import preview_location as pl # noqa: E402
from py.api import preview_store # noqa: E402
REPO = Path(__file__).resolve().parent.parent
class TestParse(unittest.TestCase):
def test_missing_or_source_is_inside_each_source(self):
for value in (None, {"mode": "source"}, {}):
with self.subTest(value=value):
parsed = pl.parse_location(value)
self.assertEqual((parsed.mode, parsed.problem), ("source", None))
def test_a_folder_location_is_kept_as_spelled(self):
parsed = pl.parse_location(
{"mode": "folder", "path": "D:\\Thumbs", "id": "x", "install": "i"},
flavor=ntpath,
)
self.assertEqual(
(parsed.mode, parsed.path, parsed.id, parsed.problem),
("folder", "D:\\Thumbs", "x", None),
)
def test_invalid_values_never_fall_back_to_the_sources(self):
for value in (
"folder",
{"mode": "elsewhere"},
{"mode": "folder", "path": "relative", "id": "x"},
{"mode": "folder", "path": "/abs"},
{"mode": "folder", "path": "/abs", "id": ""},
{"mode": "folder", "path": 7, "id": "x"},
):
with self.subTest(value=value):
parsed = pl.parse_location(value, flavor=posixpath)
self.assertEqual((parsed.mode, parsed.problem), ("folder", "invalid"))
class TestForm(unittest.TestCase):
def test_windows_spellings(self):
cases = {
"D:\\Thumbs": None,
"\\\\server\\share\\thumbs": None,
"C:thumbs": "location-not-absolute",
"\\thumbs": "location-not-absolute",
"thumbs": "location-not-absolute",
"": "location-not-absolute",
" D:\\Thumbs": "location-not-absolute",
"\\\\?\\D:\\Thumbs": "location-device-path",
"\\\\.\\D:\\Thumbs": "location-device-path",
"D:\\CON\\x": "location-invalid-name",
"D:\\com1.txt": "location-invalid-name",
"D:\\thumbs.": "location-invalid-name",
"D:\\thumbs ": "location-invalid-name",
"D:\\a|b": "location-invalid-name",
"D:\\a?b": "location-invalid-name",
"D:\\a\x01b": "location-invalid-name",
}
for path, code in cases.items():
with self.subTest(path=path):
self.assertEqual(pl.form_problem(path, flavor=ntpath), code)
def test_posix_spellings(self):
cases = {
"/mnt/fast/thumbs": None,
"mnt/thumbs": "location-not-absolute",
"/mnt/nul": "location-invalid-name",
"/mnt/a:b": "location-invalid-name",
"//?/x": "location-device-path",
}
for path, code in cases.items():
with self.subTest(path=path):
self.assertEqual(pl.form_problem(path, flavor=posixpath), code)
def test_roots_and_home_are_too_broad(self):
self.assertTrue(pl.is_too_broad("C:\\", home="C:\\Users\\a", flavor=ntpath))
self.assertTrue(
pl.is_too_broad("\\\\server\\share", home="C:\\Users\\a", flavor=ntpath)
)
self.assertTrue(
pl.is_too_broad("C:\\users\\A", home="C:\\Users\\a", flavor=ntpath)
)
self.assertTrue(pl.is_too_broad("/", home="/home/a", flavor=posixpath))
self.assertTrue(pl.is_too_broad("/home/a/", home="/home/a", flavor=posixpath))
self.assertFalse(
pl.is_too_broad("/home/a/thumbs", home="/home/a", flavor=posixpath)
)
def test_long_windows_paths_are_refused_without_long_paths(self):
short = "D:\\" + "a" * 100
long = "D:\\" + "a" * 200
self.assertFalse(pl.too_long_for_windows(short, long_paths=False))
self.assertTrue(pl.too_long_for_windows(long, long_paths=False))
self.assertFalse(pl.too_long_for_windows(long, long_paths=True))
class TestSourceKeys(unittest.TestCase):
def test_slug(self):
self.assertEqual(pl.slug("ComfyUI Output"), "comfyui-output")
self.assertEqual(pl.slug("Ünïcödé_Fôlder"), "unicode_folder")
self.assertEqual(pl.slug(""), "source")
self.assertEqual(pl.slug("!!!"), "source")
self.assertEqual(len(pl.slug("x" * 80)), 24)
def test_keys_are_a_slug_plus_a_hash(self):
key = pl.source_key("D:\\AI\\Output", "d:\\ai\\output", {})
self.assertRegex(key, r"^output-[0-9a-f]{8}$")
self.assertRegex(pl.source_key("D:\\", "d:\\", {}), r"^source-[0-9a-f]{8}$")
self.assertRegex(
pl.source_key("\\\\nas\\share\\Out", "\\\\nas\\share\\out", {}),
r"^out-[0-9a-f]{8}$",
)
def test_a_taken_key_grows_its_hash(self):
first = pl.source_key("/a/out", "/a/out", {})
taken = {first: "/someone/else"}
second = pl.source_key("/a/out", "/a/out", taken)
self.assertRegex(second, r"^out-[0-9a-f]{12}$")
taken[second] = "/another"
self.assertRegex(
pl.source_key("/a/out", "/a/out", taken), r"^out-[0-9a-f]{16}$"
)
self.assertEqual(pl.source_key("/a/out", "/a/out", {first: "/a/out"}), first)
def test_lookup_by_spelling_identity_then_file_id(self):
keys = {
"out-0123abcd": {
"path": "D:\\Out",
"identity": "d:\\out",
"dev_ino": [1, 2],
},
"lib-0123abcd": {"path": "E:\\Lib", "identity": "e:\\lib", "dev_ino": None},
}
self.assertEqual(pl.find_key(keys, "D:\\Out", "x", None), "out-0123abcd")
self.assertEqual(pl.find_key(keys, "Z:\\Out", "d:\\out", None), "out-0123abcd")
# A letter spelling and its UNC spelling: the same file id.
self.assertEqual(
pl.find_key(keys, "\\\\nas\\out", "\\\\nas\\out", (1, 2)), "out-0123abcd"
)
self.assertIsNone(pl.find_key(keys, "F:\\New", "f:\\new", (9, 9)))
class TestMarker(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
self.folder = self._tmp.name
def test_init_creates_once_and_adopts_after(self):
first = pl.init_folder(self.folder, created_by_pm=True)
self.assertEqual(pl.init_folder(self.folder), first)
marker = pl.read_marker(self.folder)
self.assertEqual(marker["id"], first)
self.assertTrue(marker["created_by_pm"])
self.assertEqual(marker["version"], 1)
def test_writes_merge_what_another_writer_added(self):
pl.init_folder(self.folder)
pl.write_marker(
self.folder, lambda m: m["keys"].update({"a-0123abcd": {"install": "1"}})
)
pl.write_marker(
self.folder, lambda m: m["keys"].update({"b-0123abcd": {"install": "2"}})
)
self.assertEqual(
sorted(pl.read_marker(self.folder)["keys"]), ["a-0123abcd", "b-0123abcd"]
)
# No temp file is left beside it.
self.assertEqual(os.listdir(self.folder), [pl.MARKER_NAME])
def test_a_folder_without_the_marker_is_missing(self):
self.assertEqual(pl.folder_state(self.folder, "x")[:2], (False, "missing"))
missing = os.path.join(self.folder, "gone")
self.assertEqual(pl.folder_state(missing, "x")[:2], (False, "missing"))
marker_id = pl.init_folder(self.folder)
self.assertEqual(pl.folder_state(self.folder, marker_id)[:2], (True, None))
self.assertEqual(
pl.folder_state(self.folder, "other")[:2], (False, "marker-mismatch")
)
@unittest.skipUnless(hasattr(os, "symlink"), "needs symlinks")
def test_a_linked_folder_is_not_available(self):
real = os.path.join(self.folder, "real")
os.mkdir(real)
marker_id = pl.init_folder(real)
link = os.path.join(self.folder, "link")
try:
os.symlink(real, link, target_is_directory=True)
except OSError:
self.skipTest("symlinks not permitted")
self.assertEqual(pl.folder_state(link, marker_id)[:2], (False, "invalid"))
class TestValidation(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
self.base = os.path.realpath(self._tmp.name)
self.source = os.path.join(self.base, "output")
self.node = os.path.join(self.base, "node")
os.mkdir(self.source)
os.mkdir(self.node)
self.context = pl.Context(
roots=(self.source,),
node_folder=self.node,
default_path=os.path.join(self.node, "data", "thumbnails"),
home=os.path.join(self.base, "home"),
environ={},
long_paths=True,
folder_paths=None,
)
def problem(self, path, purpose="move", **changes):
context = self.context
if changes:
context = pl.Context(**{**context.__dict__, **changes})
return pl.location_problem(path, purpose=purpose, context=context)
def test_an_empty_new_folder_beside_the_sources_is_accepted(self):
self.assertIsNone(self.problem(os.path.join(self.base, "thumbs")))
os.mkdir(os.path.join(self.base, "empty"))
Path(self.base, "empty", "Thumbs.db").write_bytes(b"")
self.assertIsNone(self.problem(os.path.join(self.base, "empty")))
def test_codes(self):
cases = {
"relative": "location-not-absolute",
os.path.join(self.source, "thumbs"): "location-served",
os.path.join(self.node, "thumbs"): "location-node-folder",
self.base: "location-contains-source",
os.path.join(self.base, "no", "parent"): "location-parent-missing",
}
for path, code in cases.items():
with self.subTest(path=path):
self.assertEqual(self.problem(path), code)
def test_the_default_may_sit_in_the_node_folder_and_make_its_parents(self):
self.assertIsNone(self.problem(self.context.default_path))
def test_the_folder_in_use_is_the_same(self):
folder = os.path.join(self.base, "thumbs")
os.mkdir(folder)
self.assertEqual(self.problem(folder, current_path=folder), "location-same")
def test_a_folder_with_other_files_is_not_empty_unless_it_is_ours(self):
folder = os.path.join(self.base, "full")
os.mkdir(folder)
Path(folder, "photo.jpg").write_bytes(b"x")
self.assertEqual(self.problem(folder), "location-not-empty")
pl.init_folder(folder)
self.assertIsNone(self.problem(folder))
@unittest.skipIf(os.name == "nt" or os.geteuid() == 0, "needs POSIX modes")
def test_a_folder_that_refuses_writes(self):
folder = os.path.join(self.base, "ro")
os.mkdir(folder)
os.chmod(folder, 0o500)
self.addCleanup(os.chmod, folder, 0o700)
self.assertEqual(self.problem(folder), "location-not-writable")
def test_a_stat_that_does_not_answer_is_unavailable(self):
release = threading.Event()
self.addCleanup(release.set)
def stuck(*_args):
release.wait(10)
with patch.object(pl, "_filesystem_problem", stuck):
code = pl.location_problem(
os.path.join(self.base, "thumbs"),
purpose="move",
context=self.context,
timeout=0.1,
)
self.assertEqual(code, "location-unavailable")
def test_a_long_windows_path(self):
path = os.path.join(self.base, "t" * 220)
self.assertEqual(self.problem(path, long_paths=False), "location-path-too-long")
def test_every_code_has_a_message_without_a_path(self):
for code, (status, text) in pl.MESSAGES.items():
with self.subTest(code=code):
self.assertIn(status, (400, 409))
self.assertTrue(text.endswith("."))
class TestNoLegacySaveCallers(unittest.TestCase):
def test_save_to_file_has_no_callers(self):
offenders = []
for folder in ("py", "utils", "database"):
for path in (REPO / folder).rglob("*.py"):
for number, line in enumerate(
path.read_text(encoding="utf-8").splitlines(), 1
):
code = line.split("#", 1)[0]
if (
re.search(r"\bsave_to_file\(", code)
and "def save_to_file" not in code
):
if "PromptManagerConfig.save_to_file('" in line:
continue # its own docstring example
offenders.append(f"{path.relative_to(REPO)}:{number}")
self.assertEqual(offenders, [])
try:
from tests.test_api_images import ImageAPITestCase
except ImportError: # discovered with tests/ as the top-level directory
from test_api_images import ImageAPITestCase
class TestLocationRoutes(ImageAPITestCase):
async def asyncSetUp(self):
await super().asyncSetUp()
self.addCleanup(preview_store.configure, None)
def use_folder(self):
folder = self.comfy_dir / "thumbs"
folder.mkdir()
marker_id = pl.init_folder(str(folder), created_by_pm=True)
preview_store.configure(
{"mode": "folder", "path": str(folder), "id": marker_id}
)
return folder
async def test_settings_get_names_the_location_with_its_path(self):
folder = self.use_folder()
resp = await self.client.get("/prompt_manager/settings")
location = (await resp.json())["thumbnail_location"]
self.assertEqual(location["mode"], "folder")
self.assertEqual(location["path"], str(folder))
self.assertTrue(location["available"])
self.assertIsNone(location["problem"])
self.assertIn("default_path", location)
async def test_settings_post_never_changes_the_location(self):
resp = await self.client.post(
"/prompt_manager/settings",
json={"thumbnail_location": {"mode": "folder", "path": "/x"}},
)
self.assertEqual(resp.status, 409)
body = await resp.json()
self.assertEqual(body["code"], "location-needs-move")
self.assertNotIn("/x", body["error"])
async def test_status_has_the_location_but_no_path(self):
folder = self.use_folder()
resp = await self.client.get("/prompt_manager/images/thumbnails/status")
body = await resp.text()
self.assertNotIn(str(folder), body)
self.assertEqual(
json.loads(body)["location"],
{"mode": "folder", "available": True, "problem": None},
)
async def test_a_source_inside_or_around_the_folder_is_refused(self):
from py.api import sources
folder = self.use_folder()
inside = folder / "sub"
inside.mkdir()
with patch(
"py.config.GalleryConfig.gallery_root_problem",
classmethod(lambda cls, path: (None, "")),
):
for path in (inside, folder, self.comfy_dir):
with self.subTest(path=path):
code, reason = sources.source_problem(str(path))
self.assertEqual(code, "holds-thumbnails")
self.assertEqual(reason, pl.HOLDS_THUMBNAILS_REASON)
self.assertEqual(sources.source_problem(str(self.output_dir))[0], None)
async def test_a_library_move_into_the_folder_is_refused(self):
from py.api import library_location as routes
folder = self.use_folder()
self.assertTrue(routes._inside_thumbnail_folder(str(folder / "lib")))
self.assertFalse(routes._inside_thumbnail_folder(str(self.output_dir)))
def test_startup_makes_a_location_inside_a_source_invalid(self):
inside = self.output_dir / "thumbs"
inside.mkdir()
marker_id = pl.init_folder(str(inside))
from py.config import PromptManagerConfig
value = {"mode": "folder", "path": str(inside), "id": marker_id}
with patch.object(PromptManagerConfig, "THUMBNAIL_LOCATION", value):
location = self.api.start_preview_location()
self.assertEqual((location.available, location.problem), (False, "invalid"))
if __name__ == "__main__":
unittest.main()
+5 -5
View File
@@ -908,20 +908,20 @@ class TestWorkspaceImages(ImageAPITestCase):
async def test_one_cards_parse_error_spares_the_page(self):
prompt = self._save_prompt("a fox")
# Cards are read on the image pool (#523), in no fixed order: the
# parser fails on bad.png's own record (seed 6), not on a call count.
for name, mtime, seed in (("bad.png", 200, 6), ("ok.png", 100, 5)):
for name, mtime in (("bad.png", 200), ("ok.png", 100)):
path = records_png(
self.output_dir / name, seeded_records("a fox", seed), mtime
self.output_dir / name, seeded_records("a fox", 5), mtime
)
self.api.db.link_image_to_prompt(prompt, str(path))
real_parse = header_params_of_images()
def parsing(info):
if '"seed": 6' in info["prompt"]:
if parsing.calls == 0:
parsing.calls += 1
raise RuntimeError("unexpected parser bug")
return real_parse(info)
parsing.calls = 0
with (
patch("py.api.images.header_params", side_effect=parsing),
self.assertLogs(self.api.logger, "WARNING") as logged,
+2
View File
@@ -54,6 +54,8 @@ class PerImageTagger(HookedTagger):
names = self.by_bytes.get(self.read_bytes[-1])
if names is None:
return result
skip = kwargs.get("skip") # as the engine does (#431)
names = [name for name in names if skip is None or not skip.matches(name)]
return [(name, 0.9) for name in names][: kwargs["limit"]]
+24 -1
View File
@@ -158,7 +158,30 @@ class TestLegacyBatch(LegacyTestCase):
self.image("b.png", (2, 2, 2))
await self.relist()
await self.legacy("model_type=gguf&prompt=%20")
self.assertEqual(self.tagger.calls[-1]["prompt"], AutotagConfig.TEMPLATE)
self.assertEqual(
self.tagger.calls[-1]["prompt"],
AutotagConfig.default_instructions_text("gguf"),
)
# #431: a blank prompt falls back to the request engine's own default.
saved = (AutotagConfig.INSTRUCTIONS, AutotagConfig.DEFAULT_INSTRUCTIONS)
self.addCleanup(
lambda: (
setattr(AutotagConfig, "INSTRUCTIONS", saved[0]),
setattr(AutotagConfig, "DEFAULT_INSTRUCTIONS", saved[1]),
)
)
AutotagConfig.INSTRUCTIONS = (
{"id": "0123456789ab", "name": "Photo", "text": "Photo tags."},
)
AutotagConfig.DEFAULT_INSTRUCTIONS = {"gguf": "builtin", "hf": "0123456789ab"}
for model_type, expected in (
("hf", "Photo tags."),
("gguf", AutotagConfig.default_instructions_text("gguf")),
):
self.image(f"{model_type}.png", (3, 3, len(model_type)))
await self.relist()
await self.legacy(f"model_type={model_type}&prompt=%20&skip_tagged=false")
self.assertEqual(self.tagger.calls[-1]["prompt"], expected, model_type)
self.assertEqual(self.tagger.custom_prompt, "legacy prompt")
async def test_a_missing_backend_package_is_one_error_frame(self):
+40 -12
View File
@@ -442,8 +442,11 @@ class TestAutotagConfig(unittest.TestCase):
"EXISTING",
"GENERAL_THRESHOLD",
"CHARACTER_THRESHOLD",
"TEMPLATE",
"SUGGESTION_LIMIT",
"SKIPPED_TAGS",
"INSTRUCTIONS",
"DEFAULT_INSTRUCTIONS",
"_LEGACY_TEMPLATE",
)
}
@@ -487,16 +490,14 @@ class TestAutotagConfig(unittest.TestCase):
"general_threshold": 0.35,
"character_threshold": 0.85,
"suggestion_limit": 10,
"skipped_tags": [],
"instructions": [],
"default_instructions": {"gguf": "builtin", "hf": "builtin"},
}
},
)
# The built-in template is not written; a saved one is.
self.config_class.TEMPLATE = "Saved."
self.assertEqual(
PromptManagerConfig.get_config()["autotag"]["defaults"]["template"],
"Saved.",
)
self.config_class.TEMPLATE = config_module.DEFAULT_TAG_TEMPLATE
# #431: the old template key is written only as the GGUF default's
# mirror (test_saved_instructions covers it).
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "config.json")
PromptManagerConfig.update_config(
@@ -524,16 +525,16 @@ class TestAutotagConfig(unittest.TestCase):
"existing": "merge",
"general_threshold": 0.4,
"character_threshold": 0.7,
"template": "Tags only.",
"suggestion_limit": 15,
"skipped_tags": ("blurry", "closed eyes"),
}
fallback = {
"model_type": "wd14-swinv2",
"existing": "skip",
"general_threshold": 0.35,
"character_threshold": 0.85,
"template": config_module.DEFAULT_TAG_TEMPLATE,
"suggestion_limit": 10,
"skipped_tags": (),
}
bad_values = {
"model_type": ["wd14-convnext", 3, None],
@@ -544,6 +545,8 @@ class TestAutotagConfig(unittest.TestCase):
# 4000 characters are at most 16,000 UTF-8 bytes, so the 16 KiB
# byte cap can only be met past the character cap.
"template": ["", " ", "x" * 4001, "\U0001f600" * 4097, "\ud800", 7],
# #431: the whole list falls back; entries are never dropped one by one.
"skipped_tags": ["blurry", ["a,b"], ["sky", "Sky"], [""], ["x"] * 201],
}
for key, values in bad_values.items():
for value in values:
@@ -552,7 +555,7 @@ class TestAutotagConfig(unittest.TestCase):
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "config.json")
with open(path, "w") as f:
json.dump({"autotag": {"defaults": saved}}, f)
json.dump({"autotag": {"defaults": saved}}, f, default=list)
with self.assertLogs(
"prompt_manager.config", level="WARNING"
) as logs:
@@ -563,7 +566,32 @@ class TestAutotagConfig(unittest.TestCase):
k: getattr(self.config_class, attr)
for k, (attr, _v, _d) in self.config_class._KEYS.items()
}
self.assertEqual(loaded, dict(good, **{key: fallback[key]}))
# The old template is no attribute any more (#431): a bad
# one is only warned about and imports nothing.
expected = (
dict(good, **{key: fallback[key]}) if key in fallback else good
)
self.assertEqual(loaded, expected)
def test_skipped_tags_load_trimmed_and_warn_once_when_damaged(self):
"""#431: the list is kept as typed (trimmed); a damaged list is
replaced by no skipped tags with one warning naming the reason."""
PromptManagerConfig.update_config(
{"autotag": {"defaults": {"skipped_tags": [" blurry ", "(fate)"]}}}
)
self.assertEqual(self.config_class.SKIPPED_TAGS, ("blurry", "(fate)"))
with self.assertLogs("prompt_manager.config", level="WARNING") as logs:
PromptManagerConfig.update_config(
{"autotag": {"defaults": {"skipped_tags": ["sky", "a,b"]}}}
)
self.assertEqual(self.config_class.SKIPPED_TAGS, ())
self.assertEqual(
[record.getMessage() for record in logs.records],
[
"autotag.defaults.skipped_tags entry 2 must not contain a comma"
" or a line break; using no skipped tags"
],
)
def test_tagging_validators_moved_with_their_messages(self):
validate_threshold = config_module.validate_threshold
-12
View File
@@ -146,18 +146,6 @@ class TestPreviewQueueRefusedAtShutdown(unittest.TestCase):
class TestMapParallelRefusedAtShutdown(unittest.TestCase):
def setUp(self):
# The job pool is long-lived (#523): start each test without one, so
# the patched pool class is the one created.
self.assertTrue(parallel.shutdown_job_pool(30))
self.addCleanup(parallel.shutdown_job_pool, 30)
def test_the_refused_pool_is_not_reused(self):
with mock.patch.object(parallel, "ThreadPoolExecutor", _ShutPool):
parallel.map_parallel(lambda n: n, [1, 2], 2)
self.assertFalse(parallel.job_pool_started())
self.assertEqual(parallel.map_parallel(lambda n: n + 1, [1, 2], 2), [2, 3])
def test_a_shut_pool_runs_the_items_on_the_calling_thread(self):
records = _capture(self, "prompt_manager.parallel")
with mock.patch.object(parallel, "ThreadPoolExecutor", _ShutPool):
-241
View File
@@ -1,241 +0,0 @@
"""Image work runs on PromptManager's own pools, never the default executor (#523).
glibc keeps each thread's malloc arena at its high-water mark, so image work
spread over the default executor's threads (min(32, cpus + 4)) kept an
image-sized buffer per thread. Interactive image work (card facts, inspector
reads, view previews) runs on the fixed interactive image pool
(``pm-image``); jobs (scan metadata, thumbnail jobs, capture previews) on the
long-lived Worker threads pool (``pm-worker``). After a scan or thumbnail
job, glibc's free pages are released once (``malloc_trim``).
"""
import asyncio
import threading
from unittest.mock import patch
import py.api as api_package
import py.api.images as images_module
import py.api.inspection_service as inspection_service
import py.api.thumbnail_auto as auto
from utils import parallel
try:
from tests.test_api_images import make_png
from tests.test_thumbnail_auto import WAIT, AutoThumbnailCase, _entry
except ImportError: # discovered with tests/ as the top-level directory
from test_api_images import make_png
from test_thumbnail_auto import WAIT, AutoThumbnailCase, _entry
IMAGE_THREAD = auto.POOL_PREFIX + "_"
JOB_THREAD = parallel.JOB_THREAD_PREFIX + "_"
class ImagePoolCase(AutoThumbnailCase):
async def asyncSetUp(self):
await super().asyncSetUp()
self.assertTrue(parallel.shutdown_job_pool(WAIT))
self.addCleanup(parallel.shutdown_job_pool, WAIT)
self.threads = []
self.lock = threading.Lock()
def spy(self, owner, name):
"""Record the thread every call of ``owner.name`` runs on."""
original = getattr(owner, name)
def recording(*args, **kwargs):
with self.lock:
self.threads.append(threading.current_thread().name)
return original(*args, **kwargs)
patcher = patch.object(owner, name, recording)
patcher.start()
self.addCleanup(patcher.stop)
def assert_threads(self, prefix):
self.assertTrue(self.threads, "the spied call never ran")
self.assertTrue(
all(name.startswith(prefix) for name in self.threads), self.threads
)
async def run_job(self, path, body=None):
response = await self.client.post(path, json=body or {})
self.assertEqual(response.status, 202, await response.text())
job = self.api._jobs.get((await response.json())["job"]["id"])
await asyncio.wait_for(asyncio.shield(job.task), WAIT)
return job
class TestInteractiveImagePool(ImagePoolCase):
async def test_workspace_card_facts_are_read_on_the_image_pool(self):
for index in range(5):
make_png(self.output_dir / f"c{index}.png", size=(32, 32))
self.spy(images_module, "_card_header")
response = await self.client.get(
"/prompt_manager/images/output?mode=workspace&limit=100"
)
self.assertEqual(response.status, 200, await response.text())
images = (await response.json())["images"]
self.assertEqual([image["width"] for image in images], [32] * 5)
self.assert_threads(IMAGE_THREAD)
self.assertEqual(len(self.threads), 5)
async def test_cached_card_facts_need_no_image_pool_trip(self):
make_png(self.output_dir / "c.png", size=(32, 32))
url = "/prompt_manager/images/output?mode=workspace&limit=100"
await (await self.client.get(url)).read()
self.spy(images_module, "_card_header")
response = await self.client.get(url)
self.assertEqual((await response.json())["images"][0]["width"], 32)
self.assertEqual(self.threads, [])
async def test_the_inspector_reads_on_the_image_pool(self):
make_png(self.output_dir / "i.png", size=(32, 32))
self.spy(inspection_service, "read_inspection_file")
response = await self.client.get("/prompt_manager/images/inspect/i.png?root=0")
self.assertEqual(response.status, 200, await response.text())
self.assert_threads(IMAGE_THREAD)
async def test_view_previews_and_card_reads_share_three_threads(self):
names = [f"v{i}.png" for i in range(auto.ON_DEMAND_LIMIT + 3)]
for name in names:
make_png(self.output_dir / name, size=(64, 64))
calls = []
original = self.api._generate_one
def recording(*args, **kwargs):
calls.append(threading.current_thread().name)
return original(*args, **kwargs)
self.api._generate_one = recording
for name in names:
url = _entry(self.api, self.output_dir, name)["thumbnail_url"]
await (await self.client.get(url)).read()
self.assertEqual(len(calls), len(names))
self.assertTrue(all(name.startswith(IMAGE_THREAD) for name in calls), calls)
self.assertLessEqual(len(self.api._image_pool_threads()), auto.IMAGE_POOL_SIZE)
def test_map_image_keeps_order_and_runs_inline_on_a_pool_thread(self):
self.assertEqual(self.api._map_image(lambda n: n * 2, [3, 1, 2]), [6, 2, 4])
nested = self.api._image_pool().submit(
self.api._map_image, lambda _: threading.current_thread().name, [1]
)
(name,) = nested.result(WAIT)
self.assertTrue(name.startswith(IMAGE_THREAD), name)
def test_map_image_after_a_close_runs_on_the_calling_thread(self):
pool = self.api._image_pool()
pool.shutdown(wait=True)
here = threading.current_thread().name
with patch.object(self.api, "_image_pool", return_value=pool):
names = self.api._map_image(
lambda _: threading.current_thread().name, [1, 2]
)
self.assertEqual(names, [here, here])
async def test_after_the_final_close_no_image_thread_starts(self):
await asyncio.get_running_loop().run_in_executor(
None, lambda: self.api.close_auto_thumbnails(final=True)
)
self.assertIsNone(self.api._image_pool())
here = threading.current_thread().name
names = self.api._map_image(lambda _: threading.current_thread().name, [1])
self.assertEqual(names, [here])
name = await self.api._run_image(lambda: threading.current_thread().name)
self.assertFalse(name.startswith(IMAGE_THREAD), name)
self.assertEqual(self.api._image_pool_threads(), [])
def test_map_image_runs_items_a_shutdown_cancelled_here(self):
entered = threading.Barrier(auto.IMAGE_POOL_SIZE + 1)
release = threading.Event()
self.addCleanup(release.set)
pool = self.api._image_pool()
for _ in range(auto.IMAGE_POOL_SIZE): # occupy every pool thread
pool.submit(lambda: (entered.wait(WAIT), release.wait(WAIT)))
entered.wait(WAIT)
here = threading.current_thread().name
results, submitted = {}, threading.Event()
class Counting:
calls = 0
def submit(self, *args):
future = pool.submit(*args)
Counting.calls += 1
if Counting.calls == 2:
submitted.set()
return future
def call():
with patch.object(self.api, "_image_pool", return_value=Counting()):
results["names"] = self.api._map_image(
lambda _: threading.current_thread().name, [1, 2]
)
caller = threading.Thread(target=call, name="map-image-caller")
caller.start()
# The caller's two items are queued behind the busy threads; the
# shutdown cancels them and the caller runs them itself.
self.assertTrue(submitted.wait(WAIT))
pool.shutdown(wait=False, cancel_futures=True)
caller.join(WAIT)
release.set()
self.assertFalse(caller.is_alive())
self.assertEqual(results["names"], ["map-image-caller"] * 2)
self.assertNotEqual(here, "map-image-caller")
async def test_run_image_after_a_close_uses_the_default_executor(self):
pool = self.api._image_pool()
pool.shutdown(wait=True)
with patch.object(self.api, "_image_pool", return_value=pool):
name = await self.api._run_image(lambda: threading.current_thread().name)
self.assertFalse(name.startswith(IMAGE_THREAD), name)
class TestJobPool(ImagePoolCase):
def test_scan_metadata_is_read_on_the_job_pool(self):
paths = [
make_png(self.output_dir / f"s{i}.png", size=(16, 16)) for i in range(4)
]
self.spy(self.api, "_extract_comfyui_metadata")
results = self.api._extract_batch_metadata_sync(paths)
self.assertEqual([path for path, _ in results], paths)
self.assert_threads(JOB_THREAD)
async def test_thumbnail_job_writes_on_the_job_pool_and_trims_after(self):
for index in range(3):
make_png(self.output_dir / f"t{index}.png", size=(64, 64))
self.spy(self.api, "_generate_one")
with patch.object(api_package, "release_free_memory") as trim:
job = await self.run_job("/prompt_manager/jobs/thumbnails")
self.assertEqual(job.state, "finished")
self.assert_threads(JOB_THREAD)
trim.assert_called_once_with()
async def test_scan_job_trims_after_it_finishes(self):
make_png(self.output_dir / "s.png", size=(16, 16))
states = []
def trim():
states.append(self.api._jobs.active("scan"))
return True
with patch.object(api_package, "release_free_memory", side_effect=trim):
job = await self.run_job("/prompt_manager/jobs/scan")
self.assertEqual(job.state, "finished")
self.assertGreaterEqual(len(states), 1)
self.assertIsNone(states[0], "trimmed after the scan finished")
async def test_a_failing_trim_never_fails_the_job(self):
make_png(self.output_dir / "t.png", size=(64, 64))
with patch.object(
api_package, "release_free_memory", side_effect=RuntimeError("no")
):
job = await self.run_job("/prompt_manager/jobs/thumbnails")
self.assertEqual(job.state, "finished")
async def test_capture_previews_write_on_the_job_pool(self):
path = make_png(self.output_dir / "a.png", size=(64, 64))
self.spy(self.api, "_generate_one")
self.assertEqual(self.api.queue_capture_thumbnail(str(path)), "queued")
await self._idle()
self.assert_threads(JOB_THREAD)
-67
View File
@@ -136,11 +136,6 @@ def cleanup_package(package, require_stopped=True):
if registration is not None:
assert registration.dispose(3), "package history worker leaked"
close_every_database()
# The package's exit actions outlive it otherwise: at interpreter exit
# they would import from a package no longer in sys.modules (#580).
exit_signal = getattr(package, "_exit_signal", None)
if exit_signal is not None:
exit_signal.reset()
sys.modules.pop(package.__name__, None)
@@ -407,29 +402,6 @@ class TestConsoleBanner(unittest.TestCase):
self.assertNotIn("Loaded:", banner)
class TestFixtureExitActions(unittest.TestCase):
"""A disposed fixture package runs nothing at interpreter exit (#580)."""
def test_a_disposed_package_leaves_no_exit_action(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.addCleanup(close_every_database)
output_dir = os.path.join(tmp.name, "output")
os.makedirs(output_dir)
name = "prompt_manager_pkg_exit_actions"
self.addCleanup(sys.modules.pop, name, None)
with contextlib.redirect_stdout(io.StringIO()):
with load_package(name, os.path.join(tmp.name, "p.db"), output_dir) as (
package,
_,
):
signal = package._exit_signal
self.assertTrue(signal._on_exit, "the package installs exit actions")
self.assertEqual(signal._on_exit, [])
class PriorFileHandler(logging.FileHandler):
"""A caller-owned sink that stays closed once its owner closes it.
@@ -799,45 +771,6 @@ class TestPackageTeardown(PackageTeardownCase):
self.assertIsNone(seen["worker"])
self.assertTrue(all(result.values()), result)
def test_teardown_stops_the_job_pool_once_a_job_used_it(self):
"""#523: the long-lived job pool is a teardown part of its own."""
package = self.load()
parallel = sys.modules[package.__name__ + ".utils.parallel"]
self.assertEqual(parallel.map_parallel(lambda n: n * 2, [1, 2], 2), [2, 4])
# This package's own pool (the suite's top-level one may also run).
threads = list(parallel._pool[1]._threads)
self.assertTrue(threads)
result = package._stop_background_work()
self.assertEqual(list(result), ["monitor", "thumbnails", "workers", "tracker"])
self.assertTrue(all(result.values()), result)
self.assertFalse(parallel.job_pool_started())
self.assertFalse(any(t.is_alive() for t in threads))
def test_teardown_reports_a_job_thread_past_its_bound(self):
package = self.load(require_stopped=False)
parallel = sys.modules[package.__name__ + ".utils.parallel"]
entered, release = threading.Event(), threading.Event()
def hold(_):
entered.set()
release.wait(30)
caller = threading.Thread(target=parallel.map_parallel, args=(hold, [1], 1))
caller.start()
self.addCleanup(caller.join, 30)
self.addCleanup(release.set)
self.assertTrue(entered.wait(30))
with (
mock.patch.object(parallel, "JOB_POOL_JOIN_SECONDS", 0.05),
self.assertLogs("prompt_manager.init", "WARNING") as logs,
):
result = package._stop_background_work()
self.assertFalse(result["workers"])
self.assertTrue(result["tracker"])
self.assertRegex("\n".join(logs.output), r"not stopped in time .*workers")
def test_teardown_kills_ffmpeg_and_stops_the_preview_worker(self):
package = self.load()
held = self.hold_a_preview(package, advance=4.0, kill_releases=True)
-86
View File
@@ -1,86 +0,0 @@
"""``release_free_memory``: glibc's malloc_trim(0), a no-op elsewhere (#523)."""
import sys
import unittest
from unittest import mock
from utils import native_memory
class ReleaseFreeMemoryTests(unittest.TestCase):
def setUp(self):
# Each test looks the symbol up again.
patcher = mock.patch.multiple(native_memory, _trim=None, _looked_up=False)
patcher.start()
self.addCleanup(patcher.stop)
@unittest.skipUnless(native_memory._glibc(), "glibc only")
def test_glibc_trims_once_per_call(self):
self.assertTrue(native_memory.release_free_memory())
self.assertTrue(native_memory.release_free_memory())
def test_other_platforms_do_nothing(self):
for platform in ("win32", "darwin", "cygwin"):
with self.subTest(platform), mock.patch.object(sys, "platform", platform):
native_memory._looked_up = False
with mock.patch.object(native_memory.ctypes, "CDLL") as cdll:
self.assertFalse(native_memory.release_free_memory())
cdll.assert_not_called()
def test_musl_does_nothing(self):
with (
mock.patch.object(sys, "platform", "linux"),
# Windows has no os.confstr: this test fakes Linux there too.
mock.patch.object(
native_memory.os,
"confstr",
side_effect=ValueError("unknown name"),
create=True,
),
mock.patch.object(native_memory.ctypes, "CDLL") as cdll,
):
self.assertFalse(native_memory.release_free_memory())
cdll.assert_not_called()
def test_a_missing_symbol_is_false(self):
library = mock.Mock(spec=[]) # no malloc_trim attribute
with (
mock.patch.object(native_memory, "_glibc", return_value=True),
mock.patch.object(native_memory.ctypes, "CDLL", return_value=library),
):
self.assertFalse(native_memory.release_free_memory())
def test_an_unloadable_libc_is_false(self):
with (
mock.patch.object(native_memory, "_glibc", return_value=True),
mock.patch.object(native_memory.ctypes, "CDLL", side_effect=OSError),
):
self.assertFalse(native_memory.release_free_memory())
def test_a_failing_trim_never_raises(self):
trim = mock.Mock(side_effect=OSError("boom"))
library = mock.Mock(malloc_trim=trim)
with (
mock.patch.object(native_memory, "_glibc", return_value=True),
mock.patch.object(native_memory.ctypes, "CDLL", return_value=library),
):
self.assertFalse(native_memory.release_free_memory())
trim.assert_called_once_with(0)
def test_the_symbol_is_looked_up_once(self):
trim = mock.Mock(return_value=1)
library = mock.Mock(malloc_trim=trim)
with (
mock.patch.object(native_memory, "_glibc", return_value=True),
mock.patch.object(
native_memory.ctypes, "CDLL", return_value=library
) as cdll,
):
self.assertTrue(native_memory.release_free_memory())
self.assertTrue(native_memory.release_free_memory())
cdll.assert_called_once_with(None)
self.assertEqual(trim.call_count, 2)
if __name__ == "__main__":
unittest.main()
-455
View File
@@ -1,455 +0,0 @@
"""PNG text without a pixel decode (#523), against the legacy reader floor.
Pillow's ``PngImageFile.text`` loads (decodes) every pixel to reach text
chunks written after IDAT. ``utils.png_text.png_text`` walks the remaining
chunks instead. The legacy reader (``Image.open(path).text``, every failure
read as ``{}``) is the floor: a file it read must read the same, and the
curated corpus below must read identically. The documented difference is
pixel data, which the walk never decodes: undecodable or cut-short pixel data
made the legacy reader fail, the walk still reads the text before it.
"""
import io
import random
import struct
import tempfile
import unittest
import zlib
from pathlib import Path
from unittest.mock import patch
from PIL import Image, ImageFile, PngImagePlugin
from utils.metadata_extractor import ComfyUIMetadataExtractor
from utils.png_text import png_text
FIXTURES = Path(__file__).resolve().parent / "fixtures"
IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"}
SIGNATURE = b"\x89PNG\r\n\x1a\n"
FUZZ_SEED = 523
FUZZ_CASES = 400
def legacy_text(path):
"""The legacy scan reader: Pillow's ``.text`` (a full decode)."""
try:
with Image.open(path) as image:
return dict(getattr(image, "text", None) or {})
except Exception:
return {}
def walked_text(path):
"""The new reader under the same failure rule."""
try:
with Image.open(path) as image:
return png_text(image)
except Exception:
return {}
def chunk(kind, data):
return (
struct.pack(">I", len(data))
+ kind
+ data
+ struct.pack(">I", zlib.crc32(kind + data) & 0xFFFFFFFF)
)
def chunks_of(raw):
"""``[(kind, data)]`` of a well-formed PNG."""
found, offset = [], 8
while offset < len(raw):
length, kind = struct.unpack(">I4s", raw[offset : offset + 8])
found.append((kind, raw[offset + 8 : offset + 8 + length]))
offset += 12 + length
return found
def assemble(parts):
return SIGNATURE + b"".join(
part if isinstance(part, bytes) else chunk(*part) for part in parts
)
def base_png(mode="RGB", size=(64, 64), text=None, noise=False):
"""A PNG from Pillow (text before IDAT), as ``[(kind, data)]``."""
image = Image.new(mode, size)
if noise:
rng = random.Random(7)
image = Image.frombytes(
"RGB", size, bytes(rng.randrange(256) for _ in range(size[0] * size[1] * 3))
)
info = PngImagePlugin.PngInfo()
for key, value in (text or {}).items():
info.add_text(key, value)
buffer = io.BytesIO()
image.save(buffer, format="PNG", pnginfo=info)
return chunks_of(buffer.getvalue())
def split_idat(parts, pieces=3):
"""Spread the IDAT payload over ``pieces`` consecutive IDAT chunks."""
data = b"".join(d for k, d in parts if k == b"IDAT")
step = max(1, len(data) // pieces)
idats = [(b"IDAT", data[i : i + step]) for i in range(0, len(data), step)]
first = next(i for i, (k, _) in enumerate(parts) if k == b"IDAT")
rest = [p for p in parts if p[0] != b"IDAT"]
return rest[:first] + idats + rest[first:]
def before_iend(parts, *extra):
return parts[:-1] + list(extra) + parts[-1:]
def text_chunk(key, value):
return (b"tEXt", key.encode("latin-1") + b"\0" + value.encode("latin-1"))
def ztxt_chunk(key, value):
return (b"zTXt", key.encode() + b"\0\0" + zlib.compress(value.encode("latin-1")))
def itxt_chunk(key, value, compressed=False):
body = value.encode("utf-8")
if compressed:
body = zlib.compress(body)
flag = b"\1\0" if compressed else b"\0\0"
return (b"iTXt", key.encode() + b"\0" + flag + b"en\0" + b"Title\0" + body)
def bad_crc(kind, data):
good = chunk(kind, data)
return good[:-4] + bytes(b ^ 0xFF for b in good[-4:])
PROMPT = '{"3": {"class_type": "KSampler", "inputs": {"seed": 1}}}'
WORKFLOW = '{"nodes": [], "links": []}'
def curated():
"""Name to PNG bytes; each must read the same with both readers."""
plain = base_png(text={"prompt": PROMPT})
trailing = before_iend(
base_png(), text_chunk("prompt", PROMPT), text_chunk("workflow", WORKFLOW)
)
cases = {
"no-text": assemble(base_png()),
"text-before-pixels": assemble(plain),
"text-after-pixels": assemble(trailing),
"ztxt-after-pixels": assemble(
before_iend(base_png(), ztxt_chunk("prompt", PROMPT))
),
"itxt-plain-and-compressed": assemble(
before_iend(
base_png(),
itxt_chunk("prompt", "café " + PROMPT),
itxt_chunk("workflow", WORKFLOW, compressed=True),
)
),
"xmp-after-pixels": assemble(
before_iend(
base_png(),
itxt_chunk("XML:com.adobe.xmp", "<x:xmpmeta/>"),
text_chunk("prompt", PROMPT),
)
),
"itxt-bad-utf8": assemble(
before_iend(
base_png(),
(b"iTXt", b"prompt\0\0\0\0\0\xff\xfe"),
text_chunk("workflow", WORKFLOW),
)
),
"later-copy-wins": assemble(
before_iend(plain, text_chunk("prompt", '{"later": 1}'))
),
"empty-key-and-no-separator": assemble(
before_iend(base_png(), (b"tEXt", b"\0value"), (b"tEXt", b"lonely"))
),
"split-pixels-text-between": assemble(
split_idat(before_iend(base_png(noise=True), text_chunk("prompt", PROMPT)))
),
"palette": assemble(
before_iend(base_png(mode="P"), text_chunk("prompt", PROMPT))
),
"sixteen-bit": assemble(
before_iend(base_png(mode="I;16"), text_chunk("prompt", PROMPT))
),
"bad-crc-before-pixels": assemble(
[base_png()[0], bad_crc(*text_chunk("prompt", PROMPT))] + base_png()[1:]
),
"bad-crc-after-pixels": assemble(
base_png()[:-1] + [bad_crc(*text_chunk("prompt", PROMPT))] + base_png()[-1:]
),
"unknown-chunk-after-pixels": assemble(
before_iend(base_png(), (b"prVt", b"x" * 40), text_chunk("prompt", PROMPT))
),
"garbage-chunk-id-after-pixels": assemble(
base_png()[:-1]
+ [b"\0\0\0\4\x00\x01\x02\x03abcd\0\0\0\0"]
+ [chunk(*text_chunk("prompt", PROMPT))]
+ base_png()[-1:]
),
"oversized-ztxt-after-pixels": assemble(
before_iend(
base_png(),
(
b"zTXt",
b"prompt\0\0"
+ zlib.compress(b"a" * (PngImagePlugin.MAX_TEXT_CHUNK + 10)),
),
)
),
"pixels-length-past-end": assemble(
[p for p in base_png() if p[0] != b"IDAT" and p[0] != b"IEND"]
)
+ b"\xff\xff\x00\x00IDAT"
+ b"\0" * 64,
}
full = assemble(trailing)
idat_at = full.index(b"IDAT")
cases["truncated-in-pixels"] = full[: idat_at + 20]
cases["no-end-after-pixels"] = full[: full.rindex(b"IEND") - 4]
cases["truncated-in-trailing-text"] = full[: full.rindex(b"workflow") + 4]
cases["unknown-chunk-past-end"] = (
assemble(before_iend(base_png(), ztxt_chunk("prompt", PROMPT))[:-1])
+ chunk(b"prVt", b"x" * 40)[:-20]
)
cases["not-a-png"] = b"nope, not an image at all"
cases["signature-only"] = SIGNATURE
return cases
def corrupt_pixels():
"""Complete IDAT chunks with valid CRCs over data zlib cannot inflate."""
parts = before_iend(base_png(), text_chunk("prompt", PROMPT))
return assemble(
[(k, b"\x78\x9c" + b"\xff" * 30) if k == b"IDAT" else (k, d) for k, d in parts]
)
def apng():
frames = [Image.new("RGB", (16, 16), colour) for colour in ("red", "blue")]
info = PngImagePlugin.PngInfo()
info.add_text("prompt", PROMPT)
buffer = io.BytesIO()
frames[0].save(
buffer, format="PNG", save_all=True, append_images=frames[1:], pnginfo=info
)
return assemble(
before_iend(chunks_of(buffer.getvalue()), text_chunk("workflow", WORKFLOW))
)
def fixture_images():
return sorted(p for p in FIXTURES.rglob("*") if p.suffix.lower() in IMAGE_SUFFIXES)
class _TempFiles(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
def write(self, name, raw):
path = Path(self._tmp.name) / name
path.write_bytes(raw)
return path
class PngTextParityTests(_TempFiles):
def test_every_fixture_image_reads_like_the_legacy_reader(self):
images = fixture_images()
self.assertGreaterEqual(len(images), 25)
for path in images:
with self.subTest(path=path.relative_to(FIXTURES)):
self.assertEqual(walked_text(path), legacy_text(path))
def test_curated_corpus_reads_like_the_legacy_reader(self):
for name, raw in curated().items():
with self.subTest(name):
path = self.write(name + ".png", raw)
self.assertEqual(walked_text(path), legacy_text(path))
def test_curated_corpus_exercises_trailing_text(self):
"""The corpus is not vacuous: trailing text exists and is read."""
cases = curated()
path = self.write("t.png", cases["text-after-pixels"])
self.assertEqual(walked_text(path), {"prompt": PROMPT, "workflow": WORKFLOW})
with Image.open(path) as image:
self.assertNotIn("prompt", image.info)
self.assertEqual(
walked_text(self.write("x.png", cases["truncated-in-pixels"])), {}
)
def test_itxt_values_keep_their_language_and_type(self):
path = self.write("i.png", curated()["itxt-plain-and-compressed"])
new, old = walked_text(path), legacy_text(path)
self.assertIsInstance(new["prompt"], PngImagePlugin.iTXt)
self.assertEqual(new["prompt"].lang, old["prompt"].lang)
self.assertEqual(new["prompt"].tkey, old["prompt"].tkey)
def test_animated_png_falls_back_to_the_legacy_read(self):
path = self.write("a.png", apng())
self.assertEqual(walked_text(path), legacy_text(path))
self.assertEqual(walked_text(path)["workflow"], WORKFLOW)
def test_truncated_pixels_read_alike_when_truncated_images_load(self):
"""With LOAD_TRUNCATED_IMAGES set (a node pack may set it) both readers
accept a short file alike."""
path = self.write("t.png", curated()["truncated-in-pixels"])
with patch.object(ImageFile, "LOAD_TRUNCATED_IMAGES", True):
self.assertEqual(walked_text(path), legacy_text(path))
def test_a_text_decode_error_ends_the_walk_like_legacy(self):
"""Pillow's ``load_end`` stops at a chunk whose handler raises
``UnicodeDecodeError`` and keeps the text read so far."""
path = self.write(
"u.png",
assemble(
before_iend(
base_png(),
ztxt_chunk("prompt", PROMPT),
text_chunk("workflow", WORKFLOW),
ztxt_chunk("parameters", "steps 20"),
)
),
)
def undecodable(stream, position, length):
raise UnicodeDecodeError("latin-1", b"", 0, 1, "test")
with patch.object(PngImagePlugin.PngStream, "chunk_tEXt", undecodable):
old = legacy_text(path)
self.assertEqual(walked_text(path), old)
self.assertEqual(old, {"prompt": PROMPT})
def test_the_text_memory_budget_counts_text_before_the_pixels(self):
"""Pillow's MAX_TEXT_MEMORY spans the whole file: text before and
after the pixels, each under it, fail together in both readers."""
path = self.write(
"m.png",
assemble(
before_iend(
base_png(text={"prompt": PROMPT}), text_chunk("workflow", WORKFLOW)
)
),
)
budget = len(PROMPT) + len(WORKFLOW) - 1
with patch.object(PngImagePlugin, "MAX_TEXT_MEMORY", budget):
self.assertEqual(legacy_text(path), {})
self.assertEqual(walked_text(path), {})
with patch.object(PngImagePlugin, "MAX_TEXT_MEMORY", budget + 1):
self.assertEqual(walked_text(path), legacy_text(path))
self.assertEqual(len(walked_text(path)), 2)
def test_complete_but_undecodable_pixels_read_more_than_legacy(self):
path = self.write("c.png", corrupt_pixels())
self.assertEqual(legacy_text(path), {})
self.assertEqual(walked_text(path), {"prompt": PROMPT})
def test_cut_short_pixels_read_the_text_before_them(self):
full = assemble(base_png(text={"prompt": PROMPT}))
path = self.write("s.png", full[: full.index(b"IDAT") + 12])
self.assertEqual(legacy_text(path), {})
self.assertEqual(walked_text(path), {"prompt": PROMPT})
def test_mutated_files_never_read_less_than_legacy(self):
"""Seeded fuzz: truncations and byte flips of a file with text before,
between and after its pixel chunks. Wherever the legacy reader found
text, the walk finds exactly the same."""
base = assemble(
split_idat(
before_iend(
base_png(text={"workflow": WORKFLOW}),
ztxt_chunk("prompt", PROMPT),
itxt_chunk("parameters", "steps 20"),
)
)
)
rng = random.Random(FUZZ_SEED)
compared = 0
for case in range(FUZZ_CASES):
raw = bytearray(base)
if case % 2:
del raw[rng.randrange(8, len(raw)) :]
else:
for _ in range(rng.randrange(1, 4)):
raw[rng.randrange(8, len(raw))] = rng.randrange(256)
path = self.write(f"fuzz{case}.png", bytes(raw))
old = legacy_text(path)
if old:
compared += 1
with self.subTest(case=case):
self.assertEqual(walked_text(path), old)
self.assertGreater(compared, FUZZ_CASES // 5)
def test_non_png_images_have_no_text(self):
for name in ("tiny.jpg", "tiny.webp"):
with Image.open(FIXTURES / "local-inspect" / name) as image:
self.assertEqual(png_text(image), {})
class NoPixelDecodeTests(_TempFiles):
"""Metadata reads never decode pixels (the #523 memory finding)."""
def png_fixtures(self):
return sorted((FIXTURES / "inspection_parity").glob("*.png"))
def test_png_text_does_not_load_pixels_and_leaves_the_image_usable(self):
path = self.write("t.png", curated()["text-after-pixels"])
with Image.open(path) as image:
with patch.object(ImageFile.ImageFile, "load", autospec=True) as load:
self.assertEqual(png_text(image)["workflow"], WORKFLOW)
load.assert_not_called()
image.load()
self.assertEqual(image.size, (64, 64))
self.assertEqual(image.text["workflow"], WORKFLOW)
def test_scan_reader_does_not_decode_pixels(self):
from py.api import PromptManagerAPI
api = PromptManagerAPI.__new__(PromptManagerAPI)
fixtures = self.png_fixtures() + [
self.write("t.png", curated()["text-after-pixels"])
]
with patch.object(ImageFile.ImageFile, "load", autospec=True) as load:
found = [api._extract_comfyui_metadata(str(p)) for p in fixtures]
load.assert_not_called()
self.assertEqual(found, [legacy_text(p) for p in fixtures])
self.assertTrue(all(found))
def test_monitor_extractor_does_not_decode_pixels(self):
extractor = ComfyUIMetadataExtractor()
fixtures = self.png_fixtures()
with patch.object(ImageFile.ImageFile, "load", autospec=True) as load:
found = [extractor.extract_metadata(str(p)) for p in fixtures]
load.assert_not_called()
self.assertTrue(all(metadata and len(metadata) > 1 for metadata in found))
class MonitorExtractorParityTests(_TempFiles):
"""``extract_metadata`` answers exactly as with the legacy ``.text``."""
def legacy_extract(self, path):
def old_text(image):
return dict(getattr(image, "text", None) or {})
with patch("utils.metadata_extractor.png_text", old_text):
return ComfyUIMetadataExtractor().extract_metadata(str(path))
def test_every_fixture_and_curated_file_extracts_like_legacy(self):
paths = fixture_images() + [
self.write(name + ".png", raw) for name, raw in curated().items()
]
extractor = ComfyUIMetadataExtractor()
for path in paths:
with self.subTest(path=path.name):
self.assertEqual(
extractor.extract_metadata(str(path)), self.legacy_extract(path)
)
if __name__ == "__main__":
unittest.main()
-457
View File
@@ -1,457 +0,0 @@
"""One resolver for where previews live on disk (#320 PR 1).
``py/api/preview_store.py`` owns every mapping between an image source, a
media file and its preview on disk. In source mode (the only mode so far)
it returns exactly the paths the code built by hand before
(``<source>/thumbnails/<rel>_thumb<ext>``), so nothing changes on disk. The
structural test keeps new ``thumbnails`` path joins out of the rest of the
backend.
"""
import asyncio
import os
import re
import sys
import tempfile
import threading
import unittest
from pathlib import Path
import unittest.mock
from unittest.mock import MagicMock
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.modules.setdefault("server", MagicMock())
from py.api import preview_store, thumbnail_settings # noqa: E402
from py.api.thumbnail_encode import ( # noqa: E402
PreviewWriteHeld,
replace_unless_held,
)
REPO = Path(__file__).resolve().parent.parent
WAIT = 10
class SourceModeCase(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
self.root = Path(self._tmp.name) / "output"
self.root.mkdir()
class TestSourceModePaths(SourceModeCase):
def test_default_location_is_inside_each_source(self):
location = preview_store.current()
self.assertEqual(location.mode, preview_store.SOURCE_MODE)
self.assertTrue(location.available)
self.assertIsNone(location.problem)
def test_store_of_a_source_is_its_thumbnails_folder(self):
self.assertEqual(preview_store.store_dir(self.root), self.root / "thumbnails")
def test_preview_path_matches_the_old_join(self):
rel = Path("sub", "deep", "a.png")
self.assertEqual(
preview_store.preview_path(self.root, rel, ".webp"),
self.root / "thumbnails/sub/deep/a_thumb.webp",
)
self.assertEqual(
preview_store.preview_path(self.root, "a.mp4", ".jpg"),
self.root / "thumbnails" / "a_thumb.jpg",
)
def test_preview_path_defaults_to_the_written_extension(self):
from py.api.thumbnail_encode import writer_settings
self.assertEqual(
preview_store.preview_path(self.root, "a.png").suffix,
writer_settings().extension,
)
def test_request_path_maps_url_parts_to_the_old_join(self):
parts = ("thumbnails", "sub", "a_thumb.webp")
self.assertEqual(
preview_store.request_path(self.root, parts), self.root.joinpath(*parts)
)
def test_request_path_is_none_for_anything_but_preview_parts(self):
self.assertIsNone(preview_store.request_path(self.root, ("sub", "a.png")))
self.assertIsNone(preview_store.request_path(self.root, ("Thumbnails", "a")))
self.assertIsNone(preview_store.request_path(self.root, ("thumbnails",)))
def test_ensure_store_is_the_store(self):
self.assertEqual(
preview_store.ensure_store(self.root), self.root / "thumbnails"
)
def test_owns_a_preview_inside_the_store(self):
inside = self.root / "thumbnails" / "sub" / "a_thumb.webp"
self.assertTrue(preview_store.owns(self.root, inside))
self.assertFalse(preview_store.owns(self.root, self.root / "a.png"))
self.assertFalse(
preview_store.owns(self.root, self.root.parent / "elsewhere.webp")
)
@unittest.skipUnless(hasattr(os, "symlink"), "needs symlinks")
def test_owns_refuses_a_store_linked_out_of_the_source(self):
outside = Path(self._tmp.name) / "outside"
outside.mkdir()
try:
(self.root / "thumbnails").symlink_to(outside, target_is_directory=True)
except OSError:
self.skipTest("symlinks not permitted")
self.assertFalse(
preview_store.owns(self.root, self.root / "thumbnails" / "a_thumb.webp")
)
@unittest.skipUnless(hasattr(os, "symlink"), "needs symlinks")
def test_owns_refuses_a_preview_folder_linked_out_of_the_store(self):
outside = Path(self._tmp.name) / "outside"
outside.mkdir()
(self.root / "thumbnails").mkdir()
try:
(self.root / "thumbnails" / "sub").symlink_to(
outside, target_is_directory=True
)
except OSError:
self.skipTest("symlinks not permitted")
self.assertFalse(
preview_store.owns(
self.root, self.root / "thumbnails" / "sub" / "a_thumb.webp"
)
)
def test_store_container_is_the_source(self):
self.assertEqual(preview_store.store_container(self.root), self.root)
def test_given_store_argument_is_kept_unless_it_is_the_default(self):
default = self.root / "thumbnails"
other = Path(self._tmp.name) / "custom"
self.assertEqual(preview_store.given_store(self.root, default), default)
self.assertEqual(preview_store.given_store(self.root, other), other)
class TestPreviewText(unittest.TestCase):
def test_preview_text_is_an_exact_thumbnails_prefix_and_preview_name(self):
for text in (
"thumbnails/a_thumb.webp",
"thumbnails/sub/a_thumb.jxl",
"thumbnails/a_thumb.JPG",
"thumbnails/a_thumb.Png",
):
with self.subTest(text=text):
self.assertTrue(preview_store.is_preview_text(text))
def test_other_text_is_not_preview_text(self):
for text in (
"Thumbnails/a_thumb.webp",
"thumbnails/a.png",
"thumbnails/_thumb.webp",
"thumbnails/a_thumb.txt",
"sub/thumbnails/a_thumb.webp",
"thumbnails/../a_thumb.webp",
"thumbnails//a_thumb.webp",
"thumbnails/./a_thumb.webp",
"thumbnails\\a_thumb.webp",
"thumbnails/c:a_thumb.webp",
"thumbnails/sub/thumbnails/a_thumb.webp",
"thumbnails",
"",
):
with self.subTest(text=text):
self.assertFalse(preview_store.is_preview_text(text))
class TestWritesAndTheSwitch(SourceModeCase):
def _temp_and_dst(self):
folder = self.root / "thumbnails"
folder.mkdir()
tmp = folder / ".a_thumb.part-1-1.webp"
tmp.write_bytes(b"new")
return tmp, folder / "a_thumb.webp"
def test_a_write_from_the_current_generation_lands(self):
tmp, dst = self._temp_and_dst()
replace_unless_held(tmp, dst, preview_store.write_guard())
self.assertEqual(dst.read_bytes(), b"new")
def test_a_write_whose_generation_changed_is_dropped(self):
tmp, dst = self._temp_and_dst()
guard = preview_store.write_guard()
preview_store.bump_generation()
with self.assertRaises(PreviewWriteHeld):
replace_unless_held(tmp, dst, guard)
self.assertFalse(dst.exists())
def test_the_hold_check_still_applies(self):
tmp, dst = self._temp_and_dst()
with self.assertRaises(PreviewWriteHeld):
replace_unless_held(tmp, dst, preview_store.write_guard(lambda: False))
self.assertFalse(dst.exists())
def test_the_check_and_the_rename_run_under_the_write_lock(self):
"""The hold check and the rename hold ``write_lock``, the lock the
generation bump takes: a switch cannot fall between them."""
tmp, dst = self._temp_and_dst()
in_check = threading.Event()
release = threading.Event()
seen = {}
def slow_hold():
seen["locked"] = preview_store.write_lock.locked()
in_check.set()
release.wait(WAIT)
return True
guard = preview_store.write_guard(slow_hold)
writer = threading.Thread(target=replace_unless_held, args=(tmp, dst, guard))
writer.start()
try:
self.assertTrue(in_check.wait(WAIT))
self.assertTrue(seen["locked"])
self.assertTrue(preview_store.write_lock.locked())
finally:
release.set()
writer.join(WAIT)
self.assertFalse(preview_store.write_lock.locked())
self.assertEqual(dst.read_bytes(), b"new")
bumped = preview_store.bump_generation()
self.assertEqual(preview_store.generation(), bumped)
class TestOnlyTheResolverBuildsStorePaths(unittest.TestCase):
"""No backend module joins ``thumbnails`` onto a path by hand any more."""
ALLOWED = {
Path("py/api/preview_store.py"),
Path("py/api/thumbnail_settings.py"), # URL text
}
# A path join with a literal "thumbnails" operand.
JOIN = re.compile(
r"""/\s*["']thumbnails["']"""
r"""|["']thumbnails["']\s*/"""
r"""|joinpath\([^)]*["']thumbnails["']"""
r"""|os\.path\.join\([^)]*["']thumbnails["']"""
r"""|["']thumbnails/"""
r"""|/\s*PREVIEW_FOLDER|joinpath\([^)]*PREVIEW_FOLDER"""
r"""|Path\([^)]*,\s*["']thumbnails["']"""
)
def test_no_thumbnails_join_outside_the_resolver(self):
offenders = []
files = sorted(REPO.glob("*.py"))
for folder in ("py", "utils", "database"):
files += sorted((REPO / folder).rglob("*.py"))
for path in files:
rel = path.relative_to(REPO)
if rel in self.ALLOWED:
continue
for number, line in enumerate(
path.read_text(encoding="utf-8").splitlines(), 1
):
code = line.split("#", 1)[0]
if self.JOIN.search(code):
offenders.append(f"{rel}:{number}: {line.strip()}")
self.assertEqual(offenders, [])
class TestSettingsLookupsFollowTheStore(SourceModeCase):
def test_owned_thumbnails_reads_the_store(self):
folder = self.root / "thumbnails" / "sub"
folder.mkdir(parents=True)
(folder / "a_thumb.webp").write_bytes(b"x")
self.assertEqual(
thumbnail_settings.owned_thumbnails(self.root, Path("sub", "a.png")),
[folder / "a_thumb.webp"],
)
try:
from tests.test_thumbnail_auto import AutoThumbnailCase, _entry
from tests.test_api_images import make_png
except ImportError: # discovered with tests/ as the top-level directory
from test_thumbnail_auto import AutoThumbnailCase, _entry
from test_api_images import make_png
class RedirectedStoreCase(AutoThumbnailCase):
"""Every caller follows the resolver: with the store of the output root
moved to ``<tmp>/elsewhere/output-key`` (a stand-in for a later mode),
previews are read, written, listed and removed there and only there."""
async def asyncSetUp(self):
await super().asyncSetUp()
self.container = self.comfy_dir / "elsewhere"
self.store = self.container / "output-key"
self.store.mkdir(parents=True)
self.old_store = self.output_dir / "thumbnails"
root = self.output_dir
real_store = preview_store.store_dir
real_container = preview_store.store_container
def store_dir(r, location=None):
return self.store if Path(r) == root else real_store(r, location)
def store_container(r, location=None):
if Path(r) == root:
return self.container
return real_container(r, location)
for name, fake in (
("store_dir", store_dir),
("store_container", store_container),
):
patcher = unittest.mock.patch.object(preview_store, name, fake)
patcher.start()
self.addCleanup(patcher.stop)
def _preview(self, rel, ext=".webp"):
return self.store / (thumbnail_settings.preview_stem(rel) + ext)
class TestEveryCallerFollowsTheStore(RedirectedStoreCase):
async def test_an_existing_preview_is_served_from_the_store(self):
make_png(self.output_dir / "sub" / "a.png", size=(64, 64))
preview = self._preview("sub/a.png")
make_png(preview, size=(32, 32))
url = _entry(self.api, self.output_dir, "sub/a.png")["thumbnail_url"]
self.assertIn("v=", url) # the listing found it in the store
resp = await self.client.get(url)
self.assertEqual(resp.status, 200)
self.assertEqual(await resp.read(), preview.read_bytes())
async def test_a_legacy_upper_case_preview_is_served_from_the_store(self):
make_png(self.output_dir / "k.JPG", size=(64, 64))
legacy = self.store / "k_thumb.JPG"
legacy.write_bytes(b"legacy preview")
resp = await self.client.get(
"/prompt_manager/images/serve/thumbnails/k_thumb.JPG?root=0"
)
self.assertEqual(resp.status, 200)
self.assertEqual(await resp.read(), b"legacy preview")
async def test_a_missing_preview_is_made_in_the_store_on_first_view(self):
make_png(self.output_dir / "sub" / "b.png", size=(640, 480))
url = _entry(self.api, self.output_dir, "sub/b.png")["thumbnail_url"]
resp = await self.client.get(url)
self.assertEqual(resp.status, 200)
self.assertTrue(self._preview("sub/b.png").is_file())
self.assertFalse(self.old_store.exists())
async def test_a_leftover_in_the_source_folder_is_not_served(self):
make_png(self.output_dir / "c.png", size=(640, 480))
leftover = self.old_store / "c_thumb.webp"
leftover.parent.mkdir()
leftover.write_bytes(b"old preview bytes")
resp = await self.client.get(
"/prompt_manager/images/serve/thumbnails/c_thumb.webp?root=0"
)
self.assertEqual(resp.status, 200)
self.assertNotEqual(await resp.read(), b"old preview bytes")
self.assertTrue(self._preview("c.png").is_file())
async def test_capture_writes_into_the_store(self):
path = make_png(self.output_dir / "run" / "d.png", size=(64, 64))
self.assertEqual(self.api.queue_capture_thumbnail(str(path)), "queued")
await self._idle()
self.assertTrue(self._preview("run/d.png").is_file())
self.assertFalse(self.old_store.exists())
async def test_generate_and_clear_follow_the_store(self):
make_png(self.output_dir / "e.png", size=(64, 64))
payload = await asyncio.get_running_loop().run_in_executor(
None, self.api._generate_gallery_thumbnails_sync, [self.output_dir]
)
self.assertTrue(payload["success"])
self.assertTrue(self._preview("e.png").is_file())
self.assertFalse(self.old_store.exists())
self.assertTrue(payload["thumbnails_path"].endswith("output-key"))
cleared, _size = self.api._clear_gallery_thumbnails_sync([self.output_dir])
self.assertEqual(cleared, 1)
self.assertFalse(self._preview("e.png").exists())
self.assertTrue(self.store.is_dir()) # the store itself stays
async def test_removing_a_file_removes_its_preview_in_the_store(self):
from py.api.admin import _remove_thumbnail
make_png(self.output_dir / "f.png", size=(64, 64))
preview = self._preview("f.png")
preview.write_bytes(b"x")
self.assertTrue(_remove_thumbnail(self.output_dir, Path("f.png")))
self.assertFalse(preview.exists())
async def test_the_temp_sweep_walks_the_store(self):
stray = self.store / ".g_thumb.part-1-1.webp"
stray.write_bytes(b"x")
old = os.path.getmtime(stray) - 10 * 24 * 3600
os.utime(stray, (old, old))
self.assertEqual(self.api.sweep_thumbnail_temps(), 1)
self.assertFalse(stray.exists())
async def test_history_cover_writes_into_the_store(self):
from py.api import history_current
self.assertTrue(
history_current._preview_writable(self.output_dir, self._preview("h.png"))
)
self.assertFalse(
history_current._preview_writable(
self.output_dir, self.old_store / "h_thumb.webp"
)
)
class TestCaptureResolvesAtWriteTime(RedirectedStoreCase):
def test_a_queued_capture_follows_a_store_that_moved_before_its_write(self):
from py.api import thumbnail_auto as auto
written = []
thumbnailer = auto.AutoThumbnailer(
lambda src, dst, *a, **k: written.append(dst) or {"action": "generated"},
lambda: True,
self.api.logger,
start_worker=False,
)
self.addCleanup(thumbnailer.close)
src = make_png(self.output_dir / "i.png", size=(32, 32))
deferred = auto.DeferredPreview(self.output_dir, Path("i.png"))
self.assertTrue(thumbnailer.enqueue(src, deferred, False))
moved = self.container / "moved"
moved.mkdir()
self.store = moved # the location changes before the worker runs
thumbnailer._write_queued(thumbnailer._queue.popleft())
self.assertEqual(written, [moved / "i_thumb.webp"])
def test_the_after_scan_queue_maps_each_target_to_its_own_root(self):
from py.api import thumbnail_auto as auto
nested = self.output_dir / "inner"
nested.mkdir()
src = nested / "j.png"
outer = auto._deferred(
[self.output_dir, nested], src, self._preview("inner/j.png")
)
inner = auto._deferred(
[self.output_dir, nested], src, nested / "thumbnails" / "j_thumb.webp"
)
self.assertEqual(
(outer.root, outer.rel), (self.output_dir, Path("inner/j.png"))
)
self.assertEqual((inner.root, inner.rel), (nested, Path("j.png")))
stray = self.comfy_dir / "x_thumb.webp"
self.assertEqual(auto._deferred([self.output_dir], src, stray), stray)
def test_serve_and_history_share_one_flight_key(self):
from py.api import thumbnail_auto as auto
parts = ("thumbnails", "sub", "a_thumb.webp")
self.assertEqual(
auto._request_key(self.output_dir, parts),
auto._dst_key(self._preview("sub/a.png")),
)
if __name__ == "__main__":
unittest.main()
-539
View File
@@ -1,539 +0,0 @@
"""One folder mode (#320 PR 2): previews in ``<folder>/<source key>``.
Folder mode is reachable from config.json and tests only (D4B). These tests
set it on the resolver directly (``preview_store.configure``) and drive
every route that reads, writes, lists or removes previews: they must touch
only ``<folder>/<key>``, never ``<source>/thumbnails``, never through a link,
and nothing at all while the folder is unavailable. Waits are events; no
sleeps.
"""
import asyncio
import json
import os
import sys
import threading
import unittest
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
try:
from tests.test_thumbnail_auto import AutoThumbnailCase, _entry, WAIT
from tests.test_api_images import make_png
except ImportError: # discovered with tests/ as the top-level directory
from test_thumbnail_auto import AutoThumbnailCase, _entry, WAIT
from test_api_images import make_png
from py.api import preview_location, preview_store, thumbnail_settings # noqa: E402
SERVE = "/prompt_manager/images/serve/"
class FolderModeCase(AutoThumbnailCase):
"""The output root's previews live in ``<comfy>/thumbs/<key>``."""
async def asyncSetUp(self):
await super().asyncSetUp()
self.folder = self.use_folder(self.comfy_dir / "thumbs")
self.old_store = self.output_dir / "thumbnails"
def use_folder(self, folder, install="install-a"):
folder.mkdir(exist_ok=True)
marker_id = preview_location.init_folder(str(folder), created_by_pm=True)
self.addCleanup(preview_store.configure, None)
preview_store.configure(
{"mode": "folder", "path": str(folder), "id": marker_id, "install": install}
)
self.assertTrue(preview_store.current().available)
return folder
def key_folder(self):
store = preview_store.ensure_store(self.output_dir, create=True)
self.assertIsNotNone(store)
return store
def preview(self, rel, ext=".webp"):
return self.key_folder() / (thumbnail_settings.preview_stem(rel) + ext)
def marker(self):
return json.loads(
(self.folder / preview_location.MARKER_NAME).read_text(encoding="utf-8")
)
class TestFolderModeRoutes(FolderModeCase):
async def test_the_key_is_a_readable_name_plus_a_hash_in_the_marker(self):
key = self.key_folder().name
self.assertRegex(key, r"^output-[0-9a-f]{8}$")
entry = self.marker()["keys"][key]
self.assertEqual(entry["path"], str(self.output_dir))
self.assertEqual(entry["install"], "install-a")
async def test_another_case_of_the_source_finds_the_same_key(self):
key = self.key_folder()
other = Path(str(self.output_dir).swapcase())
if not other.is_dir():
self.skipTest("a case-sensitive volume")
self.assertEqual(preview_store.store_dir(other), key)
@unittest.skipUnless(hasattr(os, "symlink"), "needs symlinks")
async def test_a_linked_spelling_of_the_source_finds_the_same_key(self):
key = self.key_folder()
link = self.comfy_dir / "output-link"
try:
link.symlink_to(self.output_dir, target_is_directory=True)
except OSError:
self.skipTest("symlinks not permitted")
self.assertEqual(preview_store.store_dir(link), key)
async def test_an_existing_preview_is_listed_and_served_from_the_key_folder(self):
make_png(self.output_dir / "sub" / "a.png", size=(64, 64))
preview = self.preview("sub/a.png")
make_png(preview, size=(32, 32))
url = _entry(self.api, self.output_dir, "sub/a.png")["thumbnail_url"]
self.assertIn("v=", url)
self.assertTrue(url.startswith(SERVE + "thumbnails/sub/a_thumb.webp"))
resp = await self.client.get(url)
self.assertEqual(resp.status, 200)
self.assertEqual(await resp.read(), preview.read_bytes())
async def test_a_missing_preview_is_made_in_the_key_folder(self):
make_png(self.output_dir / "b.png", size=(640, 480))
url = _entry(self.api, self.output_dir, "b.png")["thumbnail_url"]
resp = await self.client.get(url)
self.assertEqual(resp.status, 200)
self.assertTrue(self.preview("b.png").is_file())
self.assertFalse(self.old_store.exists())
async def test_a_leftover_in_the_source_folder_is_never_served(self):
make_png(self.output_dir / "c.png", size=(640, 480))
self.old_store.mkdir()
(self.old_store / "c_thumb.webp").write_bytes(b"leftover")
resp = await self.client.get(SERVE + "thumbnails/c_thumb.webp?root=0")
self.assertEqual(resp.status, 200)
self.assertNotEqual(await resp.read(), b"leftover")
async def test_other_text_under_thumbnails_is_refused(self):
self.old_store.mkdir()
make_png(self.old_store / "notes.png", size=(8, 8))
resp = await self.client.get(SERVE + "thumbnails/notes.png?root=0")
self.assertEqual(resp.status, 403)
async def test_traversal_through_thumbnails_is_refused(self):
for text in (
"thumbnails/../../thumbs/x_thumb.webp",
"thumbnails/..%2F..%2Fx_thumb.webp",
"thumbnails/%2e%2e/x_thumb.webp",
):
with self.subTest(text=text):
resp = await self.client.get(SERVE + text + "?root=0")
self.assertIn(resp.status, (403, 404))
@unittest.skipUnless(hasattr(os, "symlink"), "needs symlinks")
async def test_a_link_out_of_the_key_folder_is_never_served_or_written(self):
outside = self.comfy_dir / "outside"
outside.mkdir()
make_png(outside / "d_thumb.webp", size=(8, 8))
make_png(self.output_dir / "sub" / "d.png", size=(64, 64))
try:
(self.key_folder() / "sub").symlink_to(outside, target_is_directory=True)
except OSError:
self.skipTest("symlinks not permitted")
resp = await self.client.get(SERVE + "thumbnails/sub/d_thumb.webp?root=0")
self.assertEqual(resp.status, 403)
self.assertFalse(preview_store.owns(self.output_dir, outside / "d_thumb.webp"))
self.assertFalse(
preview_store.owns(self.output_dir, self.key_folder() / "sub" / "x.webp")
)
@unittest.skipUnless(os.name == "nt", "junctions are Windows only")
async def test_a_junction_inside_a_store_is_refused(self):
import _winapi
outside = self.comfy_dir / "outside"
outside.mkdir()
(outside / "e_thumb.webp").write_bytes(b"x")
_winapi.CreateJunction(str(outside), str(self.key_folder() / "sub"))
self.assertTrue(preview_location.is_link_like(self.key_folder() / "sub"))
resp = await self.client.get(SERVE + "thumbnails/sub/e_thumb.webp?root=0")
self.assertEqual(resp.status, 403)
async def test_capture_writes_into_the_key_folder(self):
path = make_png(self.output_dir / "run" / "f.png", size=(64, 64))
self.assertEqual(self.api.queue_capture_thumbnail(str(path)), "queued")
await self._idle()
self.assertTrue(self.preview("run/f.png").is_file())
self.assertFalse(self.old_store.exists())
async def test_clear_removes_previews_only(self):
make_png(self.output_dir / "g.png", size=(64, 64))
payload = await asyncio.get_running_loop().run_in_executor(
None, self.api._generate_gallery_thumbnails_sync, [self.output_dir]
)
self.assertTrue(payload["success"])
keep = self.key_folder() / "readme.txt"
keep.write_text("not a preview", encoding="utf-8")
resp = await self.client.post("/prompt_manager/images/clear-thumbnails")
self.assertEqual(resp.status, 200)
self.assertFalse(self.preview("g.png").exists())
self.assertTrue(keep.exists())
self.assertTrue((self.folder / preview_location.MARKER_NAME).is_file())
self.assertTrue(self.output_dir.joinpath("g.png").is_file())
async def test_removing_a_file_removes_its_preview_in_the_key_folder(self):
from py.api.admin import _remove_thumbnail
make_png(self.output_dir / "h.png", size=(64, 64))
preview = self.preview("h.png")
preview.write_bytes(b"x")
self.assertTrue(_remove_thumbnail(self.output_dir, Path("h.png")))
self.assertFalse(preview.exists())
async def test_history_cover_is_authorized_by_the_store(self):
from py.api import history_current
preview = self.preview("i.png")
preview.write_bytes(b"x")
target = history_current._canonical(preview)
self.assertTrue(
history_current._target_authorized(
target, self.output_dir, None, self.output_dir
)
)
stray = self.comfy_dir / "stray_thumb.webp"
stray.write_bytes(b"x")
self.assertFalse(
history_current._target_authorized(
history_current._canonical(stray),
self.output_dir,
None,
self.output_dir,
)
)
async def test_the_thumbnail_folder_is_never_recreated_by_a_write(self):
make_png(self.output_dir / "j.png", size=(64, 64))
self.key_folder()
for child in sorted(self.folder.rglob("*"), reverse=True):
child.rmdir() if child.is_dir() else child.unlink()
self.folder.rmdir()
with self.assertRaises(FileNotFoundError):
preview_store.make_parent(self.folder / "output-x" / "sub" / "a.webp")
self.assertFalse(self.folder.exists())
class TestUnavailableFolder(FolderModeCase):
def go_missing(self):
(self.folder / preview_location.MARKER_NAME).unlink()
self.assertFalse(preview_store.refresh())
location = preview_store.current()
self.assertEqual((location.available, location.problem), (False, "missing"))
async def test_a_folder_without_its_marker_is_missing_and_writes_nothing(self):
make_png(self.output_dir / "k.png", size=(640, 480))
self.go_missing()
entry = _entry(self.api, self.output_dir, "k.png")
self.assertFalse(entry.get("thumbnail_ready", False))
resp = await self.client.get(SERVE + "thumbnails/k_thumb.webp?root=0")
self.assertEqual(resp.status, 200)
self.assertEqual(resp.headers["Cache-Control"], "no-store")
self.assertEqual(
await resp.read(), (self.output_dir / "k.png").read_bytes()
) # the original
self.assertEqual(
sorted(p.name for p in self.folder.rglob("*") if p.is_file()), []
)
self.assertFalse(self.old_store.exists())
async def test_thumbnail_work_is_refused_while_unavailable(self):
self.go_missing()
for method, url, body in (
("POST", "/prompt_manager/images/generate-thumbnails", {}),
("POST", "/prompt_manager/images/generate-thumbnails/progress", None),
("POST", "/prompt_manager/images/clear-thumbnails", None),
("POST", "/prompt_manager/jobs/thumbnails", {"mode": "missing"}),
):
with self.subTest(url=url):
resp = await self.client.request(method, url, json=body)
self.assertEqual(resp.status, 409)
self.assertEqual((await resp.json())["code"], "location-unavailable")
async def test_status_reports_the_location_without_a_path(self):
self.go_missing()
resp = await self.client.get("/prompt_manager/images/thumbnails/status")
location = (await resp.json())["location"]
self.assertEqual(
location, {"mode": "folder", "available": False, "problem": "missing"}
)
async def test_another_folders_marker_is_a_mismatch(self):
marker = self.marker()
marker["id"] = "someone-else"
(self.folder / preview_location.MARKER_NAME).write_text(
json.dumps(marker), encoding="utf-8"
)
self.assertFalse(preview_store.refresh())
self.assertEqual(preview_store.current().problem, "marker-mismatch")
async def test_the_temp_sweep_skips_an_unavailable_folder(self):
stray = self.key_folder() / ".l_thumb.part-1-1.webp"
stray.write_bytes(b"x")
old = os.path.getmtime(stray) - 10 * 24 * 3600
os.utime(stray, (old, old))
self.go_missing()
self.assertEqual(self.api.sweep_thumbnail_temps(), 0)
self.assertTrue(stray.exists())
async def test_a_stalled_store_call_marks_it_unreachable_and_stops_io(self):
make_png(self.output_dir / "m.png", size=(64, 64))
self.preview("m.png").write_bytes(b"x")
ticks = iter([0.0, preview_store.IO_BUDGET + 1.0])
with patch.object(preview_store, "_clock", lambda: next(ticks, 99.0)):
_entry(self.api, self.output_dir, "m.png") # one slow lookup
self.assertEqual(preview_store.current().problem, "unreachable")
calls = []
real = thumbnail_settings.owned_in_folder
with patch.object(
thumbnail_settings,
"owned_in_folder",
lambda *a, **k: calls.append(a) or real(*a, **k),
):
entry = _entry(self.api, self.output_dir, "m.png")
self.assertEqual(calls, []) # no store I/O while unreachable
self.assertNotIn("v=", entry["thumbnail_url"])
self.assertTrue(preview_store.refresh()) # the probe finds it again
async def test_a_check_that_blocks_is_unreachable_within_the_budget(self):
entered = threading.Event()
release = threading.Event()
self.addCleanup(release.set)
def blocking(path, expected_id):
entered.set()
release.wait(WAIT)
return True, None, None
with (
patch.object(preview_store, "IO_BUDGET", 0.2),
patch.object(preview_location, "folder_state", blocking),
):
self.assertFalse(preview_store.refresh())
self.assertTrue(entered.is_set())
self.assertEqual(preview_store.current().problem, "unreachable")
release.set()
async def test_an_invalid_config_value_writes_nothing_anywhere(self):
preview_store.configure({"mode": "folder", "path": "relative/thumbs"})
location = preview_store.current()
self.assertEqual((location.available, location.problem), (False, "invalid"))
make_png(self.output_dir / "n.png", size=(640, 480))
resp = await self.client.get(SERVE + "thumbnails/n_thumb.webp?root=0")
self.assertEqual(resp.status, 200)
self.assertFalse(self.old_store.exists())
class TestSharedFolders(FolderModeCase):
async def test_two_installs_keep_each_others_keys(self):
self.key_folder()
other_root = self.comfy_dir / "other"
other_root.mkdir()
setting = preview_store.setting()
preview_store.configure(
{
"mode": "folder",
"path": setting.path,
"id": setting.id,
"install": "install-b",
}
)
self.assertIsNotNone(preview_store.ensure_store(other_root))
keys = self.marker()["keys"]
installs = sorted(entry["install"] for entry in keys.values())
self.assertEqual(installs, ["install-a", "install-b"])
async def test_init_folder_adopts_an_existing_marker(self):
marker_id = self.marker()["id"]
self.assertEqual(preview_location.init_folder(str(self.folder)), marker_id)
if __name__ == "__main__":
unittest.main()
class TestHostileMarker(FolderModeCase):
"""A hand-edited or foreign marker never takes a write or a delete out of
the thumbnail folder (shared and UNC folders can be edited by others)."""
def plant(self, keys):
marker = self.marker()
marker["keys"] = keys
(self.folder / preview_location.MARKER_NAME).write_text(
json.dumps(marker), encoding="utf-8"
)
self.assertTrue(preview_store.refresh())
def outside_files(self):
return sorted(
p.relative_to(self.comfy_dir).as_posix()
for p in self.comfy_dir.rglob("*")
if p.is_file()
and not p.is_relative_to(self.folder)
and not p.is_relative_to(self.output_dir)
and p.suffix in (".webp", ".jpg", ".png")
)
async def test_keys_that_are_not_one_folder_name_are_dropped(self):
entry = {"path": str(self.output_dir), "identity": "x", "install": "i"}
self.plant(
{
"../escape-0123abcd": entry,
str(self.comfy_dir / "abs-0123abcd"): entry,
"a/b-0123abcd": entry,
"a\\b-0123abcd": entry,
"Upper-0123abcd": entry,
"nohash": entry,
"fine-0123abcd": "not a dict",
}
)
self.assertEqual(preview_location.read_marker(str(self.folder))["keys"], {})
store = self.key_folder()
self.assertEqual(store.parent, self.folder)
make_png(self.output_dir / "p.png", size=(640, 480))
resp = await self.client.get(SERVE + "thumbnails/p_thumb.webp?root=0")
self.assertEqual(resp.status, 200)
self.assertTrue((store / "p_thumb.webp").is_file())
self.assertEqual(self.outside_files(), [])
@unittest.skipUnless(hasattr(os, "symlink"), "needs symlinks")
async def test_a_key_folder_that_is_a_link_is_never_written_or_cleared(self):
outside = self.comfy_dir / "outside"
outside.mkdir()
victim = outside / "q_thumb.webp"
victim.write_bytes(b"keep me")
store = self.key_folder()
store.rmdir()
try:
store.symlink_to(outside, target_is_directory=True)
except OSError:
self.skipTest("symlinks not permitted")
make_png(self.output_dir / "q.png", size=(640, 480))
from py.api.admin import _remove_thumbnail
self.assertFalse(_remove_thumbnail(self.output_dir, Path("q.png")))
resp = await self.client.post("/prompt_manager/images/clear-thumbnails")
self.assertIn(resp.status, (200, 400))
resp = await self.client.get(SERVE + "thumbnails/q_thumb.webp?root=0")
self.assertIn(resp.status, (200, 403))
self.assertEqual(victim.read_bytes(), b"keep me")
self.assertEqual(sorted(p.name for p in outside.iterdir()), ["q_thumb.webp"])
@unittest.skipUnless(hasattr(os, "symlink"), "needs symlinks")
async def test_a_linked_folder_below_a_key_is_never_deleted_through(self):
outside = self.comfy_dir / "outside"
outside.mkdir()
victim = outside / "r_thumb.webp"
victim.write_bytes(b"keep me")
try:
(self.key_folder() / "sub").symlink_to(outside, target_is_directory=True)
except OSError:
self.skipTest("symlinks not permitted")
make_png(self.output_dir / "sub" / "r.png", size=(64, 64))
from py.api.admin import _remove_thumbnail
self.assertFalse(_remove_thumbnail(self.output_dir, Path("sub/r.png")))
resp = await self.client.post("/prompt_manager/images/clear-thumbnails")
self.assertEqual(resp.status, 200)
self.assertEqual(victim.read_bytes(), b"keep me")
@unittest.skipUnless(os.name == "nt", "junctions are Windows only")
async def test_a_key_folder_that_is_a_junction_is_never_written_or_cleared(self):
import _winapi
outside = self.comfy_dir / "outside"
outside.mkdir()
victim = outside / "v_thumb.webp"
victim.write_bytes(b"keep me")
store = self.key_folder()
store.rmdir()
_winapi.CreateJunction(str(outside), str(store))
make_png(self.output_dir / "v.png", size=(640, 480))
from py.api.admin import _remove_thumbnail
self.assertFalse(_remove_thumbnail(self.output_dir, Path("v.png")))
resp = await self.client.post("/prompt_manager/images/clear-thumbnails")
self.assertIn(resp.status, (200, 400))
resp = await self.client.get(SERVE + "thumbnails/v_thumb.webp?root=0")
self.assertIn(resp.status, (200, 403))
self.assertEqual(victim.read_bytes(), b"keep me")
self.assertEqual(sorted(p.name for p in outside.iterdir()), ["v_thumb.webp"])
async def test_a_write_outside_the_folder_is_refused(self):
with self.assertRaises(OSError):
preview_store.make_parent(self.comfy_dir / "elsewhere" / "a_thumb.webp")
self.assertFalse((self.comfy_dir / "elsewhere").exists())
async def test_an_unmounted_folder_takes_no_write_before_the_next_check(self):
"""An unmounted mount point is an empty folder: the flag may still
say available, but without the marker nothing is made there."""
store = self.key_folder()
store.rmdir()
(self.folder / preview_location.MARKER_NAME).unlink()
self.assertTrue(preview_store.current().available) # not checked yet
with self.assertRaises(OSError):
preview_store.make_parent(store / "sub" / "s_thumb.webp")
self.assertEqual(list(self.folder.iterdir()), [])
class TestStartupNeverFallsBackToTheSources(AutoThumbnailCase):
async def test_a_folder_setting_is_in_force_before_its_check(self):
self.addCleanup(preview_store.configure, None)
folder = self.comfy_dir / "thumbs"
value = {"mode": "folder", "path": str(folder), "id": "x"}
preview_store.configure(value, check=False)
location = preview_store.current()
self.assertEqual((location.mode, location.available), ("folder", False))
make_png(self.output_dir / "t.png", size=(640, 480))
resp = await self.client.get(SERVE + "thumbnails/t_thumb.webp?root=0")
self.assertEqual(resp.status, 200)
self.assertFalse((self.output_dir / "thumbnails").exists())
async def test_a_failed_startup_check_leaves_the_folder_invalid(self):
from py.config import PromptManagerConfig
self.addCleanup(preview_store.configure, None)
value = {"mode": "folder", "path": str(self.comfy_dir / "thumbs"), "id": "x"}
with (
patch.object(PromptManagerConfig, "THUMBNAIL_LOCATION", value),
patch.object(
type(self.api),
"_thumbnail_location_context",
side_effect=RuntimeError("boom"),
),
):
self.api.start_preview_location()
location = preview_store.current()
self.assertEqual(
(location.mode, location.available, location.problem),
("folder", False, "invalid"),
)
class TestNoStoreIoOnTheEventLoop(FolderModeCase):
async def test_legacy_listings_do_not_stat_the_folder(self):
make_png(self.output_dir / "u.png", size=(64, 64))
self.preview("u.png").write_bytes(b"x")
calls = []
real = thumbnail_settings.owned_in_folder
with patch.object(
thumbnail_settings,
"owned_in_folder",
lambda *a, **k: calls.append(threading.current_thread().name)
or real(*a, **k),
):
images = self.api._enrich_images(
[{"image_path": str(self.output_dir / "u.png")}]
)
self.assertEqual(calls, [])
self.assertEqual(images[0]["thumbnail_url"], SERVE + "thumbnails/u_thumb.webp")
+379
View File
@@ -0,0 +1,379 @@
"""Saved instructions with a default per language-model engine (#431, PR 2).
The entry and default validation, the in-memory migration of the old single
``autotag.defaults.template`` at load (first upgrade and re-upgrade), the
load fallbacks, and ``AutotagConfig.default_instructions_text``.
"""
import hashlib
import json
import os
import sys
import tempfile
import unittest
from unittest.mock import MagicMock
# config.py imports ComfyUI's server module; stand it in (as test_config does).
_mock_server = MagicMock()
_mock_server.PromptServer.instance.routes = MagicMock()
sys.modules.setdefault("server", _mock_server)
from py.config import AutotagConfig, PromptManagerConfig # noqa: E402
from py.tagging_defaults import ( # noqa: E402
BUILTIN_INSTRUCTIONS_ID,
DEFAULT_TAG_TEMPLATE,
MAX_SAVED_INSTRUCTIONS,
validate_default_instructions,
validate_instructions,
)
ATTRS = ("MODEL_TYPE", "INSTRUCTIONS", "DEFAULT_INSTRUCTIONS", "_LEGACY_TEMPLATE")
def entry(id_, name, text="Tags only."):
return {"id": id_, "name": name, "text": text}
def sha12(text):
return hashlib.sha256(text.encode("utf-8")).hexdigest()[:12]
class TestValidation(unittest.TestCase):
def test_entries_are_kept_with_trimmed_names(self):
self.assertEqual(
validate_instructions([entry("0123456789ab", " Photo ", "Tags.")]),
[entry("0123456789ab", "Photo", "Tags.")],
)
self.assertEqual(validate_instructions([]), [])
def test_every_refusal_sentence(self):
ok = entry("0123456789ab", "One")
cases = (
("x", "Saved instructions must be a list."),
(
[
entry(f"{i:012x}", f"n{i}")
for i in range(MAX_SAVED_INSTRUCTIONS + 1)
],
"Saved instructions: at most 20 entries besides Built-in.",
),
([ok, "x"], "Saved instructions: entry 2 has an invalid id."),
(
[entry("ABCDEF012345", "A")],
"Saved instructions: entry 1 has an invalid id.",
),
([entry("builtin", "A")], "Saved instructions: entry 1 has an invalid id."),
(
[{"id": "0123456789ab", "name": "A"}],
"Saved instructions: entry 1 has an invalid id.",
),
(
[ok, entry("0123456789ab", "Two")],
"Saved instructions: entry 2 repeats the id of entry 1.",
),
(
[entry("0123456789ab", " ")],
"Saved instructions: entry 1 needs a name.",
),
(
[entry("0123456789ab", "x" * 61)],
"Saved instructions: entry 1 name must be at most 60 characters.",
),
(
[entry("0123456789ab", "a\nb")],
"Saved instructions: entry 1 name must not contain a line break.",
),
(
[ok, entry("0123456789ac", "one")],
"Saved instructions: entry 2 name “one” is already used.",
),
(
[entry("0123456789ab", "BUILT-IN")],
"Saved instructions: the name “Built-in” is reserved.",
),
(
[entry("0123456789ab", "A", " ")],
"Saved instructions: entry 1 text must be non-empty text.",
),
(
[entry("0123456789ab", "A", "x" * 4001)],
"Saved instructions: entry 1 text must be at most 4000 characters.",
),
)
for value, message in cases:
with self.subTest(message=message):
with self.assertRaises(ValueError) as caught:
validate_instructions(value)
self.assertEqual(str(caught.exception), message)
def test_defaults_name_built_in_or_a_saved_entry(self):
saved = [entry("0123456789ab", "One")]
self.assertEqual(
validate_default_instructions(
{"gguf": "builtin", "hf": "0123456789ab"}, saved
),
{"gguf": "builtin", "hf": "0123456789ab"},
)
for value, message in (
(
{"gguf": "ffffffffffff", "hf": "builtin"},
"Default for GGUF JoyCaption must be one of the saved instructions.",
),
(
{"gguf": "builtin", "hf": "nope"},
"Default for HuggingFace JoyCaption must be one of the saved "
"instructions.",
),
(
{"gguf": "builtin"},
"Default instructions must name GGUF and HuggingFace.",
),
(
{"gguf": [], "hf": "builtin"},
"Default for GGUF JoyCaption must be one of the saved instructions.",
),
("builtin", "Default instructions must name GGUF and HuggingFace."),
):
with self.subTest(value=value):
with self.assertRaises(ValueError) as caught:
validate_default_instructions(value, saved)
self.assertEqual(str(caught.exception), message)
class ConfigTestCase(unittest.TestCase):
def setUp(self):
saved = {attr: getattr(AutotagConfig, attr) for attr in ATTRS}
self.addCleanup(
lambda: [setattr(AutotagConfig, a, v) for a, v in saved.items()]
)
AutotagConfig.update_config({"defaults": {}})
def load(self, defaults, warnings=0):
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "config.json")
with open(path, "w", encoding="utf-8") as handle:
json.dump({"autotag": {"defaults": defaults}}, handle)
before = os.stat(path).st_mtime_ns, open(path, "rb").read()
if warnings:
with self.assertLogs("prompt_manager.config", level="WARNING") as logs:
PromptManagerConfig.load_from_file(path)
self.assertEqual(len(logs.records), warnings, logs.output)
self.logs = [record.getMessage() for record in logs.records]
else:
PromptManagerConfig.load_from_file(path)
# Nothing is written at load.
self.assertEqual(
(os.stat(path).st_mtime_ns, open(path, "rb").read()), before
)
def state(self):
return [dict(e) for e in AutotagConfig.INSTRUCTIONS], dict(
AutotagConfig.DEFAULT_INSTRUCTIONS
)
class TestLoad(ConfigTestCase):
def test_a_new_install_has_built_in_only_for_both_engines(self):
self.load({})
self.assertEqual(self.state(), ([], {"gguf": "builtin", "hf": "builtin"}))
self.assertEqual(
AutotagConfig.default_instructions_text("gguf"), DEFAULT_TAG_TEMPLATE
)
self.assertEqual(
AutotagConfig.all_instructions()[0],
{
"id": BUILTIN_INSTRUCTIONS_ID,
"name": "Built-in",
"text": DEFAULT_TAG_TEMPLATE,
},
)
def test_saved_entries_and_per_engine_defaults_load(self):
saved = [
entry("0123456789ab", "Photo", "Photo tags."),
entry("0123456789ac", "Anime", "Anime tags."),
]
self.load(
{
"instructions": saved,
"default_instructions": {"gguf": "0123456789ac", "hf": "0123456789ab"},
}
)
self.assertEqual(
self.state(), (saved, {"gguf": "0123456789ac", "hf": "0123456789ab"})
)
self.assertEqual(AutotagConfig.default_instructions_text("gguf"), "Anime tags.")
self.assertEqual(AutotagConfig.default_instructions_text("hf"), "Photo tags.")
with self.assertRaises(ValueError):
AutotagConfig.default_instructions_text("wd14-vit")
def test_a_dangling_default_falls_back_to_built_in_with_a_warning(self):
saved = [entry("0123456789ab", "Photo")]
self.load(
{
"instructions": saved,
"default_instructions": {"gguf": "ffffffffffff", "hf": "0123456789ab"},
},
warnings=1,
)
self.assertEqual(
self.state(), (saved, {"gguf": "builtin", "hf": "0123456789ab"})
)
self.assertEqual(
self.logs,
[
"autotag.defaults.default_instructions.gguf names no saved "
"instructions; using Built-in"
],
)
def test_a_damaged_list_falls_back_to_built_in_only_and_the_defaults_follow(self):
self.load(
{
"instructions": [
entry("0123456789ab", "A"),
entry("0123456789ab", "B"),
],
"default_instructions": {"gguf": "0123456789ab", "hf": "builtin"},
},
warnings=1, # one warning; the defaults follow the list silently
)
self.assertEqual(self.state(), ([], {"gguf": "builtin", "hf": "builtin"}))
self.assertEqual(
self.logs[0],
"autotag.defaults.instructions entry 2 repeats the id of entry 1; "
"using Built-in only",
)
def test_damaged_defaults_fall_back_with_a_warning(self):
self.load({"default_instructions": "builtin"}, warnings=1)
self.assertEqual(self.state(), ([], {"gguf": "builtin", "hf": "builtin"}))
class TestMigration(ConfigTestCase):
CUSTOM = "My own tags, please."
def test_first_upgrade_makes_custom_template_the_default_for_both(self):
self.load({"template": self.CUSTOM})
self.assertEqual(
self.state(),
(
[entry(sha12(self.CUSTOM), "Custom template", self.CUSTOM)],
{"gguf": sha12(self.CUSTOM), "hf": sha12(self.CUSTOM)},
),
)
# Deterministic: a second load gives the same ids.
first = self.state()
self.load({"template": self.CUSTOM})
self.assertEqual(self.state(), first)
def test_a_template_equal_to_built_in_creates_no_entry(self):
self.load({"template": DEFAULT_TAG_TEMPLATE})
self.assertEqual(self.state(), ([], {"gguf": "builtin", "hf": "builtin"}))
def test_an_invalid_template_is_ignored_with_todays_warning(self):
self.load({"template": " "}, warnings=1)
self.assertEqual(self.state(), ([], {"gguf": "builtin", "hf": "builtin"}))
self.assertIn("autotag.defaults.template", self.logs[0])
def test_a_stored_default_is_never_overwritten_by_the_first_upgrade(self):
self.load(
{
"template": self.CUSTOM,
"default_instructions": {"gguf": "builtin", "hf": "builtin"},
},
warnings=0,
)
self.assertEqual(
self.state(),
(
[entry(sha12(self.CUSTOM), "Custom template", self.CUSTOM)],
{"gguf": "builtin", "hf": "builtin"},
),
)
def test_the_mirror_of_a_saved_default_imports_nothing(self):
saved = [entry(sha12(self.CUSTOM), "Custom template", self.CUSTOM)]
both = {"gguf": sha12(self.CUSTOM), "hf": "builtin"}
self.load(
{
"template": self.CUSTOM,
"instructions": saved,
"default_instructions": both,
}
)
self.assertEqual(self.state(), (saved, both))
def test_re_upgrade_imports_an_edit_made_on_an_older_build(self):
saved = [entry(sha12(self.CUSTOM), "Custom template", "Edited since.")]
both = {"gguf": sha12(self.CUSTOM), "hf": "builtin"}
with self.assertLogs("prompt_manager.config", level="INFO") as logs:
self.load(
{
"template": self.CUSTOM,
"instructions": saved,
"default_instructions": both,
}
)
instructions, defaults = self.state()
# The text's own hash is taken by the edited entry: the next free one.
imported = hashlib.sha256((self.CUSTOM + "\n2").encode()).hexdigest()[:12]
self.assertEqual(
instructions, saved + [entry(imported, "Custom template (2)", self.CUSTOM)]
)
self.assertEqual(defaults, both)
self.assertIn(
"Imported the tagging template edited on an older version as "
"'Custom template (2)'",
"\n".join(logs.output),
)
def test_re_upgrade_into_a_full_list_imports_nothing_and_warns(self):
saved = [entry(f"{i:012x}", f"n{i}") for i in range(MAX_SAVED_INSTRUCTIONS)]
self.load({"template": self.CUSTOM, "instructions": saved}, warnings=1)
self.assertEqual(self.state()[0], saved)
self.assertIn("full", self.logs[0])
class TestGetConfig(ConfigTestCase):
def test_get_config_writes_the_lists_and_the_gguf_mirror(self):
AutotagConfig.INSTRUCTIONS = (entry("0123456789ab", "Photo", "Photo tags."),)
AutotagConfig.DEFAULT_INSTRUCTIONS = {"gguf": "0123456789ab", "hf": "builtin"}
defaults = AutotagConfig.get_config()["defaults"]
self.assertEqual(
defaults["instructions"], [entry("0123456789ab", "Photo", "Photo tags.")]
)
self.assertEqual(
defaults["default_instructions"], {"gguf": "0123456789ab", "hf": "builtin"}
)
self.assertEqual(defaults["template"], "Photo tags.")
AutotagConfig.DEFAULT_INSTRUCTIONS = {"gguf": "builtin", "hf": "0123456789ab"}
self.assertNotIn("template", AutotagConfig.get_config()["defaults"])
class TestDamagedValues(ConfigTestCase):
def test_a_non_text_default_falls_back_with_a_warning(self):
self.load({"default_instructions": {"gguf": [], "hf": "builtin"}}, warnings=1)
self.assertEqual(self.state(), ([], {"gguf": "builtin", "hf": "builtin"}))
def test_a_damaged_list_warns_once_and_migrates_the_template_afresh(self):
custom = "My own tags, please."
self.load(
{
"template": custom,
"instructions": "broken",
"default_instructions": {"gguf": "0123456789ab", "hf": "builtin"},
},
warnings=1,
)
self.assertEqual(
self.state(),
(
[entry(sha12(custom), "Custom template", custom)],
{"gguf": sha12(custom), "hf": sha12(custom)},
),
)
if __name__ == "__main__":
unittest.main()
+224
View File
@@ -0,0 +1,224 @@
"""Saved instructions through POST /settings and the legacy fallbacks (#431).
Server-side pairing (a save carrying either key writes both, the missing one
from the running, migrated configuration), the downgrade mirror in the old
``template`` key, the refusals, GET /settings, and the legacy single route's
blank-prompt fallback per request engine. The "restart" is a reload of the
isolated config.json (``PROMPT_MANAGER_CONFIG_PATH`` in the temp tree).
"""
import hashlib
try:
from test_settings_sources import SourcesTestCase
from test_autotag_legacy import LegacyTestCase
except ImportError:
from tests.test_settings_sources import SourcesTestCase
from tests.test_autotag_legacy import LegacyTestCase
from py.config import AutotagConfig, PromptManagerConfig
from py.tagging_defaults import DEFAULT_TAG_TEMPLATE
ATTRS = ("MODEL_TYPE", "INSTRUCTIONS", "DEFAULT_INSTRUCTIONS", "_LEGACY_TEMPLATE")
SETTINGS = "/prompt_manager/settings"
CUSTOM = "My own tags, please."
CUSTOM_ID = hashlib.sha256(CUSTOM.encode()).hexdigest()[:12]
def entry(id_, name, text):
return {"id": id_, "name": name, "text": text}
class InstructionsTestCase(SourcesTestCase):
async def asyncSetUp(self):
await super().asyncSetUp()
saved = {attr: getattr(AutotagConfig, attr) for attr in ATTRS}
self.addCleanup(
lambda: [setattr(AutotagConfig, a, v) for a, v in saved.items()]
)
def restart(self):
PromptManagerConfig.load_from_file(self.config_path)
async def save(self, payload, revision=True):
if revision:
payload = {"expected_revision": (await self._get())["revision"], **payload}
return await self._post(SETTINGS, payload)
def disk(self):
return self._read_config()["autotag"]["defaults"]
class TestMigrationPersists(InstructionsTestCase):
async def asyncSetUp(self):
await super().asyncSetUp()
self._write_config(
{"custom": {"keep": True}, "autotag": {"defaults": {"template": CUSTOM}}}
)
self.restart()
async def test_get_shows_the_migrated_entry_and_writes_nothing(self):
before = self._config_bytes()
settings = (await self._get())["settings"]
self.assertEqual(
settings["autotag_instructions"],
[entry(CUSTOM_ID, "Custom template", CUSTOM)],
)
self.assertEqual(
settings["autotag_default_instructions"],
{"gguf": CUSTOM_ID, "hf": CUSTOM_ID},
)
self.assertEqual(settings["autotag_template"], CUSTOM)
self.assertEqual(settings["autotag_builtin_instructions"], DEFAULT_TAG_TEMPLATE)
self.assertEqual(self._config_bytes(), before)
async def test_a_save_of_only_the_hf_default_writes_the_migrated_list_too(self):
status, data = await self.save(
{"autotag_default_instructions": {"gguf": CUSTOM_ID, "hf": "builtin"}}
)
self.assertEqual(status, 200, data)
disk = self.disk()
self.assertEqual(
disk["instructions"], [entry(CUSTOM_ID, "Custom template", CUSTOM)]
)
self.assertEqual(
disk["default_instructions"], {"gguf": CUSTOM_ID, "hf": "builtin"}
)
self.assertEqual(disk["template"], CUSTOM)
self.assertEqual(self._read_config()["custom"], {"keep": True})
self.restart()
self.assertEqual(
(list(AutotagConfig.INSTRUCTIONS), AutotagConfig.DEFAULT_INSTRUCTIONS),
(
[entry(CUSTOM_ID, "Custom template", CUSTOM)],
{"gguf": CUSTOM_ID, "hf": "builtin"},
),
)
async def test_gguf_built_in_deletes_the_mirror_and_keeps_custom_template(self):
status, data = await self.save(
{"autotag_default_instructions": {"gguf": "builtin", "hf": CUSTOM_ID}}
)
self.assertEqual(status, 200, data)
self.assertNotIn("template", self.disk())
self.restart()
self.assertEqual(
list(AutotagConfig.INSTRUCTIONS),
[entry(CUSTOM_ID, "Custom template", CUSTOM)],
)
self.assertEqual(
AutotagConfig.DEFAULT_INSTRUCTIONS, {"gguf": "builtin", "hf": CUSTOM_ID}
)
self.assertEqual(
AutotagConfig.default_instructions_text("gguf"), DEFAULT_TAG_TEMPLATE
)
async def test_a_save_of_only_the_list_keeps_the_defaults_that_still_exist(self):
photo = entry("0123456789ab", "Photo", "Photo tags.")
status, data = await self.save(
{
"autotag_instructions": [
entry(CUSTOM_ID, "Custom template", CUSTOM),
photo,
]
}
)
self.assertEqual(status, 200, data)
self.assertEqual(
self.disk()["default_instructions"], {"gguf": CUSTOM_ID, "hf": CUSTOM_ID}
)
self.assertEqual(AutotagConfig.INSTRUCTIONS[1], photo)
# A list that drops a default without moving it is refused, nothing written.
before = self._config_bytes()
status, data = await self.save({"autotag_instructions": [photo]})
self.assertEqual(status, 400, data)
self.assertEqual(
data["error"],
"Default for GGUF JoyCaption must be one of the saved instructions.",
)
self.assertEqual(self._config_bytes(), before)
class TestRefusals(InstructionsTestCase):
async def test_the_old_template_key_is_refused(self):
before = self._config_bytes()
status, data = await self.save({"autotag_template": "Tags only."})
self.assertEqual(
(status, data["error"]),
(
400,
"LLM tag template was replaced by Saved instructions. Reload the page.",
),
)
self.assertEqual(self._config_bytes(), before)
async def test_instruction_keys_without_a_revision_are_refused(self):
for payload in (
{"autotag_instructions": []},
{"autotag_default_instructions": {"gguf": "builtin", "hf": "builtin"}},
):
with self.subTest(payload=payload):
status, data = await self.save(payload, revision=False)
self.assertEqual(
(status, data["error"]), (400, "Reload the page and try again.")
)
async def test_entry_refusals_name_the_entry(self):
status, data = await self.save(
{
"autotag_instructions": [
entry("0123456789ab", "Photo", "Photo tags."),
entry("0123456789ac", "photo", "x"),
]
}
)
self.assertEqual(
(status, data["error"]),
(400, "Saved instructions: entry 2 name “photo” is already used."),
)
async def test_a_new_entry_can_become_a_default_in_one_save(self):
photo = entry("0123456789ab", "Photo", "Photo tags.")
status, data = await self.save(
{
"autotag_instructions": [photo],
"autotag_default_instructions": {
"gguf": "0123456789ab",
"hf": "builtin",
},
}
)
self.assertEqual(status, 200, data)
self.assertEqual(self.disk()["template"], "Photo tags.")
settings = (await self._get())["settings"]
self.assertEqual(settings["autotag_template"], "Photo tags.")
self.assertEqual(AutotagConfig.default_instructions_text("gguf"), "Photo tags.")
self.assertEqual(AutotagConfig._LEGACY_TEMPLATE, "Photo tags.")
class TestLegacySingleFallback(LegacyTestCase):
async def test_a_blank_prompt_uses_the_request_engines_default(self):
saved = {attr: getattr(AutotagConfig, attr) for attr in ATTRS}
self.addCleanup(
lambda: [setattr(AutotagConfig, a, v) for a, v in saved.items()]
)
AutotagConfig.INSTRUCTIONS = (entry("0123456789ab", "Photo", "Photo tags."),)
AutotagConfig.DEFAULT_INSTRUCTIONS = {"gguf": "builtin", "hf": "0123456789ab"}
prompt = self.prompt("a prompt")
image = self.link(prompt, self.image("a.png", (1, 2, 3), sees=["bird"]))
for model_type, expected in (
("hf", "Photo tags."),
("gguf", DEFAULT_TAG_TEMPLATE),
):
with self.subTest(model_type=model_type):
status, data = await self.single(
{"image_id": image, "model_type": model_type, "prompt": " "}
)
self.assertEqual(status, 200, data)
self.assertEqual(self.tagger.calls[-1]["prompt"], expected)
if __name__ == "__main__":
import unittest
unittest.main()
+72 -6
View File
@@ -1023,15 +1023,17 @@ class TestAutotagDefaultsSettings(SourcesTestCase):
"EXISTING",
"GENERAL_THRESHOLD",
"CHARACTER_THRESHOLD",
"TEMPLATE",
"SUGGESTION_LIMIT",
"SKIPPED_TAGS",
"INSTRUCTIONS",
"DEFAULT_INSTRUCTIONS",
"_LEGACY_TEMPLATE",
)
SAVED = {
"autotag_model_type": "gguf",
"autotag_existing": "merge",
"autotag_general_threshold": 0.4,
"autotag_character_threshold": 0.8,
"autotag_template": "Tags only.",
"autotag_suggestion_limit": 15,
}
@@ -1067,8 +1069,11 @@ class TestAutotagDefaultsSettings(SourcesTestCase):
"EXISTING": "merge",
"GENERAL_THRESHOLD": 0.4,
"CHARACTER_THRESHOLD": 0.8,
"TEMPLATE": "Tags only.",
"SUGGESTION_LIMIT": 15,
"SKIPPED_TAGS": (),
"INSTRUCTIONS": (),
"DEFAULT_INSTRUCTIONS": {"gguf": "builtin", "hf": "builtin"},
"_LEGACY_TEMPLATE": None,
},
)
settings = (await self._get())["settings"]
@@ -1139,11 +1144,11 @@ class TestAutotagDefaultsSettings(SourcesTestCase):
"replace",
"Existing tags must be skip, merge or replace_after_review.",
),
("autotag_template", " ", "LLM tag template must be non-empty text."),
# #431: the old template is refused whatever it holds.
(
"autotag_template",
"x" * 4001,
"LLM tag template must be at most 4000 characters.",
"Tags only.",
"LLM tag template was replaced by Saved instructions. Reload the page.",
),
(
"autotag_suggestion_limit",
@@ -1166,3 +1171,64 @@ class TestAutotagDefaultsSettings(SourcesTestCase):
self.assertEqual(status, 400, data)
self.assertEqual(data["error"], message)
self.assertEqual((self._config_bytes(), self._runtime()), before)
async def test_skipped_tags_save_trimmed_and_read_back(self):
"""#431: the list is saved as typed (trimmed), published at once and
read back by GET /settings."""
self._write_config({"custom": {"keep": True}})
settings = (await self._get())["settings"]
self.assertEqual(settings["autotag_skipped_tags"], [])
revision = (await self._get())["revision"]
status, data = await self._post(
"/prompt_manager/settings",
{
"expected_revision": revision,
"autotag_skipped_tags": [" blurry ", "closed eyes", "(fate)"],
},
)
self.assertEqual(status, 200, data)
saved = self._read_config()
self.assertEqual(
saved["autotag"]["defaults"],
{"skipped_tags": ["blurry", "closed eyes", "(fate)"]},
)
self.assertEqual(saved["custom"], {"keep": True})
self.assertEqual(self.autotag.SKIPPED_TAGS, ("blurry", "closed eyes", "(fate)"))
settings = (await self._get())["settings"]
self.assertEqual(
settings["autotag_skipped_tags"], ["blurry", "closed eyes", "(fate)"]
)
async def test_bad_skipped_tags_are_refused_with_their_sentence(self):
self._write_config({"custom": {"keep": True}})
revision = (await self._get())["revision"]
before = (self._config_bytes(), self._runtime())
for value, message in (
(["sky", " "], "Skipped tags: entry 2 is empty."),
(["x" * 51], "Skipped tags: entry 1 must be at most 50 characters."),
(
["a, b"],
"Skipped tags: entry 1 must not contain a comma or a line break.",
),
(["blurry", "Blurry"], "Skipped tags: entry 2 repeats entry 1."),
([f"w{i}" for i in range(201)], "Skipped tags: at most 200 entries."),
):
with self.subTest(value=value[:3]):
status, data = await self._post(
"/prompt_manager/settings",
{"expected_revision": revision, "autotag_skipped_tags": value},
)
self.assertEqual(status, 400, data)
self.assertEqual(data["error"], message)
self.assertEqual((self._config_bytes(), self._runtime()), before)
async def test_skipped_tags_without_a_revision_are_refused(self):
"""A list saved without expected_revision could merge a stale copy."""
self._write_config({"custom": {"keep": True}})
before = (self._config_bytes(), self._runtime())
status, data = await self._post(
"/prompt_manager/settings", {"autotag_skipped_tags": ["blurry"]}
)
self.assertEqual(status, 400, data)
self.assertEqual(data["error"], "Reload the page and try again.")
self.assertEqual((self._config_bytes(), self._runtime()), before)
+281
View File
@@ -0,0 +1,281 @@
"""Skipped tags (#431): the words whose suggested tags are never offered.
The normalizer, the entry validation, the whole-word matcher, the WD14 label
reader and the filter in ``AutoTagService.generate_tag_suggestions``. No model
is loaded: ``_scored_tags`` is replaced by a fixed list.
"""
import csv
import os
import tempfile
import unicodedata
import unittest
from pathlib import Path
from unittest.mock import patch
from py.tagging_defaults import (
MAX_SKIPPED_TAG_CHARS,
MAX_SKIPPED_TAGS,
SkipMatcher,
tag_words,
validate_skipped_tags,
wd14_label_names,
)
# The design's matching table: (entry, tag, hidden).
MATCHING = (
("blurry", "blurry", True),
("blurry", "blurry_background", True),
("blurry", "very_blurry", True),
("blurry", "slightly_blurry_background", True),
("blurry", "unblurry", False),
("blur", "blurry", False),
("blurry background", "blurry_background", True),
("blurry background", "Blurry-Background", True),
("background blurry", "blurry_background", False),
("shirt", "t-shirt", True),
("shirt", "shirt_lift", True),
("closed eyes", "half-closed_eyes", True),
("girl", "1girl", False),
("girl", "2girls", False),
("girls", "multiple_girls", True),
("fate", "saber_(fate)", False),
("(fate)", "saber_(fate)", True),
("saber", "saber_(fate)", True),
(":d", ":d", True),
("café", unicodedata.normalize("NFD", "café"), True),
)
class TestWords(unittest.TestCase):
def test_words_split_only_on_space_underscore_and_hyphen(self):
self.assertEqual(tag_words("Half-closed_eyes"), ("half", "closed", "eyes"))
self.assertEqual(tag_words("saber_(fate)"), ("saber", "(fate)"))
self.assertEqual(tag_words(" a__b--c d "), ("a", "b", "c", "d"))
self.assertEqual(tag_words("_-- _"), ())
self.assertEqual(tag_words(":d"), (":d",))
def test_words_are_nfc_and_case_folded(self):
self.assertEqual(
tag_words("CAFÉ"), tag_words(unicodedata.normalize("NFD", "café"))
)
self.assertEqual(tag_words("Straße"), ("strasse",))
class TestMatcher(unittest.TestCase):
def test_every_row_of_the_matching_table(self):
for entry, tag, hidden in MATCHING:
with self.subTest(entry=entry, tag=tag):
self.assertIs(SkipMatcher([entry]).matches(tag), hidden)
def test_a_tag_without_words_never_matches(self):
matcher = SkipMatcher(["blurry"])
for tag in ("", "_", "- _"):
with self.subTest(tag=tag):
self.assertFalse(matcher.matches(tag))
def test_an_empty_matcher_hides_nothing_and_is_false(self):
self.assertFalse(SkipMatcher())
self.assertFalse(SkipMatcher([]).matches("blurry"))
self.assertTrue(SkipMatcher(["blurry"]))
def test_entries_of_different_lengths_are_all_checked(self):
matcher = SkipMatcher(["sky", "closed eyes", "very long entry here"])
self.assertTrue(matcher.matches("blue_sky"))
self.assertTrue(matcher.matches("half-closed_eyes"))
self.assertTrue(matcher.matches("a_very_long_entry_here_b"))
self.assertFalse(matcher.matches("very_long_entry"))
def test_counting_view_records_the_distinct_names_it_hid(self):
counting = SkipMatcher(["blurry"]).counting()
self.assertTrue(counting.matches("blurry"))
self.assertTrue(counting.matches("Blurry"))
self.assertTrue(counting.matches("blurry_background"))
self.assertFalse(counting.matches("sky"))
self.assertEqual(counting.hidden, {"blurry", "blurry_background"})
class TestValidation(unittest.TestCase):
def test_entries_are_trimmed_and_kept_as_typed(self):
self.assertEqual(
validate_skipped_tags([" Blurry ", "closed eyes", "(fate)"]),
["Blurry", "closed eyes", "(fate)"],
)
self.assertEqual(validate_skipped_tags([]), [])
def test_caps_count_code_points_after_trimming(self):
self.assertEqual(
validate_skipped_tags([" " + "🏜" * MAX_SKIPPED_TAG_CHARS + " "]),
["🏜" * MAX_SKIPPED_TAG_CHARS],
)
entries = [f"word{index}" for index in range(MAX_SKIPPED_TAGS)]
self.assertEqual(validate_skipped_tags(entries), entries)
def test_every_refusal_sentence(self):
cases = (
("blurry", "Skipped tags must be a list of words."),
(
[f"w{index}" for index in range(MAX_SKIPPED_TAGS + 1)],
"Skipped tags: at most 200 entries.",
),
(["ok", 3], "Skipped tags: entry 2 must be text."),
(["ok", " "], "Skipped tags: entry 2 is empty."),
(["_ --"], "Skipped tags: entry 1 is empty."),
(["x" * 51], "Skipped tags: entry 1 must be at most 50 characters."),
(
["a, b"],
"Skipped tags: entry 1 must not contain a comma or a line break.",
),
(
["a\nb"],
"Skipped tags: entry 1 must not contain a comma or a line break.",
),
(
["a\x7fb"],
"Skipped tags: entry 1 must not contain a comma or a line break.",
),
(
["blurry", "sky", "Blurry"],
"Skipped tags: entry 3 repeats entry 1.",
),
(
["blurry background", "blurry-Background"],
"Skipped tags: entry 2 repeats entry 1.",
),
)
for value, message in cases:
with self.subTest(value=value):
with self.assertRaises(ValueError) as caught:
validate_skipped_tags(value)
self.assertEqual(str(caught.exception), message)
class TestWd14Labels(unittest.TestCase):
def setUp(self):
directory = tempfile.TemporaryDirectory()
self.addCleanup(directory.cleanup)
self.path = Path(directory.name) / "selected_tags.csv"
def write(self, rows):
lines = ["tag_id,name,category,count"]
lines += [f"{i},{name},{category},1" for i, (name, category) in enumerate(rows)]
self.path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def test_only_general_and_character_names_are_read(self):
self.write(
[
("blurry", "0"),
("general", "9"),
("saber_(fate)", "4"),
("artist", "1"),
("café", "0"),
]
)
self.assertEqual(
wd14_label_names(self.path), ("blurry", "saber_(fate)", "café")
)
def test_the_list_is_read_again_only_when_the_file_changes(self):
self.write([("blurry", "0")])
with patch(
"py.tagging_defaults.read_wd14_label_rows",
wraps=__import__(
"py.tagging_defaults", fromlist=["x"]
).read_wd14_label_rows,
) as reader:
self.assertEqual(wd14_label_names(self.path), ("blurry",))
self.assertEqual(wd14_label_names(self.path), ("blurry",))
self.assertEqual(reader.call_count, 1)
self.write([("blurry", "0"), ("sky", "0")])
os.utime(self.path, ns=(1, 2))
self.assertEqual(wd14_label_names(self.path), ("blurry", "sky"))
self.assertEqual(reader.call_count, 2)
def test_a_damaged_list_is_a_value_error_for_the_caller_to_skip(self):
# A field over the csv module's size limit raises csv.Error.
big = "x" * (csv.field_size_limit() + 1)
self.path.write_text(
f"tag_id,name,category,count\n1,{big},0,1\n", encoding="utf-8"
)
with self.assertRaises(ValueError):
wd14_label_names(self.path)
def test_a_missing_list_raises_for_the_caller_to_skip(self):
with self.assertRaises(OSError):
wd14_label_names(self.path)
class FilterService:
"""``AutoTagService`` with a fixed model output instead of a model."""
def __init__(self, scored):
from py.autotag import AutoTagService
self.service = AutoTagService.__new__(AutoTagService)
self.service._scored_tags = lambda *args, **kwargs: list(scored)
def suggest(self, limit, skip=None):
return self.service.generate_tag_suggestions(
"image.png", limit=limit, skip=skip
)
class TestFilter(unittest.TestCase):
def test_skipped_tags_do_not_use_up_slots(self):
"""WD14: the next-best tags above the thresholds fill the limit."""
scored = [
("blurry", 0.99),
("sky", 0.9),
("blurry_background", 0.8),
("cloud", 0.7),
("sun", 0.6),
("sea", 0.5),
]
service = FilterService(scored)
self.assertEqual(
service.suggest(3, SkipMatcher(["blurry"])),
[("sky", 0.9), ("cloud", 0.7), ("sun", 0.6)],
)
self.assertEqual(service.suggest(3), scored[:3])
def test_a_language_model_may_return_fewer_than_the_limit(self):
service = FilterService([("blurry", None), ("sky", None), ("signature", None)])
self.assertEqual(
service.suggest(10, SkipMatcher(["blurry", "signature"])), [("sky", None)]
)
def test_order_is_invalid_name_then_skip_then_dedupe_then_limit(self):
hidden = []
class Recording:
def matches(self, name):
hidden.append(name)
return name.startswith("skip")
service = FilterService(
[
("bad,name", None),
(" Sky ", None),
("skip_me", None),
("sky", None),
("cloud", None),
("sun", None),
]
)
self.assertEqual(
service.suggest(2, Recording()), [("Sky", None), ("cloud", None)]
)
# The refused name never reaches the matcher; trimmed names do; the
# duplicate is checked after the skip; nothing after the limit.
self.assertEqual(hidden, ["Sky", "skip_me", "sky", "cloud"])
def test_the_counting_view_reports_hidden_names(self):
counting = SkipMatcher(["blurry"]).counting()
FilterService([("blurry", 0.9), ("sky", 0.8), ("Blurry", 0.7)]).suggest(
5, counting
)
self.assertEqual(counting.hidden, {"blurry"})
if __name__ == "__main__":
unittest.main()
+280
View File
@@ -0,0 +1,280 @@
"""Skipped tags on every suggestion path, and their preview (#431).
Tag image (``/image-annotations/suggest``), Auto-tag selected (the
``image-tags`` job), the System batch (the ``autotag`` job), the legacy
``/autotag/start`` and ``/autotag/single`` all hide a suggested tag whose
words contain a skipped entry; a job runs with the list it started with.
``POST /autotag/skipped-tags/preview`` counts what a list would hide among
the library's tags and the downloaded WD14 label lists, writing nothing.
"""
import tempfile
from pathlib import Path
try:
from test_autotag_legacy import LegacyTestCase
except ImportError:
from tests.test_autotag_legacy import LegacyTestCase
from py.config import AutotagConfig
PREVIEW = "/prompt_manager/autotag/skipped-tags/preview"
class SkippedTestCase(LegacyTestCase):
async def get_application(self):
app = await super().get_application()
self.addCleanup(
setattr, AutotagConfig, "SKIPPED_TAGS", AutotagConfig.SKIPPED_TAGS
)
AutotagConfig.SKIPPED_TAGS = ()
return app
def job_lines(self, job):
return [line.split(" ", 1)[1] for line in job.log]
class TestEveryPathHidesSkippedTags(SkippedTestCase):
async def test_tag_image_suggestions_omit_skipped_tags_and_count_them(self):
AutotagConfig.SKIPPED_TAGS = ("sandstone", "tag 001")
observed = await self.observed()
body = await self.suggest_ok(self.suggest_body(observed))
names = [row["name"] for row in body["suggestions"]]
self.assertNotIn("sandstone", names)
self.assertNotIn("tag_001", names)
# Skipped tags use up no slot: the next ones fill the limit.
self.assertEqual(len(names), 10)
self.assertEqual(names[:3], ["desert", "tag_000", "tag_002"])
self.assertEqual(body["skipped"], 2)
async def test_tag_image_without_skipped_tags_reports_zero(self):
observed = await self.observed()
body = await self.suggest_ok(self.suggest_body(observed))
self.assertEqual(body["suggestions"][0]["name"], "sandstone")
self.assertEqual(body["skipped"], 0)
async def test_auto_tag_selected_runs_with_the_list_of_its_start(self):
self.png("a.png")
self.png("b.png", color=(1, 2, 3))
AutotagConfig.SKIPPED_TAGS = ("desert",)
def save_mid_run(_path):
# A save while the job runs does not change that job.
AutotagConfig.SKIPPED_TAGS = ("sandstone",)
self.tagger.on_generate = save_mid_run
options = {
"general_threshold": 0.35,
"character_threshold": 0.85,
"suggestion_limit": 2,
}
job = await self.run_job(self.auto_body(["a.png", "b.png"], options=options))
self.assertEqual(job.state, "finished")
self.assertEqual(self.stored("a.png")["tags"], ["sandstone", "tag_000"])
self.assertEqual(self.stored("b.png")["tags"], ["sandstone", "tag_000"])
self.assertIn("Skipped tags: 1 entry", self.job_lines(job))
async def test_the_system_batch_hides_skipped_tags_and_logs_the_list(self):
self.image("a.png", (1, 2, 3), sees=["bird", "blurry_background", "sky"])
AutotagConfig.SKIPPED_TAGS = ("blurry", "watermark")
job = await self.run_batch(existing="merge")
self.assertEqual(self.stored("a.png")["tags"], ["bird", "sky"])
self.assertIn("Skipped tags: 2 entries", self.job_lines(job))
AutotagConfig.SKIPPED_TAGS = ()
self.image("b.png", (4, 5, 6), sees=["blurry"])
await self.relist()
job = await self.run_batch(existing="merge")
self.assertEqual(self.stored("b.png")["tags"], ["blurry"])
self.assertIn("Skipped tags: none", self.job_lines(job))
async def test_the_legacy_batch_hides_skipped_tags(self):
self.image("a.png", (1, 2, 3), sees=["bird", "signature"])
AutotagConfig.SKIPPED_TAGS = ("signature",)
response, frames = await self.legacy()
self.assertEqual(response.status, 200)
self.assertEqual(frames[-1]["type"], "complete", frames[-1])
self.assertEqual(self.stored("a.png")["tags"], ["bird"])
async def test_the_legacy_single_review_hides_skipped_tags(self):
prompt = self.prompt("a prompt")
image = self.link(
prompt, self.image("a.png", (1, 2, 3), sees=["bird", "t-shirt"])
)
AutotagConfig.SKIPPED_TAGS = ("shirt",)
status, data = await self.single({"image_id": image, "model_type": "wd14-vit"})
self.assertEqual(status, 200, data)
self.assertEqual(data["tags"], ["bird"])
async def test_hand_typed_and_stored_tags_are_never_filtered(self):
path = self.image("a.png", (1, 2, 3), sees=["bird"])
self.saved_set("a.png", path.read_bytes(), ["blurry_hand_tag"])
AutotagConfig.SKIPPED_TAGS = ("blurry",)
await self.run_batch(existing="merge")
job = await self.run_job(self.add_body(["a.png"], ["blurry"]))
self.assertEqual(job.state, "finished")
self.assertEqual(
self.stored("a.png")["tags"], ["blurry_hand_tag", "bird", "blurry"]
)
def write_labels(directory, subdir, rows):
path = Path(directory) / subdir / "selected_tags.csv"
path.parent.mkdir(parents=True, exist_ok=True)
lines = ["tag_id,name,category,count"]
lines += [f"{i},{name},{category},1" for i, (name, category) in enumerate(rows)]
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
class TestPreview(SkippedTestCase):
async def asyncSetUp(self):
await super().asyncSetUp()
models = tempfile.TemporaryDirectory()
self.addCleanup(models.cleanup)
self.models_dir = Path(models.name)
self.tagger.models_dir = self.models_dir
for name in ("Blurry", "portrait", "my_signature_style"):
self.prompt(f"prompt with {name}", tags=[name])
async def preview(self, entries):
response = await self.client.post(PREVIEW, json={"entries": entries})
return response.status, await self.json_of(response)
async def test_library_tags_only_when_no_label_list_is_downloaded(self):
status, data = await self.preview(["blurry", "signature", "watermark"])
self.assertEqual(status, 200, data)
self.assertEqual(
data,
{
"success": True,
"count": 2,
"sample": ["Blurry", "my_signature_style"],
"sample_limit": 12,
"per_entry": [
{"entry": "blurry", "count": 1},
{"entry": "signature", "count": 1},
{"entry": "watermark", "count": 0},
],
"sources": {"library": 3, "wd14": []},
},
)
async def test_both_label_lists_join_the_library_tags(self):
write_labels(
self.models_dir,
"WD14-SwinV2",
[
("blurry", "0"),
("blurry_background", "0"),
("watermark", "0"),
("general", "9"),
("saber_(fate)", "4"),
],
)
write_labels(
self.models_dir,
"WD14-ViT",
[
("blurry_foreground", "0"),
("watermark", "0"),
("fate_(series)", "3"),
],
)
status, data = await self.preview(["blurry", "watermark", "(fate)", "general"])
self.assertEqual(status, 200, data)
# Library spellings first (case-folded duplicates are one tag), then the
# label lists' names, each part sorted case-folded; rating and copyright
# rows are never suggested, so never counted.
self.assertEqual(
data["sample"],
[
"Blurry",
"blurry_background",
"blurry_foreground",
"saber_(fate)",
"watermark",
],
)
self.assertEqual(data["count"], 5)
self.assertEqual(
data["per_entry"],
[
{"entry": "blurry", "count": 3},
{"entry": "watermark", "count": 1},
{"entry": "(fate)", "count": 1},
{"entry": "general", "count": 0},
],
)
self.assertEqual(
data["sources"], {"library": 3, "wd14": ["WD14 SwinV2", "WD14 ViT"]}
)
async def test_the_sample_holds_at_most_twelve(self):
write_labels(
self.models_dir,
"WD14-SwinV2",
[(f"sky_{index:02d}", "0") for index in range(20)],
)
status, data = await self.preview(["sky"])
self.assertEqual(status, 200, data)
self.assertEqual(data["count"], 20)
self.assertEqual(data["sample"], [f"sky_{index:02d}" for index in range(12)])
async def test_a_bad_list_is_refused_with_the_save_sentence(self):
for body, message in (
({"entries": ["sky", "Sky"]}, "Skipped tags: entry 2 repeats entry 1."),
(
{"entries": ["a,b"]},
"Skipped tags: entry 1 must not contain a comma or a line break.",
),
({"entries": "sky"}, "Skipped tags must be a list of words."),
):
with self.subTest(body=body):
response = await self.client.post(PREVIEW, json=body)
self.assertEqual(response.status, 400)
self.assertEqual((await response.json())["error"], message)
response = await self.client.post(PREVIEW, json={"entries": [], "x": 1})
self.assertEqual(response.status, 400)
async def test_preview_loads_no_model_opens_no_image_and_writes_nothing(self):
write_labels(self.models_dir, "WD14-SwinV2", [("blurry", "0")])
self.image("a.png", (1, 2, 3))
before = self.prompt_rows()
status, data = await self.preview(["blurry"])
self.assertEqual(status, 200, data)
self.assertEqual(self.tagger.load_calls, [])
self.assertEqual(self.tagger.read_paths, [])
self.assertEqual(self.prompt_rows(), before)
self.assertEqual(self.api._jobs.list(), [])
async def test_preview_is_busy_while_an_exclusive_job_runs(self):
async with self.api._jobs.hold(
"restore", "Restore library", admission="exclusive"
):
status, data = await self.preview(["blurry"])
self.assertEqual(status, 409, data)
self.assertEqual(data["code"], "busy")
self.assertEqual(data["running"]["title"], "Restore library")
if __name__ == "__main__":
import unittest
unittest.main()
+3 -4
View File
@@ -291,7 +291,7 @@ class TestThumbnailOnFirstView(AutoThumbnailCase):
await self._writers(state, auto.ON_DEMAND_LIMIT)
await self._waiting(len(names))
self.assertEqual(default_calls, [], "no view waits on a default thread")
self.assertTrue(all(name.startswith("pm-image") for name in state["threads"]))
self.assertTrue(all(name.startswith("pm-preview") for name in state["threads"]))
status = await self.client.get("/prompt_manager/images/thumbnails/status")
self.assertEqual(status.status, 200, "another route answers meanwhile")
await status.read()
@@ -678,7 +678,7 @@ class TestShutdown(AutoThumbnailCase):
_entry(self.api, self.output_dir, "viewed.png")["thumbnail_url"]
)
await resp.read()
pool_threads = list(self.api._image_pool_threads())
pool_threads = list(self.api._preview_pool_threads())
self.assertTrue(pool_threads)
loop = asyncio.get_running_loop()
await loop.run_in_executor(
@@ -904,8 +904,7 @@ class TestThumbnailOnCapture(AutoThumbnailCase):
self.api.queue_capture_thumbnail(str(path))
release.set()
await self._idle()
# The job pool's size is the setting; each batch is at most that (#523).
self.assertEqual(batches, [(1, 2), (2, 2), (2, 2)])
self.assertEqual(batches, [(1, 1), (2, 2), (2, 2)])
self.assertLessEqual(state["peak"], 2)
for i in range(5):
self.assertTrue(
+4 -110
View File
@@ -1,8 +1,4 @@
"""Tests for utils/parallel.py: bounded thread-pool map used by long jobs.
The jobs share one long-lived pool of Worker threads (#523): its threads (and
their malloc arenas) are reused from job to job instead of a new set per call.
"""
"""Tests for utils/parallel.py: bounded thread-pool map used by long jobs."""
import os
import sys
@@ -12,22 +8,10 @@ import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from utils import parallel # noqa: E402
from utils.parallel import JOB_THREAD_PREFIX, map_parallel # noqa: E402
WAIT = 30
def _job_threads():
return {
t for t in threading.enumerate() if t.name.startswith(JOB_THREAD_PREFIX + "_")
}
from utils.parallel import map_parallel # noqa: E402
class TestMapParallel(unittest.TestCase):
def setUp(self):
self.assertTrue(parallel.shutdown_job_pool(WAIT))
self.addCleanup(parallel.shutdown_job_pool, WAIT)
def test_results_keep_input_order(self):
def slow_square(n):
@@ -38,102 +22,12 @@ class TestMapParallel(unittest.TestCase):
map_parallel(slow_square, [1, 2, 3, 4], workers=4), [1, 4, 9, 16]
)
def test_one_worker_runs_on_the_one_job_thread(self):
def test_one_worker_runs_on_the_calling_thread(self):
names = []
map_parallel(
lambda _: names.append(threading.current_thread().name), [1, 2, 3], 1
)
self.assertEqual(len(set(names)), 1)
self.assertTrue(names[0].startswith(JOB_THREAD_PREFIX + "_"), names)
def test_jobs_reuse_the_same_long_lived_threads(self):
"""Arenas are reused instead of a new set of threads per job."""
first, second = set(), set()
map_parallel(lambda _: first.add(threading.current_thread()), range(8), 2)
map_parallel(lambda _: second.add(threading.current_thread()), range(8), 2)
self.assertTrue(second <= first, (first, second))
self.assertLessEqual(len(_job_threads()), 2)
self.assertTrue(all(t.is_alive() for t in first | second))
def test_a_new_worker_count_replaces_the_pool(self):
map_parallel(lambda n: n, range(4), 2)
old = _job_threads()
map_parallel(lambda n: n, range(4), 3)
for thread in old:
thread.join(WAIT)
self.assertFalse(thread.is_alive(), thread.name)
self.assertLessEqual(len(_job_threads()), 3)
def test_shutdown_also_joins_a_pool_a_resize_retired(self):
entered, release = threading.Event(), threading.Event()
def hold(_):
entered.set()
release.wait(WAIT)
caller = threading.Thread(target=map_parallel, args=(hold, [1], 1))
caller.start()
self.addCleanup(caller.join, WAIT)
self.addCleanup(release.set)
self.assertTrue(entered.wait(WAIT))
map_parallel(lambda n: n, [1, 2], 2) # a new size retires the busy pool
self.assertFalse(parallel.shutdown_job_pool(0.05))
release.set()
caller.join(WAIT)
self.assertTrue(parallel.shutdown_job_pool(WAIT))
self.assertFalse(parallel.job_pool_started())
def test_an_item_run_from_a_job_thread_runs_inline(self):
"""A nested map never waits for the threads it is running on."""
def outer(n):
return map_parallel(lambda m: (m, threading.current_thread()), [n], 1)
results = map_parallel(outer, [1, 2], 1)
self.assertEqual([r[0][0] for r in results], [1, 2])
def test_shutdown_stops_the_threads_and_a_later_job_starts_fresh(self):
map_parallel(lambda n: n, range(4), 2)
threads = _job_threads()
self.assertTrue(threads)
self.assertTrue(parallel.job_pool_started())
self.assertTrue(parallel.shutdown_job_pool(WAIT))
self.assertFalse(parallel.job_pool_started())
self.assertFalse(any(t.is_alive() for t in threads))
self.assertEqual(map_parallel(lambda n: n + 1, [1], 2), [2])
def test_shutdown_reports_a_thread_still_running(self):
entered, release = threading.Event(), threading.Event()
def hold(_):
entered.set()
release.wait(WAIT)
caller = threading.Thread(target=map_parallel, args=(hold, [1], 1))
caller.start()
self.assertTrue(entered.wait(WAIT))
self.assertFalse(parallel.shutdown_job_pool(0.05))
release.set()
caller.join(WAIT)
self.assertFalse(caller.is_alive())
def test_a_failing_item_waits_for_the_running_ones(self):
"""The caller sees the error only after its other items stopped."""
running = threading.Event()
finished = []
def item(n):
if n == 0:
self.assertTrue(running.wait(WAIT))
raise ValueError("zero")
running.set()
time.sleep(0.05)
finished.append(n)
return n
with self.assertRaises(ValueError):
map_parallel(item, [0, 1], 2)
self.assertEqual(finished, [1])
self.assertEqual(set(names), {threading.current_thread().name})
def test_several_workers_use_several_threads(self):
names = set()
+1
View File
@@ -164,6 +164,7 @@ ROUTE_AUDIT = {
"PATCH /prompt_manager/saved-views/{view_id}",
"PATCH /prompt_manager/vocabulary/tags/{tag_id}",
"POST /prompt_manager/autotag/download/{model_type}",
"POST /prompt_manager/autotag/skipped-tags/preview",
"POST /prompt_manager/autotag/unload",
"POST /prompt_manager/bulk/category",
"POST /prompt_manager/bulk/delete",
-395
View File
@@ -1,395 +0,0 @@
"""Measure the server's resident memory under a busy Images/Library session (#523).
Manual only, Linux only (it reads ``/proc`` and glibc's ``malloc_info``); never
run in CI. It starts the production API inside the disposable browser test
server (``tests/browser/server.py``: its own temporary library, database and
output folder; never a live ComfyUI) and drives it for ``--minutes``:
- ``--clients`` threads loop over: a workspace grid page of 100 images, 40 of
its thumbnails, 6 inspector reads and 6 full-file reads, the Library list
with 3 entries' history and images, and the recent prompt and image lists;
- every minute, ``--batch`` new PNGs (832 to 1536 px, ComfyUI-shaped
metadata) are copied into the output folder, then a scan job and a
thumbnail job run to completion.
Every 5 s the server samples its RSS, Python heap (tracemalloc), threads and
glibc arena count. The test server pins glibc's mmap threshold (#516); this
driver turns that off so the production allocator behaviour is measured,
unless ``--mallopt`` asks for the pinned comparison run.
python tools/perf/memory_drive.py --label after --minutes 12
python tools/perf/memory_drive.py --repo /path/to/other/checkout --label before
The checkout under ``--repo`` (default: this one) is the code measured; the
driver itself only needs Pillow. The last line printed is a Markdown table row.
"""
import argparse
import ctypes
import json
import os
import random
import shutil
import subprocess # nosec B404 - starts this script's own server child
import sys
import tempfile
import threading
import time
import tracemalloc
import urllib.parse
import urllib.request
from concurrent.futures import ThreadPoolExecutor
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(os.path.dirname(HERE))
PORT_ANNOUNCEMENT = "PM_TEST_SERVER_PORT="
OUTPUT_ANNOUNCEMENT = "output="
SAMPLE_SECONDS = 5
MEDIA_FILES = 120
MEDIA_SIDES = (832, 1024, 1216, 1536)
REQUEST_TIMEOUT = 120
JOB_POLL_LIMIT = 600
FINAL_STATES = ("finished", "cancelled", "failed")
M_MMAP_THRESHOLD = -3
# ── the server child ────────────────────────────────────────────────────
def _arena_count(libc):
"""glibc's arena count (``<heap nr=`` entries of ``malloc_info``)."""
buffer, size = ctypes.c_char_p(), ctypes.c_size_t()
stream = libc.open_memstream(ctypes.byref(buffer), ctypes.byref(size))
libc.malloc_info(0, stream)
libc.fclose(stream)
text = ctypes.string_at(buffer, size.value).decode()
libc.free(ctypes.cast(buffer, ctypes.c_void_p))
return text.count("<heap nr=")
def _status():
with open("/proc/self/status", encoding="ascii") as status:
return {
line.split(":")[0]: int(line.split()[1])
for line in status
if line.startswith(("VmRSS", "RssAnon", "Threads"))
}
def _sample_forever(libc, samples_path):
start = time.monotonic()
announced = False
with open(samples_path, "a", buffering=1, encoding="utf-8") as out:
while True:
folder_paths = sys.modules.get("folder_paths")
if not announced and hasattr(folder_paths, "get_output_directory"):
out.write(OUTPUT_ANNOUNCEMENT + folder_paths.get_output_directory())
out.write("\n")
announced = True
status = _status()
out.write(
json.dumps(
{
"t": round(time.monotonic() - start),
"rss": status["VmRSS"] >> 10,
"anon": status["RssAnon"] >> 10,
"heap": tracemalloc.get_traced_memory()[0] >> 20,
"threads": status["Threads"],
"arenas": _arena_count(libc),
}
)
+ "\n"
)
time.sleep(SAMPLE_SECONDS if announced else 0.5)
def serve(repo, samples_path, mallopt):
"""Run the browser test server of *repo* in this process, sampled."""
sys.path.insert(0, repo)
os.chdir(repo)
tracemalloc.start(1)
libc = ctypes.CDLL(None)
libc.open_memstream.restype = ctypes.c_void_p
libc.open_memstream.argtypes = [
ctypes.POINTER(ctypes.c_char_p),
ctypes.POINTER(ctypes.c_size_t),
]
libc.fclose.argtypes = [ctypes.c_void_p]
libc.malloc_info.argtypes = [ctypes.c_int, ctypes.c_void_p]
libc.free.argtypes = [ctypes.c_void_p]
if mallopt:
libc.mallopt(M_MMAP_THRESHOLD, 128 * 1024)
threading.Thread(
target=_sample_forever, args=(libc, samples_path), daemon=True
).start()
from tests.browser import server
if not mallopt:
server.pin_mmap_threshold = lambda: False # production allocator
server.main(["--port", "0", "--exit-on-stdin-eof"])
# ── the driver ──────────────────────────────────────────────────────────
def make_media(folder, count=MEDIA_FILES, seed=523):
"""Synthetic ComfyUI-shaped PNGs (smooth noise, prompt and workflow)."""
from PIL import Image, PngImagePlugin
rng = random.Random(seed)
os.makedirs(folder, exist_ok=True)
for index in range(count):
side = MEDIA_SIDES[index % len(MEDIA_SIDES)]
small = Image.frombytes(
"RGB", (64, 64), bytes(rng.randrange(256) for _ in range(64 * 64 * 3))
)
image = small.resize((side, side), Image.BICUBIC)
text = f"synthetic scene {index}, soft light, detailed"
prompt = {
"3": {
"class_type": "KSampler",
"inputs": {
"seed": index,
"steps": 20,
"cfg": 7.0,
"sampler_name": "euler",
"scheduler": "normal",
"denoise": 1.0,
"model": ["4", 0],
"positive": ["6", 0],
"negative": ["7", 0],
"latent_image": ["5", 0],
},
},
"4": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": "model.safetensors"},
},
"5": {
"class_type": "EmptyLatentImage",
"inputs": {"width": side, "height": side, "batch_size": 1},
},
"6": {
"class_type": "CLIPTextEncode",
"inputs": {"text": text, "clip": ["4", 1]},
},
"7": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "blurry", "clip": ["4", 1]},
},
"9": {
"class_type": "SaveImage",
"inputs": {"filename_prefix": "img", "images": ["8", 0]},
},
}
workflow = {
"nodes": [
{"id": int(k), "type": v["class_type"]} for k, v in prompt.items()
]
}
info = PngImagePlugin.PngInfo()
info.add_text("prompt", json.dumps(prompt))
info.add_text("workflow", json.dumps(workflow))
image.save(
os.path.join(folder, f"m{index:03d}.png"), pnginfo=info, compress_level=4
)
class Driver:
def __init__(self, base, output, media, batch):
self.base, self.output, self.media, self.batch = base, output, media, batch
self.errors, self.batches = [], 0
self.names = sorted(os.listdir(media))
def call(self, method, path, body=None, raw=False):
data = None if body is None else json.dumps(body).encode()
request = urllib.request.Request( # nosec B310 - loopback test server
self.base + path,
data=data,
method=method,
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen( # nosec B310
request, timeout=REQUEST_TIMEOUT
) as response:
payload = response.read()
except Exception as error: # keep driving; errors are counted
self.errors.append(f"{method} {path.split('?')[0]}: {error}")
return None
return payload if raw else json.loads(payload or b"{}")
def add_batch(self):
self.batches += 1
folder = os.path.join(self.output, f"batch{self.batches:03d}")
os.makedirs(folder)
for index in range(self.batch):
name = self.names[(self.batches * self.batch + index) % len(self.names)]
shutil.copyfile(
os.path.join(self.media, name),
os.path.join(folder, f"img_{self.batches:03d}_{index:03d}.png"),
)
def wait_job(self, response):
job = ((response or {}).get("job") or {}).get("id")
for _ in range(JOB_POLL_LIMIT if job else 0):
state = self.call("GET", f"/prompt_manager/jobs/{job}")
if not state or (state.get("job") or {}).get("state") in FINAL_STATES:
return
time.sleep(1)
def scan_and_thumbnails(self):
self.add_batch()
self.wait_job(self.call("POST", "/prompt_manager/jobs/scan", {}))
self.wait_job(self.call("POST", "/prompt_manager/jobs/thumbnails", {}))
def browse(self, _index=None):
offset = random.randint(0, 3) * 100 # nosec B311 - load shape only
page = self.call(
"GET",
f"/prompt_manager/images/output?mode=workspace&limit=100&offset={offset}",
)
images = [i for i in (page or {}).get("images") or [] if isinstance(i, dict)]
urls = [i.get("thumbnail_url") or i.get("url") for i in images]
urls = [u for u in urls if u]
for url in random.sample(urls, min(40, len(urls))):
self.call("GET", url, raw=True)
for image in random.sample(images, min(6, len(images))):
self.inspect(image)
if image.get("url"):
self.call("GET", image["url"], raw=True)
library = self.call("GET", "/prompt_manager/library?limit=100") or {}
entries = [e for e in library.get("items") or [] if isinstance(e, dict)]
for entry in random.sample(entries, min(3, len(entries))):
prompt_id = entry.get("id") or entry.get("positive_id")
if prompt_id is not None:
self.call(
"GET", f"/prompt_manager/library/{prompt_id}/history", raw=True
)
self.call(
"GET", f"/prompt_manager/library/{prompt_id}/images", raw=True
)
self.call("GET", "/prompt_manager/recent?limit=50", raw=True)
self.call("GET", "/prompt_manager/images/recent?limit=50", raw=True)
def inspect(self, image):
relative, index = image.get("path"), image.get("root_index")
if not relative or not isinstance(index, int):
return
query = {"root": str(index)}
if image.get("root_scope"):
query["root_scope"] = image["root_scope"]
self.call(
"GET",
"/prompt_manager/images/inspect/"
+ urllib.parse.quote(relative)
+ "?"
+ urllib.parse.urlencode(query),
raw=True,
)
def _wait_for_output(samples_path, process):
while process.poll() is None:
with open(samples_path, encoding="utf-8") as samples:
for line in samples:
if line.startswith(OUTPUT_ANNOUNCEMENT):
return line[len(OUTPUT_ANNOUNCEMENT) :].strip()
time.sleep(0.2)
raise RuntimeError("the server exited before it started")
def summarize(samples_path, label, batches, errors):
rows = []
with open(samples_path, encoding="utf-8") as samples:
for line in samples:
if line.startswith("{"):
rows.append(json.loads(line))
if not rows:
raise RuntimeError("no samples were taken")
rss = [row["rss"] for row in rows]
late = [row for row in rows if row["t"] >= 120] or rows
end = rows[-1]
return (
f"| {label} | {rss[0]} → {end['rss']} | {min(r['rss'] for r in late)}–"
f"{max(r['rss'] for r in late)} | {max(rss)} | {rows[0]['heap']} → "
f"{end['heap']} | {max(r['threads'] for r in rows)} | "
f"{max(r['arenas'] for r in rows)} | {batches} | {len(errors)} |"
)
def drive(args):
work = args.out or tempfile.mkdtemp(prefix="pm-memory-")
media = os.path.join(work, "media")
if not os.path.isdir(media):
print(f"Writing {MEDIA_FILES} PNGs to {media}", flush=True)
make_media(media)
samples_path = os.path.join(work, f"{args.label}.samples")
open(samples_path, "w", encoding="utf-8").close()
command = [sys.executable, os.path.abspath(__file__), "--serve", samples_path]
command += ["--repo", os.path.abspath(args.repo)]
command += ["--mallopt"] if args.mallopt else []
with open(os.path.join(work, f"{args.label}.stderr"), "w") as stderr:
process = subprocess.Popen( # nosec B603
command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=stderr
)
try:
port = None
for line in process.stdout:
text = line.decode(errors="replace")
if text.startswith(PORT_ANNOUNCEMENT):
port = int(text[len(PORT_ANNOUNCEMENT) :])
break
if port is None:
raise RuntimeError("the server announced no port")
output = _wait_for_output(samples_path, process)
driver = Driver(f"http://127.0.0.1:{port}", output, media, args.batch)
run(driver, args)
finally:
process.stdin.close()
process.wait(60)
print(f"Samples: {samples_path}")
print(
"| run | RSS start → end (MB) | after 2 min | max | heap (MB) | threads "
"| arenas | batches | errors |"
)
print(summarize(samples_path, args.label, driver.batches, driver.errors))
def run(driver, args):
driver.call("POST", "/__test__/reset", {})
driver.scan_and_thumbnails()
deadline = time.monotonic() + args.minutes * 60
next_batch = time.monotonic() + 60
with ThreadPoolExecutor(args.clients) as pool:
while time.monotonic() < deadline:
list(pool.map(driver.browse, range(args.clients)))
if time.monotonic() > next_batch:
driver.scan_and_thumbnails()
next_batch = time.monotonic() + 60
time.sleep(SAMPLE_SECONDS + 1) # one more sample after the load stops
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("--repo", default=REPO, help="checkout to measure")
parser.add_argument("--label", default="run")
parser.add_argument("--minutes", type=float, default=12)
parser.add_argument("--clients", type=int, default=6)
parser.add_argument("--batch", type=int, default=60, help="new PNGs a minute")
parser.add_argument("--out", help="work folder (media, samples); default temp")
parser.add_argument(
"--mallopt", action="store_true", help="pin the mmap threshold (comparison)"
)
parser.add_argument("--serve", metavar="SAMPLES", help=argparse.SUPPRESS)
args = parser.parse_args(argv)
if not sys.platform.startswith("linux"):
parser.error("Linux only: it reads /proc and glibc's malloc_info")
if args.serve:
serve(os.path.abspath(args.repo), args.serve, args.mallopt)
else:
drive(args)
if __name__ == "__main__":
main()
+12
View File
@@ -629,6 +629,18 @@
"functions": 95,
"statements": 98
},
"workspace/system-instructions.mjs": {
"lines": 80,
"branches": 80,
"functions": 80,
"statements": 80
},
"workspace/system-skipped-tags.mjs": {
"lines": 80,
"branches": 80,
"functions": 80,
"statements": 80
},
"workspace/system-state.mjs": {
"lines": 98,
"branches": 94,
+9 -12
View File
@@ -37,7 +37,6 @@ from typing import Optional, Dict, Any
from PIL import Image
from .logging_config import get_logger
from .png_text import png_text
class ComfyUIMetadataExtractor:
@@ -89,14 +88,12 @@ class ComfyUIMetadataExtractor:
# Add basic file information
metadata["file_info"] = self.get_file_info(image_path, image)
# Extract ComfyUI-specific metadata from PNG text chunks,
# read without decoding the pixels (#523)
text = png_text(image)
if text:
# Extract ComfyUI-specific metadata from PNG text chunks
if hasattr(image, "text") and image.text:
# Look for ComfyUI workflow data
if "workflow" in text:
if "workflow" in image.text:
try:
workflow_data = json.loads(text["workflow"])
workflow_data = json.loads(image.text["workflow"])
metadata["workflow"] = workflow_data
# Extract text encoder nodes from workflow
@@ -110,9 +107,9 @@ class ComfyUIMetadataExtractor:
self.logger.warning(f"Failed to parse workflow JSON: {e}")
# Look for prompt data
if "prompt" in text:
if "prompt" in image.text:
try:
prompt_data = json.loads(text["prompt"])
prompt_data = json.loads(image.text["prompt"])
metadata["prompt"] = prompt_data
except json.JSONDecodeError as e:
self.logger.warning(f"Failed to parse prompt JSON: {e}")
@@ -132,13 +129,13 @@ class ComfyUIMetadataExtractor:
]
for field in metadata_fields:
if field in text:
if field in image.text:
try:
# Try to parse as JSON first
metadata[field] = json.loads(text[field])
metadata[field] = json.loads(image.text[field])
except (json.JSONDecodeError, TypeError):
# Store as string if not valid JSON
metadata[field] = text[field]
metadata[field] = image.text[field]
return (
metadata
-65
View File
@@ -1,65 +0,0 @@
"""Give glibc's free heap pages back to the OS after a long image job (#523).
Image work (decode, resize, encode) leaves image-sized free blocks in the
malloc arenas of the threads that did it; glibc keeps those pages resident.
``release_free_memory`` calls ``malloc_trim(0)`` once, which returns the free
pages of every arena and changes no allocator setting (no ``mallopt``, no
``MALLOC_ARENA_MAX``: ComfyUI owns this process's allocator).
glibc only: on Windows, macOS and musl (Alpine) it does nothing. It never
raises; a missing or failing ``malloc_trim`` is a quiet ``False``.
"""
import ctypes
import os
import sys
import threading
_lock = threading.Lock()
_trim = None
_looked_up = False
def _glibc() -> bool:
"""Whether this process runs on glibc (musl has no CS_GNU_LIBC_VERSION)."""
if not sys.platform.startswith("linux"):
return False
try:
version = os.confstr("CS_GNU_LIBC_VERSION")
except (AttributeError, ValueError, OSError):
return False
return bool(version) and version.startswith("glibc")
def _malloc_trim():
"""glibc's ``malloc_trim``, looked up once; None where there is none."""
global _trim, _looked_up
with _lock:
if not _looked_up:
_looked_up = True
_trim = _lookup()
return _trim
def _lookup():
if not _glibc():
return None
try:
trim = ctypes.CDLL(None).malloc_trim
except (OSError, AttributeError):
return None
trim.argtypes = [ctypes.c_size_t]
trim.restype = ctypes.c_int
return trim
def release_free_memory() -> bool:
"""Return free heap pages to the OS once (glibc); True when it ran."""
trim = _malloc_trim()
if trim is None:
return False
try:
trim(0)
except Exception: # a foreign libc symbol: never fail the caller's job
return False
return True
+21 -113
View File
@@ -15,36 +15,18 @@ create new thread at interpreter shutdown") only once the interpreter is
marked finalizing, after the ``atexit`` callbacks. ``refused_at_shutdown``
recognises both; ``note_refusal`` logs one DEBUG line per place, with no
traceback.
One long-lived pool (#523): every ``map_parallel`` call shares one pool of
``workers`` threads (``JOB_THREAD_PREFIX``), kept between jobs, instead of a
new pool per call. Image work then reuses the same few threads, and their
malloc arenas, rather than leaving each call's image-sized buffers in a new
set of arenas. A different ``workers`` count (the Worker threads setting
changed) replaces the pool; the old one finishes what it holds and exits.
``shutdown_job_pool`` stops it (the package teardown); the next call starts
a fresh one.
"""
import logging
import threading
import time
from concurrent.futures import ThreadPoolExecutor, wait
from concurrent.futures import ThreadPoolExecutor
from .exit_signal import checked
SHUTDOWN_REFUSAL = "interpreter shutdown"
# ``submit`` on a pool already shut down (this module's teardown or a resize).
SHUT_POOL_REFUSAL = "after shutdown"
JOB_THREAD_PREFIX = "pm-worker"
# The package teardown's bound for the job threads to finish their item.
JOB_POOL_JOIN_SECONDS = 5
_logger = logging.getLogger("prompt_manager.parallel")
_noted = set()
_noted_lock = threading.Lock()
_pool_lock = threading.Lock()
_pool = None # (workers, ThreadPoolExecutor)
_retired = [] # pools replaced by a new size, finishing what they hold
def refused_at_shutdown(error):
@@ -63,113 +45,39 @@ def note_refusal(logger, what):
logger.debug(f"{what}: refused, the interpreter is exiting")
def job_pool_started():
"""Whether a job pool exists (the teardown stops it)."""
return _pool is not None or bool(_retired)
def _job_pool(workers):
"""The shared pool of ``workers`` threads, replacing one of another size."""
global _pool
with _pool_lock:
if _pool is not None and _pool[0] == workers:
return _pool[1]
retired = _pool
_pool = (
workers,
ThreadPoolExecutor(workers, thread_name_prefix=JOB_THREAD_PREFIX),
)
current = _pool[1]
if retired is not None:
retired[1].shutdown(wait=False) # it still runs what it holds
with _pool_lock:
_retired[:] = [p for p in _retired if _alive(p)] + [retired[1]]
return current
def shutdown_job_pool(timeout=None):
"""Stop the job pool (and any pool a resize retired): queued items are
cancelled (their callers run them on their own thread), running ones are
joined up to ``timeout`` seconds. True when no job thread is left."""
global _pool
with _pool_lock:
current, _pool = _pool, None
executors = _retired[:] + ([] if current is None else [current[1]])
_retired.clear()
threads = []
for executor in executors:
# After shutdown: it takes the lock ``submit`` holds while it starts
# and records a thread, so every thread is listed and none starts.
executor.shutdown(wait=False, cancel_futures=True)
threads.extend(getattr(executor, "_threads", ()))
deadline = None if timeout is None else time.monotonic() + timeout
for thread in threads:
thread.join(None if deadline is None else max(0.0, deadline - time.monotonic()))
return not any(thread.is_alive() for thread in threads)
def _alive(executor):
return any(t.is_alive() for t in getattr(executor, "_threads", ()))
def _forget(executor):
"""Drop *executor* as the shared pool (a later call starts a new one)."""
global _pool
with _pool_lock:
if _pool is not None and _pool[1] is executor:
_pool = None
def _on_job_thread():
return threading.current_thread().name.startswith(JOB_THREAD_PREFIX + "_")
def map_parallel(func, items, workers):
"""Apply ``func`` to every item on the shared pool of ``workers``
threads, keeping order.
"""Apply ``func`` to every item on up to ``workers`` threads, keeping order.
``workers`` below 1 counts as 1. A call made from a job thread runs on
that thread, so nested work never waits for its own pool. An exception
in ``func`` propagates to the caller once the call's other items have
stopped (those not started are dropped), so callers that must survive
bad input wrap ``func`` themselves. A pool refused at interpreter exit
runs the items it did not start on the calling thread (#375). An item
``workers`` of 1 (or fewer) runs on the calling thread with no pool. An
exception in ``func`` propagates to the caller, so callers that must
survive bad input wrap ``func`` themselves. A pool refused at interpreter
exit runs the items it did not start on the calling thread (#375). An item
that has not started when the interpreter starts exiting raises
ExitRequested instead (#376).
"""
func = checked(func)
items = list(items)
if not items or _on_job_thread():
workers = max(1, int(workers or 1))
if workers == 1 or len(items) <= 1:
return [func(item) for item in items]
pool = _job_pool(max(1, int(workers or 1)))
futures = []
try:
for item in items:
futures.append(pool.submit(func, item))
except RuntimeError as error:
if refused_at_shutdown(error):
max_workers = min(workers, len(items))
with ThreadPoolExecutor(max_workers, thread_name_prefix="pm-worker") as pool:
futures = []
try:
for item in items:
futures.append(pool.submit(func, item))
except RuntimeError as error:
if not refused_at_shutdown(error):
raise
note_refusal(_logger, "A worker pool")
# Drop what was queued but not started; it runs here instead.
# (A refused Thread.start leaves its item queued without a future
# here: it is cancelled too, unless a busy pool thread took it in
# between, the one case where an item can run twice.)
_forget(pool)
pool.shutdown(wait=False, cancel_futures=True)
elif SHUT_POOL_REFUSAL not in str(error):
raise
return _collect(func, items, futures)
def _collect(func, items, futures):
"""``_run_rest``; on an error, first drop the call's items not started
and wait for its running ones, as the old per-call pool's exit did."""
try:
return _run_rest(func, items, futures)
except BaseException:
for future in futures:
future.cancel()
wait(futures)
raise
pool.shutdown(wait=True, cancel_futures=True)
else:
return [future.result() for future in futures]
return _run_rest(func, items, futures)
def _run_rest(func, items, futures):
-116
View File
@@ -1,116 +0,0 @@
"""PNG text chunks without decoding pixels (#523).
Pillow's ``PngImageFile.text`` calls ``load()``, which decodes every pixel, to
reach text chunks written after the image data. On a scan or a busy image
monitor that is a full-size buffer per file for a few kilobytes of text.
``png_text`` returns the same dictionary as ``image.text`` for an opened,
single-frame PNG, by walking the chunks after the first IDAT with Pillow's own
chunk handlers (same decoding, limits and duplicate rule) and seeking over the
pixel data. It follows Pillow's ``load_end`` loop: chunk checksums after the
pixels are not checked, an unreadable chunk header ends the walk, and another
chunk that runs past the end of the file fails like ``ImageFile._safe_read``
(unless ``ImageFile.LOAD_TRUNCATED_IMAGES``).
The one difference is pixel data, which is never decoded: pixel data that is
undecodable, or cut short, no longer hides the text read before it. Pillow
itself accepts a file cut inside its last pixel chunk once the decoder has
all rows, and only a decode can tell those apart, so the walk never reads
less than ``.text`` did (the PR #170 "legacy parity floor").
Anything else (other formats, animated PNGs, an image already loaded) is
answered by Pillow itself.
"""
import os
import struct
from PIL import ImageFile
_PIXEL_CHUNKS = (b"IDAT", b"fdAT")
# Signature, IHDR chunk and IDAT chunk header: the earliest pixel data offset.
_FIRST_DATA_OFFSET = 8 + 25 + 8
def png_text(image) -> dict:
"""``dict(image.text)`` for an opened image, without a pixel decode.
The image stays usable: its file position and Pillow state are restored.
Non-PNG images answer ``{}`` (only PNG has text chunks in Pillow).
"""
if not _walkable(image):
return dict(getattr(image, "text", None) or {})
fp = image.fp
resume = fp.tell()
try:
return _walk_after_pixels(image.png, fp, image.tile[0][2] - 8)
finally:
fp.seek(resume)
def _walkable(image) -> bool:
"""A plain single-frame PNG whose header pass stopped at its first IDAT."""
tile = getattr(image, "tile", None)
return (
image.format == "PNG"
and getattr(image, "png", None) is not None
and getattr(image, "fp", None) is not None
and not getattr(image, "is_animated", False)
and isinstance(tile, list)
and len(tile) == 1
and tile[0][0] == "zip"
# The first IDAT's data offset; anything else is not a layout we know.
and isinstance(tile[0][2], int)
and tile[0][2] >= _FIRST_DATA_OFFSET
)
def _walk_after_pixels(stream, fp, offset: int) -> dict:
"""Pillow's ``load_end`` chunk loop from the first IDAT header, skipping pixels.
``stream`` is the image's own ``PngStream``: its text so far and its text
memory budget carry on exactly as a ``load()`` would carry them. The text
and info dictionaries are copied first, so ``image.text`` and
``image.info`` are unchanged. Like ``load_end``, a handler for a chunk
misplaced after the pixels (a second IHDR or PLTE) may still update the
stream's other header fields; Pillow ignores them once the image is open.
"""
end = fp.seek(0, os.SEEK_END)
saved = stream.im_text, stream.im_info, stream.text_memory
stream.im_text, stream.im_info = dict(saved[0]), dict(saved[1])
try:
fp.seek(offset)
while True:
try:
kind, position, length = stream.read()
except (struct.error, SyntaxError):
break
if kind == b"IEND":
break
try:
handled = kind not in _PIXEL_CHUNKS and _handle(
stream, kind, position, length
)
except UnicodeDecodeError:
break
if not handled:
if position + length > end:
if kind in _PIXEL_CHUNKS or ImageFile.LOAD_TRUNCATED_IMAGES:
break
raise OSError("Truncated File Read")
fp.seek(position + length + 4)
continue
fp.read(4) # checksum: not verified after the pixels, as in Pillow
return stream.im_text
finally:
stream.im_text, stream.im_info, stream.text_memory = saved
def _handle(stream, kind: bytes, position: int, length: int) -> bool:
"""Run Pillow's handler; ``False`` when the chunk's bytes must be skipped."""
try:
stream.call(kind, position, length)
except (EOFError, AttributeError):
# A chunk Pillow reads past (EOFError) or has no handler for.
return False
return True
+11 -3
View File
@@ -257,11 +257,18 @@ function initialState(source, preset) {
image: null, savedTags: [], tagRevision: 0, currentTags: null, manual: '',
model: preset?.model ?? MODEL_OPTIONS[0].id, options: {...DEFAULTS, ...preset?.options}, generated: false, suggestions: [],
suggestionsText: COPY.modelChanged, statusText: '', generateSupported: false, generateReason: null, toast: null,
skippedCount: 0,
// Bumped whenever the controller itself rewrites the option fields or the manual draft, never while typing.
optionsRevision: 0, draftRevision: 0,
};
}
/** #431: how many suggestions the saved Skipped tags hid, after the chip hint ('' when none). */
export function skippedNote(count) {
if (!Number.isSafeInteger(count) || count <= 0) return '';
return count === 1 ? ' 1 skipped tag was not suggested.' : ` ${count.toLocaleString('en-US')} skipped tags were not suggested.`;
}
/** Derived labels and permissions; never stored independently of the fields they follow. */
function derive(state) {
const editable = EDITABLE.has(state.phase);
@@ -269,7 +276,7 @@ function derive(state) {
...state,
primaryLabel: state.generated ? 'Approve selected tags' : 'Save manual tags',
countText: state.generated ? `${state.suggestions.length} ${noun(state.suggestions.length, 'suggestion')}` : 'Not generated',
chipHint: state.suggestions.length ? COPY.chipHint : '',
chipHint: state.suggestions.length ? COPY.chipHint + skippedNote(state.skippedCount) : '',
canSave: editable,
canGenerate: editable && state.generateSupported,
canRetry: RETRYABLE.has(state.phase),
@@ -445,13 +452,14 @@ export function createImageTaggingController({api, target, source = 'images', on
const options = requestOptions(model, state.options);
const stale = () => closed || serial !== generateSerial;
set({phase: 'generating', statusText: COPY.generating});
let rows;
let rows, skippedCount;
try {
const value = await api.post(SUGGEST_URL, {
target: {...imageTarget}, capture_token: capture.token, expected_epoch: epoch, model_type: model, options,
}, {signal: controller.signal});
if (stale()) return false;
rows = validateSuggestions(value, options.suggestion_limit);
skippedCount = Number.isSafeInteger(value.skipped) && value.skipped > 0 ? value.skipped : 0;
} catch (error) {
if (stale()) return false;
const phase = isApiError(error) && REOPEN[error.code];
@@ -460,7 +468,7 @@ export function createImageTaggingController({api, target, source = 'images', on
}
generateAbort = null;
set({
phase: 'ready', generated: true, suggestions: rows, suggestionsText: rows.length ? '' : COPY.empty,
phase: 'ready', generated: true, suggestions: rows, suggestionsText: rows.length ? '' : COPY.empty, skippedCount,
statusText: `Suggestions from ${modelOf(model).label}. Approve tags to save.`,
});
return true;
+38 -150
View File
@@ -11,13 +11,6 @@ const linkedTo = (target,roles) => Object.keys(roles).filter(role=>Array.isArray
const nodeLabel = target => `#${target.id} ${target.label}`;
// A lost reply leaves the outcome unknown; anything else the host said is a refusal, and nothing was written.
const LOST_REPLY = /timed out|aborted|disposed/i;
const outcomeUnknown = (posted,error) => posted && (LOST_REPLY.test(error.message) || /unexpected acknowledgement/.test(error.message));
const UNKNOWN_ADVICE = 'Check ComfyUI, then preview again.';
/** The Destination node value that sends to every eligible node of the window (#542, D191). Node ids never take it. */
export const ALL_NODES = '*';
// "All" sends in node id order: 2 before 10, and a subgraph id "3:1" after "3".
const byNodeId = (a,b) => a.id.localeCompare(b.id,'en',{numeric:true});
const allMessage = (count,where) => `Sending to all ${count} eligible nodes in ${where}.`;
/** Why nothing in the discovered windows can receive these roles, most specific first (D173). */
function unavailableMessage(found,roles) {
@@ -47,90 +40,23 @@ function distinctLabels(items,suffix) {
export function createSendSession({client, roles:rawRoles, onChange}) {
const roles = Object.freeze(validateRoles(rawRoles));
// `previews` holds the reviewed nodes of "All", in send order; `preview` the one node otherwise.
let state = {phase:'idle',hosts:[],hostId:'',targetId:'',preview:null,previews:[],outcomeUnknown:false,message:''};
let state = {phase:'idle',hosts:[],hostId:'',targetId:'',preview:null,outcomeUnknown:false,message:''};
let operation, generation = 0, disposed = false;
const emit = patch => { if (!disposed) { state = Object.freeze({...state,...patch}); onChange(state); } };
function invalidate() { generation++; operation?.abort(); operation = null; }
function begin(phase) { invalidate(); operation = new AbortController(); emit({phase,preview:null,previews:[],outcomeUnknown:false,message:''}); return {signal:operation.signal,version:generation}; }
function begin(phase) { invalidate(); operation = new AbortController(); emit({phase,preview:null,outcomeUnknown:false,message:''}); return {signal:operation.signal,version:generation}; }
const current = request => !disposed && generation === request.version;
const chosenHost = () => state.hosts.find(host=>host.hostId===state.hostId);
/** The nodes the current choice sends to: one, every eligible node of the window for "All", or none. */
function chosen() {
const host=chosenHost();
if (!host) return [];
if (state.targetId===ALL_NODES) return host.targets.length>1 ? host.targets.toSorted(byNodeId) : [];
const target=host.targets.find(item=>item.id===state.targetId);
return target ? [target] : [];
}
// checkRevision is off only for a later node of "All", whose revision our own earlier write may have moved.
async function fetchPreview(request,hostId,target,checkRevision=true) {
const preview=await client.preview(hostId,target.id,roles,{signal:request.signal});
const selected = () => state.hosts.find(host=>host.hostId===state.hostId)?.targets.find(target=>target.id===state.targetId);
async function fetchPreview(request) {
const preview=await client.preview(state.hostId,state.targetId,roles,{signal:request.signal});
if (!current(request)) return null;
const fields=preview?.fields;
if(typeof preview?.ticket!=='string' || !preview.ticket || preview.target?.id!==target.id || !Array.isArray(fields) || fields.length!==Object.keys(roles).length || new Set(fields.map(field=>field?.role)).size!==fields.length || Array.from(fields).some(field=>!field || !Object.hasOwn(roles,field.role) || typeof field.before!=='string' || field.after!==roles[field.role])) throw Error('ComfyUI returned an unexpected preview. Find the destination and preview again.');
if(typeof preview?.ticket!=='string' || !preview.ticket || preview.target?.id!==state.targetId || !Array.isArray(fields) || fields.length!==Object.keys(roles).length || new Set(fields.map(field=>field?.role)).size!==fields.length || Array.from(fields).some(field=>!field || !Object.hasOwn(roles,field.role) || typeof field.before!=='string' || field.after!==roles[field.role])) throw Error('ComfyUI returned an unexpected preview. Find the destination and preview again.');
// Undefined on a tab still running older code, which has nothing to compare.
const found=target.revision;
if(checkRevision && found!==undefined && preview.revision!==found) throw Error('The workflow changed since this window was found. Nothing was sent. Find windows again.');
const found=selected()?.revision;
if(found!==undefined && preview.revision!==found) throw Error('The workflow changed since this window was found. Nothing was sent. Find windows again.');
return preview;
}
function checkAcknowledgement(acknowledgement,targetId) {
if(acknowledgement?.targetId!==targetId || !acknowledgement.values || Object.keys(acknowledgement.values).length!==Object.keys(roles).length || Object.keys(roles).some(role=>!Object.hasOwn(acknowledgement.values,role) || acknowledgement.values[role]!==roles[role])) throw Error('ComfyUI returned an unexpected acknowledgement.');
}
const reviewedText = change => JSON.stringify([change.fields.map(field=>[field.role,field.before]).sort(),change.composition ?? null]);
const sameChanges = (reviewed,fresh) => reviewedText(reviewed)===reviewedText(fresh);
/** Q542-2 C: every node of "All" previews before anything is written; the first refusal names its node. */
async function previewEach(request,hostId,targets) {
const reviewed=[];
for (const target of targets) {
try {
const preview=await fetchPreview(request,hostId,target);
if (!preview) return null;
reviewed.push({target,label:target.label,preview});
} catch(error) {
if (!current(request)) return null;
throw Error(`${target.label}: ${error.message}${/Nothing was sent/.test(error.message) ? '' : ' Nothing was sent.'}`);
}
}
return reviewed;
}
/**
* Sends the reviewed nodes one after another, best effort past refusals. Only the first node of a pass taken in this
* click keeps its ticket: a held review may have expired at the host (30 s), and our own writes may make later
* tickets stale, so every other node takes a fresh ticket that must show exactly the reviewed text.
*/
async function applyEach(request,hostId,reviewed,where,held) {
const sent=[],refused=[];let written=false;
for (const [index,{target,preview}] of reviewed.entries()) {
let posted=false;
try {
let ticket=preview.ticket;
if (index>0 || held) {
// Until something was posted the revision is still the discovered one; after that our own write may move it.
const fresh=await fetchPreview(request,hostId,target,!written);
if (!fresh) return;
if (!sameChanges(preview,fresh)) throw Error('Its prompt changed since the preview. Preview again.');
ticket=fresh.ticket;
}
posted=written=true;
const acknowledgement=await client.apply(hostId,ticket,{signal:request.signal});
if (!current(request)) return;
checkAcknowledgement(acknowledgement,target.id);
sent.push(target);
} catch(error) {
if (!current(request)) return;
const summary=[`Sent to ${sent.length} of ${reviewed.length} nodes in ${where}.`,...refused];
if (outcomeUnknown(posted,error)) {
const unsent=reviewed.slice(index+1).map(item=>item.label);
emit({phase:'error',outcomeUnknown:true,message:[...summary,`No confirmation received from ${target.label}; the update may have applied.`,...(unsent.length ? [`Not sent: ${unsent.join(', ')}.`] : []),UNKNOWN_ADVICE,error.message].join(' ')});
return;
}
refused.push(`${target.label}: ${error.message}`);
}
}
const message=[`Sent to ${sent.length} of ${reviewed.length} nodes in ${where}.`,...refused].join(' ');
emit(refused.length ? {phase:'error',message} : {phase:'success',message});
}
function hostChoice(host) {
if (host.targets.length>1) return {hostId:host.hostId,targetId:'',message:`Choose a destination node in ${host.label}.`};
const [target] = host.targets;
@@ -153,61 +79,38 @@ export function createSendSession({client, roles:rawRoles, onChange}) {
else { const choice=hostChoice(hosts[0]); emit({phase:'select',hosts,...choice,message:choice.message+note}); }
} catch (error) { if (current(request)) emit({phase:'error',message:error.message}); }
},
selectHost(hostId) { if (disposed) return; invalidate(); const host=state.hosts.find(host=>host.hostId===hostId); emit({phase:'select',preview:null,previews:[],outcomeUnknown:false,...(host ? hostChoice(host) : {hostId:'',targetId:'',message:'Choose a ComfyUI window.'})}); },
selectTarget(targetId) {
if (disposed) return; invalidate(); const host=chosenHost();
// D173: "All" is a choice only where there are two or more nodes, and it is never made for you.
const all=targetId===ALL_NODES && host?.targets.length>1;
const chosenOne=all || Boolean(host?.targets.some(target=>target.id===targetId));
const message=all ? `${allMessage(host.targets.length,host.label)} Apply to send it, or preview the changes first.` : chosenOne ? 'Apply to send it, or preview the changes first.' : 'Choose a destination node, then preview changes.';
emit({phase:'select',outcomeUnknown:false,targetId:chosenOne?targetId:'',preview:null,previews:[],message});
},
selectHost(hostId) { if (disposed) return; invalidate(); const host=state.hosts.find(host=>host.hostId===hostId); emit({phase:'select',preview:null,outcomeUnknown:false,...(host ? hostChoice(host) : {hostId:'',targetId:'',message:'Choose a ComfyUI window.'})}); },
selectTarget(targetId) { if (disposed) return; invalidate(); const host=state.hosts.find(host=>host.hostId===state.hostId); const chosen=host?.targets.some(target=>target.id===targetId); emit({phase:'select',outcomeUnknown:false,targetId:chosen?targetId:'',preview:null,message:chosen?'Apply to send it, or preview the changes first.':'Choose a destination node, then preview changes.'}); },
async preview() {
const targets=chosen();
if (disposed || !targets.length || ['previewing','applying'].includes(state.phase)) return;
const all=state.targetId===ALL_NODES; const hostId=state.hostId; const windowLabel=chosenHost()?.label;
if (disposed || !selected() || ['previewing','applying'].includes(state.phase)) return;
const request=begin('previewing');
try {
if (all) {
const previews=await previewEach(request,hostId,targets);
if (previews) emit({phase:'ready',previews,message:`${allMessage(targets.length,windowLabel)} Review the complete changes, then apply.`});
return;
}
const preview=await fetchPreview(request,hostId,targets[0]);
const preview=await fetchPreview(request);
if (!preview) return;
const windowLabel=state.hosts.find(host=>host.hostId===state.hostId)?.label;
emit({phase:'ready',preview,message:`Sending to ${nodeLabel({id:preview.target.id,label:preview.target.label ?? ''})} in ${windowLabel}. Review the complete changes, then apply.`});
} catch(error) { if(current(request)) emit({phase:'error',preview:null,previews:[],message:error.message}); }
} catch(error) { if(current(request)) emit({phase:'error',preview:null,message:error.message}); }
},
async apply() {
// Success stays closed: the second click of a double click must not send again.
const targets=chosen();
if(disposed || !targets.length || !['select','ready','error'].includes(state.phase) || state.outcomeUnknown) return;
const all=state.targetId===ALL_NODES;
const held=state.phase==='ready' ? (all ? state.previews : state.preview) : null;
const hostId=state.hostId; const windowLabel=chosenHost()?.label;
if(disposed || !selected() || !['select','ready','error'].includes(state.phase) || state.outcomeUnknown) return;
const held=state.phase==='ready' ? state.preview : null;
const targetId=state.targetId; const hostId=state.hostId;
const request=begin('applying');
emit({message:'Waiting for ComfyUI confirmation. Closing this dialog does not undo a sent update.'});
if (all) {
let reviewed;
try { reviewed=held?.length ? held : await previewEach(request,hostId,targets); }
catch(error) { if(current(request)) emit({phase:'error',message:error.message}); return; }
if (reviewed) await applyEach(request,hostId,reviewed,windowLabel,Boolean(held?.length));
return;
}
const [target]=targets;
let posted=false;
try {
const preview=held ?? await fetchPreview(request,hostId,target);
const preview=held ?? await fetchPreview(request);
if(!preview || !current(request)) return;
posted=true;
const acknowledgement=await client.apply(hostId,preview.ticket,{signal:request.signal});
if(!current(request)) return;
checkAcknowledgement(acknowledgement,target.id);
if(acknowledgement?.targetId!==targetId || !acknowledgement.values || Object.keys(acknowledgement.values).length!==Object.keys(roles).length || Object.keys(roles).some(role=>!Object.hasOwn(acknowledgement.values,role) || acknowledgement.values[role]!==roles[role])) throw Error('ComfyUI returned an unexpected acknowledgement.');
emit({phase:'success',message:'Sent to ComfyUI.'});
} catch(error) {
if(!current(request)) return;
const unknown=outcomeUnknown(posted,error);
emit({phase:'error',preview:null,outcomeUnknown:unknown,message:unknown ? `No confirmation received. The update may have applied. ${UNKNOWN_ADVICE} ${error.message}` : error.message});
const unknown=posted && (LOST_REPLY.test(error.message) || /unexpected acknowledgement/.test(error.message));
emit({phase:'error',preview:null,outcomeUnknown:unknown,message:unknown ? `No confirmation received. The update may have applied. Check ComfyUI, then preview again. ${error.message}` : error.message});
}
},
dispose() { if(disposed) return; disposed=true; invalidate(); client.dispose(); },
@@ -256,7 +159,7 @@ export function createPromptSender({scope,baseUrl=window.ApiClient.resolveUrl('/
inspect.disabled=true;apply.disabled=true;
const actions=element('footer',undefined,'prompt-sender-actions');actions.append(find,inspect,apply);
dialog.append(header,description,controls,status,preview,actions);document.body.append(dialog);
let session,closed=false,headingIds=0;
let session,closed=false;
function dispose() {
if(closed) return;closed=true;local.dispose();session?.dispose();
if(dialog.open) dialog.close();dialog.remove();
@@ -273,8 +176,8 @@ export function createPromptSender({scope,baseUrl=window.ApiClient.resolveUrl('/
function options(select,placeholder,entries,value) {
select.replaceChildren(...[["",placeholder],...entries].map(([value,label])=>{const option=element('option',label);option.value=value;return option;}));select.value=value;
}
function textBlock(parent,label,value,level=4) {
parent.append(element(`h${Math.min(level,6)}`,label));const text=element('pre');text.tabIndex=0;text.append(element('code',value === '' ? '(empty)' : value));parent.append(text);
function textBlock(parent,label,value) {
parent.append(element('h4',label));const text=element('pre');text.tabIndex=0;text.append(element('code',value === '' ? '(empty)' : value));parent.append(text);
}
function render(state) {
if(closed) return;
@@ -284,41 +187,26 @@ export function createPromptSender({scope,baseUrl=window.ApiClient.resolveUrl('/
dialog.setAttribute('aria-busy',String(busy));
options(host,'Choose a ComfyUI window',state.hosts.map(item=>[item.hostId,item.label]),state.hostId);
const selected=state.hosts.find(item=>item.hostId===state.hostId);
const nodes=(selected?.targets || []).map(item=>[item.id,item.label]);
// Q542-4 A: the last option sends to every eligible node of this window.
if(nodes.length>1) nodes.push([ALL_NODES,`All ${nodes.length} eligible nodes`]);
options(target,'Choose a destination node',nodes,state.targetId);
options(target,'Choose a destination node',(selected?.targets || []).map(item=>[item.id,item.label]),state.targetId);
host.disabled=busy;target.disabled=busy || !selected;
find.disabled=busy;inspect.disabled=busy || !state.targetId;apply.disabled=busy || !state.targetId || state.phase==='success' || state.outcomeUnknown;
preview.replaceChildren();
if(state.preview) {
preview.append(element('h3','Review changes'));
changes(preview,state.preview,3);
}
if(state.previews?.length) {
preview.append(element('h3','Review changes'));
// One section per node of "All", in the order they will be sent, named by its heading.
for(const item of state.previews) {
const node=element('section',undefined,'prompt-sender-node');const heading=element('h4',item.label);
heading.id=`prompt-sender-node-${++headingIds}`;node.setAttribute('aria-labelledby',heading.id);
node.append(heading);changes(node,item.preview,5);preview.append(node);
for(const field of state.preview.fields || []) {
const section=element('section',undefined,`prompt-sender-field prompt-role-${field.role}`);
section.append(element('h3',field.role==='positive'?'Positive prompt':'Negative prompt'));
textBlock(section,'Before',field.before);textBlock(section,'After',field.after);preview.append(section);
}
const composition=state.preview.composition;
if(composition) {
const section=element('section',undefined,'prompt-sender-composition');section.append(element('h3','Positive composition'));
textBlock(section,'Prepend (preserved)',composition.prepend || '');textBlock(section,'Append (preserved)',composition.append || '');
textBlock(section,'Combined before',composition.before ?? 'Resolved at execution from connected inputs.');
textBlock(section,'Combined after',composition.after ?? 'Resolved at execution from connected inputs.');
if(composition.prependConnected || composition.appendConnected) section.append(element('p','Connected prepend and append inputs are preserved and resolved when the workflow runs.'));
preview.append(section);
}
}
}
function changes(parent,change,level) {
for(const field of change.fields || []) {
const section=element('section',undefined,`prompt-sender-field prompt-role-${field.role}`);
section.append(element(`h${level}`,field.role==='positive'?'Positive prompt':'Negative prompt'));
textBlock(section,'Before',field.before,level+1);textBlock(section,'After',field.after,level+1);parent.append(section);
}
const composition=change.composition;
if(composition) {
const section=element('section',undefined,'prompt-sender-composition');section.append(element(`h${level}`,'Positive composition'));
textBlock(section,'Prepend (preserved)',composition.prepend || '',level+1);textBlock(section,'Append (preserved)',composition.append || '',level+1);
textBlock(section,'Combined before',composition.before ?? 'Resolved at execution from connected inputs.',level+1);
textBlock(section,'Combined after',composition.after ?? 'Resolved at execution from connected inputs.',level+1);
if(composition.prependConnected || composition.appendConnected) section.append(element('p','Connected prepend and append inputs are preserved and resolved when the workflow runs.'));
parent.append(section);
}
}
dialog.showModal();
+2 -16
View File
@@ -179,27 +179,13 @@ export function reviewRefusal(error) {
return REVIEW_FAILED;
}
/**
* #583: the review uploads not answered yet. WebKit can read a FormData's file part after the fetch call returned (it
* did whenever a request interceptor, here Playwright's, was armed); a garbage collection in between dropped a form
* nothing referenced any more, and the server got an empty file. Each form is held here until its upload is answered.
* Exported for the unit tests only.
*/
export const uploadsInFlight = new Set();
/** Stage `file` for review; the server's answer, or an ApiError naming its refusal. */
export async function uploadRestore(file, { fetchUrl = window.ApiClient.fetchUrl, ApiError = window.ApiClient.ApiError,
signal } = {}) {
const form = new FormData();
form.append('database_file', file, file.name);
uploadsInFlight.add(form);
let response;
try {
response = await fetchUrl(PREVIEW_URL, { method: 'POST', body: form, cache: 'no-store', credentials: 'same-origin',
signal });
} finally {
uploadsInFlight.delete(form);
}
const response = await fetchUrl(PREVIEW_URL, { method: 'POST', body: form, cache: 'no-store', credentials: 'same-origin',
signal });
let data = null;
try {
data = await response.json();
+351
View File
@@ -0,0 +1,351 @@
/**
* System › Auto-tagging "Saved instructions" (#431, D203): named language-model instructions and the default of each
* language-model engine. It replaces the concept's "LLM tag template" textarea.
*
* A `.setting-block` (`data-setting="tag-instructions"`, `data-key="autotag_instructions"`): the label, the
* description with the count ("N of 20 saved · Built\u2011in is not counted."), a `role="listbox"` list (Built-in first,
* "Read-only", then the saved entries, each with its "Default for GGUF" / "Default for HuggingFace" chips), "+ New",
* "Duplicate" and "Delete", and the editor beside the list (under it below 700 px): "Name" (60 characters, live count)
* and "Text" ("N of 4,000 characters"), both read-only for Built-in with its note. Then two `.setting-row` selects,
* "Default for GGUF JoyCaption" and "Default for HuggingFace JoyCaption" (`data-key="autotag_default_instructions"`),
* and the note a deleted default leaves ("GGUF now uses Built-in."). Host drafts: `autotag_instructions` (the saved
* entries, never Built-in) and `autotag_default_instructions`; the server pairs them on save. Built-in's text is the
* read-only `autotag_builtin_instructions` of GET /settings. Ids are 12 lowercase hex characters made here.
*/
import { createButton } from '../shared/toolkit/core.js';
import { settingRow } from './system-controls.mjs';
export const INSTRUCTIONS_KEY = 'autotag_instructions';
export const DEFAULTS_KEY = 'autotag_default_instructions';
export const BUILTIN_TEXT_KEY = 'autotag_builtin_instructions';
export const BUILTIN_ID = 'builtin';
export const BUILTIN_NAME = 'Built-in';
export const MAX_SAVED = 20;
export const MAX_NAME_CHARS = 60;
export const MAX_TEXT_CHARS = 4000;
export const ENGINES = Object.freeze([['gguf', 'GGUF', 'GGUF JoyCaption'], ['hf', 'HuggingFace', 'HuggingFace JoyCaption']]);
export const INSTRUCTIONS_COPY = Object.freeze({
label: 'Saved instructions',
description: 'Instructions for the language-model engines. Built-in can’t be changed; duplicate it to make your own.',
count: saved => `${saved} of ${MAX_SAVED} saved · Built\u2011in is not counted.`,
readOnly: 'Read-only',
defaultFor: short => `Default for ${short}`,
newLabel: '+ New',
duplicate: 'Duplicate',
delete: 'Delete',
cap: `At most ${MAX_SAVED} saved instructions.`,
name: 'Name',
text: 'Text',
nameCount: length => `${length} of ${MAX_NAME_CHARS}`,
textCount: length => `${length.toLocaleString('en-US')} of ${MAX_TEXT_CHARS.toLocaleString('en-US')} characters`,
builtinNote: 'Built-in instructions can’t be changed. Duplicate them to make your own.',
newName: 'New instructions',
copyName: name => `${name} copy`,
defaultRow: long => `Default for ${long}`,
defaultDescription: 'The Tag image dialog and batches start from this.',
reset: shorts => `${shorts.join(' and ')} now ${shorts.length > 1 ? 'use' : 'uses'} Built-in.`,
});
/** Characters as Python's `len` counts them (code points). */
const characters = text => [...String(text ?? '')].length;
const fold = text => String(text ?? '').trim().toLowerCase();
/** The saved entries of the drafts (`[]` when unread or damaged). */
export const savedEntries = values => (Array.isArray(values?.[INSTRUCTIONS_KEY]) ? values[INSTRUCTIONS_KEY] : []);
/** Built-in (its text from GET /settings) and then the saved entries. */
export function allEntries(values) {
const text = typeof values?.[BUILTIN_TEXT_KEY] === 'string' ? values[BUILTIN_TEXT_KEY] : '';
return [{ id: BUILTIN_ID, name: BUILTIN_NAME, text }, ...savedEntries(values)];
}
/** The drafted default entry of `engine` (`gguf` or `hf`); Built-in when the id names nothing. */
export function defaultEntry(values, engine) {
const entries = allEntries(values);
const id = values?.[DEFAULTS_KEY]?.[engine];
return entries.find(entry => entry.id === id) ?? entries[0];
}
/** `base`, else `base (2)`, `base (3)`… whichever no entry uses (case-insensitive). */
export function freeName(base, entries) {
const taken = new Set(entries.map(entry => fold(entry.name)));
if (!taken.has(fold(base))) return base;
for (let n = 2; ; n += 1) if (!taken.has(fold(`${base} (${n})`))) return `${base} (${n})`;
}
/** "<name> copy" (then "(2)"…), the name cut so the whole stays within the 60-character limit. */
export function copyName(name, entries) {
for (let room = MAX_NAME_CHARS - ' copy'.length; room > 0; room -= 1) {
const base = [...String(name ?? '').trim()].slice(0, room).join('').trimEnd();
const candidate = freeName(INSTRUCTIONS_COPY.copyName(base), entries);
if (characters(candidate) <= MAX_NAME_CHARS) return candidate;
}
return freeName(INSTRUCTIONS_COPY.copyName(BUILTIN_NAME), entries);
}
/** A new entry id: 12 lowercase hex characters from `random` (crypto.getRandomValues), never one already used. */
export function newId(entries, random = bytes => globalThis.crypto.getRandomValues(bytes)) {
const used = new Set(entries.map(entry => entry.id));
for (;;) {
const id = [...random(new Uint8Array(6))].map(byte => byte.toString(16).padStart(2, '0')).join('');
if (!used.has(id)) return id;
}
}
/** The editor's note for a saved entry's name and text (the mock's short sentences), or ''. */
export function entryNote(entry, entries) {
const name = String(entry.name ?? '').trim();
if (!name) return 'Needs a name.';
if (characters(name) > MAX_NAME_CHARS) return `The name must be at most ${MAX_NAME_CHARS} characters.`;
if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/u.test(name)) return 'The name must not contain a line break.';
if (fold(name) === fold(BUILTIN_NAME)) return 'The name “Built-in” is reserved.';
if (entries.some(other => other !== entry && other.id !== BUILTIN_ID && fold(other.name) === fold(name))) {
return `The name “${name}” is already used.`;
}
if (!String(entry.text ?? '').trim()) return 'The text must not be empty.';
if (characters(entry.text) > MAX_TEXT_CHARS) return `The text must be at most ${MAX_TEXT_CHARS.toLocaleString('en-US')} characters.`;
return '';
}
/**
* The server's sentence for the first saved entry it would refuse (Built-in is not counted), else null. The Save row
* shows the server's own refusal; this one holds back Preview tagging review and the batch Review.
*/
export function listProblem(values) {
const entries = savedEntries(values);
for (const [index, entry] of entries.entries()) {
// As the server checks them: a repeated name is the later entry's problem.
const note = entryNote(entry, entries.slice(0, index + 1));
if (!note) continue;
const n = index + 1;
if (note.startsWith('Needs')) return `Saved instructions: entry ${n} needs a name.`;
if (note.includes('reserved')) return 'Saved instructions: the name “Built-in” is reserved.';
if (note.includes('already used')) return `Saved instructions: entry ${n} name “${String(entry.name).trim()}” is already used.`;
if (note.includes('line break')) return `Saved instructions: entry ${n} name must not contain a line break.`;
if (note.startsWith('The name')) return `Saved instructions: entry ${n} name must be at most ${MAX_NAME_CHARS} characters.`;
if (note.includes('empty')) return `Saved instructions: entry ${n} text must be non-empty text.`;
return `Saved instructions: entry ${n} text must be at most ${MAX_TEXT_CHARS} characters.`;
}
return null;
}
function el(tag, text, className) {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
if (className) node.className = className;
return node;
}
/** A `.field` with a label row (text and a muted count) and its control. */
function field(id, label, control) {
const wrap = el('div', undefined, 'field');
const caption = el('label');
caption.htmlFor = id;
const count = el('span', '', 'small muted');
caption.append(el('span', label), count);
control.id = id;
wrap.append(caption, control);
return { wrap, count };
}
/**
* Build the block and the two default rows; `drafts` is the System draft store. Returns `{nodes, repaint}`: System
* appends `nodes` in order and calls `repaint` whenever the drafts change.
*/
export function savedInstructionsBlock({ scope, drafts, random }) {
const block = el('div', undefined, 'setting-block');
block.dataset.setting = 'tag-instructions';
block.dataset.key = INSTRUCTIONS_KEY;
const text = el('div', undefined, 'setting-block-text');
const label = el('label', INSTRUCTIONS_COPY.label);
label.id = 'instructions-label';
const description = el('p', `${INSTRUCTIONS_COPY.description} `);
const count = el('span', '');
count.id = 'instructions-count';
description.append(count);
text.append(label, description);
const layout = el('div', undefined, 'instructions-layout');
const side = el('div', undefined, 'instructions-side');
const list = el('div', undefined, 'instruction-list');
list.id = 'instruction-list';
list.setAttribute('role', 'listbox');
list.setAttribute('aria-labelledby', 'instructions-label');
list.tabIndex = 0;
const actions = el('div', undefined, 'row-actions instruction-actions');
const create = createButton(INSTRUCTIONS_COPY.newLabel);
create.id = 'instruction-new';
const duplicate = createButton(INSTRUCTIONS_COPY.duplicate);
duplicate.id = 'instruction-duplicate';
const remove = createButton(INSTRUCTIONS_COPY.delete, 'danger');
remove.id = 'instruction-delete';
actions.append(create, duplicate, remove);
const cap = el('p', INSTRUCTIONS_COPY.cap, 'small');
cap.id = 'instruction-cap';
cap.hidden = true;
side.append(list, actions, cap);
const editor = el('div', undefined, 'instruction-editor');
const nameInput = el('input');
nameInput.autocomplete = 'off';
const name = field('instruction-name', INSTRUCTIONS_COPY.name, nameInput);
const textArea = el('textarea');
textArea.rows = 7;
const body = field('instruction-text', INSTRUCTIONS_COPY.text, textArea);
const note = el('p', '', 'small');
note.id = 'instruction-note';
note.setAttribute('role', 'status');
editor.append(name.wrap, body.wrap, note);
layout.append(side, editor);
block.append(text, layout);
const selects = ENGINES.map(([engine, , long]) => {
const row = settingRow({ id: `instruction-default-${engine}`, label: INSTRUCTIONS_COPY.defaultRow(long),
description: INSTRUCTIONS_COPY.defaultDescription, control: { type: 'select', options: [[BUILTIN_ID, BUILTIN_NAME]] } });
const select = row.children[1];
select.dataset.key = DEFAULTS_KEY;
return { engine, select, row };
});
const reset = el('p', '', 'small instruction-reset');
reset.id = 'instruction-reset';
reset.setAttribute('role', 'status');
let selectedId = BUILTIN_ID;
const values = () => ({ [INSTRUCTIONS_KEY]: drafts.get('host', INSTRUCTIONS_KEY),
[DEFAULTS_KEY]: drafts.get('host', DEFAULTS_KEY), [BUILTIN_TEXT_KEY]: drafts.get('host', BUILTIN_TEXT_KEY) });
const ready = () => Array.isArray(drafts.get('host', INSTRUCTIONS_KEY)) && drafts.get('host', DEFAULTS_KEY) !== undefined;
const defaults = () => ({ ...(drafts.get('host', DEFAULTS_KEY) ?? {}) });
const setEntries = next => drafts.set('host', INSTRUCTIONS_KEY, next);
const selected = () => allEntries(values()).find(entry => entry.id === selectedId) ?? allEntries(values())[0];
/** Replace the selected saved entry's `key` with `value` (a new list of new objects). */
function edit(key, value) {
if (selectedId === BUILTIN_ID) return;
setEntries(savedEntries(values()).map(entry => (entry.id === selectedId ? { ...entry, [key]: value } : entry)));
}
function add(entry) {
const entries = savedEntries(values());
if (!ready() || entries.length >= MAX_SAVED) return;
selectedId = entry.id;
reset.textContent = '';
setEntries([...entries, entry]);
}
function deleteSelected() {
const entries = savedEntries(values());
const index = entries.findIndex(entry => entry.id === selectedId);
if (index < 0) return;
const gone = entries[index];
const next = defaults();
const shorts = [];
for (const [engine, short] of ENGINES) {
if (next[engine] === gone.id) { next[engine] = BUILTIN_ID; shorts.push(short); }
}
const left = entries.filter(entry => entry.id !== gone.id);
selectedId = (left[index] ?? left[index - 1])?.id ?? BUILTIN_ID;
reset.textContent = shorts.length ? INSTRUCTIONS_COPY.reset(shorts) : '';
if (shorts.length) drafts.set('host', DEFAULTS_KEY, next);
setEntries(left);
}
function select(id) {
selectedId = id;
repaint();
}
function renderList(entries, chosen) {
list.replaceChildren(...entries.map(entry => {
const option = el('div');
option.id = `instruction-option-${entry.id}`;
option.dataset.id = entry.id;
option.setAttribute('role', 'option');
option.setAttribute('aria-selected', String(entry.id === selectedId));
option.append(el('span', entry.name));
const badges = el('span', undefined, 'badges');
if (entry.id === BUILTIN_ID) badges.append(el('span', INSTRUCTIONS_COPY.readOnly, 'chip'));
for (const [engine, short] of ENGINES) {
if (chosen[engine] === entry.id) badges.append(el('span', INSTRUCTIONS_COPY.defaultFor(short), 'chip blue'));
}
if (badges.children.length) option.append(badges);
return option;
}));
list.setAttribute('aria-activedescendant', `instruction-option-${selectedId}`);
}
function repaint() {
// The note a deleted default left goes once the defaults are saved (or set back).
if (!Object.hasOwn(drafts.changes('host'), DEFAULTS_KEY)) reset.textContent = '';
const loaded = ready();
for (const control of [create, duplicate, remove, nameInput, textArea, ...selects.map(item => item.select)]) {
control.disabled = !loaded;
}
if (!loaded) return;
const current = values();
const entries = allEntries(current);
if (!entries.some(entry => entry.id === selectedId)) selectedId = BUILTIN_ID;
const chosen = defaults();
const saved = savedEntries(current);
renderList(entries, chosen);
const entry = selected();
const builtin = entry.id === BUILTIN_ID;
// A focused field keeps what the user is typing.
if (document.activeElement !== nameInput) nameInput.value = entry.name;
if (document.activeElement !== textArea) textArea.value = entry.text;
nameInput.readOnly = textArea.readOnly = builtin;
name.count.textContent = builtin ? '' : INSTRUCTIONS_COPY.nameCount(characters(entry.name));
body.count.textContent = INSTRUCTIONS_COPY.textCount(characters(entry.text));
const problem = builtin ? '' : entryNote(entry, entries);
note.textContent = builtin ? INSTRUCTIONS_COPY.builtinNote : problem;
note.className = problem ? 'small skip-problem' : 'small';
const full = saved.length >= MAX_SAVED;
create.disabled = duplicate.disabled = full;
remove.disabled = builtin;
cap.hidden = !full;
count.textContent = INSTRUCTIONS_COPY.count(saved.length);
for (const { engine, select: menu } of selects) {
menu.replaceChildren(...entries.map(item => {
const option = el('option', item.name);
option.value = item.id;
return option;
}));
menu.value = chosen[engine] ?? BUILTIN_ID;
}
}
scope.listen(create, 'click', () => {
const entries = allEntries(values());
add({ id: newId(entries, random), name: freeName(INSTRUCTIONS_COPY.newName, entries), text: '' });
textArea.focus?.();
});
scope.listen(duplicate, 'click', () => {
const entries = allEntries(values());
const source = selected();
add({ id: newId(entries, random), name: copyName(source.name, entries), text: source.text });
});
scope.listen(remove, 'click', deleteSelected);
// One listener for every option, however often the list is rebuilt.
scope.listen(list, 'click', event => {
let node = event.target;
while (node && node !== list && node.getAttribute?.('role') !== 'option') node = node.parentNode;
if (node && node !== list) select(node.dataset.id);
});
scope.listen(nameInput, 'input', () => edit('name', nameInput.value));
scope.listen(textArea, 'input', () => edit('text', textArea.value));
scope.listen(list, 'keydown', event => {
const ids = allEntries(values()).map(entry => entry.id);
const at = ids.indexOf(selectedId);
const next = { ArrowDown: at + 1, ArrowUp: at - 1, Home: 0, End: ids.length - 1 }[event.key];
if (next === undefined) return;
event.preventDefault?.();
select(ids[Math.max(0, Math.min(ids.length - 1, next))]);
});
for (const { engine, select: menu } of selects) {
scope.listen(menu, 'change', () => {
reset.textContent = '';
drafts.set('host', DEFAULTS_KEY, { ...defaults(), [engine]: menu.value });
});
}
return { nodes: [block, ...selects.map(item => item.row), reset], repaint };
}
+246
View File
@@ -0,0 +1,246 @@
/**
* System › Auto-tagging "Skipped tags" (#431, D202): words whose suggested tags are never offered, on every suggestion
* path (the server filters; hand-typed and stored tags are never touched).
*
* A `.setting-block` after "Suggestions per image": the label and description, 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, and a preview line (`role="status"`) that says how many known tags the list hides
* (`POST /prompt_manager/autotag/skipped-tags/preview`: the library's tags plus the downloaded WD14 label lists). The
* block carries `data-setting="tag-skipped"` and `data-key="autotag_skipped_tags"`, so a refused save highlights it.
* The list is the host draft `autotag_skipped_tags` (an array), saved by the one Save row. The client checks repeat the
* server's rules (the server is the authority): an empty, too long, comma or duplicate entry is not added and the line
* under the input says why; the list holds at most 200 entries.
*/
import { createButton } from '../shared/toolkit/core.js';
export const SKIPPED_KEY = 'autotag_skipped_tags';
export const PREVIEW_URL = '/prompt_manager/autotag/skipped-tags/preview';
export const MAX_ENTRIES = 200;
export const MAX_ENTRY_CHARS = 50;
export const PREVIEW_DELAY_MS = 300;
export const SAMPLE_LIMIT = 12;
export const SKIPPED_COPY = Object.freeze({
label: 'Skipped tags',
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.',
placeholder: 'Add a word or words',
add: 'Add',
chips: 'Skipped tags',
empty: 'Enter a word.',
comma: 'No commas or line breaks.',
long: `At most ${MAX_ENTRY_CHARS} characters.`,
duplicate: 'Already in the list.',
full: `The list is full (${MAX_ENTRIES}).`,
none: 'No tags are skipped.',
checking: 'Checking…',
failed: 'The preview could not be read.',
unchanged: 'Tags already on images are not changed.',
libraryOnly: 'Checked against library tags only; no WD14 label list is downloaded.',
hidesNothing: 'Hides no known tag',
});
const plural = (count, word) => `${count.toLocaleString('en-US')} ${word}${count === 1 ? '' : 's'}`;
/** The words of an entry or a tag as the server splits them: NFC, lower case, split on space, `_` and `-`. */
export const entryWords = text => String(text ?? '').normalize('NFC').toLowerCase().split(/[ _-]+/).filter(Boolean);
const entryKey = text => entryWords(text).join(' ');
/** Why `raw` cannot be added to `entries` (the line under the input), or '' when it can. */
export function entryProblem(raw, entries) {
const entry = String(raw ?? '').trim();
if (!entryWords(entry).length) return SKIPPED_COPY.empty;
// Commas and control characters (a pasted line break), as the server refuses them.
if (/[,\u0000-\u001f\u007f-\u009f\u2028\u2029]/u.test(entry)) return SKIPPED_COPY.comma;
if ([...entry].length > MAX_ENTRY_CHARS) return SKIPPED_COPY.long;
const key = entryKey(entry);
if (entries.some(item => entryKey(item) === key)) return SKIPPED_COPY.duplicate;
if (entries.length >= MAX_ENTRIES) return SKIPPED_COPY.full;
return '';
}
/** A chip's title and count text for an entry the last preview counted (`count` undefined: not counted yet). */
export function chipCount(count) {
if (!Number.isSafeInteger(count)) return null;
return count ? { title: `Hides ${plural(count, 'tag')}`, text: count.toLocaleString('en-US') }
: { title: SKIPPED_COPY.hidesNothing, text: 'none' };
}
/**
* The preview line for a preview answer (`{count, sample, sources}`): the bold lead "Hides N tags from suggestions",
* then the sample (": a, b and K more. ") and the closing sentences. Returns `{lead, rest}`.
*/
export function previewText(result) {
const count = Number.isSafeInteger(result?.count) && result.count > 0 ? result.count : 0;
const sample = Array.isArray(result?.sample) ? result.sample.filter(name => typeof name === 'string') : [];
const more = count - sample.length;
const listed = count && sample.length ? `: ${sample.join(', ')}${more > 0 ? ` and ${more.toLocaleString('en-US')} more` : ''}. ` : '. ';
const labels = Array.isArray(result?.sources?.wd14) && result.sources.wd14.length > 0;
return {
lead: `Hides ${plural(count, 'tag')} from suggestions`,
rest: `${listed}${labels ? '' : `${SKIPPED_COPY.libraryOnly} `}${SKIPPED_COPY.unchanged}`,
};
}
function el(tag, text, className) {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
if (className) node.className = className;
return node;
}
/**
* Build the block; `drafts` is the System draft store, `api` the workspace client. Returns `{node, repaint}`: System
* calls `repaint` whenever the drafts change (the chips follow the drafted list, and a changed list is previewed
* `PREVIEW_DELAY_MS` after the last change, one request at a time: a newer list aborts the older request).
*/
export function skippedTagsBlock({ api, scope, drafts, delay = PREVIEW_DELAY_MS }) {
const block = el('div', undefined, 'setting-block');
block.dataset.setting = 'tag-skipped';
block.dataset.key = SKIPPED_KEY;
const text = el('div', undefined, 'setting-block-text');
const label = el('label', SKIPPED_COPY.label);
label.htmlFor = 'tag-skipped-input';
text.append(label, el('p', SKIPPED_COPY.description));
const addRow = el('div', undefined, 'skip-add');
const input = el('input');
input.id = 'tag-skipped-input';
input.autocomplete = 'off';
input.placeholder = SKIPPED_COPY.placeholder;
input.setAttribute('aria-describedby', 'tag-skipped-problem');
const add = createButton(SKIPPED_COPY.add);
add.id = 'tag-skipped-add';
addRow.append(input, add);
const problem = el('p', '', 'small skip-problem');
problem.id = 'tag-skipped-problem';
problem.setAttribute('role', 'alert');
const chips = el('div', undefined, 'tags skip-chips');
chips.setAttribute('role', 'group');
chips.setAttribute('aria-label', SKIPPED_COPY.chips);
const preview = el('p', '', 'small skip-preview');
preview.setAttribute('role', 'status');
preview.setAttribute('aria-live', 'polite');
block.append(text, addRow, problem, chips, preview);
const entries = () => {
const value = drafts.get('host', SKIPPED_KEY);
return Array.isArray(value) ? value : null;
};
let counts = new Map();
let shown = null; // the list the chips show (JSON)
let pending = null; // the list the preview line is (or is being) made for (JSON)
let timer = null;
let request = null;
scope.cleanup(() => { clearTimeout(timer); request?.abort(); });
const say = message => {
problem.textContent = message;
if (message) input.setAttribute('aria-invalid', 'true');
else input.removeAttribute('aria-invalid');
};
const setList = next => drafts.set('host', SKIPPED_KEY, next);
function addEntry() {
const list = entries();
if (!list) return;
const message = entryProblem(input.value, list);
say(message);
if (message) return;
const entry = input.value.trim();
input.value = '';
setList([...list, entry]);
}
function removeEntry(index) {
const list = entries();
if (!list) return;
const buttons = [...chips.querySelectorAll?.('button') ?? []];
const focused = buttons.indexOf(document.activeElement);
setList(list.filter((_entry, at) => at !== index));
// The removed chip's focus moves to its neighbour's ×, else to the input.
if (focused >= 0) {
const left = [...chips.querySelectorAll?.('button') ?? []];
(left[Math.min(focused, left.length - 1)] ?? input).focus?.();
}
}
function renderChips(list) {
chips.replaceChildren(...list.map((entry, index) => {
const count = chipCount(counts.get(entry));
const chip = el('span', undefined, `chip tag-suggestion${count?.text === 'none' ? ' none' : ''}`);
if (count) chip.title = count.title;
chip.append(el('span', entry));
if (count) chip.append(el('span', count.text, 'tag-confidence'));
const remove = createButton('×');
remove.setAttribute('aria-label', `Remove ${entry}`);
scope.listen(remove, 'click', () => removeEntry(index));
chip.append(remove);
return chip;
}));
}
function showPreview(result) {
const { lead, rest } = previewText(result);
preview.replaceChildren(el('b', lead), rest);
}
async function runPreview(list) {
request?.abort();
const controller = new AbortController();
request = controller;
try {
const result = await api.post(PREVIEW_URL, { entries: list }, { signal: controller.signal });
if (controller.signal.aborted || scope.signal.aborted) return;
counts = new Map((Array.isArray(result?.per_entry) ? result.per_entry : [])
.filter(row => typeof row?.entry === 'string').map(row => [row.entry, row.count]));
showPreview(result);
renderChips(entries() ?? list);
} catch {
if (controller.signal.aborted || scope.signal.aborted) return;
preview.textContent = SKIPPED_COPY.failed;
} finally {
if (request === controller) request = null;
}
}
function schedule(list) {
clearTimeout(timer);
request?.abort();
request = null;
if (!list.length) {
counts = new Map();
preview.textContent = SKIPPED_COPY.none;
return;
}
preview.textContent = SKIPPED_COPY.checking;
timer = setTimeout(() => runPreview(list), delay);
}
function repaint() {
const list = entries();
input.disabled = add.disabled = list === null;
if (list === null) return;
const full = list.length >= MAX_ENTRIES;
add.disabled = full;
if (full && !problem.textContent) say(SKIPPED_COPY.full);
if (!full && problem.textContent === SKIPPED_COPY.full) say('');
const key = JSON.stringify(list);
if (key !== shown) {
shown = key;
renderChips(list);
}
if (key !== pending) {
pending = key;
schedule(list);
}
}
scope.listen(add, 'click', addEntry);
scope.listen(input, 'keydown', event => {
if (event.key !== 'Enter') return;
event.preventDefault?.();
addEntry();
});
scope.listen(input, 'input', () => { if (problem.textContent !== SKIPPED_COPY.full) say(''); });
return { node: block, repaint };
}
+19 -3
View File
@@ -76,6 +76,22 @@ export function statusText(outcome) {
return saved.join(' ') || SETTINGS_STATUS.clean;
}
const plain = value => value !== null && typeof value === 'object'
&& (Array.isArray(value) || Object.getPrototypeOf(value) === Object.prototype);
/** JSON with every object's keys sorted, so key order never makes two values differ. */
const canonical = value => JSON.stringify(value, (key, item) => (item && typeof item === 'object' && !Array.isArray(item)
? Object.fromEntries(Object.keys(item).sort().map(name => [name, item[name]])) : item));
/**
* #431: whether a draft equals a saved value: `Object.is`, or both are arrays or plain objects with the same JSON (keys
* sorted). A list setting (Skipped tags) whose added entry was removed again is clean, not a new array that differs.
*/
export function sameDraft(a, b) {
if (Object.is(a, b)) return true;
if (!plain(a) || !plain(b) || Array.isArray(a) !== Array.isArray(b)) return false;
return canonical(a) === canonical(b);
}
function requireDomain(domain) {
if (!DRAFT_DOMAINS.includes(domain)) throw new TypeError(`Unknown settings domain: ${domain}`);
}
@@ -112,7 +128,7 @@ export function createSystemDrafts(adapters) {
values = await (domain === 'browser' ? adapters.readBrowser() : adapters.readRuntime());
}
baseline[domain] = { ...(values ?? {}) };
for (const [key, value] of drafts[domain]) if (Object.is(baseline[domain][key], value)) drafts[domain].delete(key);
for (const [key, value] of drafts[domain]) if (sameDraft(baseline[domain][key], value)) drafts[domain].delete(key);
notify();
return { ...baseline[domain] };
}
@@ -124,7 +140,7 @@ export function createSystemDrafts(adapters) {
function set(domain, key, value) {
requireDomain(domain);
if (Object.is(baseline[domain][key], value)) drafts[domain].delete(key);
if (sameDraft(baseline[domain][key], value)) drafts[domain].delete(key);
else drafts[domain].set(key, value);
failedKeys[domain].delete(key);
outcome = null;
@@ -153,7 +169,7 @@ export function createSystemDrafts(adapters) {
function commit(domain, written) {
for (const [key, value] of written) {
baseline[domain][key] = value;
if (drafts[domain].has(key) && Object.is(drafts[domain].get(key), value)) drafts[domain].delete(key);
if (drafts[domain].has(key) && sameDraft(drafts[domain].get(key), value)) drafts[domain].delete(key);
}
}
+41 -30
View File
@@ -9,7 +9,9 @@
* - "Existing tags", "General-tag confidence", "Character-tag confidence": the concept's labels and descriptions
* verbatim (they state the shipped defaults); host keys `autotag_existing`, `autotag_general_threshold`,
* `autotag_character_threshold`. D133 adds "Suggestions per image" (`autotag_suggestion_limit`, 1–100) after them.
* - The `.field` "LLM tag template" (`#tag-template`, `autotag_template`).
* - D202 (#431): the "Skipped tags" block after it (system-skipped-tags.mjs, host key `autotag_skipped_tags`).
* - D203 (#431): "Saved instructions" and the two per-engine default rows (system-instructions.mjs, host keys
* `autotag_instructions` and `autotag_default_instructions`) replace the concept's "LLM tag template" field.
* - `.row-actions`: "Preview tagging review" (`ca-primary`) opens the Tag image dialog for the image selected in Images
* with the section's current model and values, aria-disabled without one (the D115 sentence) or with an invalid
* value (the toast names it). D132: the concept's two simulated job buttons are "Download" (or
@@ -35,6 +37,9 @@ import { selectedImage } from './images.mjs';
import { NO_SELECTION } from './vocabulary.mjs';
import { showWorkspaceToast } from './toast.mjs';
import { navigateTo } from './state.mjs';
import { skippedTagsBlock } from './system-skipped-tags.mjs';
import { BUILTIN_TEXT_KEY, DEFAULTS_KEY, INSTRUCTIONS_KEY, defaultEntry, listProblem, savedInstructionsBlock }
from './system-instructions.mjs';
export const MODELS_URL = '/prompt_manager/autotag/models';
export const UNLOAD_URL = '/prompt_manager/autotag/unload';
@@ -68,10 +73,10 @@ export const SETTING_KEYS = Object.freeze({
'threshold-general': 'autotag_general_threshold',
'threshold-character': 'autotag_character_threshold',
'tag-suggestions': 'autotag_suggestion_limit',
'tag-template': 'autotag_template',
});
/** #431: the values the instruction readers take besides the rows' (D203, D205). */
const INSTRUCTION_VALUES = Object.freeze([INSTRUCTIONS_KEY, DEFAULTS_KEY, BUILTIN_TEXT_KEY]);
const NUMERIC = new Set(['autotag_general_threshold', 'autotag_character_threshold', 'autotag_suggestion_limit']);
const MAX_TEMPLATE_CHARS = 4000;
export const STATUS_COPY = Object.freeze({
loading: 'Loading model status…',
@@ -91,9 +96,6 @@ export const SETTING_PROBLEMS = Object.freeze({
autotag_general_threshold_step: 'General-tag confidence must be a multiple of 0.05.',
autotag_character_threshold_step: 'Character-tag confidence must be a multiple of 0.05.',
autotag_suggestion_limit: 'Suggestions per image must be a whole number from 1 to 100.',
// The server's two template sentences (`validate_template`), blank first.
autotag_template: 'LLM tag template must be non-empty text.',
autotag_template_length: `LLM tag template must be at most ${MAX_TEMPLATE_CHARS} characters.`,
});
/** Characters as Python's `len` counts them (code points), not UTF-16 units. */
export const characters = text => [...String(text ?? '')].length;
@@ -191,20 +193,23 @@ const onStep = value => Math.abs(value * 20 - Math.round(value * 20)) <= 1e-12;
// A threshold on the grid, handed on exactly as the server stores it (anything else is left for draftProblem to refuse).
const snapped = value => (unit(value) && onStep(value) ? Math.round(value * 20) / 20 : value);
/** #431 (D205): the drafted default instructions of the drafted language-model engine. */
export const engineInstructions = values => defaultEntry(values, values.autotag_model_type === 'hf' ? 'hf' : 'gguf');
/**
* The first setting the chosen model would use that the section cannot send, as `{key, message}`, else null: WD14
* reads both thresholds (0 to 1 in steps of 0.05, #369), the language models the template; Generate also reads the
* suggestion count (`limit`).
* reads both thresholds (0 to 1 in steps of 0.05, #369), the language models the saved instructions (#431: the server's
* sentence for the first entry it would refuse); Generate also reads the suggestion count (`limit`).
*/
export function draftProblem(values, { limit = true } = {}) {
const model = values.autotag_model_type;
const keys = CLASSIFIERS.has(model) ? ['autotag_general_threshold', 'autotag_character_threshold'] : ['autotag_template'];
const keys = CLASSIFIERS.has(model) ? ['autotag_general_threshold', 'autotag_character_threshold'] : [INSTRUCTIONS_KEY];
if (limit) keys.push('autotag_suggestion_limit');
for (const key of keys) {
const value = values[key];
if (key === 'autotag_template') {
if (typeof value !== 'string' || value.trim() === '') return { key, message: SETTING_PROBLEMS.autotag_template };
if (characters(value) > MAX_TEMPLATE_CHARS) return { key, message: SETTING_PROBLEMS.autotag_template_length };
if (key === INSTRUCTIONS_KEY) {
const message = listProblem(values);
if (message) return { key, message };
continue;
}
const valid = key === 'autotag_suggestion_limit' ? Number.isInteger(value) && value >= 1 && value <= 100 : unit(value);
@@ -220,7 +225,7 @@ export function taggingPreset(values) {
const options = CLASSIFIERS.has(model)
? { general_threshold: snapped(values.autotag_general_threshold),
character_threshold: snapped(values.autotag_character_threshold) }
: { instructions: values.autotag_template };
: { instructions: engineInstructions(values).text };
return { model, options: { ...options, suggestion_limit: values.autotag_suggestion_limit } };
}
@@ -230,7 +235,7 @@ export function batchBody(values) {
const options = CLASSIFIERS.has(model)
? { general_threshold: snapped(values.autotag_general_threshold),
character_threshold: snapped(values.autotag_character_threshold) }
: { instructions: values.autotag_template };
: { instructions: engineInstructions(values).text };
return { model_type: model, existing: values.autotag_existing, options };
}
@@ -285,14 +290,20 @@ export function timeText(plan) {
return durationText(Math.ceil(seconds / 60)) + (plan.estimate_device === 'gpu' ? TIME_ON_GPU : TIME_ON_CPU);
}
/** D134: the review's rows from the server's preview and the drafts it was made with (image counts, #379). */
/**
* D134: the review's rows from the server's preview and the drafts it was made with (image counts, #379). D205 (#431):
* a language-model engine shows its default instructions by name in an "Instructions" row, and Settings reads "Up to L
* tags per image".
*/
export function batchGrid(plan, values) {
const id = plan.model?.type ?? values.autotag_model_type;
let settings = CLASSIFIERS.has(id)
? `General ${values.autotag_general_threshold} · Character ${values.autotag_character_threshold}`
: `Template of ${number(characters(values.autotag_template))} characters`;
const classifier = CLASSIFIERS.has(id);
// The saved count the run uses (the preview's), not an unsaved draft.
if (Number.isSafeInteger(plan.suggestion_limit)) settings += ` · up to ${number(plan.suggestion_limit)} tags per image`;
const limit = Number.isSafeInteger(plan.suggestion_limit) ? `up to ${number(plan.suggestion_limit)} tags per image` : '';
const settings = classifier
? `General ${values.autotag_general_threshold} · Character ${values.autotag_character_threshold}${limit ? ` · ${limit}` : ''}`
: limit.charAt(0).toUpperCase() + limit.slice(1);
const instructions = classifier ? [] : [['Instructions', defaultEntry(values, id === 'hf' ? 'hf' : 'gguf').name]];
return [
['Model', `${modelLabel(id, plan.model)} · ${modelSize(id, plan.model)}`],
['Existing tags', existingLabel(plan.existing ?? values.autotag_existing)],
@@ -300,6 +311,7 @@ export function batchGrid(plan, values) {
['Already auto-tagged', number(plan.already_tagged)],
['Images to tag', `about ${number(plan.to_tag)}`],
['Time', timeText(plan)],
...instructions,
['Settings', settings],
['Backup', backupText(plan.backup)],
];
@@ -410,17 +422,13 @@ export function mountTagging(container, { api, activity, scope, drafts, notify =
// D133: the persisted suggestion count, which the concept does not show.
add(settingRow({ id: 'tag-suggestions', label: 'Suggestions per image', description: SUGGESTIONS_DESCRIPTION,
control: { type: 'number', min: 1, max: 100, step: 1 } }), 'tag-suggestions');
// D202 (#431): the words whose suggested tags are hidden on every suggestion path.
const skipped = skippedTagsBlock({ api, scope, drafts });
surface.append(skipped.node);
const field = el('div', undefined, 'field');
const templateLabel = el('label', 'LLM tag template');
templateLabel.htmlFor = 'tag-template';
const template = el('textarea');
template.id = 'tag-template';
template.dataset.setting = 'tag-template';
template.dataset.key = SETTING_KEYS['tag-template'];
field.append(templateLabel, template);
surface.append(field);
controls.push({ id: 'tag-template', key: SETTING_KEYS['tag-template'], input: template, text: null });
// D203 (#431): the saved instructions and each language-model engine's default replace the template field.
const instructions = savedInstructionsBlock({ scope, drafts });
surface.append(...instructions.nodes);
for (const { key, input } of controls) {
// Text controls draft while typing (the Save row follows at once); selects and numbers on change.
@@ -449,11 +457,14 @@ export function mountTagging(container, { api, activity, scope, drafts, notify =
// The "could not be read" line under each row, while the server settings could not be read.
const notes = new Map();
const image = selected();
const current = () => Object.fromEntries(Object.values(SETTING_KEYS).map(key => [key, drafts.get('host', key)]));
const current = () => Object.fromEntries([...Object.values(SETTING_KEYS), ...INSTRUCTION_VALUES]
.map(key => [key, drafts.get('host', key)]));
const chosen = () => drafts.get('host', 'autotag_model_type');
const known = () => MODEL_IDS.includes(chosen());
const repaint = () => {
skipped.repaint();
instructions.repaint();
const values = current();
const unread = values.autotag_model_type === undefined;
const failed = drafts.hostState?.() === 'failed' && unread;
+43 -7
View File
@@ -416,9 +416,6 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc
.prompt-sender-preview { margin-block: 20px; min-inline-size: 0; /* D48 */ }
.prompt-sender-field { border: 1px solid var(--ca-line); border-radius: 7px; padding: 14px; margin-block: 12px; min-inline-size: 0; /* D48 */ }
.prompt-sender-field :where(pre, textarea), .prompt-sender-composition { white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; font-size: 12px; line-height: 1.65; max-inline-size: 100%; /* D48 */ }
/* D191 (#542): each node of "All eligible nodes" reviews under its own heading. */
.prompt-sender-node { border-block-start: 1px solid var(--ca-line); padding-block-start: 14px; margin-block: 18px; min-inline-size: 0; /* D191 */ }
.prompt-sender-node > h4 { font-size: 15px; /* D191 */ }
.prompt-sender-field textarea { inline-size: 100%; min-block-size: 100px; resize: vertical; /* D48 */ }
.prompt-sender-composition { padding: 12px; background: var(--ca-field); border-radius: 6px; /* D48 */ }
.prompt-sender-actions { justify-content: flex-end; margin-block-start: 18px; /* D48 */ }
@@ -446,12 +443,12 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc
suggestions are removable chips, each the concept's `.chip` corner (4px; the later `.tag-suggestion` row rule would
give 5px) around the row's 12px text in its checked suggestion row's tokens (`.tag-suggestion:has(input:checked)`,
s1655): a name, the confidence and a × button. */
#tag-suggestions .chip.tag-suggestion { display: inline-flex; gap: 6px; padding: 2px 2px 2px 8px; margin-bottom: 0; cursor: default; color: inherit; border-color: var(--ca-accent); background: var(--soft-blue); border-radius: 4px; /* D83 */ }
#tag-suggestions .chip.tag-suggestion .tag-confidence { margin-inline-start: 0; /* D83 */ }
.ca-tools #tag-suggestions .chip.tag-suggestion > button { min-block-size: 24px; min-inline-size: 24px; padding: 0 6px; /* D83 */ }
#tag-suggestions .chip.tag-suggestion, .skip-chips .chip.tag-suggestion { display: inline-flex; gap: 6px; padding: 2px 2px 2px 8px; margin-bottom: 0; cursor: default; color: inherit; border-color: var(--ca-accent); background: var(--soft-blue); border-radius: 4px; /* D83 */ }
#tag-suggestions .chip.tag-suggestion .tag-confidence, .skip-chips .chip.tag-suggestion .tag-confidence { margin-inline-start: 0; /* D83 */ }
.ca-tools #tag-suggestions .chip.tag-suggestion > button, .ca-tools .skip-chips .chip.tag-suggestion > button { min-block-size: 24px; min-inline-size: 24px; padding: 0 6px; /* D83 */ }
@media (pointer: coarse) {
/* D83: the chip's × keeps the concept's 40px coarse target. */
.ca-tools #tag-suggestions .chip.tag-suggestion > button { min-block-size: 40px; min-inline-size: 40px; }
.ca-tools #tag-suggestions .chip.tag-suggestion > button, .ca-tools .skip-chips .chip.tag-suggestion > button { min-block-size: 40px; min-inline-size: 40px; }
}
/* Needs review (#249 PR F): the concept's Metadata review page (app.js a749–760) in its own classes. */
@@ -495,15 +492,54 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc
@media (max-width: 700px) {
#workspace-main .setting-row :is(#send-destination, #renderer) { max-width: 160px !important; /* D106 */ }
}
/* D202 (#431): System › Auto-tagging "Skipped tags", a setting whose control (an add input, the chips and the preview
line) is wider than a `.setting-row` column: the row's label and description tokens over the full width, the chips
in the Tag image dialog's D83 chip markup (an entry that hides no known tag is muted). */
.setting-block { padding: 17px 0; border-top: 1px solid var(--line); /* D202 */ }
.setting-block-text label { font-size: 12px; font-weight: 500; display: block; /* D202 */ }
.setting-block-text p { font-size: 11px; color: var(--ca-muted); margin-top: 4px; max-width: 640px; /* D202 */ }
.setting-block[aria-invalid="true"] { box-shadow: inset 3px 0 0 var(--red); padding-inline-start: 12px; /* D202 */ }
.skip-add { display: flex; gap: 8px; margin-top: 12px; max-width: 460px; /* D202 */ }
.skip-add input { flex: 1; min-width: 0; font-size: 12px; /* D202 */ }
.skip-problem { color: var(--red); margin-top: 6px; /* D202 */ }
.skip-problem:empty, .skip-chips:empty { display: none; /* D202 */ }
.skip-chips .chip.tag-suggestion.none { border-color: var(--line); background: var(--ca-field); color: var(--ca-muted); /* D202 */ }
.skip-preview { margin-top: 12px; max-width: 760px; /* D202 */ }
.skip-preview b { font-weight: 500; color: var(--ca-text); /* D202 */ }
@media (max-width: 700px) {
.skip-add { max-width: none; /* D202 */ }
}
/* D203 (#431): "Saved instructions": the list (Built-in first, its chips) beside the Name/Text editor, stacked under
it below 700px, and the note a deleted default leaves under the two default rows. */
.instructions-layout { display: grid; grid-template-columns: minmax(200px, 260px) minmax(0, 1fr); gap: 18px; margin-top: 12px; /* D203 */ }
.instruction-list { border: 1px solid var(--line); border-radius: 6px; background: var(--ca-field); max-height: 300px; overflow: auto; /* D203 */ }
.instruction-list [role="option"] { display: flex; flex-direction: column; gap: 4px; padding: 9px 12px; cursor: pointer; border-bottom: 1px solid var(--line); font-size: 12px; /* D203 */ }
.instruction-list [role="option"]:last-child { border-bottom: 0; /* D203 */ }
.instruction-list [role="option"][aria-selected="true"] { background: var(--soft-blue); box-shadow: inset 3px 0 0 var(--ca-accent); /* D203 */ }
.instruction-list .badges { display: flex; flex-wrap: wrap; gap: 4px; /* D203 */ }
.instruction-actions { margin-top: 10px; flex-wrap: wrap; /* D203 */ }
.instruction-editor .field:first-child { margin-top: 0; /* D203 */ }
.instruction-editor input[readonly], .instruction-editor textarea[readonly] { color: var(--ca-muted); /* D203 */ }
.instruction-editor label { display: flex; justify-content: space-between; gap: 8px; /* D203 */ }
.instruction-reset { color: var(--warning); margin: 0 0 14px; /* D203 */ }
.instruction-reset:empty { display: none; /* D203 */ }
@media (max-width: 700px) {
.instructions-layout { grid-template-columns: 1fr; /* D203 */ }
}
/* D132: at phone width the concept's 120px cap cuts every engine label ("WD14 SwinV2 · ~400 MB"); the Tagging model
select alone keeps its natural width there (its widest engine label) and, with no room beside the row text, wraps
under it. Existing tags (#398) never shrinks for its row text and, past the cap, keeps its natural width beside the
text ("Replace after review"), bounded by its row like the engine select. */
.setting-row #tag-existing { flex-shrink: 0; /* D132 */ }
/* D203: the default-instructions selects show their chosen name whole, as Existing tags does (#398). */
.setting-row #instruction-default-gguf, .setting-row #instruction-default-hf { flex-shrink: 0; /* D203 */ }
@media (max-width: 700px) {
#workspace-main .setting-row:has(> #tag-model) { flex-wrap: wrap; /* D132 */ }
#workspace-main .setting-row #tag-model { flex-shrink: 0; max-width: 100% !important; /* D132 */ }
#workspace-main .setting-row #tag-existing { max-width: 100% !important; /* D132 */ }
#workspace-main .setting-row #instruction-default-gguf, #workspace-main .setting-row #instruction-default-hf { max-width: 100% !important; /* D203 */ }
}
/* D109: System › Diagnostics & logs' added Lines field sits in the concept's toolbar like its Level select, and a value