Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db5a1dc131 | ||
|
|
41027aa1b1 | ||
|
|
c7375573ed | ||
|
|
4f37af209c |
+130
-99
@@ -688,7 +688,7 @@ The shared inline and collection inspector reads the current rooted file's suppo
|
||||
embedded records, exact generation facts and recorded or inferred prompt evidence.
|
||||
It renders in two concept surfaces (#249 D20–D22, D38). The Images side panel shows
|
||||
only the concept content: cover, title, filename, the evidence badge with a category
|
||||
chip, the + and − prompt blocks with copy and send, tag chips (the file's own image
|
||||
chip naming its origin (D146), the + and − prompt blocks with copy and send, tag chips (the file's own image
|
||||
tags first, D88, then the linked tags; image tags and the linked positive's own tags
|
||||
filter Images, D21), the six facts, and a footer with "Send to ComfyUI" and
|
||||
"Prompt history" only when prompt roles resolved or a link is saved; the sticky panel
|
||||
@@ -1399,22 +1399,18 @@ Recorded deviations on this page (spec §8):
|
||||
inspector's own "Download metadata JSON" envelopes in listing order); the button is
|
||||
`aria-disabled` and ignores a second press while it runs, the selection is kept, and
|
||||
the toast names the unread images (none read: no download); leaving Images aborts the
|
||||
reads with no download and no toast. Set category opens the
|
||||
concept's `simpleInput` dialog ("Categorize selected images", `input#action-input`
|
||||
labelled Category, Cancel / Apply) and posts the selection's distinct linked positive
|
||||
ids to `/prompt_manager/bulk/category` (images have no category; the filter matches
|
||||
the linked positive's). With no linked image checked it posts nothing and opens no
|
||||
dialog; an empty value keeps the dialog open; the field stops at the server's 100
|
||||
characters; success reloads the categories and the listing together (clearing the
|
||||
selection, focus to the heading, and, as any replaced collection does, the inspector
|
||||
moves to the first listed image); failure keeps the selection. The write carries no
|
||||
page signal: leaving Images while it runs lets it finish without a toast, and the return
|
||||
loads the collection afresh instead of the saved visit. Three deviations from the
|
||||
concept's `simpleInput`: the field starts empty (the concept pre-fills its demo
|
||||
"Landscapes"), the action reads "Apply" (not "Apply in prototype"), and the
|
||||
nothing-linked check runs on the Set category press, so no dialog opens. The route
|
||||
rejects anything but 1-1000 positive integer ids within SQLite's INTEGER range
|
||||
(deduplicated) and a category that fails `validate_category`. `images selection bar matches
|
||||
reads with no download and no toast. Set category (amended by D140, #332) sets the
|
||||
checked images' own categories: it opens the concept's `simpleInput` dialog
|
||||
("Categorize selected images", `input#action-input` labelled Category) and writes
|
||||
`PUT`/`DELETE /prompt_manager/images/category`, never a prompt (see D140). Prompt
|
||||
categories are changed in the Prompt library editor and on the legacy admin page's
|
||||
"Set Category"; `POST /prompt_manager/bulk/category` is unchanged and serves that
|
||||
legacy page only. The write carries no page signal: leaving Images while it runs lets
|
||||
it finish without a toast, and the return loads the collection afresh instead of the
|
||||
saved visit. Its success reloads the categories and the listing together (clearing
|
||||
the selection, focus to the heading, and, as any replaced collection does, the
|
||||
inspector moves to the first listed image); a failure keeps the selection, except a
|
||||
changed source (409), which reloads the listing as a success does. `images selection bar matches
|
||||
the concept` compares the bar and the checked card with one card checked; the omitted
|
||||
actions flow again from the concept's own boxes, checked against the concept page with
|
||||
them removed at runtime.
|
||||
@@ -1481,6 +1477,9 @@ Recorded deviations on this page (spec §8):
|
||||
filters, while recipe, recorded-run and embedded tags stay plain `span.chip`.
|
||||
Amended by D88 (#249 PR C): the file's own current image tags lead the same row
|
||||
and are filters too; a linked tag already shown (case-insensitive) is left out.
|
||||
Amended by D146 (#332): the category chip also names its origin in visible text
|
||||
after the category (" · this image", " · prompt", " · recipe"), and an image's own
|
||||
category shows for an unlinked or ambiguous file too.
|
||||
- **D22**: an empty positive reads "No positive prompt (explicitly empty)" (the concept
|
||||
applies its negative wording to both roles), and a confirmed `p.prompt-text` is
|
||||
focusable (`tabindex=0`, marked focus ring) so long prompts stay keyboard-readable.
|
||||
@@ -1543,8 +1542,9 @@ Recorded deviations on this page (spec §8):
|
||||
The category filter matches the same inherited category as the panel (D142, #332), and
|
||||
an image's own category overrides both (D141, D146). The inspection response names each
|
||||
field's source (`library.category_scope`, `library.tags_scope`: `pair`, `positive` or
|
||||
null); the category chip's visually hidden prefix (D21) says "Recipe category: " or
|
||||
"Positive prompt category: " from it, and tag chips are Tags filter buttons only when
|
||||
null, or `image` for an own category, #332); the category chip's visually hidden
|
||||
prefix (D21) says "Recipe category: ", "Positive prompt category: " or "Image category: "
|
||||
from it, and its visible origin (D146) " · recipe", " · prompt" or " · this image", and tag chips are Tags filter buttons only when
|
||||
they are the positive's (#260 D2). History's media inspector uses the same rule. The
|
||||
concept shows the prompt's category and tags for every image and the concept
|
||||
fixture's recipes have neither, so the inspector comparison needs no mapping for it.
|
||||
@@ -1562,7 +1562,11 @@ Recorded deviations on this page (spec §8):
|
||||
files whose own category is the value by path, so their rating and positive count.
|
||||
`test_filter_budget` seeds 2,000 own categories on its 50,000-file fixture.
|
||||
The option list adds the image categories of listed files
|
||||
(`GET /prompt_manager/images/categories`, PR 3).
|
||||
(`GET /prompt_manager/images/categories`, PR 3), merged with the Library's and sorted
|
||||
(D36); if only that read fails, the prompt categories show and the categories alert is
|
||||
raised. `images category options and Set category dialog match the concept` sets one
|
||||
image's own category ("Portraits") through the real route and compares the toolbar
|
||||
with it sorted into the concept's options; unmapped, only the options differ.
|
||||
- **D142** (#332 MC4, amends D39's filter note): the inherited category is one rule,
|
||||
`inherited_category` in `utils/inspection_response.py`: a linked recipe's own
|
||||
category when it is not blank, else the linked positive's. Before #332 the filter
|
||||
@@ -1578,7 +1582,53 @@ Recorded deviations on this page (spec §8):
|
||||
output sources, as Images keys it. A temp output or a file no source holds has none;
|
||||
a failed read hides the chip (DC5). The outputs list and media requests read nothing
|
||||
new. The chip's hidden prefix reads "Image category: " for an own category; the
|
||||
visible origin text and its comparison are D146 (PR 3).
|
||||
visible origin text and its comparison are D146 (PR 3). `history media inspector
|
||||
category chip matches the concept` (history-concept.spec) sets an own category on the
|
||||
Quality baseline's newest output, opens its Recorded run and "View current file", and
|
||||
compares the viewer's chip with the concept viewer's category chip (app.js a402) laid
|
||||
out at runtime with the D146 origin and that category; its place is not compared (what
|
||||
precedes it differs), only its size, tokens, text, prefix and origin. Unmapped, the two
|
||||
differ only in the text, the prefix, the width and `span.chip-source`.
|
||||
- **D140** (#332, amends D8): Set category sets the checked images' own categories, so
|
||||
every checked file is a target (no nothing-linked check). More than 500 checked (or
|
||||
"all N matching" over 500) toasts "Select at most 500 images." and opens nothing.
|
||||
The dialog is the concept's `simpleInput` with three recorded differences: the field
|
||||
starts empty (the concept pre-fills its demo "Landscapes") and suggests the toolbar's
|
||||
current categories (`datalist#workspace-input-suggestions`, `openInputDialog`'s
|
||||
`suggestions`); "Use prompt category", a plain button between Cancel and Apply
|
||||
(`secondary`), closes it and opens the "Use prompt category" confirmation ("Remove the
|
||||
image category from the 1 selected image? / … the N selected images? Each one then
|
||||
shows its prompt's category, or none if it has no linked prompt. This cannot be
|
||||
undone.", Cancel and a danger Remove, built like saved views' delete confirmation);
|
||||
and the action reads "Apply" (D8). Apply sends `PUT /prompt_manager/images/category`
|
||||
`{items | filter, category}`, Remove `DELETE` `{items | filter}`; Cancel sends
|
||||
nothing. An empty value keeps the dialog open and the field stops at 100 characters.
|
||||
Toasts: "Category “X” set on 1 image." / "… on N images." (unchanged files count as
|
||||
set) plus " N could not be found." for unlisted files; "Removed the image category
|
||||
from N images." or "None of the selected images had its own category."; 404 "None of
|
||||
the selected images could be found. Refresh the images and try again."; 409 "The
|
||||
sources changed. Refresh the images and try again." (the listing reloads); anything
|
||||
else "Category could not be set. Try again.". `images category options and Set
|
||||
category dialog match the concept` (images-concept.spec) checks a card, opens the
|
||||
dialog in both documents and compares it (title, body tree, field, input, suggestions,
|
||||
actions and every button's box and tokens) with the concept's own dialog changed at
|
||||
runtime as recorded: field emptied, the datalist added after the field, a clone of
|
||||
its Cancel reading "Use prompt category" after Cancel, the primary renamed "Apply".
|
||||
Unmapped, the dialog differs only in the input's value and list, the body's tree, the
|
||||
suggestions and the actions.
|
||||
- **D146** (#332, amends D21 and D39): the category chip names its origin in visible
|
||||
text. It is `span.chip` > `span.ca-visually-hidden` (the D21 prefix) + the category +
|
||||
`span.chip-source` (" · this image", " · prompt" or " · recipe"; muted
|
||||
`--ca-muted`, `white-space: nowrap`, so at 390px the origin never splits; the chip may wrap
|
||||
before it or inside a long category). It shows when the inspection names a non-empty category and either the
|
||||
scope is `image` (any link status, so an unlinked or ambiguous file with an own
|
||||
category shows it) or the file is linked. The concept's chip shows the bare name
|
||||
(app.js a402). The inspector comparison (inspector-concept.spec) measures each chip's
|
||||
`span.chip-source` (text, font, color, `white-space`, box) and lays the concept's own
|
||||
chip out with the recorded rule and " · prompt" added at runtime (`withChipSource` in
|
||||
`concept-inventory.mjs`), in the side panel and the viewer's details; unmapped they
|
||||
differ only in the chip's text, width and `span.chip-source`. Its mutation check
|
||||
removes the origin. History's chip (D145) is compared the same way.
|
||||
- **D42**: the concept never shows a toast and a page alert together. Production keeps
|
||||
the concept's fixed toast (centred, 22px from the bottom, 12px at 700px and below)
|
||||
and, when an Import → Rescan toast leaves the Images discovery alert
|
||||
@@ -1603,7 +1653,8 @@ applies D3 (the `.file-inspector` class of the production-only rules), D20 (the
|
||||
viewer's plain tag chips, laid out by the concept page itself with its chips made
|
||||
plain at runtime), the D20/D21 side-panel scroll (`overflow: auto`,
|
||||
`max-height: calc(100dvh - 40px)` above 700px), D21 (the category's hidden scope
|
||||
prefix) and D22 (focusable prompt text). `inspector comparison detects mutations`
|
||||
prefix), D22 (focusable prompt text) and D146 (the chip's visible " · prompt", laid out
|
||||
by the concept page itself). `inspector comparison detects mutations`
|
||||
changes `.prompt-heading .role-label` letter spacing and removes `.detail-footer`.
|
||||
|
||||
Manual check (quick), after a browser refresh on Images: hover an image (it zooms);
|
||||
@@ -1938,11 +1989,13 @@ ranges include cards that bounded rendering has unmounted.
|
||||
Over the limit, the client toasts "Narrow the view to at most 10,000 images." and the
|
||||
server answers 400 `too-many-images`. No match is 400 `no-images`. Auto-tag in this
|
||||
mode does not offer Review each image, because the review walks images in the
|
||||
browser. Set category posts `{images_filter, category}` to `/prompt_manager/bulk/category`.
|
||||
The server sets the category on the distinct linked positives and answers
|
||||
`prompt_count` and `skipped`. A listing whose `positive_count` is 0 toasts the
|
||||
nothing-linked sentence and opens no dialog, and over 10,000 matches it toasts the
|
||||
limit sentence. Export metadata reads every image in
|
||||
browser. Set category (D140, #332) sends `{filter, category}` to
|
||||
`PUT /prompt_manager/images/category` (Use prompt category `{filter}` to `DELETE`),
|
||||
which sets or removes each matching image's own category; over 500 matches the client
|
||||
toasts "Select at most 500 images." and opens no dialog, and the server answers 400
|
||||
`too-many-images`. (`POST /prompt_manager/bulk/category` still accepts
|
||||
`{images_filter, category}` and sets the linked positives' category, but no workspace
|
||||
control sends it any more.) Export metadata reads every image in
|
||||
the browser, so it is `aria-disabled` in this mode, and its `title` says why.
|
||||
|
||||
Tests: `tests/browser/workspace-images.spec.mjs` ("Shift+click selects a range across
|
||||
@@ -4810,6 +4863,21 @@ category; without one it shows the category its link supplies.
|
||||
appears at the same path shows it. The real-host check is the last test in
|
||||
`tests/comfyui/image-attribution.spec.mjs`: PUT, list, DELETE on a generated
|
||||
output.
|
||||
- Interface (PR 3, a browser refresh; no restart beyond PR 1-2's): the Images
|
||||
selection bar's Set category writes these routes (D140, which amends D8); the toolbar
|
||||
category options add the listed images' own categories (D141); the inspector's
|
||||
category chip names its origin, " · this image", " · prompt" or " · recipe" (D146), in
|
||||
the side panel, the viewer and History's media inspector (D145); Tags & categories
|
||||
counts images on each category row and lists image-only values (D143), and Manage
|
||||
category previews and renames image rows too (D144). Prompt categories are changed in
|
||||
the Prompt library editor and on the legacy admin page's "Set Category". Concept
|
||||
comparisons, at 1600x1100 and 390x844 in both themes: `images-concept.spec.mjs` (the
|
||||
Set category dialog and the options), `inspector-concept.spec.mjs` (the chip),
|
||||
`history-concept.spec.mjs` (History's chip) and `vocabulary-visual.spec.mjs` (rows and
|
||||
preview, with `vocabulary-canonical-comparison.mjs`). Behavior: `workspace-images`,
|
||||
`full-inspector`, `workspace-history` and `workspace-vocabulary` specs, and
|
||||
`tests/js/workspace-images-category.test.js`, `workspace-file-inspector.test.js`,
|
||||
`workspace-vocabulary-state.test.js`, `workspace-toolbar.test.js`.
|
||||
|
||||
Image annotations schema (#249, `database/annotation_schema.py`): the group
|
||||
`media_annotation_metadata` (one row: schema version, `image_index_revision`),
|
||||
@@ -5274,75 +5342,6 @@ same as a file the scan could not attribute, so the file stays on disk and in th
|
||||
gallery as unlinked, and its favorite and tags (kept by file) are untouched. Every
|
||||
run prints this. Queue-linked batch images can't be proven after the fact and read
|
||||
`unlink`, so review before applying. Stop ComfyUI first.
|
||||
|
||||
### Foreign workflows (#247): the shared per-file decision
|
||||
|
||||
The output scan decides each file through `utils/file_prompt.py`
|
||||
(`decide_file_prompt`, the ladder), which the monitor will share for outputs of
|
||||
workflows without a PromptManager node. It does no file or database I/O.
|
||||
|
||||
- **Save to library (#90).** Before anything else, a file whose PromptManager
|
||||
node had Save to library off is `not_saved`, and one whose switch is wired
|
||||
from a node the file cannot show is `not_saved_wired`: nothing is written, no
|
||||
recorded-run fallback, no Needs review item. A resolved pair whose negative's
|
||||
node is off stores the positive alone (source `opted-out-negative`). The
|
||||
#90 graph walk follows only prompt-like inputs, so the ladder also walks
|
||||
every input upstream of each sampler or guider positive (not negative, model,
|
||||
clip, latent or image inputs; every PromptManager node of a graph without a
|
||||
sampler) and checks the text later steps name: a resolved or inspected
|
||||
positive whose path passes such a node (`side_opted_out`), or a legacy answer
|
||||
that is such a node's text (resolved, else literal). A switch that is off
|
||||
makes the file `not_saved`; one wired from a node the graph cannot read makes
|
||||
it `not_saved_wired`, as #90's direct check does.
|
||||
- **Order.** The default profile decides first, exactly as before (resolved
|
||||
pair, A1111, sampler-less). Only a graph it leaves ambiguous or unresolved
|
||||
reaches the inspector's resolve (`inspection_resolution` in
|
||||
`utils/prompt_inspector.py`, the inspection profile, then the recorded saver
|
||||
anchor of #386; `name_anchor=False`: the file-name anchor never decides a
|
||||
link; the scan's legacy floor may still link the file, as before #247, and a
|
||||
file nothing links is queued with the anchor's choice per #390), and only that positive's `storage_text` is stored (never
|
||||
prepend or append text, never `recorded_texts`). Then the #170 browser parser
|
||||
(`graph_prompt_text`), the legacy floor, then the recorded run's stored
|
||||
prompt. The legacy answer is refused when a PromptManager-family node has a
|
||||
prepend or append part and the answer is not that node's own `text` (the
|
||||
parser joins the parts, so the stored text cannot be told from it). History is asked once per file and only at that step; the scan's
|
||||
batch lookup stays lazy and makes one `recorded_runs` pass for the stored
|
||||
prompt and the saver (`_BatchHistory`).
|
||||
- **Policies.** `SCAN` keeps the legacy floor for every undecided file.
|
||||
`MONITOR` waits for history inside the output folder (`history`) and stores
|
||||
nothing for a file the inspector calls ambiguous; an untraceable or
|
||||
malformed one still gets the legacy floor.
|
||||
- **Junk.** `junk_text` refuses `blank`, `number` (the #252 `_numeric` rule),
|
||||
`lora-only` (only `<lora:...>` tags and separators) and `model-file` (one
|
||||
token ending in a model extension) at whichever step found the text and
|
||||
nothing later is tried; the file reports `found`, except at the undecided
|
||||
graph step, which keeps its own status (an ambiguous file is still queued).
|
||||
- **One prompt per file.** `database/scanned_prompts.py` stores a positive in
|
||||
one `BEGIN IMMEDIATE` transaction: a file already linked to another prompt
|
||||
(either spelling, `file_link_rows`/`link_conflict`, the pair ingester's rule)
|
||||
is `conflict` and nothing is written; a failed link rolls the new prompt back
|
||||
(`found`); two writers of one text store one prompt. A recorded run's stored
|
||||
prompt links the same way (`link_stored_prompt_or_raise`). A rescan therefore
|
||||
never adds a second link to a file an older scan linked to the legacy answer;
|
||||
the row reads "Kept: already linked to a different prompt". Link rows are
|
||||
matched under the path and its `realpath` spelling (`file_link_rows`), so a
|
||||
file the scan stored through the resolved folder is found when it arrives
|
||||
through a symlinked or junction folder. The reverse order (a row stored under
|
||||
the watcher's raw spelling, then a scan whose root resolves differently) is
|
||||
matched only when that raw spelling equals the realpath. Every scan write
|
||||
runs through `survive_library_switch`, so a library move reruns it.
|
||||
- **Marks.** Every prompt ingestion creates, pair components included, carries
|
||||
category `scanned`, tag `auto-scanned` and the note `Auto-scanned from
|
||||
<file>` (`SCAN_MARKS`); a reused prompt keeps its fields. Run counts are
|
||||
never touched.
|
||||
|
||||
Tests: `tests/test_file_prompt.py` (one test per ladder step and junk class),
|
||||
`tests/test_scanned_prompts.py` (store, conflict, rollback, concurrency with
|
||||
`FirstWriterHold`, pair marks), `tests/test_pair_scan.py` `TestScanDecision`,
|
||||
`tests/test_run_attribution.py` (dataset branches linked by the recorded
|
||||
saver, a legacy-linked file left as it is, `_BatchHistory.recorded`) and
|
||||
`tests/test_recorded_saver_anchor.py` `TestInspectionResolution`.
|
||||
|
||||
## Linked prompt text (#343)
|
||||
|
||||
A link drives `text` on every PromptManager node, and `negative_text` on the Pair,
|
||||
@@ -5906,13 +5905,18 @@ Deploying this backend needs a ComfyUI restart (Python and route changes).
|
||||
longer hands off to the legacy tag manager. That page stays reachable from Overview's
|
||||
"Open existing tag manager" card for tag search and sort, the untagged entry and
|
||||
multi-tag AND/OR browsing; bulk prompt actions (delete, tag, category) stay on the
|
||||
legacy admin page. The screen consumes only the `/prompt_manager/vocabulary/*` routes
|
||||
legacy admin page. A prompt's category is changed in the Prompt library editor or with
|
||||
the legacy admin page's "Set Category" (`POST /prompt_manager/bulk/category`, unchanged);
|
||||
the Images selection bar's Set category sets images' own categories only (D140, #332),
|
||||
and Manage category renames a value on prompts, recipes and images together (D144). The screen consumes only the `/prompt_manager/vocabulary/*` routes
|
||||
above and adds no route, so PR B needs no ComfyUI restart: a browser refresh is enough.
|
||||
|
||||
Screen: a `+ Create tag` heading action, a Tag vocabulary table (columns Tag, Prompts,
|
||||
Images counting the files that carry the tag (`image_count`, #249 C2), and Actions with Browse and
|
||||
Rename / merge), and a Categories panel whose category rows end with the Manage
|
||||
category row (Review). Lists load 100 rows at a time, append when scrolled into view
|
||||
category row (Review). A category row reads "N positive prompts", "N positive prompts ·
|
||||
M images" when images hold the value as their own category too, or "M images" for a
|
||||
value only images hold (D143, #332). Lists load 100 rows at a time, append when scrolled into view
|
||||
and keep `Load more tags` / `Load more categories` while more remain. Prompts counts
|
||||
are the Library Browse totals.
|
||||
|
||||
@@ -5955,7 +5959,34 @@ Recorded deviations from the concept (maintainer acceptance): bounded lists with
|
||||
Load more; title "Tags"; Manage categories adds a Category select and Preview before
|
||||
Apply; "Apply" instead of "Apply in prototype"; Delete tag… inside Rename or merge;
|
||||
Browse sets the exact filter; singular "1 positive prompt"; the production list appends
|
||||
fixture-only tags after the concept's sample tags. Paired concept/production captures
|
||||
fixture-only tags after the concept's sample tags.
|
||||
|
||||
- **D143** (#332): category rows count the images whose own category is the value
|
||||
(`image_count`, the stored rows, missing files included): "N positive prompts · M
|
||||
images" beside prompts, "M images" for an image-only value, which follows every
|
||||
prompt-backed row (by value) before Manage category. Browse on an image-only row opens
|
||||
Images with exactly that Category filter (its Library would be empty), as a fresh
|
||||
Images visit that becomes the Images return state, the way Browse seeds the Library's; every other
|
||||
row opens the Library as before. Manage category's select lists image-only values
|
||||
last, so past 1,000 categories they are the first left out (the dialog says the list is
|
||||
capped).
|
||||
- **D144** (#332): Manage category's preview counts the images holding the value ("“X”
|
||||
is used by N positive prompts and M images. Apply renames it to “Y”."; D9's sentence
|
||||
with "M images" added when above 0), an image-only value can be renamed, and Apply
|
||||
renames the image rows in the same transaction; an image row gaining or losing the
|
||||
value between Preview and Apply is 409 `vocabulary-conflict` with nothing written.
|
||||
- `category rows and the Manage category preview match the concept with image
|
||||
categories` (vocabulary-visual.spec, 1600x1100 and 390x844, both themes) seeds the
|
||||
fixture with `image_categories: true` ("Landscapes" on two images, "Portraits" on one
|
||||
image only, rows keyed as Images keys them) and compares the page with D143 mapped
|
||||
(`mapVocabularyConcept(reference, imageCategories)`, whose row model is checked against
|
||||
the concept page with those rows added at runtime) and the Manage dialog after
|
||||
Preview with D9 and D144 (`mapVocabularyDialog`'s `preview`). Before the preview is
|
||||
measured the pointer leaves the pressed Apply and focus returns to ×, the state the
|
||||
opening comparison in workspace-vocabulary.spec measures. Unmapped, the page differs
|
||||
in the Landscapes row's text and the added Portraits row, the dialog only in the
|
||||
sentence. The default fixture seeds no image category, so the other vocabulary
|
||||
comparisons are unchanged. Paired concept/production captures
|
||||
for both themes and widths are attached as test artifacts; no PNG baseline is added
|
||||
until acceptance. Not compared: hover states and the dialog focus ring (the page-level
|
||||
keyboard ring and link ring offsets are asserted). No coverage floor was lowered;
|
||||
|
||||
@@ -9,6 +9,8 @@ IMMEDIATE`` transaction, so no row ever names a deleted file:
|
||||
already links the kept copy: then its saved-pair links join that record
|
||||
and it is deleted;
|
||||
- its favorite;
|
||||
- its collection memberships (#331), joined onto the kept copy's as their
|
||||
union under the same keys a favorite moves by;
|
||||
- its image tags (``media_image_tags`` of its byte record), joined onto the
|
||||
kept copy's byte record as their union, in order;
|
||||
- its own image category (#332), copied to the kept copy when that copy has
|
||||
@@ -54,6 +56,15 @@ INSERT_FAVORITE_SQL = "INSERT OR IGNORE INTO image_favorites VALUES (?, ?, ?)"
|
||||
DELETE_FAVORITE_SQL = (
|
||||
"DELETE FROM image_favorites WHERE root_key = ? AND relative_path = ?"
|
||||
)
|
||||
COPY_MEMBERSHIPS_SQL = (
|
||||
"INSERT OR IGNORE INTO image_collection_items "
|
||||
"(collection_id, root_key, relative_path, added_at) "
|
||||
"SELECT collection_id, ?, ?, added_at FROM image_collection_items "
|
||||
"WHERE root_key = ? AND relative_path = ?"
|
||||
)
|
||||
DELETE_MEMBERSHIPS_SQL = (
|
||||
"DELETE FROM image_collection_items WHERE root_key = ? AND relative_path = ?"
|
||||
)
|
||||
COPY_BYTE_SQL = (
|
||||
f"SELECT b.id FROM {FILES_TABLE} f JOIN {BYTES_TABLE} b " # nosec B608
|
||||
"ON b.file_id=f.id WHERE f.locator=? AND b.sha256=?"
|
||||
@@ -77,6 +88,8 @@ FILE_LINK_SQL = (
|
||||
DELETE_LINK_SQL,
|
||||
FAVORITE_SQL,
|
||||
DELETE_FAVORITE_SQL,
|
||||
COPY_MEMBERSHIPS_SQL,
|
||||
DELETE_MEMBERSHIPS_SQL,
|
||||
COPY_BYTE_SQL,
|
||||
COPY_TAG_COUNT_SQL,
|
||||
PENDING_COUNT_SQL,
|
||||
@@ -140,14 +153,17 @@ class DuplicateCopyOperations:
|
||||
by), ``targets`` (``{removed spelling: kept spelling}``, the spelling
|
||||
a moved record takes; else ``kept_path``), ``favorites`` (``(root key,
|
||||
removed key, kept root key, kept key)`` per listed root holding the
|
||||
file), ``removed_locator``,
|
||||
``kept_locator``, ``sha256`` and the kept copy's ``kept_size`` and
|
||||
``kept_mtime_ns``; ``categories``: ``(removed file key, kept file
|
||||
key)`` pairs of the image categories (#332). Returns ``{moved,
|
||||
merged, favorites, tags, review, categories}`` (``categories``: the
|
||||
image categories copied to the kept copy).
|
||||
file; they move its favorite and its collection memberships),
|
||||
``removed_locator``, ``kept_locator``, ``sha256`` and the kept copy's
|
||||
``kept_size`` and ``kept_mtime_ns``; ``categories``: ``(removed file
|
||||
key, kept file key)`` pairs of the image categories (#332). Returns
|
||||
``{moved, merged, favorites, tags, review, collections, categories}``
|
||||
(``collections``: the memberships inserted on the kept copy;
|
||||
``categories``: the image categories copied to the kept copy).
|
||||
"""
|
||||
counts = dict.fromkeys(("moved", "merged", "favorites", "tags", "review"), 0)
|
||||
counts = dict.fromkeys(
|
||||
("moved", "merged", "favorites", "tags", "review", "collections"), 0
|
||||
)
|
||||
kept_keys = [normalize_image_path(path) for path in move["kept"]]
|
||||
with self._pair_transaction() as conn:
|
||||
for spelled in dict.fromkeys(move["removed"]):
|
||||
@@ -180,6 +196,7 @@ class DuplicateCopyOperations:
|
||||
counts["moved"] += 1
|
||||
for favorite in move.get("favorites", ()):
|
||||
counts["favorites"] += _move_favorite(conn, *favorite)
|
||||
counts["collections"] += _move_memberships(conn, *favorite)
|
||||
counts["tags"] = self._move_copy_tags(conn, move)
|
||||
counts["categories"] = sum(
|
||||
copy_image_category(conn, removed, kept)
|
||||
@@ -224,3 +241,12 @@ def _move_favorite(conn, root_key, removed_key, kept_root, kept_key):
|
||||
conn.execute(INSERT_FAVORITE_SQL, (kept_root, kept_key, row[0]))
|
||||
conn.execute(DELETE_FAVORITE_SQL, (root_key, removed_key))
|
||||
return 1
|
||||
|
||||
|
||||
def _move_memberships(conn, root_key, removed_key, kept_root, kept_key):
|
||||
"""Copy a removed copy's memberships to the kept copy; the rows inserted."""
|
||||
inserted = conn.execute(
|
||||
COPY_MEMBERSHIPS_SQL, (kept_root, kept_key, root_key, removed_key)
|
||||
).rowcount
|
||||
conn.execute(DELETE_MEMBERSHIPS_SQL, (root_key, removed_key))
|
||||
return inserted
|
||||
|
||||
@@ -0,0 +1,490 @@
|
||||
"""Collections (#331): named, hand-picked groups of image files.
|
||||
|
||||
A member names its file exactly as a favorite does (``image_favorites.py``):
|
||||
the source folder's real, case-normalized path (``root_key``) and the path
|
||||
below it (``relative_path``), both server-side only. A member whose file is
|
||||
moved, renamed or deleted outside PromptManager is kept and simply not listed;
|
||||
nothing follows a file to a new place.
|
||||
|
||||
Names are trimmed with each run of whitespace collapsed to one space, 1 to 100
|
||||
characters without control characters; ``name_key`` (NFC of the case-folded
|
||||
NFC name) is UNIQUE, so names are unique ignoring case and Unicode form.
|
||||
|
||||
The two tables install like ``image_favorites`` and ``saved_views``: optional
|
||||
in old backups, required once a database is current, created empty with no
|
||||
backfill. Startup checks only the structure, so a value saved under older
|
||||
rules never stops the library from opening; restore checks the values too,
|
||||
but never recomputes ``name_key`` (case tables differ between Python
|
||||
versions).
|
||||
"""
|
||||
|
||||
import re
|
||||
import sqlite3
|
||||
import unicodedata
|
||||
from typing import Optional
|
||||
|
||||
from .image_favorites import MAX_KEY_LENGTH
|
||||
|
||||
COLLECTIONS_TABLE = "image_collections"
|
||||
ITEMS_TABLE = "image_collection_items"
|
||||
FILE_INDEX = "idx_image_collection_items_file"
|
||||
TABLES = (COLLECTIONS_TABLE, ITEMS_TABLE)
|
||||
MAX_NAME_LENGTH = 100
|
||||
_TIMESTAMP_BYTES = 64
|
||||
# A name key's byte bound: case folding expands a character to at most
|
||||
# three, each at most 4 UTF-8 bytes.
|
||||
_MAX_KEY_BYTES = MAX_NAME_LENGTH * 3 * 4
|
||||
_WHITESPACE = re.compile(r"\s+")
|
||||
|
||||
_TABLE_DDL = {
|
||||
COLLECTIONS_TABLE: (
|
||||
f"CREATE TABLE {COLLECTIONS_TABLE} (id INTEGER PRIMARY KEY AUTOINCREMENT, "
|
||||
"name TEXT NOT NULL, name_key TEXT NOT NULL UNIQUE, "
|
||||
"created_at TEXT NOT NULL)"
|
||||
),
|
||||
ITEMS_TABLE: (
|
||||
f"CREATE TABLE {ITEMS_TABLE} (collection_id INTEGER NOT NULL REFERENCES "
|
||||
f"{COLLECTIONS_TABLE}(id) ON DELETE CASCADE, root_key TEXT NOT NULL, "
|
||||
"relative_path TEXT NOT NULL, added_at TEXT NOT NULL, "
|
||||
"PRIMARY KEY (collection_id, root_key, relative_path))"
|
||||
),
|
||||
}
|
||||
_INDEX_DDL = f"CREATE INDEX {FILE_INDEX} ON {ITEMS_TABLE}(root_key, relative_path)"
|
||||
# (name, declared type, not null, default, primary key position)
|
||||
_COLUMNS = {
|
||||
COLLECTIONS_TABLE: (
|
||||
("id", "INTEGER", 0, None, 1),
|
||||
("name", "TEXT", 1, None, 0),
|
||||
("name_key", "TEXT", 1, None, 0),
|
||||
("created_at", "TEXT", 1, None, 0),
|
||||
),
|
||||
ITEMS_TABLE: (
|
||||
("collection_id", "INTEGER", 1, None, 1),
|
||||
("root_key", "TEXT", 1, None, 2),
|
||||
("relative_path", "TEXT", 1, None, 3),
|
||||
("added_at", "TEXT", 1, None, 0),
|
||||
),
|
||||
}
|
||||
# (column, parent table, parent column, on delete)
|
||||
_FOREIGN_KEYS = {
|
||||
COLLECTIONS_TABLE: set(),
|
||||
ITEMS_TABLE: {("collection_id", COLLECTIONS_TABLE, "id", "CASCADE")},
|
||||
}
|
||||
|
||||
|
||||
class CollectionInvalid(ValueError):
|
||||
"""A name, id or target breaks the write rules (a 400)."""
|
||||
|
||||
|
||||
def _has_control(text: str) -> bool:
|
||||
"""Any Unicode control character (category Cc: C0, DEL and C1)."""
|
||||
return any(unicodedata.category(char) == "Cc" for char in text)
|
||||
|
||||
|
||||
def normalize_collection_name(name: object) -> str:
|
||||
"""The stored spelling of *name*: trimmed, whitespace collapsed, checked.
|
||||
|
||||
Raises ``CollectionInvalid`` unless the result is 1 to 100 characters
|
||||
without control characters.
|
||||
"""
|
||||
if not isinstance(name, str):
|
||||
raise CollectionInvalid("Name must be a string")
|
||||
collapsed = _WHITESPACE.sub(" ", name).strip()
|
||||
if not 1 <= len(collapsed) <= MAX_NAME_LENGTH:
|
||||
raise CollectionInvalid(f"Name must be 1 to {MAX_NAME_LENGTH} characters")
|
||||
if _has_control(collapsed):
|
||||
raise CollectionInvalid("Name contains control characters")
|
||||
return collapsed
|
||||
|
||||
|
||||
def collection_name_key(name: str) -> str:
|
||||
"""The uniqueness key of a name: NFC of the case-folded NFC spelling."""
|
||||
return unicodedata.normalize("NFC", unicodedata.normalize("NFC", name).casefold())
|
||||
|
||||
|
||||
def _compact(sql: str) -> str:
|
||||
return re.sub(r"\s+", "", sql).lower()
|
||||
|
||||
|
||||
def _expected_catalog() -> set:
|
||||
objects = {("table", name, _compact(sql)) for name, sql in _TABLE_DDL.items()}
|
||||
objects.add(("index", FILE_INDEX, _compact(_INDEX_DDL)))
|
||||
objects |= {("index", f"sqlite_autoindex_{table}_1", "") for table in TABLES}
|
||||
return objects
|
||||
|
||||
|
||||
def _catalog(conn: sqlite3.Connection) -> list:
|
||||
# sqlite_sequence (AUTOINCREMENT) is shared with other tables and ignored.
|
||||
return conn.execute(
|
||||
"SELECT type,name,sql FROM sqlite_master "
|
||||
"WHERE name IN (?,?,?) OR tbl_name IN (?,?)",
|
||||
(*TABLES, FILE_INDEX, *TABLES),
|
||||
).fetchall()
|
||||
|
||||
|
||||
def _structure_error(conn: sqlite3.Connection) -> Optional[str]:
|
||||
for table, expected in _COLUMNS.items():
|
||||
columns = tuple(
|
||||
(row[1], row[2].upper(), row[3], row[4], row[5])
|
||||
for row in conn.execute(f"PRAGMA table_info({table})")
|
||||
)
|
||||
if columns != expected:
|
||||
return f"Image collections table {table} has invalid columns"
|
||||
foreign_keys = {
|
||||
(row[3], row[2], row[4], row[6])
|
||||
for row in conn.execute(f"PRAGMA foreign_key_list({table})")
|
||||
}
|
||||
if foreign_keys != _FOREIGN_KEYS[table]:
|
||||
return f"Image collections table {table} has invalid foreign keys"
|
||||
return None
|
||||
|
||||
|
||||
def _decoded_too_long(conn, sql: str, limit: int) -> bool:
|
||||
"""Whether a byte-bounded candidate row decodes to over *limit* characters."""
|
||||
for row in conn.execute(sql):
|
||||
for raw in row:
|
||||
if len(bytes(raw).decode("utf-8", "replace")) > limit:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _value_error(conn: sqlite3.Connection) -> Optional[str]:
|
||||
# Only this module's constants are interpolated into the SQL below. Bytes
|
||||
# bound characters from above (UTF-8 is at most 4 bytes each); the few
|
||||
# candidates over the character bound are measured decoded in Python.
|
||||
if conn.execute(
|
||||
f"SELECT 1 FROM {COLLECTIONS_TABLE} WHERE " # nosec B608
|
||||
"typeof(id) != 'integer' OR typeof(name) != 'text' "
|
||||
"OR typeof(name_key) != 'text' OR typeof(created_at) != 'text' "
|
||||
f"OR length(CAST(name AS BLOB)) > {MAX_NAME_LENGTH * 4} "
|
||||
"OR length(CAST(name_key AS BLOB)) = 0 "
|
||||
f"OR length(CAST(name_key AS BLOB)) > {_MAX_KEY_BYTES} "
|
||||
f"OR length(CAST(created_at AS BLOB)) > {_TIMESTAMP_BYTES} LIMIT 1"
|
||||
).fetchone():
|
||||
return "Image collections contains invalid values"
|
||||
# The key is not recomputed: Unicode case tables differ between Python
|
||||
# versions, and a key written by another version must stay restorable.
|
||||
# Its UNIQUE constraint keeps names distinct; writes compute it.
|
||||
for (name,) in conn.execute(f"SELECT name FROM {COLLECTIONS_TABLE}"): # nosec B608
|
||||
if (
|
||||
not 1 <= len(name) <= MAX_NAME_LENGTH
|
||||
or _has_control(name)
|
||||
or name != name.strip()
|
||||
):
|
||||
return "Image collections contains invalid names"
|
||||
limit = MAX_KEY_LENGTH * 4
|
||||
if conn.execute(
|
||||
f"SELECT 1 FROM {ITEMS_TABLE} WHERE " # nosec B608
|
||||
"typeof(collection_id) != 'integer' OR typeof(root_key) != 'text' "
|
||||
"OR typeof(relative_path) != 'text' OR typeof(added_at) != 'text' "
|
||||
f"OR length(CAST(root_key AS BLOB)) > {limit} "
|
||||
f"OR length(CAST(relative_path AS BLOB)) > {limit} "
|
||||
f"OR length(CAST(added_at AS BLOB)) > {_TIMESTAMP_BYTES} LIMIT 1"
|
||||
).fetchone() or _decoded_too_long(
|
||||
conn,
|
||||
f"SELECT CAST(root_key AS BLOB), CAST(relative_path AS BLOB) " # nosec B608
|
||||
f"FROM {ITEMS_TABLE} WHERE length(CAST(root_key AS BLOB)) > {MAX_KEY_LENGTH} "
|
||||
f"OR length(CAST(relative_path AS BLOB)) > {MAX_KEY_LENGTH}",
|
||||
MAX_KEY_LENGTH,
|
||||
):
|
||||
return "Image collections contains invalid members"
|
||||
if conn.execute(f"PRAGMA foreign_key_check({ITEMS_TABLE})").fetchone():
|
||||
return "Image collections contains members of a missing collection"
|
||||
return None
|
||||
|
||||
|
||||
def image_collections_schema_error(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
allow_missing: bool = True,
|
||||
check_values: bool = True,
|
||||
) -> Optional[str]:
|
||||
"""Reject partial or unsupported collections schemas and invalid rows.
|
||||
|
||||
Reads only: nothing is ever repaired. A database without either table is
|
||||
acceptable for backups that predate them unless ``allow_missing`` is
|
||||
false. ``check_values=False`` (live startup) checks the structure only.
|
||||
"""
|
||||
objects = _catalog(conn)
|
||||
if not objects:
|
||||
return None if allow_missing else "Missing image collections schema"
|
||||
present = {name for kind, name, _sql in objects if kind == "table"}
|
||||
if present != set(TABLES):
|
||||
return "Incomplete image collections schema"
|
||||
actual = {(kind, name, _compact(sql or "")) for kind, name, sql in objects}
|
||||
if actual != _expected_catalog():
|
||||
return "Image collections has an invalid schema definition"
|
||||
error = _structure_error(conn)
|
||||
if error or not check_values:
|
||||
return error
|
||||
return _value_error(conn)
|
||||
|
||||
|
||||
def migrate_image_collections(conn: sqlite3.Connection) -> None:
|
||||
"""Install both tables atomically; validate before and after creating them."""
|
||||
conn.execute("SAVEPOINT image_collections_schema")
|
||||
try:
|
||||
error = image_collections_schema_error(conn, check_values=False)
|
||||
if error:
|
||||
raise sqlite3.DatabaseError(error)
|
||||
for sql in _TABLE_DDL.values():
|
||||
conn.execute(sql.replace("CREATE TABLE", "CREATE TABLE IF NOT EXISTS", 1))
|
||||
conn.execute(_INDEX_DDL.replace("INDEX", "INDEX IF NOT EXISTS", 1))
|
||||
error = image_collections_schema_error(
|
||||
conn, allow_missing=False, check_values=False
|
||||
)
|
||||
if error:
|
||||
raise sqlite3.DatabaseError(error)
|
||||
conn.execute("RELEASE image_collections_schema")
|
||||
except BaseException:
|
||||
conn.execute("ROLLBACK TO image_collections_schema")
|
||||
conn.execute("RELEASE image_collections_schema")
|
||||
raise
|
||||
|
||||
|
||||
class CollectionMissing(LookupError):
|
||||
"""No collection has the id (a 404 ``collection-not-found``)."""
|
||||
|
||||
|
||||
class CollectionNameTaken(ValueError):
|
||||
"""Another collection already has the name's key (a 409 ``name-taken``)."""
|
||||
|
||||
def __init__(self, collection_id: int):
|
||||
super().__init__("Another collection has this name")
|
||||
self.collection_id = collection_id
|
||||
|
||||
|
||||
class CollectionFilesMissing(LookupError):
|
||||
"""A new name with nothing to insert (a 404 ``files-not-found``)."""
|
||||
|
||||
|
||||
LIST_SQL = f"SELECT id, name FROM {COLLECTIONS_TABLE} ORDER BY name_key, id"
|
||||
GET_SQL = f"SELECT id, name FROM {COLLECTIONS_TABLE} WHERE id = ?"
|
||||
BY_KEY_SQL = f"SELECT id, name FROM {COLLECTIONS_TABLE} WHERE name_key = ?"
|
||||
MEMBERS_SQL = (
|
||||
f"SELECT relative_path FROM {ITEMS_TABLE} "
|
||||
"WHERE collection_id = ? AND root_key = ?"
|
||||
)
|
||||
ROOT_MEMBERS_SQL = (
|
||||
f"SELECT collection_id, relative_path FROM {ITEMS_TABLE} WHERE root_key = ?"
|
||||
)
|
||||
COUNTS_SQL = (
|
||||
f"SELECT c.id, (SELECT COUNT(*) FROM {ITEMS_TABLE} i "
|
||||
f"WHERE i.collection_id = c.id) FROM {COLLECTIONS_TABLE} c"
|
||||
)
|
||||
FILE_SQL = (
|
||||
f"SELECT collection_id FROM {ITEMS_TABLE} "
|
||||
"WHERE root_key = ? AND relative_path = ? ORDER BY collection_id"
|
||||
)
|
||||
# Every read, for the EXPLAIN QUERY PLAN check: none scans the items table and
|
||||
# only the two whole-list reads (LIST_SQL, COUNTS_SQL) scan the collections.
|
||||
READ_SQL = (
|
||||
LIST_SQL,
|
||||
GET_SQL,
|
||||
BY_KEY_SQL,
|
||||
MEMBERS_SQL,
|
||||
ROOT_MEMBERS_SQL,
|
||||
COUNTS_SQL,
|
||||
FILE_SQL,
|
||||
)
|
||||
_INSERT_ITEM_SQL = f"INSERT OR IGNORE INTO {ITEMS_TABLE} VALUES (?, ?, ?, ?)"
|
||||
_DELETE_ITEM_SQL = (
|
||||
f"DELETE FROM {ITEMS_TABLE} "
|
||||
"WHERE collection_id = ? AND root_key = ? AND relative_path = ?"
|
||||
)
|
||||
|
||||
|
||||
def _collection_id(value: object) -> int:
|
||||
if type(value) is not int or not 1 <= value <= 999_999_999:
|
||||
raise CollectionInvalid("Collection id must be a positive integer")
|
||||
return value
|
||||
|
||||
|
||||
def _items(items: object) -> list:
|
||||
"""Distinct ``(root key, relative key)`` pairs, in order."""
|
||||
if not isinstance(items, (list, tuple)):
|
||||
raise CollectionInvalid("Items must be a list")
|
||||
found = []
|
||||
for item in items:
|
||||
if (
|
||||
not isinstance(item, (list, tuple))
|
||||
or len(item) != 2
|
||||
or not all(
|
||||
isinstance(part, str) and 1 <= len(part) <= MAX_KEY_LENGTH
|
||||
for part in item
|
||||
)
|
||||
):
|
||||
raise CollectionInvalid("Each item is a root key and a relative key")
|
||||
found.append((item[0], item[1]))
|
||||
return list(dict.fromkeys(found))
|
||||
|
||||
|
||||
def _row(row) -> dict:
|
||||
return {"id": row[0], "name": row[1]}
|
||||
|
||||
|
||||
class ImageCollectionOperations:
|
||||
"""PromptDatabase mixin: collections and their members.
|
||||
|
||||
Each write runs in its own ``BEGIN IMMEDIATE`` transaction
|
||||
(``_pair_transaction``), so two tabs creating one name are serialized.
|
||||
"""
|
||||
|
||||
def list_collections(self) -> list[dict]:
|
||||
"""Every collection ``{id, name}`` in name-key order, then id."""
|
||||
rows = self.model.get_connection().execute(LIST_SQL)
|
||||
return [_row(row) for row in rows]
|
||||
|
||||
def get_collection(self, collection_id: int) -> Optional[dict]:
|
||||
"""``{id, name}`` of one collection, or None."""
|
||||
row = (
|
||||
self.model.get_connection()
|
||||
.execute(GET_SQL, (_collection_id(collection_id),))
|
||||
.fetchone()
|
||||
)
|
||||
return _row(row) if row else None
|
||||
|
||||
def collection_exists(self, collection_id: int) -> bool:
|
||||
return self.get_collection(collection_id) is not None
|
||||
|
||||
def collection_members(self, collection_id: int, root_key: str) -> set[str]:
|
||||
"""The relative keys stored for one collection under one source."""
|
||||
rows = self.model.get_connection().execute(
|
||||
MEMBERS_SQL, (_collection_id(collection_id), root_key)
|
||||
)
|
||||
return {path for (path,) in rows}
|
||||
|
||||
def root_collection_members(self, root_key: str) -> dict[int, set[str]]:
|
||||
"""``{collection id: relative keys}`` stored under one source."""
|
||||
found: dict[int, set[str]] = {}
|
||||
for collection_id, path in self.model.get_connection().execute(
|
||||
ROOT_MEMBERS_SQL, (root_key,)
|
||||
):
|
||||
found.setdefault(collection_id, set()).add(path)
|
||||
return found
|
||||
|
||||
def collection_count_snapshot(self, root_keys: list) -> tuple[dict, dict]:
|
||||
"""``({root key: {id: relative keys}}, {id: stored rows})`` read in
|
||||
one read transaction, so a write between the reads cannot skew them.
|
||||
"""
|
||||
conn = self.model.get_connection()
|
||||
if conn.in_transaction:
|
||||
raise RuntimeError("Collection counts require their own transaction")
|
||||
conn.execute("BEGIN")
|
||||
try:
|
||||
members = {}
|
||||
for root_key in dict.fromkeys(root_keys):
|
||||
found: dict[int, set[str]] = {}
|
||||
for collection_id, path in conn.execute(ROOT_MEMBERS_SQL, (root_key,)):
|
||||
found.setdefault(collection_id, set()).add(path)
|
||||
members[root_key] = found
|
||||
stored = {cid: count for cid, count in conn.execute(COUNTS_SQL)}
|
||||
finally:
|
||||
conn.rollback() # it only read
|
||||
return members, stored
|
||||
|
||||
def collection_counts(self) -> dict[int, int]:
|
||||
"""``{collection id: stored rows}`` for every collection."""
|
||||
rows = self.model.get_connection().execute(COUNTS_SQL)
|
||||
return {collection_id: count for collection_id, count in rows}
|
||||
|
||||
def file_collections(self, root_key: str, relative_path: str) -> list[int]:
|
||||
"""Ids of the collections holding one file."""
|
||||
rows = self.model.get_connection().execute(FILE_SQL, (root_key, relative_path))
|
||||
return [collection_id for (collection_id,) in rows]
|
||||
|
||||
def add_to_collection(
|
||||
self, *, items: object, collection_id: object = None, name: object = None
|
||||
) -> dict:
|
||||
"""Add files to an existing id or a name; create the name when needed.
|
||||
|
||||
A name whose key exists uses that collection; otherwise a collection
|
||||
is created only when at least one item is inserted. Returns
|
||||
``{collection, created, added, already}``.
|
||||
"""
|
||||
from .models import utc_now_iso # models imports this module's schema
|
||||
|
||||
if (collection_id is None) == (name is None):
|
||||
raise CollectionInvalid("Give either a collection id or a name")
|
||||
pairs = _items(items)
|
||||
if name is not None:
|
||||
spelled = normalize_collection_name(name)
|
||||
else:
|
||||
collection_id = _collection_id(collection_id)
|
||||
now = utc_now_iso()
|
||||
with self._pair_transaction() as conn:
|
||||
created = False
|
||||
if name is not None:
|
||||
row = conn.execute(
|
||||
BY_KEY_SQL, (collection_name_key(spelled),)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
if not pairs:
|
||||
raise CollectionFilesMissing("Nothing to add")
|
||||
cursor = conn.execute(
|
||||
f"INSERT INTO {COLLECTIONS_TABLE} " # nosec B608
|
||||
"(name, name_key, created_at) VALUES (?, ?, ?)",
|
||||
(spelled, collection_name_key(spelled), now),
|
||||
)
|
||||
row, created = (cursor.lastrowid, spelled), True
|
||||
else:
|
||||
row = conn.execute(GET_SQL, (collection_id,)).fetchone()
|
||||
if row is None:
|
||||
raise CollectionMissing("No such collection")
|
||||
collection = _row(row)
|
||||
added = sum(
|
||||
conn.execute(
|
||||
_INSERT_ITEM_SQL, (collection["id"], root, path, now)
|
||||
).rowcount
|
||||
for root, path in pairs
|
||||
)
|
||||
return {
|
||||
"collection": collection,
|
||||
"created": created,
|
||||
"added": added,
|
||||
"already": len(pairs) - added,
|
||||
}
|
||||
|
||||
def remove_from_collection(self, collection_id: object, items: object) -> dict:
|
||||
"""Remove files from one collection; ``{collection, removed}``."""
|
||||
collection_id = _collection_id(collection_id)
|
||||
pairs = _items(items)
|
||||
with self._pair_transaction() as conn:
|
||||
row = conn.execute(GET_SQL, (collection_id,)).fetchone()
|
||||
if row is None:
|
||||
raise CollectionMissing("No such collection")
|
||||
removed = sum(
|
||||
conn.execute(_DELETE_ITEM_SQL, (collection_id, root, path)).rowcount
|
||||
for root, path in pairs
|
||||
)
|
||||
return {"collection": _row(row), "removed": removed}
|
||||
|
||||
def rename_collection(self, collection_id: object, name: object) -> dict:
|
||||
"""The renamed ``{id, name}``; a case variant of its own name is fine."""
|
||||
collection_id = _collection_id(collection_id)
|
||||
spelled = normalize_collection_name(name)
|
||||
key = collection_name_key(spelled)
|
||||
with self._pair_transaction() as conn:
|
||||
if conn.execute(GET_SQL, (collection_id,)).fetchone() is None:
|
||||
raise CollectionMissing("No such collection")
|
||||
taken = conn.execute(BY_KEY_SQL, (key,)).fetchone()
|
||||
if taken is not None and taken[0] != collection_id:
|
||||
raise CollectionNameTaken(taken[0])
|
||||
conn.execute(
|
||||
f"UPDATE {COLLECTIONS_TABLE} SET name = ?, name_key = ? " # nosec B608
|
||||
"WHERE id = ?",
|
||||
(spelled, key, collection_id),
|
||||
)
|
||||
return {"id": collection_id, "name": spelled}
|
||||
|
||||
def delete_collection(self, collection_id: object) -> bool:
|
||||
"""Delete one collection and its members (never a file). True."""
|
||||
collection_id = _collection_id(collection_id)
|
||||
with self._pair_transaction() as conn:
|
||||
cursor = conn.execute(
|
||||
f"DELETE FROM {COLLECTIONS_TABLE} WHERE id = ?", # nosec B608
|
||||
(collection_id,),
|
||||
)
|
||||
if cursor.rowcount == 0:
|
||||
raise CollectionMissing("No such collection")
|
||||
return True
|
||||
+12
-2
@@ -22,6 +22,10 @@ from .managed_connection import (
|
||||
from . import restore_stage
|
||||
from .library_schema import library_schema_error, migrate_library_state
|
||||
from .image_favorites import image_favorites_schema_error, migrate_image_favorites
|
||||
from .image_collections import (
|
||||
image_collections_schema_error,
|
||||
migrate_image_collections,
|
||||
)
|
||||
from .annotation_schema import annotation_schema_error, migrate_annotation_state
|
||||
from .autotag_schema import (
|
||||
autotag_schema_error,
|
||||
@@ -338,6 +342,7 @@ class PromptModel:
|
||||
migrate_library_state(conn)
|
||||
migrate_image_favorites(conn)
|
||||
migrate_saved_views(conn)
|
||||
migrate_image_collections(conn)
|
||||
# A review group without its annotation group is refused before the
|
||||
# annotation install could make it look valid (catalog only; the rows
|
||||
# are validated once, in migrate_review_state).
|
||||
@@ -1159,8 +1164,8 @@ class PromptModel:
|
||||
path: File to inspect
|
||||
require_library_state: Also require the tables every current
|
||||
database has (Library state, image favorites, image
|
||||
categories, saved views, image annotations and the review
|
||||
queue), as a migrated
|
||||
categories, saved views, collections, image annotations and
|
||||
the review queue), as a migrated
|
||||
restore stage must; old backups may lack them.
|
||||
documents: Read every workflow document and refuse a damaged
|
||||
one. A restore checks its upload without it and repairs
|
||||
@@ -1234,6 +1239,11 @@ class PromptModel:
|
||||
)
|
||||
if views_error:
|
||||
return False, views_error
|
||||
collections_error = image_collections_schema_error(
|
||||
conn, allow_missing=not require_library_state
|
||||
)
|
||||
if collections_error:
|
||||
return False, collections_error
|
||||
annotation_error = annotation_schema_error(
|
||||
conn, allow_missing=not require_library_state
|
||||
)
|
||||
|
||||
@@ -18,6 +18,7 @@ from .models import PromptModel, normalize_image_path, utc_now_iso
|
||||
from .pairs import PairDependencyError, PairOperations # noqa: F401 (re-exported)
|
||||
from .library import LibraryOperations, titled
|
||||
from .image_favorites import ImageFavoriteOperations
|
||||
from .image_collections import ImageCollectionOperations
|
||||
from .annotations import AnnotationOperations
|
||||
from .review import ReviewOperations
|
||||
from .review_decisions import ReviewDecisionOperations
|
||||
@@ -453,6 +454,7 @@ class PromptDatabase(
|
||||
ExecutionOutputOperations,
|
||||
HistoryReadOperations,
|
||||
ImageFavoriteOperations,
|
||||
ImageCollectionOperations,
|
||||
AnnotationOperations,
|
||||
ReviewOperations,
|
||||
ReviewDecisionOperations,
|
||||
|
||||
+15
-111
@@ -1,14 +1,7 @@
|
||||
"""Atomic metadata enrichment for a single known generation context.
|
||||
|
||||
Also the helpers both ingestion store paths share (#247): the file's link
|
||||
rows and the one-prompt-per-file rule (``file_link_rows``,
|
||||
``link_conflict``) and the marks a prompt created by ingestion carries
|
||||
(``Marks``, ``mark_created``).
|
||||
"""
|
||||
"""Atomic metadata enrichment for a single known generation context."""
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from typing import Any, Dict
|
||||
|
||||
from .models import normalize_image_path
|
||||
from .operations import _coerce_file_info
|
||||
@@ -44,85 +37,16 @@ def blank_positive(resolution: Dict[str, Any]) -> bool:
|
||||
return not (isinstance(text, str) and text.strip())
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Marks:
|
||||
"""What a prompt ingestion creates carries, so it can be found and removed.
|
||||
|
||||
``note`` is formatted with ``name``, the file's name.
|
||||
"""
|
||||
|
||||
category: str
|
||||
tags: Tuple[str, ...]
|
||||
note: str
|
||||
|
||||
|
||||
SCAN_MARKS = Marks("scanned", ("auto-scanned",), "Auto-scanned from {name}")
|
||||
RUN_MARKS = Marks("scanned", ("auto-scanned",), "Saved from a workflow run: {name}")
|
||||
|
||||
|
||||
def mark_created(conn: Any, db: Any, prompt_id: int, marks: Marks, name: str) -> None:
|
||||
"""Apply ``marks`` to a prompt row the caller just created on ``conn``."""
|
||||
conn.execute(
|
||||
"UPDATE prompts SET category = ?, notes = ? WHERE id = ?",
|
||||
(marks.category, marks.note.format(name=name), prompt_id),
|
||||
)
|
||||
db._sync_prompt_tags(conn, prompt_id, list(marks.tags))
|
||||
|
||||
|
||||
def file_link_rows(conn: Any, image_path: str) -> List[Any]:
|
||||
"""The file's link rows, each with its prompt's hash.
|
||||
|
||||
Matched under the path and its resolved spelling (an output folder
|
||||
reached through a symlink or junction), each normalized and raw;
|
||||
deduplicated by row id.
|
||||
"""
|
||||
rows: Dict[Any, Any] = {}
|
||||
for spelling in dict.fromkeys((image_path, _resolved(image_path))):
|
||||
for row in conn.execute(
|
||||
"SELECT gi.*,p.hash AS positive_hash FROM generated_images gi "
|
||||
"JOIN prompts p ON p.id=gi.prompt_id "
|
||||
"WHERE gi.file_path=? OR gi.image_path=?",
|
||||
(normalize_image_path(spelling), spelling),
|
||||
).fetchall():
|
||||
rows.setdefault(row["id"], row)
|
||||
return list(rows.values())
|
||||
|
||||
|
||||
def _resolved(path: str) -> str:
|
||||
try:
|
||||
return os.path.realpath(path)
|
||||
except (OSError, ValueError):
|
||||
return path
|
||||
|
||||
|
||||
def link_conflict(rows: List[Any], prompt_hash: str) -> bool:
|
||||
"""One prompt per file: more than one row, or a row of another prompt."""
|
||||
return len(rows) > 1 or bool(rows and rows[0]["positive_hash"] != prompt_hash)
|
||||
|
||||
|
||||
def ingest_resolved_pair(
|
||||
db: Any,
|
||||
image_path: str,
|
||||
metadata: Dict[str, Any],
|
||||
resolution: Dict[str, Any],
|
||||
*,
|
||||
marks: Optional[Marks] = None,
|
||||
db: Any, image_path: str, metadata: Dict[str, Any], resolution: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""Store a resolved pair and link the file to it in one transaction.
|
||||
|
||||
With ``marks`` (ingestion from files), each pair component this call
|
||||
creates carries them; without (the PromptManager node path) components
|
||||
are created bare, as always.
|
||||
"""
|
||||
state = resolution.get("state", "unresolved")
|
||||
if state == "resolved" and blank_positive(resolution):
|
||||
state = "unresolved"
|
||||
if state != "resolved":
|
||||
return {"status": state, "pairs_added": 0, "links_added": 0}
|
||||
with db._pair_transaction() as conn:
|
||||
return _ingest_resolved_pair_in(
|
||||
conn, db, image_path, metadata, resolution, marks=marks
|
||||
)
|
||||
return _ingest_resolved_pair_in(conn, db, image_path, metadata, resolution)
|
||||
|
||||
|
||||
def _conflict() -> Dict[str, Any]:
|
||||
@@ -137,7 +61,6 @@ def _ingest_resolved_pair_in(
|
||||
resolution: Dict[str, Any],
|
||||
*,
|
||||
provenance: Dict[str, Any] | None = None,
|
||||
marks: Optional[Marks] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""``ingest_resolved_pair`` inside the caller's open ``_pair_transaction``.
|
||||
|
||||
@@ -146,8 +69,7 @@ def _ingest_resolved_pair_in(
|
||||
negative is a ``conflict`` and nothing is written. The caller owns the
|
||||
transaction, so its own later failure rolls the link back too.
|
||||
``provenance`` replaces the link's default resolver provenance (Needs
|
||||
review records the user's selection there). ``marks`` as in
|
||||
``ingest_resolved_pair``.
|
||||
review records the user's selection there).
|
||||
"""
|
||||
positive, negative = resolution["positive"], resolution["negative"]
|
||||
positive_text = positive.get("storage_text", positive["text"])
|
||||
@@ -160,8 +82,15 @@ def _ingest_resolved_pair_in(
|
||||
"negative": negative,
|
||||
}
|
||||
encoded = dumps_strict(provenance, sort_keys=True, separators=(",", ":"))
|
||||
images = file_link_rows(conn, image_path)
|
||||
if link_conflict(images, generate_prompt_hash(positive_text)):
|
||||
images = conn.execute(
|
||||
"SELECT gi.*,p.hash AS positive_hash FROM generated_images gi "
|
||||
"JOIN prompts p ON p.id=gi.prompt_id "
|
||||
"WHERE gi.file_path=? OR gi.image_path=?",
|
||||
(normalized, image_path),
|
||||
).fetchall()
|
||||
if len(images) > 1 or (
|
||||
images and images[0]["positive_hash"] != generate_prompt_hash(positive_text)
|
||||
):
|
||||
return _conflict()
|
||||
existing = images[0] if images else None
|
||||
if existing:
|
||||
@@ -186,12 +115,7 @@ def _ingest_resolved_pair_in(
|
||||
generate_prompt_hash(negative_text) if negative_text.strip() else None,
|
||||
),
|
||||
).fetchone()
|
||||
if marks is None:
|
||||
pair_id = db._save_pair(conn, positive_text, negative_text)
|
||||
else:
|
||||
pair_id = _save_marked_pair(
|
||||
conn, db, positive_text, negative_text, marks, os.path.basename(image_path)
|
||||
)
|
||||
pair_id = db._save_pair(conn, positive_text, negative_text)
|
||||
pair = conn.execute("SELECT * FROM prompt_pairs WHERE id=?", (pair_id,)).fetchone()
|
||||
pairs_added = int(prior_pair is None)
|
||||
file_size, width, height, image_format = _coerce_file_info(metadata)
|
||||
@@ -273,26 +197,6 @@ def _ingest_resolved_pair_in(
|
||||
}
|
||||
|
||||
|
||||
def _save_marked_pair(
|
||||
conn: Any,
|
||||
db: Any,
|
||||
positive_text: str,
|
||||
negative_text: str,
|
||||
marks: Marks,
|
||||
name: str,
|
||||
) -> int:
|
||||
"""``_save_pair`` that marks each component it creates."""
|
||||
texts = [positive_text] + ([negative_text] if negative_text.strip() else [])
|
||||
ids = []
|
||||
for text in texts:
|
||||
prompt_id, created = db._pair_component_outcome(conn, text)
|
||||
if created:
|
||||
mark_created(conn, db, prompt_id, marks, name)
|
||||
ids.append(prompt_id)
|
||||
negative_id = ids[1] if len(ids) > 1 else None
|
||||
return db._save_pair_components(conn, ids[0], negative_id)[0]
|
||||
|
||||
|
||||
def _document_form(conn: Any, values: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""``values`` with the workflow and prompt graph texts stored once.
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ SUMMARY_KEYS = (
|
||||
"image_links",
|
||||
"runs",
|
||||
"saved_views",
|
||||
"collections",
|
||||
)
|
||||
|
||||
|
||||
@@ -144,7 +145,8 @@ def library_summary(conn: sqlite3.Connection) -> Dict[str, int]:
|
||||
``positives`` are the Library's prompts (``library_counts``'s
|
||||
``positive_prompts``); ``pairs`` the active saved pairs and
|
||||
``archived_pairs`` the archived ones; ``image_links`` the indexed image
|
||||
rows; ``runs`` the recorded executions.
|
||||
rows; ``runs`` the recorded executions; ``collections`` the image
|
||||
collections (#331).
|
||||
"""
|
||||
from .library import _ELIGIBLE
|
||||
|
||||
@@ -169,6 +171,7 @@ def library_summary(conn: sqlite3.Connection) -> Dict[str, int]:
|
||||
"image_links": _count(conn, "generated_images"),
|
||||
"runs": _count(conn, "execution_runs"),
|
||||
"saved_views": _count(conn, "saved_views"),
|
||||
"collections": _count(conn, "image_collections"),
|
||||
}
|
||||
|
||||
|
||||
@@ -180,6 +183,7 @@ _FINGERPRINT_SQL = (
|
||||
("prompt_pairs", "SELECT MAX(id), MAX(updated_at) FROM prompt_pairs"),
|
||||
("execution_runs", "SELECT COUNT(*), MAX(rowid) FROM execution_runs"),
|
||||
("saved_views", "SELECT MAX(rowid) FROM saved_views"),
|
||||
("image_collections", "SELECT MAX(rowid) FROM image_collections"),
|
||||
)
|
||||
|
||||
|
||||
@@ -190,9 +194,9 @@ def live_fingerprint(conn: sqlite3.Connection):
|
||||
tables a restore replaces, or when a prompt, pair, run or saved view is
|
||||
the newest edited (counts plus the newest row and edit of each table);
|
||||
the apply refuses with ``plan-changed`` then. It does not see every
|
||||
edit (a tag or favorite change on an older row); those are kept in the
|
||||
restore's safety backup. A read transaction the caller already has is
|
||||
used and left open; one opened here is ended here.
|
||||
edit (a tag, favorite or collection membership change on an older row);
|
||||
those are kept in the restore's safety backup. A read transaction the
|
||||
caller already has is used and left open; one opened here is ended here.
|
||||
"""
|
||||
import json
|
||||
|
||||
|
||||
@@ -164,7 +164,7 @@ def _runs_by_location(db: Any, names: List[str]) -> Optional[Dict[tuple, dict]]:
|
||||
return found
|
||||
|
||||
|
||||
def recorded_runs(
|
||||
def _recorded_runs(
|
||||
db: Any,
|
||||
image_paths: Iterable[str],
|
||||
output_root: Optional[str],
|
||||
@@ -201,8 +201,7 @@ def recorded_runs(
|
||||
return attributed
|
||||
|
||||
|
||||
def named_prompt(db: Any, run_id: str) -> Optional[int]:
|
||||
"""The one stored prompt every positive context of ``run_id`` names, else None."""
|
||||
def _named_prompt(db: Any, run_id: str) -> Optional[int]:
|
||||
contexts = db.get_execution_contexts(run_id).get("contexts") or []
|
||||
named = {
|
||||
context.get("positive_prompt_id")
|
||||
@@ -229,11 +228,11 @@ def stored_run_prompts(
|
||||
paths = list(dict.fromkeys(path for path in image_paths if path))
|
||||
result: Dict[str, Optional[int]] = dict.fromkeys(paths)
|
||||
prompts: Dict[str, Optional[int]] = {}
|
||||
for path, (run_id, _nodes) in recorded_runs(
|
||||
for path, (run_id, _nodes) in _recorded_runs(
|
||||
db, paths, output_root, path_style
|
||||
).items():
|
||||
if run_id not in prompts:
|
||||
prompts[run_id] = named_prompt(db, run_id)
|
||||
prompts[run_id] = _named_prompt(db, run_id)
|
||||
result[path] = prompts[run_id]
|
||||
return result
|
||||
|
||||
@@ -276,7 +275,7 @@ def recorded_output(
|
||||
) -> Optional[RecordedOutput]:
|
||||
"""The recorded run facts for ``image_path``, else None (#386).
|
||||
|
||||
The file must be attributed to one run (``recorded_runs``: one run
|
||||
The file must be attributed to one run (``_recorded_runs``: one run
|
||||
reported its location and it was written during that run). Read-only; it
|
||||
never links anything.
|
||||
"""
|
||||
@@ -287,7 +286,7 @@ def recorded_output(
|
||||
if own_transaction: # outputs and texts from one state of history
|
||||
conn.execute("BEGIN")
|
||||
try:
|
||||
found = recorded_runs(db, [image_path], output_root, path_style)
|
||||
found = _recorded_runs(db, [image_path], output_root, path_style)
|
||||
if image_path not in found:
|
||||
return None
|
||||
run_id, nodes = found[image_path]
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
"""Link an output file to the prompt of one text, creating it when new (#247).
|
||||
|
||||
The store step of ingestion from file metadata when the decision is a
|
||||
positive text (``utils.file_prompt``). The prompt insert and the link run in
|
||||
one ``BEGIN IMMEDIATE`` transaction: two writers never store one text twice,
|
||||
a failed link leaves no new prompt behind, and a file stays linked to one
|
||||
prompt (a file already linked to another prompt is a ``conflict``).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from .pair_ingestion import Marks, file_link_rows, link_conflict, mark_created
|
||||
|
||||
try:
|
||||
from ..utils.hashing import generate_prompt_hash
|
||||
except ImportError:
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
_LOGGER = logging.getLogger("prompt_manager.database.scanned_prompts")
|
||||
|
||||
|
||||
def store_scanned_prompt_or_raise(
|
||||
db: Any, path: str, canonical: Dict[str, Any], text: str, marks: Marks
|
||||
) -> Tuple[str, Optional[int]]:
|
||||
"""``(status, prompt id)`` after linking ``path`` to ``text``'s prompt.
|
||||
|
||||
``added`` (prompt created, carrying ``marks``), ``linked`` (stored text
|
||||
reused), ``unchanged`` (already linked to it) or ``conflict`` (linked to
|
||||
another prompt; nothing written, id None). Database errors are raised
|
||||
and roll the whole store back, so a caller can run it again.
|
||||
"""
|
||||
with db._pair_transaction() as conn:
|
||||
rows = file_link_rows(conn, path)
|
||||
if link_conflict(rows, generate_prompt_hash(text)):
|
||||
return "conflict", None
|
||||
if rows:
|
||||
return "unchanged", rows[0]["prompt_id"]
|
||||
prompt_id, created = db._pair_component_outcome(conn, text)
|
||||
if created:
|
||||
mark_created(conn, db, prompt_id, marks, os.path.basename(path))
|
||||
db._insert_image_link(conn, prompt_id, path, canonical)
|
||||
return ("added" if created else "linked"), prompt_id
|
||||
|
||||
|
||||
def store_scanned_prompt(
|
||||
db: Any, path: str, canonical: Dict[str, Any], text: str, marks: Marks
|
||||
) -> Tuple[str, Optional[int]]:
|
||||
"""The scan's form: an error is logged and reported as ``("found", None)``.
|
||||
|
||||
A write that failed because Move library switched runs again on the new
|
||||
library (``survive_library_switch``); the rerun re-checks the link rows.
|
||||
"""
|
||||
try:
|
||||
return db.survive_library_switch(
|
||||
lambda: store_scanned_prompt_or_raise(db, path, canonical, text, marks)
|
||||
)
|
||||
except Exception as error:
|
||||
_LOGGER.error(
|
||||
f"Failed to store the prompt of {os.path.basename(path)}: {error}"
|
||||
)
|
||||
return "found", None
|
||||
|
||||
|
||||
def link_stored_prompt_or_raise(
|
||||
db: Any, path: str, canonical: Dict[str, Any], prompt_id: int
|
||||
) -> Optional[str]:
|
||||
"""Link ``path`` to the stored prompt ``prompt_id`` (recorded history, #342).
|
||||
|
||||
``linked``, ``unchanged`` (already linked to it), ``conflict`` (linked to
|
||||
another prompt; nothing written) or None when the prompt is gone. One
|
||||
``BEGIN IMMEDIATE`` transaction; database errors are raised.
|
||||
"""
|
||||
with db._pair_transaction() as conn:
|
||||
rows = file_link_rows(conn, path)
|
||||
if len(rows) > 1 or (rows and rows[0]["prompt_id"] != prompt_id):
|
||||
return "conflict"
|
||||
if rows:
|
||||
return "unchanged"
|
||||
exists = conn.execute(
|
||||
"SELECT 1 FROM prompts WHERE id = ?", (prompt_id,)
|
||||
).fetchone()
|
||||
if exists is None:
|
||||
return None
|
||||
db._insert_image_link(conn, prompt_id, path, canonical)
|
||||
return "linked"
|
||||
+85
-3
@@ -48,6 +48,7 @@ from .review import ReviewRoutesMixin
|
||||
from .review_recording import ReviewRecordingMixin
|
||||
from .review_events import ReviewEventsMixin
|
||||
from .saved_views import SavedViewRoutesMixin
|
||||
from .collections import CollectionRoutesMixin
|
||||
from .history import HistoryRoutesMixin
|
||||
from .images import ImageRoutesMixin
|
||||
from .gallery_listing import WALK_STOPPED, RootListing, WalkOutcome
|
||||
@@ -76,7 +77,6 @@ from .image_categories import ImageCategoryRoutesMixin
|
||||
|
||||
try:
|
||||
from ...database.operations import PromptDatabase
|
||||
from ...utils.file_prompt import parse_comfyui_prompt
|
||||
from ...utils.logging_config import get_logger
|
||||
from ...utils.video_metadata import is_video_path, read_video_metadata
|
||||
except ImportError:
|
||||
@@ -86,7 +86,6 @@ except ImportError:
|
||||
0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
)
|
||||
from database.operations import PromptDatabase
|
||||
from utils.file_prompt import parse_comfyui_prompt
|
||||
from utils.logging_config import get_logger
|
||||
from utils.video_metadata import is_video_path, read_video_metadata
|
||||
|
||||
@@ -317,6 +316,7 @@ class PromptManagerAPI(
|
||||
ReviewRecordingMixin,
|
||||
ReviewEventsMixin,
|
||||
SavedViewRoutesMixin,
|
||||
CollectionRoutesMixin,
|
||||
HistoryRoutesMixin,
|
||||
PromptRoutesMixin,
|
||||
ImageRoutesMixin,
|
||||
@@ -688,6 +688,7 @@ class PromptManagerAPI(
|
||||
self._register_review_routes(routes)
|
||||
self._register_review_event_routes(routes)
|
||||
self._register_saved_view_routes(routes)
|
||||
self._register_collection_routes(routes)
|
||||
self._register_history_routes(routes)
|
||||
self._register_prompt_routes(routes)
|
||||
self._register_image_routes(routes)
|
||||
@@ -1030,4 +1031,85 @@ class PromptManagerAPI(
|
||||
|
||||
def _parse_comfyui_prompt(self, metadata):
|
||||
"""Parse ComfyUI and A1111 prompt data from metadata."""
|
||||
return parse_comfyui_prompt(metadata)
|
||||
result = {
|
||||
"prompt": None,
|
||||
"workflow": None,
|
||||
"parameters": {},
|
||||
"positive_prompt": None,
|
||||
"negative_prompt": None,
|
||||
}
|
||||
|
||||
# Check for A1111/Forge style parameters first.
|
||||
# The chunk is one block: a possibly multi-line positive prompt, then a
|
||||
# "Negative prompt:" section, then a trailing "Steps: ..." settings line.
|
||||
# Taking lines[0] as the positive prompt truncates any multi-line prompt
|
||||
# to one line, so split on the settings line instead.
|
||||
if "parameters" in metadata:
|
||||
import re as _re
|
||||
|
||||
params = metadata["parameters"]
|
||||
body = params
|
||||
settings = ""
|
||||
_sm = _re.search(r"\nSteps:\s", body)
|
||||
if _sm:
|
||||
settings = body[_sm.start() + 1 :].strip()
|
||||
body = body[: _sm.start()]
|
||||
negative = None
|
||||
_nm = _re.search(r"(?im)^\s*negative prompt:\s*", body)
|
||||
if _nm:
|
||||
negative = body[_nm.end() :].strip()
|
||||
body = body[: _nm.start()]
|
||||
elif body.lower().lstrip().startswith("negative prompt:"):
|
||||
negative = body.lower().lstrip()[len("negative prompt:") :].strip()
|
||||
body = ""
|
||||
result["positive_prompt"] = body.strip() or None
|
||||
if negative:
|
||||
result["negative_prompt"] = negative
|
||||
# Store raw parameters too
|
||||
result["parameters"]["parameters"] = params
|
||||
if settings:
|
||||
for _f in ("Steps", "Sampler", "Schedule type", "CFG scale", "Seed"):
|
||||
_fm = _re.search(
|
||||
r"(?i)(?:^|,\s*)" + _re.escape(_f) + r":\s*([^,]+)", settings
|
||||
)
|
||||
if _fm:
|
||||
result["parameters"][_f.lower().replace(" ", "_")] = _fm.group(
|
||||
1
|
||||
).strip()
|
||||
|
||||
# If no A1111 format found, proceed with ComfyUI parsing
|
||||
if result["positive_prompt"] is None:
|
||||
# Check for direct prompt field
|
||||
if "prompt" in metadata:
|
||||
try:
|
||||
prompt_data = json.loads(metadata["prompt"])
|
||||
result["prompt"] = prompt_data
|
||||
except json.JSONDecodeError:
|
||||
result["prompt"] = metadata["prompt"]
|
||||
|
||||
# Check for workflow
|
||||
if "workflow" in metadata:
|
||||
try:
|
||||
workflow_data = json.loads(metadata["workflow"])
|
||||
result["workflow"] = workflow_data
|
||||
except json.JSONDecodeError:
|
||||
result["workflow"] = metadata["workflow"]
|
||||
|
||||
# Check for other common ComfyUI fields
|
||||
common_fields = [
|
||||
"positive",
|
||||
"negative",
|
||||
"steps",
|
||||
"cfg",
|
||||
"sampler",
|
||||
"scheduler",
|
||||
"seed",
|
||||
]
|
||||
for field in common_fields:
|
||||
if field in metadata:
|
||||
try:
|
||||
result["parameters"][field] = json.loads(metadata[field])
|
||||
except json.JSONDecodeError:
|
||||
result["parameters"][field] = metadata[field]
|
||||
|
||||
return result
|
||||
|
||||
+248
-153
@@ -40,51 +40,45 @@ except ImportError:
|
||||
from utils import exit_signal
|
||||
|
||||
try:
|
||||
from ...database.pair_ingestion import SCAN_MARKS, ingest_resolved_pair
|
||||
from ...database.pair_ingestion import blank_positive, ingest_resolved_pair
|
||||
from ...database.run_attribution import (
|
||||
RecordedOutput,
|
||||
host_output_directory,
|
||||
named_prompt,
|
||||
recorded_runs,
|
||||
stored_run_prompt,
|
||||
stored_run_prompts,
|
||||
)
|
||||
from ...database.scanned_prompts import (
|
||||
link_stored_prompt_or_raise,
|
||||
store_scanned_prompt,
|
||||
)
|
||||
from ...utils.file_prompt import (
|
||||
NEVER,
|
||||
NOT_SAVED,
|
||||
NOT_SAVED_WIRED,
|
||||
PAIR,
|
||||
Policy,
|
||||
decide_file_prompt,
|
||||
from ...utils.generation_params import (
|
||||
PLACEHOLDERS,
|
||||
GenerationParamsError,
|
||||
extract_generation_params,
|
||||
parse_page_json,
|
||||
)
|
||||
from ...utils.prompt_graph import metadata_opted_out, side_opted_out
|
||||
from ...utils.prompt_pair_resolver import resolve_prompt_pair, sampler_less_prompt
|
||||
from ...utils.review_capture import reviewable
|
||||
except ImportError:
|
||||
from database.pair_ingestion import SCAN_MARKS, ingest_resolved_pair
|
||||
from database.pair_ingestion import blank_positive, ingest_resolved_pair
|
||||
from database.run_attribution import (
|
||||
RecordedOutput,
|
||||
host_output_directory,
|
||||
named_prompt,
|
||||
recorded_runs,
|
||||
stored_run_prompt,
|
||||
stored_run_prompts,
|
||||
)
|
||||
from database.scanned_prompts import (
|
||||
link_stored_prompt_or_raise,
|
||||
store_scanned_prompt,
|
||||
)
|
||||
from utils.file_prompt import (
|
||||
NEVER,
|
||||
NOT_SAVED,
|
||||
NOT_SAVED_WIRED,
|
||||
PAIR,
|
||||
Policy,
|
||||
decide_file_prompt,
|
||||
from utils.generation_params import (
|
||||
PLACEHOLDERS,
|
||||
GenerationParamsError,
|
||||
extract_generation_params,
|
||||
parse_page_json,
|
||||
)
|
||||
from utils.prompt_graph import metadata_opted_out, side_opted_out
|
||||
from utils.prompt_pair_resolver import resolve_prompt_pair, sampler_less_prompt
|
||||
from utils.review_capture import reviewable
|
||||
|
||||
|
||||
try:
|
||||
from ...database.library import display_title
|
||||
from ...utils.hashing import generate_prompt_hash
|
||||
except ImportError:
|
||||
from database.library import display_title
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff")
|
||||
VIDEO_EXTENSIONS = (".mp4", ".avi", ".mov", ".mkv", ".webm", ".m4v", ".wmv")
|
||||
@@ -264,7 +258,7 @@ SCAN_DETAILS = {
|
||||
"skipped": "Skipped · already linked",
|
||||
"ambiguous": "Needs review: more than one prompt could match",
|
||||
"unresolved": "Needs review: the prompt could not be traced in the workflow",
|
||||
"conflict": "Kept: already linked to a different prompt",
|
||||
"conflict": "Needs review: already linked to a different prompt",
|
||||
"failed": "Error: the prompt could not be stored",
|
||||
"error": "Error: the file could not be processed (see the server log)",
|
||||
"found": "Metadata found, but no prompt text to store",
|
||||
@@ -331,6 +325,22 @@ def _file_size(path):
|
||||
return None
|
||||
|
||||
|
||||
def _with_file_size(canonical, media_file):
|
||||
"""Record the file's size in ``file_info`` unless the metadata carries one.
|
||||
|
||||
Stored as ``generated_images.file_size``; the "new" scan scope skips a known
|
||||
file only while its size is unchanged.
|
||||
"""
|
||||
info = canonical.get("file_info")
|
||||
info = dict(info) if isinstance(info, dict) else {}
|
||||
if info.get("size") is None:
|
||||
size = _file_size(media_file)
|
||||
if size is not None:
|
||||
info["size"] = size
|
||||
if info:
|
||||
canonical["file_info"] = info
|
||||
|
||||
|
||||
def _scan_roots(output_dirs):
|
||||
"""(absolute root, public label, listing index), the most specific first.
|
||||
|
||||
@@ -586,56 +596,18 @@ class _BatchHistory:
|
||||
"""A scan batch's recorded-history lookup, made on the first file needing it.
|
||||
|
||||
Most files link through their metadata and never ask; the first one that
|
||||
does triggers one ``recorded_runs`` pass for the whole batch, from which
|
||||
both the stored prompt (``get``) and the recorded saver (``recorded``)
|
||||
of every file are read. A failure is logged and names nothing.
|
||||
does triggers one lookup for the whole batch (``_recorded_runs_for``).
|
||||
"""
|
||||
|
||||
def __init__(self, api, media_files):
|
||||
self._api = api
|
||||
self._files = [str(path) for path in media_files]
|
||||
self._runs = None
|
||||
self._prompts = {}
|
||||
|
||||
def _found(self):
|
||||
if self._runs is None:
|
||||
try:
|
||||
self._runs = recorded_runs(
|
||||
self._api.db, self._files, host_output_directory(), None
|
||||
)
|
||||
except Exception:
|
||||
self._api.logger.warning(
|
||||
"History attribution failed for a scan batch", exc_info=True
|
||||
)
|
||||
self._runs = {}
|
||||
return self._runs
|
||||
self._files = media_files
|
||||
self._found = None
|
||||
|
||||
def get(self, path):
|
||||
"""The stored prompt id history attributes to ``path``, else None."""
|
||||
found = self._found().get(path)
|
||||
if found is None:
|
||||
return None
|
||||
run_id = found[0]
|
||||
if run_id not in self._prompts:
|
||||
try:
|
||||
self._prompts[run_id] = named_prompt(self._api.db, run_id)
|
||||
except Exception:
|
||||
self._api.logger.warning(
|
||||
"History attribution failed for a scan batch", exc_info=True
|
||||
)
|
||||
self._prompts[run_id] = None
|
||||
return self._prompts[run_id]
|
||||
|
||||
def recorded(self, path):
|
||||
"""``RecordedOutput(saver, {})`` for an attributed file, else None.
|
||||
|
||||
``saver`` is set only when the run reported the file from one node.
|
||||
"""
|
||||
found = self._found().get(path)
|
||||
if found is None:
|
||||
return None
|
||||
nodes = found[1]
|
||||
return RecordedOutput(next(iter(nodes)) if len(nodes) == 1 else None, {})
|
||||
if self._found is None:
|
||||
self._found = self._api._recorded_runs_for(self._files)
|
||||
return self._found.get(path)
|
||||
|
||||
|
||||
class AdminRoutesMixin:
|
||||
@@ -1700,6 +1672,24 @@ class AdminRoutesMixin:
|
||||
note(media_file, status)
|
||||
return counts
|
||||
|
||||
@staticmethod
|
||||
def _graph_prompt_text(metadata):
|
||||
"""The positive prompt the browser parser traces from the graphs, or None.
|
||||
|
||||
Records parse as the legacy pages parsed them (NaN reads as null).
|
||||
"""
|
||||
records = {}
|
||||
for key in ("prompt", "workflow"):
|
||||
value = metadata.get(key)
|
||||
records[key] = parse_page_json(value) if isinstance(value, str) else value
|
||||
try:
|
||||
positive = extract_generation_params(records)["positivePrompt"]
|
||||
except GenerationParamsError:
|
||||
return None
|
||||
if not isinstance(positive, str) or positive == PLACEHOLDERS["positivePrompt"]:
|
||||
return None
|
||||
return positive.strip() or None
|
||||
|
||||
def _ingest_scanned_file(
|
||||
self,
|
||||
media_file,
|
||||
@@ -1710,81 +1700,148 @@ class AdminRoutesMixin:
|
||||
records=None,
|
||||
history=None,
|
||||
):
|
||||
"""Store the prompt the shared per-file decision names (#247).
|
||||
"""Enrich exact pairs; retain legacy positive-only import for absent pairs.
|
||||
|
||||
``utils.file_prompt.decide_file_prompt`` (scan policy) decides; this
|
||||
stores the result. Returns the file's scan status, or None without
|
||||
prompt metadata. With ``detailed``, a stored or linked file returns a
|
||||
dict instead: its ``status``, the prompt's display ``title`` and, for
|
||||
a resolved pair, the pair counts. When the resolver finds the file
|
||||
ambiguous (whether or not a later step then links it) and
|
||||
``ambiguous`` is a list, ``media_file`` is appended to it; only a file
|
||||
that then returns ``"ambiguous"`` (nothing linked it) is a Needs
|
||||
review case. An ambiguous result with nothing to choose
|
||||
(``reviewable`` is False: fewer than two candidates carry a prompt)
|
||||
goes to ``untraced`` instead and returns ``"unresolved"`` when nothing
|
||||
links it. The parsed records of either ambiguous file go to
|
||||
``records["canonical"]`` when ``records`` is a dict. ``history`` is
|
||||
the batch's lazy recorded-history lookup.
|
||||
Returns the file's scan status, or None without prompt metadata. With
|
||||
``detailed``, a stored or linked file returns a dict instead: its
|
||||
``status``, the prompt's display ``title`` and, for a resolved pair,
|
||||
the pair counts. When the resolver finds the file ambiguous (whether
|
||||
or not the browser parser then links it) and ``ambiguous`` is a list,
|
||||
``media_file`` is appended to it; only a file that then returns
|
||||
``"ambiguous"`` (nothing linked it) is a Needs review case. An
|
||||
ambiguous result with nothing to choose (``reviewable`` is False: fewer
|
||||
than two candidates carry a prompt) that nothing links returns
|
||||
``"unresolved"`` instead, and ``media_file`` goes to ``untraced``.
|
||||
The parsed records of either ambiguous file go to
|
||||
``records["canonical"]`` when ``records`` is a dict.
|
||||
"""
|
||||
path = str(media_file)
|
||||
if history is None: # one file: history is still asked once
|
||||
history = _BatchHistory(self, [media_file])
|
||||
decision = decide_file_prompt(
|
||||
metadata,
|
||||
file_name=Path(path).name,
|
||||
file_size=functools.partial(_file_size, media_file),
|
||||
in_output=False, # only the monitor policy reads it
|
||||
lookup=functools.partial(history.recorded, path),
|
||||
policy=Policy.SCAN,
|
||||
)
|
||||
if decision.kind in (NOT_SAVED, NOT_SAVED_WIRED):
|
||||
# Save to library off (#90): no write, recorded-run fallback or
|
||||
# Needs review item
|
||||
return decision.kind
|
||||
self._note_review(media_file, decision, ambiguous, untraced, records)
|
||||
if decision.kind == NEVER:
|
||||
if decision.try_recorded_run:
|
||||
linked = self._link_recorded_run(
|
||||
media_file, decision.canonical, detailed, history
|
||||
)
|
||||
if linked is not None:
|
||||
return linked
|
||||
return decision.unlinked_status
|
||||
if decision.kind == PAIR:
|
||||
# A rerun after a library move re-checks the link rows.
|
||||
result = self.db.survive_library_switch(
|
||||
lambda: ingest_resolved_pair(
|
||||
self.db,
|
||||
path,
|
||||
decision.canonical,
|
||||
decision.resolution,
|
||||
marks=SCAN_MARKS,
|
||||
)
|
||||
if not metadata:
|
||||
# No prompt metadata (--disable-metadata, bare savers): only the
|
||||
# recorded run can say which prompt made it (#342)
|
||||
return self._link_recorded_run(media_file, {}, detailed, history)
|
||||
canonical = dict(metadata)
|
||||
for key in ("prompt", "workflow"):
|
||||
if isinstance(canonical.get(key), str):
|
||||
try:
|
||||
canonical[key] = json.loads(canonical[key])
|
||||
except (ValueError, TypeError):
|
||||
# Preserve malformed metadata for conservative resolver rejection.
|
||||
pass
|
||||
if metadata_opted_out(canonical):
|
||||
# Save to library was off for the run's prompt: no prompt, link,
|
||||
# recorded-run fallback or Needs review item (#90)
|
||||
return "not_saved"
|
||||
if metadata_opted_out(canonical, unreadable=True):
|
||||
# The switch is wired from a node the file cannot show: only the
|
||||
# live run (the node saw the value) can link it, never a scan
|
||||
return "not_saved_wired"
|
||||
if not canonical.get("prompt") and not canonical.get("parameters"):
|
||||
return self._link_recorded_run(media_file, canonical, detailed, history)
|
||||
_with_file_size(canonical, media_file)
|
||||
resolution = resolve_prompt_pair(canonical)
|
||||
if resolution["state"] == "resolved" and side_opted_out(
|
||||
canonical.get("prompt"), resolution["negative"]
|
||||
):
|
||||
# Only the negative's node is off: store the positive alone (#90)
|
||||
prompt_text = (
|
||||
resolution["positive"]
|
||||
.get("storage_text", resolution["positive"]["text"])
|
||||
.strip()
|
||||
)
|
||||
if not detailed:
|
||||
return result["status"]
|
||||
title_text = decision.resolution["positive"]["text"]
|
||||
return {**result, "title": self._scan_title(media_file, title_text)}
|
||||
status, _prompt_id = store_scanned_prompt(
|
||||
self.db, path, decision.canonical, decision.text, SCAN_MARKS
|
||||
)
|
||||
if not detailed or status not in SCAN_TITLED_DETAILS:
|
||||
return status
|
||||
return {"status": status, "title": self._scan_title(media_file, decision.text)}
|
||||
|
||||
@staticmethod
|
||||
def _note_review(media_file, decision, ambiguous, untraced, records):
|
||||
"""Hand an ambiguous file to the batch's Needs review lists."""
|
||||
if decision.review == "untraced":
|
||||
status = self._store_scanned_prompt(media_file, canonical, prompt_text)
|
||||
if not detailed or status not in SCAN_TITLED_DETAILS:
|
||||
return status
|
||||
return {
|
||||
"status": status,
|
||||
"title": self._scan_title(media_file, prompt_text),
|
||||
}
|
||||
if blank_positive(resolution):
|
||||
# Only prepend/append text: no stored prompt can own it (#342)
|
||||
resolution = {**resolution, "state": "unresolved"}
|
||||
# Ambiguous without a choice: unresolved unless something links it.
|
||||
unchoosable = resolution["state"] == "ambiguous" and not reviewable(resolution)
|
||||
if unchoosable:
|
||||
if untraced is not None:
|
||||
untraced.append(media_file)
|
||||
if records is not None:
|
||||
records["canonical"] = decision.canonical
|
||||
elif decision.review == "ambiguous" and ambiguous is not None:
|
||||
records["canonical"] = canonical
|
||||
elif resolution["state"] == "ambiguous" and ambiguous is not None:
|
||||
ambiguous.append(media_file)
|
||||
if records is not None:
|
||||
records["canonical"] = decision.canonical
|
||||
records["canonical"] = canonical
|
||||
if resolution["state"] == "resolved":
|
||||
result = ingest_resolved_pair(
|
||||
self.db, str(media_file), canonical, resolution
|
||||
)
|
||||
if not detailed:
|
||||
return result["status"]
|
||||
return {
|
||||
**result,
|
||||
"title": self._scan_title(media_file, resolution["positive"]["text"]),
|
||||
}
|
||||
if canonical.get("prompt") and resolution["state"] in (
|
||||
"ambiguous",
|
||||
"unresolved",
|
||||
):
|
||||
# The resolver could not decide: link the prompt the inspector and the
|
||||
# legacy pages show for this file (the browser parser, in Python).
|
||||
# Graphs without a recognized sampler keep the legacy extractor below.
|
||||
prompt_text = self._graph_prompt_text(metadata)
|
||||
if not prompt_text:
|
||||
linked = self._link_recorded_run(
|
||||
media_file, canonical, detailed, history
|
||||
)
|
||||
if linked is not None:
|
||||
return linked
|
||||
return "unresolved" if unchoosable else resolution["state"]
|
||||
elif resolution["state"] == "ambiguous":
|
||||
return "unresolved" if unchoosable else "ambiguous"
|
||||
elif resolution["state"] == "unresolved" and not isinstance(
|
||||
canonical.get("parameters"), str
|
||||
):
|
||||
return resolution["state"]
|
||||
elif canonical.get("prompt"):
|
||||
# No node takes a positive or negative input (a guider or wrapper
|
||||
# graph): the one wired text, never a guess (#252).
|
||||
found = sampler_less_prompt(canonical["prompt"])
|
||||
prompt_text = found["text"]
|
||||
if prompt_text is None and isinstance(canonical.get("parameters"), str):
|
||||
# A metadata saver also wrote A1111 text: its positive prompt.
|
||||
parsed = self._parse_comfyui_prompt(metadata)["positive_prompt"]
|
||||
prompt_text = parsed.strip() if parsed and parsed.strip() else None
|
||||
if prompt_text is None:
|
||||
linked = self._link_recorded_run(
|
||||
media_file, canonical, detailed, history
|
||||
)
|
||||
if linked is not None:
|
||||
return linked
|
||||
return "unresolved" if found["state"] == "ambiguous" else "found"
|
||||
else:
|
||||
prompt_text = self._parse_comfyui_prompt(metadata)["positive_prompt"]
|
||||
if not (prompt_text and prompt_text.strip()):
|
||||
return "found"
|
||||
prompt_text = prompt_text.strip()
|
||||
|
||||
status = self._store_scanned_prompt(media_file, canonical, prompt_text)
|
||||
if not detailed or status not in SCAN_TITLED_DETAILS:
|
||||
return status
|
||||
return {"status": status, "title": self._scan_title(media_file, prompt_text)}
|
||||
|
||||
def _recorded_runs_for(self, media_files):
|
||||
"""``{path: prompt id or None}`` from recorded history for a scan batch.
|
||||
|
||||
One output lookup per chunk of names (``stored_run_prompts``), so a
|
||||
batch never queries history once per file. An empty dict on failure.
|
||||
"""
|
||||
try:
|
||||
return stored_run_prompts(
|
||||
self.db, [str(path) for path in media_files], host_output_directory()
|
||||
)
|
||||
except Exception:
|
||||
self.logger.warning(
|
||||
"History attribution failed for a scan batch", exc_info=True
|
||||
)
|
||||
return {}
|
||||
|
||||
def _link_recorded_run(self, media_file, canonical, detailed=False, history=None):
|
||||
"""Link a file recorded history attributes to one stored prompt (#342).
|
||||
@@ -1793,25 +1850,31 @@ class AdminRoutesMixin:
|
||||
``{"status", "title"}``), or None when history names no single stored
|
||||
prompt for the file (``database.run_attribution``). Never creates a
|
||||
prompt. Works without any prompt metadata in the file. ``history`` is
|
||||
the batch's lookup (``_BatchHistory``). A file already linked to
|
||||
another prompt is a ``conflict``: one prompt per file.
|
||||
the batch's precomputed lookup; without it the file is looked up alone.
|
||||
"""
|
||||
if history is None:
|
||||
history = _BatchHistory(self, [media_file])
|
||||
prompt_id = history.get(str(media_file))
|
||||
if history is not None:
|
||||
prompt_id = history.get(str(media_file))
|
||||
else:
|
||||
try:
|
||||
prompt_id = stored_run_prompt(
|
||||
self.db, str(media_file), host_output_directory()
|
||||
)
|
||||
except Exception:
|
||||
self.logger.warning(
|
||||
f"History attribution failed for {media_file.name}",
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
if prompt_id is None:
|
||||
return None
|
||||
try:
|
||||
status = self.db.survive_library_switch(
|
||||
lambda: link_stored_prompt_or_raise(
|
||||
self.db, str(media_file), canonical, prompt_id
|
||||
)
|
||||
image_id = self.db.link_image_to_prompt(
|
||||
prompt_id, str(media_file), metadata=canonical
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to link {media_file.name} from history: {e}")
|
||||
return "found"
|
||||
if status is None:
|
||||
return None
|
||||
status = "linked" if image_id else "unchanged"
|
||||
if not detailed or status not in SCAN_TITLED_DETAILS:
|
||||
return status
|
||||
prompt = self.db.get_prompt_by_id(prompt_id) or {}
|
||||
@@ -1831,3 +1894,35 @@ class AdminRoutesMixin:
|
||||
except Exception:
|
||||
self.logger.warning("Scan row title could not be read; using its text")
|
||||
return display_title((), text)
|
||||
|
||||
def _store_scanned_prompt(self, media_file, canonical, prompt_text):
|
||||
"""Link ``media_file`` to ``prompt_text``'s prompt, saving it when new."""
|
||||
prompt_hash = generate_prompt_hash(prompt_text)
|
||||
existing = self.db.get_prompt_by_hash(prompt_hash)
|
||||
if existing:
|
||||
try:
|
||||
image_id = self.db.link_image_to_prompt(
|
||||
existing["id"], str(media_file), metadata=canonical
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"Failed to link {media_file.name} to existing prompt: {e}"
|
||||
)
|
||||
return "found"
|
||||
return "linked" if image_id else "unchanged"
|
||||
|
||||
prompt_id = self.db.save_prompt(
|
||||
prompt_text,
|
||||
"scanned",
|
||||
["auto-scanned"],
|
||||
None,
|
||||
f"Auto-scanned from {media_file.name}",
|
||||
prompt_hash,
|
||||
)
|
||||
if not prompt_id:
|
||||
return "found"
|
||||
try:
|
||||
self.db.link_image_to_prompt(prompt_id, str(media_file), metadata=canonical)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to link {media_file.name} to new prompt: {e}")
|
||||
return "added"
|
||||
|
||||
@@ -86,6 +86,23 @@ def unavailable_message(model_name: str) -> str:
|
||||
return f"{model_name} is not downloaded. {UNAVAILABLE_HINT}"
|
||||
|
||||
|
||||
def model_refusal(status: dict, model_type: str, model_name: str):
|
||||
"""Why *model_type* cannot run, from ``get_models_status()``, else None.
|
||||
|
||||
Not downloaded first (``unavailable_message``), then a backend package
|
||||
that cannot be imported (#424). Both are a 409 ``model-unavailable``.
|
||||
"""
|
||||
entry = status.get(model_type) or {}
|
||||
if not entry.get("downloaded"):
|
||||
return unavailable_message(model_name)
|
||||
missing = entry.get("missing_packages") or []
|
||||
if missing:
|
||||
from ..autotag import missing_packages_message
|
||||
|
||||
return missing_packages_message(model_type, [str(name) for name in missing])
|
||||
return None
|
||||
|
||||
|
||||
def run_suggestions(service, capture_path: str, model_type: str, options: dict):
|
||||
"""Blocking: load (or swap to) *model_type*, then suggest for the capture.
|
||||
|
||||
|
||||
@@ -21,9 +21,9 @@ from aiohttp import web
|
||||
from .annotation_suggest import (
|
||||
SUGGEST_FIELDS,
|
||||
OptionError,
|
||||
model_refusal,
|
||||
run_suggestions,
|
||||
suggestion_rows,
|
||||
unavailable_message,
|
||||
validate_options,
|
||||
)
|
||||
from .autotag_routes import model_busy_response
|
||||
@@ -595,7 +595,7 @@ class AnnotationRoutesMixin:
|
||||
it, including a legacy batch), never downloads (``model-unavailable``)
|
||||
and never writes.
|
||||
"""
|
||||
from ..autotag import MODELS, get_autotag_service
|
||||
from ..autotag import MODELS, MissingBackendPackages, get_autotag_service
|
||||
from ..config import MAX_SUGGESTION_LIMIT, AutotagConfig
|
||||
|
||||
data, error = await self._annotation_body(request, SUGGEST_FIELDS)
|
||||
@@ -633,9 +633,10 @@ class AnnotationRoutesMixin:
|
||||
return error
|
||||
service = get_autotag_service()
|
||||
status = await self._run_in_executor(service.get_models_status)
|
||||
if not status.get(model_type, {}).get("downloaded"):
|
||||
name = MODELS.get(model_type, {}).get("name", model_type)
|
||||
return _coded("model-unavailable", unavailable_message(name))
|
||||
name = MODELS.get(model_type, {}).get("name", model_type)
|
||||
refusal = model_refusal(status, model_type, name)
|
||||
if refusal is not None:
|
||||
return _coded("model-unavailable", refusal)
|
||||
owner = model_gate.new_owner("image-annotations")
|
||||
if not model_gate.try_acquire(owner):
|
||||
return model_busy_response()
|
||||
@@ -654,6 +655,9 @@ class AnnotationRoutesMixin:
|
||||
options,
|
||||
on_skipped=functools.partial(store.release_use, token),
|
||||
)
|
||||
except MissingBackendPackages as exc:
|
||||
# A package gone since the status read (load_model logged it).
|
||||
return _coded("model-unavailable", str(exc))
|
||||
except Exception:
|
||||
self.logger.exception("Tag suggestion failed")
|
||||
return _error(
|
||||
|
||||
@@ -102,6 +102,17 @@ def size_label(size: str) -> str:
|
||||
return f"{match.group(1)} {match.group(2)}" if match else str(size or "")
|
||||
|
||||
|
||||
def package_entry(flags: dict) -> dict:
|
||||
"""The public package fields of one status entry (#424): the pip names
|
||||
that cannot be imported, the install command and note (strings or null)."""
|
||||
missing = flags.get("missing_packages") or []
|
||||
text = {
|
||||
key: flags[key] if isinstance(flags.get(key), str) else None
|
||||
for key in ("install_command", "install_note")
|
||||
}
|
||||
return {"missing_packages": [str(name) for name in missing], **text}
|
||||
|
||||
|
||||
def public_models(status: dict, loaded_type: Optional[str]) -> dict:
|
||||
"""Each engine model's public entry: names and sizes from ``MODELS``, the
|
||||
flags from ``status``; never a path (``model_path`` is dropped)."""
|
||||
@@ -122,6 +133,7 @@ def public_models(status: dict, loaded_type: Optional[str]) -> dict:
|
||||
for key in ("model_exists", "mmproj_exists"):
|
||||
if key in flags:
|
||||
entry[key] = bool(flags[key])
|
||||
entry.update(package_entry(flags))
|
||||
models[model_type] = entry
|
||||
return models
|
||||
|
||||
@@ -750,6 +762,7 @@ class AutotagRoutesMixin:
|
||||
"downloaded": bool(
|
||||
(status.get(model_type) or {}).get("downloaded")
|
||||
),
|
||||
**package_entry(status.get(model_type) or {}),
|
||||
},
|
||||
"existing": existing,
|
||||
"review_required": existing == "replace_after_review",
|
||||
@@ -1073,8 +1086,8 @@ class AutotagRoutesMixin:
|
||||
return self.db.current_image_tags({locator: fingerprint}).get(locator, [])
|
||||
|
||||
async def _suggest_single(self, target, model_type, options, base):
|
||||
from ..autotag import MODELS, get_autotag_service
|
||||
from .annotation_suggest import unavailable_message
|
||||
from ..autotag import MODELS, MissingBackendPackages, get_autotag_service
|
||||
from .annotation_suggest import model_refusal
|
||||
from .annotations import _pinned_suggestions, _settled, _submit
|
||||
|
||||
try:
|
||||
@@ -1084,8 +1097,8 @@ class AutotagRoutesMixin:
|
||||
|
||||
service = get_autotag_service()
|
||||
status = await self._run_in_executor(service.get_models_status)
|
||||
if not (status.get(model_type) or {}).get("downloaded"):
|
||||
message = unavailable_message(MODELS[model_type]["name"])
|
||||
message = model_refusal(status, model_type, MODELS[model_type]["name"])
|
||||
if message is not None:
|
||||
return self._coded_error(409, "model-unavailable", message)
|
||||
owner = model_gate.new_owner("legacy-single")
|
||||
if not model_gate.try_acquire(owner):
|
||||
@@ -1131,6 +1144,9 @@ class AutotagRoutesMixin:
|
||||
options,
|
||||
on_skipped=functools.partial(store.release_use, capture.token),
|
||||
)
|
||||
except MissingBackendPackages as exc:
|
||||
# A package gone since the status read (load_model logged it).
|
||||
return self._coded_error(409, "model-unavailable", str(exc))
|
||||
finally:
|
||||
store.release(capture.token)
|
||||
finally:
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
"""Collections (#331): named, hand-picked groups of images.
|
||||
|
||||
Routes under ``/prompt_manager/collections``. A file is named by its listing
|
||||
identity ``{root_index, root_scope, path}`` (``media_target.py``) and stored
|
||||
under the favorites' keys (``_favorite_root_key``, ``_favorite_relative_key``),
|
||||
which never leave the server. A stale scope (the sources were reordered) is a
|
||||
409 ``source-changed`` before anything is written. Responses are
|
||||
``{"success": bool, ...}``; an unexpected failure is a logged 500 with a
|
||||
generic message, never the exception text.
|
||||
"""
|
||||
|
||||
import hmac
|
||||
import re
|
||||
from functools import wraps
|
||||
from typing import Callable
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from .images import (
|
||||
_favorite_relative_key,
|
||||
_favorite_root_key,
|
||||
_forbidden,
|
||||
_json_body_or_error,
|
||||
collection_not_found,
|
||||
)
|
||||
from .media_target import media_target
|
||||
from .prompts import bad_request
|
||||
|
||||
try:
|
||||
from ...database.image_collections import (
|
||||
CollectionFilesMissing,
|
||||
CollectionInvalid,
|
||||
CollectionMissing,
|
||||
CollectionNameTaken,
|
||||
normalize_collection_name,
|
||||
)
|
||||
from ...database.image_favorites import MAX_KEY_LENGTH
|
||||
except ImportError:
|
||||
from database.image_collections import (
|
||||
CollectionFilesMissing,
|
||||
CollectionInvalid,
|
||||
CollectionMissing,
|
||||
CollectionNameTaken,
|
||||
normalize_collection_name,
|
||||
)
|
||||
from database.image_favorites import MAX_KEY_LENGTH
|
||||
|
||||
BASE = "/prompt_manager/collections"
|
||||
# The most files one add or remove names (the bulk tagging cap, MC12).
|
||||
MAX_ITEMS = 500
|
||||
MAX_ID = 999_999_999
|
||||
_PATH_ID = re.compile(r"[1-9][0-9]{0,8}")
|
||||
_ROOT_INDEX = re.compile(r"[0-9]{1,9}")
|
||||
_ADD_TARGETS = (frozenset({"collection_id", "items"}), frozenset({"name", "items"}))
|
||||
_REMOVE_FIELDS = frozenset({"collection_id", "items"})
|
||||
_RENAME_FIELDS = frozenset({"name"})
|
||||
_MEDIA_MESSAGE = "Only media files can be added to a collection"
|
||||
READ_FAILED = "Collections could not be read."
|
||||
CHANGE_FAILED = "The collection could not be changed."
|
||||
SOURCES_CHANGED = "The sources changed. Refresh the images and try again."
|
||||
FILES_NOT_FOUND = (
|
||||
"None of the selected images could be found. Refresh the images and try again."
|
||||
)
|
||||
NAME_TAKEN = "Another collection already has this name."
|
||||
|
||||
|
||||
class _Refused(Exception):
|
||||
"""A request refused before any write; carries its response."""
|
||||
|
||||
def __init__(self, response: web.Response):
|
||||
super().__init__(response.status)
|
||||
self.response = response
|
||||
|
||||
|
||||
def _coded(status: int, code: str, message: str, **extra) -> web.Response:
|
||||
return web.json_response(
|
||||
{"success": False, "code": code, "error": message, **extra},
|
||||
status=status,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
def _collection_response(failure: str) -> Callable:
|
||||
"""Map the database's refusals to their status; anything else to a 500."""
|
||||
|
||||
def decorate(handler: Callable) -> Callable:
|
||||
@wraps(handler)
|
||||
async def wrapped(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
return await handler(self, request)
|
||||
except _Refused as refused:
|
||||
return refused.response
|
||||
except CollectionInvalid as exc:
|
||||
return bad_request(str(exc))
|
||||
except CollectionMissing:
|
||||
return collection_not_found()
|
||||
except CollectionFilesMissing:
|
||||
return _coded(404, "files-not-found", FILES_NOT_FOUND)
|
||||
except CollectionNameTaken as exc:
|
||||
return _coded(
|
||||
409, "name-taken", NAME_TAKEN, collection_id=exc.collection_id
|
||||
)
|
||||
except Exception:
|
||||
self.logger.exception("Collection request failed")
|
||||
return web.json_response(
|
||||
{"success": False, "error": failure}, status=500
|
||||
)
|
||||
|
||||
return wrapped
|
||||
|
||||
return decorate
|
||||
|
||||
|
||||
def _path_id(request: web.Request) -> int:
|
||||
raw = request.match_info["collection_id"]
|
||||
if not raw.isascii() or _PATH_ID.fullmatch(raw) is None:
|
||||
raise _Refused(bad_request("Invalid collection id"))
|
||||
return int(raw)
|
||||
|
||||
|
||||
def _body_id(value: object) -> int:
|
||||
if type(value) is not int or not 1 <= value <= MAX_ID:
|
||||
raise _Refused(bad_request("Invalid collection id"))
|
||||
return value
|
||||
|
||||
|
||||
async def _body(request: web.Request, allowed: tuple) -> dict:
|
||||
data, error = await _json_body_or_error(request)
|
||||
if error is not None:
|
||||
raise _Refused(error)
|
||||
if frozenset(data) not in allowed:
|
||||
names = " or ".join(", ".join(sorted(fields)) for fields in allowed)
|
||||
raise _Refused(bad_request(f"Request must contain exactly {names}"))
|
||||
return data
|
||||
|
||||
|
||||
class CollectionRoutesMixin:
|
||||
"""Mixin providing the collection routes (#331)."""
|
||||
|
||||
def _register_collection_routes(self, routes: web.RouteTableDef) -> None:
|
||||
# The fixed paths go first: "/items" must never match "/{collection_id}".
|
||||
routes.get(BASE)(self.list_collections)
|
||||
routes.post(BASE + "/items")(self.add_collection_items)
|
||||
routes.delete(BASE + "/items")(self.remove_collection_items)
|
||||
routes.get(BASE + "/membership")(self.collection_membership)
|
||||
routes.patch(BASE + "/{collection_id}")(self.rename_collection)
|
||||
routes.delete(BASE + "/{collection_id}")(self.delete_collection)
|
||||
|
||||
# -- reads -------------------------------------------------------------
|
||||
|
||||
@_collection_response(READ_FAILED)
|
||||
async def list_collections(self, request: web.Request) -> web.Response:
|
||||
"""Every collection with its listed ``count`` and its ``missing`` rows.
|
||||
|
||||
``count`` is the members listed under every configured source (one
|
||||
primary-key range read per source, matched against its cached
|
||||
listing); ``missing`` the stored rows beyond it. When a scan or a read
|
||||
fails both are null for every collection and the names still return.
|
||||
"""
|
||||
collections = await self._run_in_executor(self.db.list_collections)
|
||||
counts = None
|
||||
if collections:
|
||||
try:
|
||||
counts = await self._collection_counts()
|
||||
except Exception:
|
||||
self.logger.warning(
|
||||
"Collection counts could not be read", exc_info=True
|
||||
)
|
||||
rows = []
|
||||
for collection in collections:
|
||||
listed, stored = (None, None) if counts is None else counts
|
||||
count = None if listed is None else listed.get(collection["id"], 0)
|
||||
stored_rows = None if stored is None else stored.get(collection["id"], 0)
|
||||
rows.append(
|
||||
{
|
||||
**collection,
|
||||
"count": count,
|
||||
# Clamped: never below zero, whatever the snapshot saw.
|
||||
"missing": None if count is None else max(0, stored_rows - count),
|
||||
}
|
||||
)
|
||||
return web.json_response({"success": True, "collections": rows})
|
||||
|
||||
async def _collection_counts(self) -> tuple[dict, dict]:
|
||||
"""``({id: listed members}, {id: stored rows})`` over every source.
|
||||
|
||||
The scans come from the gallery cache; the root keys (realpath), the
|
||||
listings and one read snapshot of every member and stored count run
|
||||
in the executor, so a write between two reads cannot skew them.
|
||||
"""
|
||||
roots = list(self._get_all_output_dirs())
|
||||
root_keys = await self._run_in_executor(
|
||||
lambda: [_favorite_root_key(root) for root in roots]
|
||||
)
|
||||
distinct = {} # one folder configured twice counts once
|
||||
for root, root_key in zip(roots, root_keys):
|
||||
distinct.setdefault(root_key, root)
|
||||
scans = {
|
||||
root_key: await self._get_gallery_files(root)
|
||||
for root_key, root in distinct.items()
|
||||
}
|
||||
return await self._run_in_executor(self._count_members, distinct, scans)
|
||||
|
||||
def _count_members(self, roots: dict, scans: dict) -> tuple[dict, dict]:
|
||||
"""Blocking: match one snapshot's members against each listing."""
|
||||
members, stored = self.db.collection_count_snapshot(list(roots))
|
||||
listed: dict[int, int] = {}
|
||||
for root_key, root in roots.items():
|
||||
listing = self._favorite_listing(root, scans[root_key])
|
||||
for collection_id, keys in members.get(root_key, {}).items():
|
||||
found = sum(1 for key in keys if key in listing)
|
||||
listed[collection_id] = listed.get(collection_id, 0) + found
|
||||
return listed, stored
|
||||
|
||||
@_collection_response(READ_FAILED)
|
||||
async def collection_membership(self, request: web.Request) -> web.Response:
|
||||
"""GET ``?root_index&root_scope&path``: the ids holding that file."""
|
||||
query = request.query
|
||||
if len(query) != 3 or set(query) != {"root_index", "root_scope", "path"}:
|
||||
# len() counts a repeated key (?path=a&path=b) twice.
|
||||
return bad_request("Query must name root_index, root_scope and path once")
|
||||
raw_index = query["root_index"]
|
||||
if not raw_index.isascii() or _ROOT_INDEX.fullmatch(raw_index) is None:
|
||||
return bad_request("Invalid gallery root")
|
||||
roots = list(self._get_all_output_dirs())
|
||||
target = {
|
||||
"root_index": int(raw_index),
|
||||
"root_scope": query["root_scope"],
|
||||
"path": query["path"],
|
||||
}
|
||||
root_key, key = await self._run_in_executor(
|
||||
self._collection_key, target, roots, {}
|
||||
)
|
||||
ids = await self._run_in_executor(self.db.file_collections, root_key, key)
|
||||
return web.json_response({"success": True, "collection_ids": ids})
|
||||
|
||||
# -- writes ------------------------------------------------------------
|
||||
|
||||
@_collection_response(CHANGE_FAILED)
|
||||
async def add_collection_items(self, request: web.Request) -> web.Response:
|
||||
"""POST ``{collection_id | name, items}``: add the listed files.
|
||||
|
||||
Only files the source's listing holds are added; the rest count in
|
||||
``not_found``. A new name with nothing to add creates nothing (404
|
||||
``files-not-found``).
|
||||
"""
|
||||
data = await _body(request, _ADD_TARGETS)
|
||||
target = {}
|
||||
if "name" in data:
|
||||
target["name"] = normalize_collection_name(data["name"])
|
||||
else:
|
||||
target["collection_id"] = _body_id(data["collection_id"])
|
||||
roots = list(self._get_all_output_dirs())
|
||||
keys = await self._run_in_executor(self._collection_items, data["items"], roots)
|
||||
found, not_found = [], 0
|
||||
listings = {}
|
||||
for index, root_key, key in keys:
|
||||
if index not in listings:
|
||||
root = roots[index]
|
||||
files = await self._get_gallery_files(root)
|
||||
listings[index] = await self._run_in_executor(
|
||||
self._favorite_listing, root, files
|
||||
)
|
||||
if key in listings[index]:
|
||||
found.append((root_key, key))
|
||||
else:
|
||||
not_found += 1
|
||||
result = await self._run_in_executor(
|
||||
lambda: self.db.add_to_collection(items=found, **target)
|
||||
)
|
||||
return web.json_response({"success": True, **result, "not_found": not_found})
|
||||
|
||||
@_collection_response(CHANGE_FAILED)
|
||||
async def remove_collection_items(self, request: web.Request) -> web.Response:
|
||||
"""DELETE ``{collection_id, items}``: listed or not, every named file."""
|
||||
data = await _body(request, (_REMOVE_FIELDS,))
|
||||
collection_id = _body_id(data["collection_id"])
|
||||
roots = list(self._get_all_output_dirs())
|
||||
keys = await self._run_in_executor(self._collection_items, data["items"], roots)
|
||||
result = await self._run_in_executor(
|
||||
self.db.remove_from_collection,
|
||||
collection_id,
|
||||
[(root_key, key) for _index, root_key, key in keys],
|
||||
)
|
||||
return web.json_response({"success": True, **result})
|
||||
|
||||
@_collection_response(CHANGE_FAILED)
|
||||
async def rename_collection(self, request: web.Request) -> web.Response:
|
||||
"""PATCH ``{name}``: a case variant of its own name is allowed."""
|
||||
collection_id = _path_id(request)
|
||||
data = await _body(request, (_RENAME_FIELDS,))
|
||||
name = normalize_collection_name(data["name"])
|
||||
collection = await self._run_in_executor(
|
||||
self.db.rename_collection, collection_id, name
|
||||
)
|
||||
return web.json_response({"success": True, "collection": collection})
|
||||
|
||||
@_collection_response(CHANGE_FAILED)
|
||||
async def delete_collection(self, request: web.Request) -> web.Response:
|
||||
"""DELETE: the collection and its members; files are never touched."""
|
||||
collection_id = _path_id(request)
|
||||
await self._run_in_executor(self.db.delete_collection, collection_id)
|
||||
return web.json_response({"success": True, "deleted": True})
|
||||
|
||||
# -- request steps -----------------------------------------------------
|
||||
|
||||
def _collection_items(self, items: object, roots: list) -> list[tuple]:
|
||||
"""``(index, root key, relative key)`` per distinct file, in order.
|
||||
|
||||
Blocking (realpath per root, once per request): run in the executor.
|
||||
Every item is checked before anything is written: 400 for a malformed
|
||||
shape, 403 for an unsafe path, 409 for a stale scope. Two spellings
|
||||
that fold to one key (Windows) count once.
|
||||
"""
|
||||
if not isinstance(items, list) or not 1 <= len(items) <= MAX_ITEMS:
|
||||
raise _Refused(bad_request(f"items must list 1 to {MAX_ITEMS} images"))
|
||||
keys, sources = {}, {}
|
||||
for item in items:
|
||||
index = item.get("root_index") if isinstance(item, dict) else None
|
||||
root_key, key = self._collection_key(item, roots, sources)
|
||||
keys.setdefault((root_key, key), index)
|
||||
return [(index, root_key, key) for (root_key, key), index in keys.items()]
|
||||
|
||||
def _collection_key(
|
||||
self, item: object, roots: list, sources: dict
|
||||
) -> tuple[str, str]:
|
||||
"""The stored keys of one target, or raise its refusal (blocking).
|
||||
|
||||
*sources* caches ``{index: (scope, root key)}`` for one request, so
|
||||
each root's realpath runs once however many items name it.
|
||||
"""
|
||||
target, error = media_target(item, media_message=_MEDIA_MESSAGE)
|
||||
if error is not None:
|
||||
raise _Refused(error)
|
||||
index, scope, relative = target
|
||||
if index >= len(roots):
|
||||
raise _Refused(_coded(409, "source-changed", SOURCES_CHANGED))
|
||||
if index not in sources:
|
||||
sources[index] = (
|
||||
self._gallery_root_scope(roots[index], index),
|
||||
_favorite_root_key(roots[index]),
|
||||
)
|
||||
current, root_key = sources[index]
|
||||
if not hmac.compare_digest(scope, current):
|
||||
raise _Refused(_coded(409, "source-changed", SOURCES_CHANGED))
|
||||
key = _favorite_relative_key(relative)
|
||||
if max(len(root_key), len(key)) > MAX_KEY_LENGTH:
|
||||
# A stored key past the schema bound would fail the next restore.
|
||||
raise _Refused(_forbidden("Path is too long to add to a collection"))
|
||||
return root_key, key
|
||||
@@ -588,7 +588,9 @@ class DuplicateFilesMixin:
|
||||
job.update(total=len(targets), status_line="Removing duplicate files")
|
||||
spellings = await self._run_in_executor(self._source_spellings)
|
||||
removed, failure, keepers = 0, None, {}
|
||||
totals = dict.fromkeys(("moved", "merged", "favorites", "tags", "review"), 0)
|
||||
totals = dict.fromkeys(
|
||||
("moved", "merged", "favorites", "tags", "review", "collections"), 0
|
||||
)
|
||||
totals["categories"] = 0 # image categories kept on the kept copies (#332)
|
||||
for done, target in enumerate(targets, start=1):
|
||||
if failure is not None:
|
||||
@@ -632,6 +634,11 @@ class DuplicateFilesMixin:
|
||||
job.log_line(
|
||||
f"Moved {_plural(totals['favorites'], 'favorite')} to the kept copies"
|
||||
)
|
||||
if totals["collections"]:
|
||||
job.log_line(
|
||||
f"Moved {_plural(totals['collections'], 'collection membership')} "
|
||||
"to the kept copies"
|
||||
)
|
||||
if totals["tags"]:
|
||||
job.log_line(
|
||||
f"Added {_plural(totals['tags'], 'image tag')} to the kept copies"
|
||||
|
||||
@@ -104,7 +104,8 @@ def copy_move(target, kept_stat, spellings):
|
||||
|
||||
A record found under a folder spelling moves to the kept copy under
|
||||
the same spelling when that folder holds it too, else to its real
|
||||
path. Its favorite moves under every listed root holding the file.
|
||||
path. Its favorite and its collection memberships move under every
|
||||
listed root holding the file.
|
||||
"""
|
||||
removed = spellings.of(target["abs"])
|
||||
kept = spellings.of(target["keeper_abs"])
|
||||
|
||||
@@ -42,7 +42,7 @@ from .. import model_gate
|
||||
from .admin import _plural, _time_left
|
||||
from .annotation_suggest import (
|
||||
OptionError,
|
||||
unavailable_message,
|
||||
model_refusal,
|
||||
validate_options,
|
||||
)
|
||||
from .annotations import (
|
||||
@@ -58,7 +58,9 @@ from .annotations import (
|
||||
from .autotag_routes import BATCH_CANCELLED, MODEL_BUSY_ERROR, model_busy_response
|
||||
from .images import (
|
||||
MAX_LISTING_SELECTION,
|
||||
CollectionMissing,
|
||||
ListingSourceChanged,
|
||||
collection_not_found,
|
||||
_current_fingerprint,
|
||||
_listing_posix,
|
||||
_public_path,
|
||||
@@ -510,10 +512,8 @@ class ImageTagJobsMixin:
|
||||
)
|
||||
)
|
||||
label = MODELS[model_type]["label"]
|
||||
if not (status.get(model_type) or {}).get("downloaded"):
|
||||
message = (
|
||||
f"{label} is not downloaded. Download it in System › Auto-tagging."
|
||||
)
|
||||
message = model_refusal(status, model_type, label)
|
||||
if message is not None:
|
||||
return None, _refusal(409, "model-unavailable", message)
|
||||
if backup_on and short is not None:
|
||||
return None, _refusal(507, "insufficient-space", short_space_message(short))
|
||||
@@ -689,6 +689,8 @@ class ImageTagJobsMixin:
|
||||
selection = await self.resolve_listing_filter(spec.filter, cap)
|
||||
except ListingSourceChanged:
|
||||
return None, _coded("source-changed")
|
||||
except CollectionMissing:
|
||||
return None, collection_not_found()
|
||||
except ValueError as exc:
|
||||
return None, bad_request(str(exc))
|
||||
except Exception:
|
||||
@@ -788,13 +790,13 @@ class ImageTagJobsMixin:
|
||||
|
||||
async def _prepare_tagging_model(self, job, spec: _Spec, owner):
|
||||
"""The loaded service, or None (logged) when the job cannot run."""
|
||||
from ..autotag import MODELS, get_autotag_service
|
||||
from ..autotag import MODELS, MissingBackendPackages, get_autotag_service
|
||||
|
||||
service = get_autotag_service()
|
||||
status = await self._run_in_executor(service.get_models_status)
|
||||
if not status.get(spec.model_type, {}).get("downloaded"):
|
||||
name = MODELS.get(spec.model_type, {}).get("name", spec.model_type)
|
||||
message = unavailable_message(name)
|
||||
name = MODELS.get(spec.model_type, {}).get("name", spec.model_type)
|
||||
message = model_refusal(status, spec.model_type, name)
|
||||
if message is not None:
|
||||
job.update(status_line=message)
|
||||
job.log_line(f"! {message}")
|
||||
return None
|
||||
@@ -806,6 +808,11 @@ class ImageTagJobsMixin:
|
||||
spec.model_type,
|
||||
spec.options["use_gpu"],
|
||||
)
|
||||
except MissingBackendPackages as exc:
|
||||
# A package gone since the status read (load_model logged it).
|
||||
job.update(status_line=str(exc))
|
||||
job.log_line(f"! {exc}")
|
||||
return None
|
||||
except Exception:
|
||||
self.logger.exception("The auto-tag job could not load its model")
|
||||
job.update(status_line=INFERENCE_FAILED)
|
||||
|
||||
+110
-43
@@ -18,7 +18,7 @@ import urllib.parse
|
||||
from bisect import bisect_left, bisect_right, insort
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from typing import Callable, NamedTuple
|
||||
from typing import Callable, Iterable, NamedTuple
|
||||
|
||||
from aiohttp import web
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
@@ -58,6 +58,7 @@ from .inspection_service import (
|
||||
)
|
||||
|
||||
try:
|
||||
from ...database.image_collections import CollectionMissing
|
||||
from ...database.image_favorites import MAX_KEY_LENGTH
|
||||
from ...database.library import display_title
|
||||
from ...database.models import normalize_image_path
|
||||
@@ -81,6 +82,7 @@ try:
|
||||
reveal_in_file_manager,
|
||||
)
|
||||
except ImportError:
|
||||
from database.image_collections import CollectionMissing
|
||||
from database.image_favorites import MAX_KEY_LENGTH
|
||||
from database.library import display_title
|
||||
from database.models import normalize_image_path
|
||||
@@ -110,6 +112,21 @@ IMAGE_EXTENSIONS = _IMAGE_EXTENSIONS
|
||||
_CARD_RECORDS = ("prompt", "workflow")
|
||||
_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
||||
_LISTING_FAILED = "The image list could not be filtered. Check server logs for details."
|
||||
_COLLECTION_ID = re.compile(r"[1-9][0-9]{0,8}")
|
||||
COLLECTION_NOT_FOUND = "That collection no longer exists."
|
||||
|
||||
|
||||
def collection_not_found() -> web.Response:
|
||||
"""The 404 a write or a listing answers for an unknown collection (#331)."""
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"code": "collection-not-found",
|
||||
"error": COLLECTION_NOT_FOUND,
|
||||
},
|
||||
status=404,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
def _card_header(path: Path) -> tuple:
|
||||
@@ -1110,8 +1127,9 @@ def _workspace_filters(query: Mapping) -> dict:
|
||||
the linked positive's text, title and tag names. ``category`` is exact,
|
||||
``tags`` is a comma list that must all be present, ``sort=rating`` ranks by
|
||||
the linked positive, and ``favorites=1`` keeps only favorites (the
|
||||
Favorites page, #249 D51). Raises ValueError for any unknown or oversized
|
||||
value.
|
||||
Favorites page, #249 D51). ``collection=<id>`` (1 to 9 digits) keeps only
|
||||
that collection's members (#331); it wins over ``favorites`` (a page never
|
||||
sets both). Raises ValueError for any unknown or oversized value.
|
||||
"""
|
||||
text = query.get("q", "").strip()
|
||||
folded = text.casefold()
|
||||
@@ -1125,8 +1143,10 @@ def _workspace_filters(query: Mapping) -> dict:
|
||||
{tag.strip() for tag in query.get("tags", "").split(",") if tag.strip()}
|
||||
)
|
||||
favorites = query.get("favorites", "0")
|
||||
collection = query.get("collection", "")
|
||||
if (
|
||||
favorites not in ("0", "1")
|
||||
or (collection != "" and _COLLECTION_ID.fullmatch(collection) is None)
|
||||
or len(text) > 500
|
||||
or len(folded) > 500
|
||||
or media not in ("all", "image", "video")
|
||||
@@ -1149,7 +1169,8 @@ def _workspace_filters(query: Mapping) -> dict:
|
||||
"path": exact.casefold(),
|
||||
"category": category,
|
||||
"tags": tags,
|
||||
"favorites": favorites == "1",
|
||||
"favorites": favorites == "1" and not collection,
|
||||
"collection": int(collection) if collection else None,
|
||||
}
|
||||
|
||||
|
||||
@@ -1167,6 +1188,8 @@ LISTING_FILTER_KEYS = frozenset(
|
||||
"media",
|
||||
"sort",
|
||||
"favorites",
|
||||
# A collection page's filter (#331): bulk actions act on its members.
|
||||
"collection",
|
||||
}
|
||||
)
|
||||
MAX_LISTING_FILTER_VALUE = 2048
|
||||
@@ -1223,7 +1246,7 @@ def _admitted_entries(
|
||||
entries: list,
|
||||
filters: dict,
|
||||
links: dict | None,
|
||||
favorites: frozenset | None = None,
|
||||
members: frozenset | None = None,
|
||||
image_tags: dict | None = None,
|
||||
own_categories: dict | None = None,
|
||||
):
|
||||
@@ -1236,11 +1259,11 @@ def _admitted_entries(
|
||||
admits a file whose effective category equals it (#332 MC5): its own
|
||||
category (*own_categories*, ``{normalized path: category}``) when it has one,
|
||||
else the inherited one, so an unlinked file with its own category is
|
||||
admitted. *favorites*, when given, admits only those listed
|
||||
paths (``favorites=1``). *image_tags* maps normalized paths to their
|
||||
_ImageTagMatch: a tag filter also admits a file whose current image tags
|
||||
(with its linked positive's tags) carry every tag, and ``q`` also admits an
|
||||
image tag name hit.
|
||||
admitted. *members*, when given, admits only those listed paths (the
|
||||
favorites of ``favorites=1`` or a ``collection``'s members). *image_tags*
|
||||
maps normalized paths to their _ImageTagMatch: a tag filter also admits a
|
||||
file whose current image tags (with its linked positive's tags) carry every
|
||||
tag, and ``q`` also admits an image tag name hit.
|
||||
"""
|
||||
query, media, folder = filters["query"], filters["media"], filters["folder"]
|
||||
category = filters["category"]
|
||||
@@ -1258,7 +1281,7 @@ def _admitted_entries(
|
||||
continue
|
||||
if media != "all" and (media == "video") != entry.video:
|
||||
continue
|
||||
if favorites is not None and entry.path not in favorites:
|
||||
if members is not None and entry.path not in members:
|
||||
continue
|
||||
match = None if links is None else links.get(entry.normalized)
|
||||
image = None if image_tags is None else image_tags.get(entry.normalized)
|
||||
@@ -1286,7 +1309,7 @@ def _filtered_page(
|
||||
size: int,
|
||||
filters: dict,
|
||||
links: dict | None,
|
||||
favorites: frozenset | None = None,
|
||||
members: frozenset | None = None,
|
||||
image_tags: dict | None = None,
|
||||
own_categories: dict | None = None,
|
||||
) -> tuple[int, list, int | None]:
|
||||
@@ -1298,7 +1321,7 @@ def _filtered_page(
|
||||
"""
|
||||
total, page, positives = 0, [], set()
|
||||
for position, entry, match in _admitted_entries(
|
||||
entries, filters, links, favorites, image_tags, own_categories
|
||||
entries, filters, links, members, image_tags, own_categories
|
||||
):
|
||||
total += 1
|
||||
if match is not None:
|
||||
@@ -2002,6 +2025,7 @@ class ImageRoutesMixin:
|
||||
filters["category"],
|
||||
filters["tags"],
|
||||
filters["favorites"],
|
||||
filters["collection"],
|
||||
]
|
||||
).encode()
|
||||
).hexdigest()
|
||||
@@ -2012,17 +2036,19 @@ class ImageRoutesMixin:
|
||||
except ValueError as exc:
|
||||
return bad_request(str(exc))
|
||||
|
||||
listings = [
|
||||
(index, root, await self._get_gallery_files(root))
|
||||
for index, root in enumerate(output_dirs)
|
||||
if root_index is None or index == root_index
|
||||
]
|
||||
try:
|
||||
if filters["collection"] is not None and not await self._run_in_executor(
|
||||
self.db.collection_exists, filters["collection"]
|
||||
):
|
||||
raise CollectionMissing("No such collection")
|
||||
listings = await self._workspace_listings(output_dirs, root_index)
|
||||
page, total, has_more, next_cursor, positive_count = (
|
||||
await self._run_in_executor(
|
||||
self._select_workspace_page, listings, filters, after, limit, scope
|
||||
)
|
||||
)
|
||||
except CollectionMissing:
|
||||
return collection_not_found()
|
||||
except Exception:
|
||||
# A filter that needs the prompt links or the image tags cannot be
|
||||
# served without them. Never echo the exception: it can carry SQL
|
||||
@@ -2282,9 +2308,11 @@ class ImageRoutesMixin:
|
||||
) -> tuple:
|
||||
"""The ordered entries and what admits them (blocking; run in executor).
|
||||
|
||||
Returns ``(entries, key, links, image_tags, favorites, own)``: the
|
||||
Returns ``(entries, key, links, image_tags, members, own)``: the
|
||||
listing in the filters' order, its keyset key, and the link, image-tag,
|
||||
favorite and own-category maps _admitted_entries reads. One SELECT
|
||||
member and own-category maps _admitted_entries reads (members: a
|
||||
collection's, else the favorites, else None). A collection deleted since
|
||||
the route checked it raises CollectionMissing. One SELECT
|
||||
(get_image_link_matches) reads every linked positive when *read_links*;
|
||||
under a category it keeps only links whose raw recipe or positive
|
||||
category is that value (the inherited one is decided in Python), and
|
||||
@@ -2368,10 +2396,14 @@ class ImageRoutesMixin:
|
||||
def key(entry):
|
||||
return _workspace_key(entry, order)
|
||||
|
||||
favorites = (
|
||||
self._workspace_favorite_paths(listings) if filters["favorites"] else None
|
||||
return (
|
||||
entries,
|
||||
key,
|
||||
links,
|
||||
image_tags,
|
||||
self._workspace_members(listings, filters),
|
||||
own,
|
||||
)
|
||||
return entries, key, links, image_tags, favorites, own
|
||||
|
||||
def _own_category_links(self, links: dict, own: dict, filters: dict) -> dict:
|
||||
"""*links* plus the links of files whose own category is the filter's.
|
||||
@@ -2423,7 +2455,7 @@ class ImageRoutesMixin:
|
||||
or bool(filters["tags"])
|
||||
or order == "rating"
|
||||
)
|
||||
entries, key, links, image_tags, favorites, own = self._workspace_admission(
|
||||
entries, key, links, image_tags, members, own = self._workspace_admission(
|
||||
listings, filters, read_links=needed or after is None
|
||||
)
|
||||
start = 0
|
||||
@@ -2434,7 +2466,7 @@ class ImageRoutesMixin:
|
||||
else:
|
||||
start = bisect_right(entries, after, key=key)
|
||||
total, page, positive_count = _filtered_page(
|
||||
entries, start, limit + 1, filters, links, favorites, image_tags, own
|
||||
entries, start, limit + 1, filters, links, members, image_tags, own
|
||||
)
|
||||
if not needed and after is not None:
|
||||
positive_count = _COUNT_OMITTED
|
||||
@@ -2456,12 +2488,12 @@ class ImageRoutesMixin:
|
||||
|
||||
The links are read strictly: the positives must be the listing's own.
|
||||
"""
|
||||
entries, _key, links, image_tags, favorites, own = self._workspace_admission(
|
||||
entries, _key, links, image_tags, members, own = self._workspace_admission(
|
||||
listings, filters, read_links=True, strict=True
|
||||
)
|
||||
targets, positives, unlinked, scopes = [], {}, 0, {}
|
||||
for _position, entry, match in _admitted_entries(
|
||||
entries, filters, links, favorites, image_tags, own
|
||||
entries, filters, links, members, image_tags, own
|
||||
):
|
||||
if len(targets) == cap:
|
||||
return ListingSelection(targets, list(positives), unlinked, True)
|
||||
@@ -2483,9 +2515,11 @@ class ImageRoutesMixin:
|
||||
) -> ListingSelection:
|
||||
"""The images a parse_listing_filter result admits right now (#392).
|
||||
|
||||
Raises ValueError for a root outside the current roots, and
|
||||
Raises ValueError for a root outside the current roots,
|
||||
ListingSourceChanged when the filter's ``root_scope`` no longer names
|
||||
its root (the sources were renumbered since the listing).
|
||||
its root (the sources were renumbered since the listing), and
|
||||
CollectionMissing when its ``collection`` no longer exists (checked
|
||||
before any scan and again after the members are read).
|
||||
"""
|
||||
output_dirs = list(self._get_all_output_dirs())
|
||||
root_index = _workspace_root(params, output_dirs)
|
||||
@@ -2495,11 +2529,11 @@ class ImageRoutesMixin:
|
||||
raise ListingSourceChanged()
|
||||
raise ValueError("Invalid gallery root scope")
|
||||
filters = _workspace_filters(params)
|
||||
listings = [
|
||||
(index, root, await self._get_gallery_files(root))
|
||||
for index, root in enumerate(output_dirs)
|
||||
if root_index is None or index == root_index
|
||||
]
|
||||
if filters["collection"] is not None and not await self._run_in_executor(
|
||||
self.db.collection_exists, filters["collection"]
|
||||
):
|
||||
raise CollectionMissing("No such collection")
|
||||
listings = await self._workspace_listings(output_dirs, root_index)
|
||||
return await self._run_in_executor(
|
||||
self._select_workspace_matches, listings, filters, cap
|
||||
)
|
||||
@@ -3684,25 +3718,58 @@ class ImageRoutesMixin:
|
||||
self._favorite_listing_cache[cache_key] = (files, listing)
|
||||
return listing
|
||||
|
||||
def _workspace_favorite_paths(self, listings: list[tuple]) -> frozenset:
|
||||
"""The listed paths of every favorite in the selected roots (blocking).
|
||||
def _workspace_members(self, listings: list[tuple], filters: dict):
|
||||
"""The member set the filters ask for, or None (blocking)."""
|
||||
collection = filters.get("collection")
|
||||
if collection is not None:
|
||||
members = self._workspace_member_paths(
|
||||
listings,
|
||||
lambda root_key: self.db.collection_members(collection, root_key),
|
||||
)
|
||||
# Checked after the reads: a collection deleted before or while
|
||||
# they ran is a 404, never an empty page passing for it.
|
||||
if not self.db.collection_exists(collection):
|
||||
raise CollectionMissing("No such collection")
|
||||
return members
|
||||
if filters["favorites"]:
|
||||
return self._workspace_favorite_paths(listings)
|
||||
return None
|
||||
|
||||
One primary-key read per root (get_root_favorites), matched against the
|
||||
root's cached ``{relative key: listing spelling}``, so the work follows
|
||||
the favorites rather than the files, and a key folds case exactly as
|
||||
the toggle stored it (Windows). A favorite whose file is gone matches
|
||||
nothing. A failed read raises: an unfiltered page must never pass for
|
||||
the favorites.
|
||||
def _workspace_favorite_paths(self, listings: list[tuple]) -> frozenset:
|
||||
"""The listed paths of every favorite in the selected roots (blocking)."""
|
||||
return self._workspace_member_paths(listings, self.db.get_root_favorites)
|
||||
|
||||
def _workspace_member_paths(
|
||||
self, listings: list[tuple], keys_for_source: Callable[[str], Iterable[str]]
|
||||
) -> frozenset:
|
||||
"""The listed paths of the stored keys of each selected root (blocking).
|
||||
|
||||
One read per root (``keys_for_source(root key)``: the favorites' or a
|
||||
collection's primary-key range), matched against the root's cached
|
||||
``{relative key: listing spelling}``, so the work follows the members
|
||||
rather than the files, and a key folds case exactly as it was stored
|
||||
(Windows). A member whose file is gone matches nothing. A failed read
|
||||
raises: an unfiltered page must never pass for the members.
|
||||
"""
|
||||
paths = set()
|
||||
for _index, root, files in listings:
|
||||
listing = self._favorite_listing(root, files)
|
||||
for key in self.db.get_root_favorites(_favorite_root_key(root)):
|
||||
for key in keys_for_source(_favorite_root_key(root)):
|
||||
listed = listing.get(key)
|
||||
if listed is not None:
|
||||
paths.add(root / listed)
|
||||
return frozenset(paths)
|
||||
|
||||
async def _workspace_listings(
|
||||
self, output_dirs: list[Path], root_index: int | None
|
||||
) -> list[tuple]:
|
||||
"""``(index, root, scan)`` of every selected root (cached scans)."""
|
||||
return [
|
||||
(index, root, await self._get_gallery_files(root))
|
||||
for index, root in enumerate(output_dirs)
|
||||
if root_index is None or index == root_index
|
||||
]
|
||||
|
||||
def _workspace_favorites(self, page: list[tuple]) -> set[tuple[int, str]]:
|
||||
"""``(root index, listing path)`` of the favorites among *page* entries."""
|
||||
by_root: dict[tuple[int, Path], dict[str, str]] = {}
|
||||
|
||||
@@ -1227,7 +1227,9 @@ class PromptRoutesMixin:
|
||||
"""
|
||||
from .images import (
|
||||
MAX_LISTING_SELECTION,
|
||||
CollectionMissing,
|
||||
ListingSourceChanged,
|
||||
collection_not_found,
|
||||
parse_listing_filter,
|
||||
)
|
||||
|
||||
@@ -1252,6 +1254,8 @@ class PromptRoutesMixin:
|
||||
},
|
||||
status=409,
|
||||
)
|
||||
except CollectionMissing:
|
||||
return collection_not_found()
|
||||
except ValueError as exc:
|
||||
return refused(str(exc))
|
||||
if selection.truncated:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
The rule (maintainer test of #284/#288): a file is queued only when its
|
||||
final outcome is ambiguous and unlinked. The strict resolver must find it
|
||||
``ambiguous``, the scan's legacy fallback (the browser parser,
|
||||
``graph_prompt_text``) must not link it, and no saved row may link it
|
||||
``_graph_prompt_text``) must not link it, and no saved row may link it
|
||||
already. A file the fallback links, an exact pair the ingester stored and an
|
||||
already linked file are never queued. Neither is an ambiguous file with
|
||||
nothing to choose: fewer than two of its candidates carry a prompt
|
||||
|
||||
+121
-7
@@ -7,6 +7,7 @@ Refactored from standalone_tagger.py for integration with PromptManager API.
|
||||
|
||||
import errno
|
||||
import gc
|
||||
import importlib.util
|
||||
import inspect
|
||||
import os
|
||||
import sys
|
||||
@@ -155,6 +156,91 @@ MODELS = {
|
||||
},
|
||||
}
|
||||
|
||||
# The Python modules each backend imports when it loads, with their pip names
|
||||
# (#424). None of them is a PromptManager requirement: they are optional, and
|
||||
# llama-cpp-python needs a build for the user's GPU. Nothing is ever installed.
|
||||
BACKEND_PACKAGES: Dict[str, Tuple[Tuple[str, str], ...]] = {
|
||||
"gguf": (("llama_cpp", "llama-cpp-python"),),
|
||||
# The default 8-bit load needs bitsandbytes, and device_map needs accelerate.
|
||||
"hf": (
|
||||
("torch", "torch"),
|
||||
("transformers", "transformers"),
|
||||
("accelerate", "accelerate"),
|
||||
("bitsandbytes", "bitsandbytes"),
|
||||
),
|
||||
"wd14-swinv2": (("onnxruntime", "onnxruntime"),),
|
||||
"wd14-vit": (("onnxruntime", "onnxruntime"),),
|
||||
}
|
||||
_INSTALL_NOTES = {
|
||||
"gguf": (
|
||||
'For a CUDA build: CMAKE_ARGS="-DGGML_CUDA=on" pip install llama-cpp-python'
|
||||
),
|
||||
"wd14-swinv2": "Use onnxruntime-gpu for CUDA.",
|
||||
"wd14-vit": "Use onnxruntime-gpu for CUDA.",
|
||||
}
|
||||
_PIP_NAMES = {
|
||||
module: pip for packages in BACKEND_PACKAGES.values() for module, pip in packages
|
||||
}
|
||||
|
||||
|
||||
def _importable(module: str) -> bool:
|
||||
"""Whether *module* (a top-level name) can be imported; imports nothing."""
|
||||
if sys.modules.get(module) is not None:
|
||||
return True
|
||||
try:
|
||||
return importlib.util.find_spec(module) is not None
|
||||
except (ImportError, ValueError, OSError):
|
||||
return False
|
||||
|
||||
|
||||
def missing_packages(model_type: str) -> List[str]:
|
||||
"""The pip names of the packages *model_type* needs and cannot import."""
|
||||
return [
|
||||
pip
|
||||
for module, pip in BACKEND_PACKAGES.get(model_type, ())
|
||||
if not _importable(module)
|
||||
]
|
||||
|
||||
|
||||
def package_fields(model_type: str, missing: List[str]) -> Dict[str, Any]:
|
||||
"""The status fields of *missing*: pip names, install command and note."""
|
||||
missing = list(missing)
|
||||
return {
|
||||
"missing_packages": missing,
|
||||
"install_command": "pip install " + " ".join(missing) if missing else None,
|
||||
"install_note": _INSTALL_NOTES.get(model_type) if missing else None,
|
||||
}
|
||||
|
||||
|
||||
def _joined(names: List[str]) -> str:
|
||||
if len(names) <= 1:
|
||||
return "".join(names)
|
||||
return ", ".join(names[:-1]) + " and " + names[-1]
|
||||
|
||||
|
||||
def missing_packages_message(model_type: str, missing: List[str]) -> str:
|
||||
"""The one sentence every refusal of a model with missing packages uses."""
|
||||
label = MODELS.get(model_type, {}).get("label", model_type)
|
||||
fields = package_fields(model_type, missing)
|
||||
pronoun = "it" if len(missing) == 1 else "them"
|
||||
message = (
|
||||
f"{label} needs {_joined(missing)}. Install {pronoun} into ComfyUI's "
|
||||
f"Python: {fields['install_command']}."
|
||||
)
|
||||
if fields["install_note"]:
|
||||
message += " " + fields["install_note"]
|
||||
return message
|
||||
|
||||
|
||||
class MissingBackendPackages(RuntimeError):
|
||||
"""A model whose backend packages cannot be imported (#424)."""
|
||||
|
||||
def __init__(self, model_type: str, missing: List[str]):
|
||||
self.model_type = model_type
|
||||
self.missing = list(missing)
|
||||
super().__init__(missing_packages_message(model_type, self.missing))
|
||||
|
||||
|
||||
# Default prompts
|
||||
DEFAULT_PROMPT = DEFAULT_TAG_TEMPLATE
|
||||
|
||||
@@ -167,6 +253,10 @@ SYSTEM_PROMPT = (
|
||||
)
|
||||
|
||||
|
||||
def _none(_model_type: str) -> List[str]:
|
||||
return []
|
||||
|
||||
|
||||
def _load_rgb(image_path: str) -> Image.Image:
|
||||
"""Decode the image as RGB and close the file before inference.
|
||||
|
||||
@@ -203,6 +293,7 @@ class AutoTagService:
|
||||
self,
|
||||
models_dir: Optional[Path] = None,
|
||||
model_loader: Optional[ModelLoader] = None,
|
||||
package_probe: Optional[Callable[[str], List[str]]] = None,
|
||||
):
|
||||
"""Initialize the AutoTag service.
|
||||
|
||||
@@ -213,9 +304,16 @@ class AutoTagService:
|
||||
``(kind, backend)`` tuple used by ``generate_tags``.
|
||||
Defaults to ``_load_model``, which imports the ML
|
||||
backends lazily. Tests inject a fake here.
|
||||
package_probe: Callable ``(model_type)`` returning the pip names
|
||||
of the backend packages that cannot be imported.
|
||||
Defaults to ``missing_packages`` with the default
|
||||
loader; an injected loader brings its own backend.
|
||||
"""
|
||||
self.logger = get_logger("autotag.service")
|
||||
self._model_loader: ModelLoader = model_loader or self._load_model
|
||||
if package_probe is None:
|
||||
package_probe = missing_packages if model_loader is None else _none
|
||||
self._package_probe = package_probe
|
||||
|
||||
# Determine models directory
|
||||
if models_dir:
|
||||
@@ -320,6 +418,9 @@ class AutoTagService:
|
||||
# Get the actual path (local or cache)
|
||||
model_status["model_path"] = str(self._get_hf_model_path())
|
||||
|
||||
model_status.update(
|
||||
package_fields(model_type, self._package_probe(model_type))
|
||||
)
|
||||
status[model_type] = model_status
|
||||
|
||||
return status
|
||||
@@ -455,13 +556,9 @@ class AutoTagService:
|
||||
|
||||
def _load_wd14_tagger(self, model_type: str, use_gpu: bool = True):
|
||||
"""Load WD14 ONNX model and tag list."""
|
||||
try:
|
||||
import onnxruntime as ort
|
||||
except ImportError:
|
||||
raise RuntimeError(
|
||||
"onnxruntime is required for WD14 models. "
|
||||
"Install with: pip install onnxruntime (or onnxruntime-gpu for CUDA)"
|
||||
)
|
||||
# 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]
|
||||
@@ -694,6 +791,7 @@ class AutoTagService:
|
||||
Raises:
|
||||
ValueError: If model_type is invalid
|
||||
RuntimeError: If model not downloaded or loading fails
|
||||
MissingBackendPackages: If its backend packages cannot be imported
|
||||
"""
|
||||
if model_type not in MODELS:
|
||||
raise ValueError(f"Invalid model type: {model_type}")
|
||||
@@ -705,6 +803,11 @@ class AutoTagService:
|
||||
status = self.get_models_status()
|
||||
if not status[model_type]["downloaded"]:
|
||||
raise RuntimeError(f"Model {model_type} not downloaded")
|
||||
missing = status[model_type].get("missing_packages") or []
|
||||
if missing:
|
||||
refusal = MissingBackendPackages(model_type, missing)
|
||||
self.logger.warning(str(refusal))
|
||||
raise refusal
|
||||
|
||||
try:
|
||||
self._tagger = self._model_loader(model_type, use_gpu)
|
||||
@@ -712,6 +815,17 @@ class AutoTagService:
|
||||
self.logger.info(f"Model {model_type} loaded successfully")
|
||||
return True
|
||||
|
||||
except ModuleNotFoundError as e:
|
||||
# A package find_spec found but that does not import (a broken
|
||||
# install), or a dependency of one: the same refusal, no traceback.
|
||||
self._tagger = None
|
||||
self._current_model_type = None
|
||||
module = (e.name or "").split(".")[0] or str(e)
|
||||
refusal = MissingBackendPackages(
|
||||
model_type, [_PIP_NAMES.get(module, module)]
|
||||
)
|
||||
self.logger.warning(str(refusal))
|
||||
raise refusal from None
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to load model {model_type}: {e}")
|
||||
self._tagger = None
|
||||
|
||||
@@ -187,6 +187,33 @@ export async function mutate(page,{css=null,remove=[],clone=[]}={}) {
|
||||
},token);
|
||||
}
|
||||
|
||||
/**
|
||||
* #332 D146: the category chip names its origin after the category, in a muted `span.chip-source` that never splits
|
||||
* (workspace.css `.chip .chip-source`). The concept's chip shows the bare name (app.js a402), so the recorded rule and
|
||||
* the span are added to the concept page at runtime and the browser lays the chip out: the first `.tags` row's
|
||||
* non-badge chip under `root`. `category` also replaces the chip's text (an own category the concept never shows).
|
||||
* Returns the undo.
|
||||
*/
|
||||
export const D146_CHIP_SOURCE='.chip .chip-source { color: var(--ca-muted); white-space: nowrap; }';
|
||||
export async function withChipSource(page,root,origin,category=null) {
|
||||
const restoreRule=await mutate(page,{css:D146_CHIP_SOURCE});
|
||||
const token=await page.evaluate(({root,origin,category})=>{
|
||||
const chip=document.querySelector(root)?.querySelector('.tags')?.querySelector(':scope > .chip:not(.blue)');
|
||||
if(!chip)throw new Error(`withChipSource: no category chip under ${root}`);
|
||||
const text=chip.textContent,source=document.createElement('span');
|
||||
source.className='chip-source';source.textContent=` · ${origin}`;
|
||||
if(category!==null)chip.textContent=category;
|
||||
chip.append(source);
|
||||
const token=`chip-source-${Math.random().toString(36).slice(2)}`;
|
||||
(window.__chipSources??=new Map()).set(token,()=>{chip.textContent=text;});
|
||||
return token;
|
||||
},{root,origin,category});
|
||||
return async()=>{
|
||||
await page.evaluate(token=>{window.__chipSources.get(token)();window.__chipSources.delete(token);},token);
|
||||
await restoreRule();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Inventory sections: {chrome, heading, toolbar, filterRow, bulkbar, caption, grid, cards, continuation, panel, library,
|
||||
* history, noPageScroll}. A section is null when its element is absent. The Images sections (toolbar … panel, #249 C7)
|
||||
@@ -615,9 +642,12 @@ export function measureInspector(selector) {
|
||||
primary:node.classList.contains('ca-primary'),...box(node),...font(node),...edges(node),...layout(node),
|
||||
minHeight:style.minHeight,minWidth:style.minWidth,icon:iconOf(node)};
|
||||
}
|
||||
// #332 D146: a category chip's visible origin (`span.chip-source`), null on every other chip.
|
||||
function chipOf(node) {
|
||||
const source=node.querySelector(':scope > .chip-source');
|
||||
return {tag:node.tagName.toLowerCase(),className:classes(node),text:textOf(node),hidden:hiddenOf(node),...box(node),...font(node),...edges(node),
|
||||
minHeight:getComputedStyle(node).minHeight};
|
||||
minHeight:getComputedStyle(node).minHeight,
|
||||
source:when(source,()=>({text:source.textContent,...font(source),...css(source,'whiteSpace'),...box(source)}))};
|
||||
}
|
||||
function rowOf(row) {
|
||||
return {...layout(row),...edges(row),...box(row),chips:[...row.children].map(chipOf)};
|
||||
|
||||
@@ -136,7 +136,8 @@ test('panel matches the concept order and copy',async({page,request})=>{
|
||||
await expect(panel.locator('.detail-inner > p.small')).toHaveText('complete.png');
|
||||
const badges=panel.locator('.detail-inner > .tags').first();
|
||||
await expect(badges.locator('span.chip.blue')).toHaveText('Workflow inferred');
|
||||
await expect(badges.locator('span.chip:not(.blue)')).toHaveText('Recipe category: Inspection');
|
||||
// #332 MC8: the chip names where its value came from, in visible text after the category.
|
||||
await expect(badges.locator('span.chip:not(.blue)')).toHaveText('Recipe category: Inspection · recipe');
|
||||
await expect(badges.locator('span.chip:not(.blue) .ca-visually-hidden')).toHaveText('Recipe category: ');
|
||||
// Recipe tags are not the positive tags the Images Tags filter matches, so they stay plain chips (D21 ruling).
|
||||
await expect(panel.locator('.detail-inner > .tags').last().locator('span.chip')).toHaveText(['fixture','geometric']);
|
||||
@@ -185,6 +186,53 @@ test('panel matches the concept order and copy',async({page,request})=>{
|
||||
await expect(panel.locator('.detail-inner > button')).toHaveText(['Retry prompt details']);
|
||||
});
|
||||
|
||||
// #332 CUJ 4, CUJ 5 (MC8, D146): every category chip names its origin: the image's own, the prompt's or the recipe's.
|
||||
const originChip=scope=>scope.locator('span.chip:has(> span.chip-source)');
|
||||
async function expectOrigin(scope,{prefix,category,origin}) {
|
||||
await expect(originChip(scope)).toHaveText(`${prefix}${category} · ${origin}`);
|
||||
await expect(originChip(scope).locator('.ca-visually-hidden')).toHaveText(prefix);
|
||||
expect(await originChip(scope).locator('.chip-source').evaluate(node=>node.textContent)).toBe(` · ${origin}`);
|
||||
}
|
||||
async function putOwnCategory(request,paths,category) {
|
||||
const {images}=await(await request.get('/prompt_manager/images/output?mode=workspace&subfolder=inspector&limit=40')).json();
|
||||
const items=paths.map(path=>images.find(image=>image.root_index===0&&image.path===path)).map(({root_index,root_scope,path})=>({root_index,root_scope,path}));
|
||||
const response=await request.put('/prompt_manager/images/category',{data:{items,category}});await expect(response).toBeOK();
|
||||
}
|
||||
|
||||
test('the category chip names its origin in the side panel and the viewer (#332 CUJ 4, CUJ 5)',async({page,request})=>{
|
||||
await page.setViewportSize({width:1600,height:1100});
|
||||
await expect(await request.post('/__test__/reset',{data:{full_inspector:true}})).toBeOK();
|
||||
// The positive's own category, set through the legacy prompt route; complete.png's recipe keeps "Inspection".
|
||||
const list=await(await request.get('/prompt_manager/library')).json();const id=list.items.find(item=>item.text.startsWith('Inspector portrait')).id;
|
||||
await expect(await request.post('/prompt_manager/bulk/category',{data:{prompt_ids:[id],category:'Landscapes'}})).toBeOK();
|
||||
await page.goto('/prompt_manager/workspace#/images');const panel=inspector(page);
|
||||
await selectFile(page,'complete.png');await expect(panel.locator('.panel-heading h2')).toHaveText(POSITIVE_TITLE);
|
||||
await expectOrigin(panel,{prefix:'Recipe category: ',category:'Inspection',origin:'recipe'});
|
||||
await selectFile(page,'workflow-only.png');await expect(panel.locator('.detail-inner > p.small')).toHaveText('workflow-only.png');
|
||||
await expectOrigin(panel,{prefix:'Positive prompt category: ',category:'Landscapes',origin:'prompt'});
|
||||
// An unlinked file shows no category until it has its own.
|
||||
await selectFile(page,'empty.png');await expect(panel.locator('.panel-heading h2')).toHaveText('empty.png');
|
||||
await expect(originChip(panel)).toHaveCount(0);
|
||||
// Own categories: one overrides the prompt's on a linked file, one sits on an unlinked file.
|
||||
await putOwnCategory(request,['inspector/workflow-only.png'],'Portraits');await putOwnCategory(request,['inspector/empty.png'],'Scans');
|
||||
await page.reload();
|
||||
await selectFile(page,'workflow-only.png');await expect(panel.locator('.detail-inner > p.small')).toHaveText('workflow-only.png');
|
||||
await expectOrigin(panel,{prefix:'Image category: ',category:'Portraits',origin:'this image'});
|
||||
await selectFile(page,'empty.png');await expect(panel.locator('.panel-heading h2')).toHaveText('empty.png');
|
||||
await expectOrigin(panel,{prefix:'Image category: ',category:'Scans',origin:'this image'});
|
||||
await selectFile(page,'complete.png');await expect(panel.locator('.panel-heading h2')).toHaveText(POSITIVE_TITLE);
|
||||
await expectOrigin(panel,{prefix:'Recipe category: ',category:'Inspection',origin:'recipe'});
|
||||
// The viewer's full details show the same chips.
|
||||
let details=await viewerDetails(page,'workflow-only.png');
|
||||
await expectOrigin(details,{prefix:'Image category: ',category:'Portraits',origin:'this image'});
|
||||
await page.keyboard.press('Escape');await expect(page.locator('dialog#viewer-dialog[open]')).toHaveCount(0);
|
||||
details=await viewerDetails(page,'complete.png');
|
||||
await expectOrigin(details,{prefix:'Recipe category: ',category:'Inspection',origin:'recipe'});
|
||||
await page.keyboard.press('Escape');await expect(page.locator('dialog#viewer-dialog[open]')).toHaveCount(0);
|
||||
details=await viewerDetails(page,'empty.png');
|
||||
await expectOrigin(details,{prefix:'Image category: ',category:'Scans',origin:'this image'});
|
||||
});
|
||||
|
||||
test('tag chip filters Images by that tag',async({page,request})=>{
|
||||
await expect(await request.post('/__test__/reset',{data:{full_inspector:true}})).toBeOK();
|
||||
const list=await(await request.get('/prompt_manager/library')).json();const id=list.items.find(item=>item.text.startsWith('Inspector portrait')).id;
|
||||
@@ -676,6 +724,9 @@ test('collection viewer uses real public transforms and resets on navigation',as
|
||||
async function slideshowFixture(page,request,preferences={videoAutoplay:false,videoMute:true,videoLoop:false}) {
|
||||
await expect(await request.post('/__test__/reset',{data:{full_inspector:true,include_video:true}})).toBeOK();
|
||||
await page.goto('/prompt_manager/workspace#/images');
|
||||
// Install the fake clock before the fixture wraps setTimeout: the wrapped timer (which the viewer session captures)
|
||||
// must be the fake one, so page.clock.runFor alone decides when the 3500 ms slideshow deadline fires.
|
||||
await page.clock.install();
|
||||
await page.evaluate(async preferences=>{
|
||||
if(preferences) localStorage.setItem('gallerySettings',JSON.stringify(preferences));
|
||||
const [{createMediaViewer},{createInspectionController},{createScope},{createPromptSender}]=await Promise.all([
|
||||
@@ -695,7 +746,7 @@ async function slideshowFixture(page,request,preferences={videoAutoplay:false,vi
|
||||
},preferences);
|
||||
await expect(mediaDialog(page).getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
|
||||
await expect(mediaDialog(page).locator('#viewer-details > div > p.small')).toHaveText('complete.png');
|
||||
await page.clock.install();await page.clock.pauseAt(new Date(Date.now()+1000));
|
||||
await page.clock.pauseAt(new Date(Date.now()+1000));
|
||||
}
|
||||
const slideshowButton=page=>mediaDialog(page).locator('#viewer-toolbar').getByRole('button',{name:/^(Play|Pause|Stop) slideshow$/});
|
||||
const timerCount=page=>page.evaluate(()=>window.slideshowTimers.size);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact} from './concept-inventory.mjs';
|
||||
import {SIZES,THEMES,MOTIONS,seedConcept,openConcept,openProduction,inventoryOf,diffInventory,mutate,stripExact,measureInspector,withChipSource} from './concept-inventory.mjs';
|
||||
|
||||
// Task F5 of #249: the production Prompt history (#/library/<id>) against the immutable concept's (#prompt/alpine,
|
||||
// app.js renderHistory a587–622, the dialogs a1282–1304, style.css 1059–1179 and 1546–1567), on the concept-shaped real
|
||||
@@ -272,6 +272,62 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`history geomet
|
||||
} finally {await host.evaluate(()=>window.conceptHost.dispose());await host.close();}
|
||||
});
|
||||
|
||||
/**
|
||||
* D145 (#332): the History media inspector (a recorded run's current file, `recorded-run.mjs`) shows the file's own
|
||||
* category with the chip rule of the Images side panel and viewer (D146). The concept has no recorded-run inspector, so
|
||||
* its chip is the concept viewer's own category chip (app.js a402, imageDetails(i, true)) with the recorded D146 origin
|
||||
* and the own category added at runtime (`withChipSource`). The chip's own place depends on what precedes it in either
|
||||
* inspector, so its box is compared by size only.
|
||||
*/
|
||||
const OWN_CATEGORY='Portraits';
|
||||
const chipOnly=chip=>{const {left,top,source,...rest}=chip;return {...rest,source:source&&(({left,top,...own})=>own)(source)};};
|
||||
const categoryChipOf=async(page,root)=>(await page.evaluate(measureInspector,root)).badges.chips.find(chip=>chip.source);
|
||||
|
||||
async function conceptViewerChip(page,theme) {
|
||||
await openConcept(page,'images',theme);await page.mouse.move(0,0);
|
||||
await page.locator('.gallery-body > .image-grid > .media-card').first().locator('.image-open').click();
|
||||
await expect(page.locator('#viewer-dialog[open] #viewer-details .panel-heading h2')).toHaveText(TITLE);
|
||||
const restore=await withChipSource(page,'#viewer-details','this image',OWN_CATEGORY);await settle(page);
|
||||
const chip=await categoryChipOf(page,'#viewer-details');
|
||||
await restore();
|
||||
return chip;
|
||||
}
|
||||
|
||||
/** Recorded run for the baseline's newest run, its one reported output opened in the viewer. */
|
||||
async function productionRunChip(page,theme,positiveId) {
|
||||
await openProductionHistory(page,theme,'no-preference',positiveId);
|
||||
await page.getByRole('button',{name:'Show repeat run for Quality baseline',exact:true}).click();
|
||||
const run=page.getByRole('dialog',{name:'Recorded run',exact:true});
|
||||
await expect(run).toBeVisible();
|
||||
const view=run.getByRole('button',{name:'View current file',exact:true});
|
||||
await expect(view).toBeEnabled();await view.click();
|
||||
const details=page.locator('#viewer-dialog[open] #viewer-details');
|
||||
await expect(details.locator('.chip-source')).toHaveText(' · this image');
|
||||
await page.mouse.move(0,0);await settle(page);
|
||||
return categoryChipOf(page,'#viewer-dialog[open] #viewer-details');
|
||||
}
|
||||
|
||||
async function setOwnCategory(request,path,category) {
|
||||
const listing=await (await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();
|
||||
const root_scope=listing.images.find(item=>item.root_index===0).root_scope;
|
||||
const response=await request.put('/prompt_manager/images/category',{data:{items:[{root_index:0,root_scope,path}],category}});
|
||||
await expect(response).toBeOK();
|
||||
}
|
||||
|
||||
for(const [width,height] of SIZES)for(const theme of THEMES)test(`history media inspector category chip matches the concept ${width}x${height} ${theme} (D145)`,async({page,request},testInfo)=>{
|
||||
await page.setViewportSize({width,height});
|
||||
const {manifest}=testInfo;
|
||||
// The baseline's newest image: the output of the run its dot opens (the fixture records one run per image).
|
||||
const baseline=manifest.images.find(image=>image.prompt==='alpine'&&image.variant===0);
|
||||
await setOwnCategory(request,baseline.path,OWN_CATEGORY);
|
||||
const reference=await conceptViewerChip(page,theme);
|
||||
const actual=await productionRunChip(page,theme,manifest.prompts[0].positive_id);
|
||||
await captureCanonicalState(page,testInfo,`production-history-run-chip-${width}-${theme}`,'#viewer-dialog[open]');
|
||||
expect.soft(reference,'concept chip').toMatchObject({tag:'span',className:'chip',text:`${OWN_CATEGORY} · this image`,hidden:null});
|
||||
// D21: the hidden prefix names the scope; D146 as above.
|
||||
expect.soft(diffInventory({...chipOnly(reference),hidden:'Image category: '},chipOnly(actual)),'chip differences after D21, D145, D146').toEqual([]);
|
||||
});
|
||||
|
||||
// The comparison is only worth something if it notices a change (spec §7 mutation check): one CSS mutation (the variant
|
||||
// dot's width) and one DOM mutation (Compare negatives removed), each applied after an exact match.
|
||||
test('history comparison detects mutations',async({page},testInfo)=>{
|
||||
|
||||
@@ -85,15 +85,17 @@ function listInfo(card,actionsWidth) {
|
||||
/** Recorded Images deviations only (spec §8); everything else in the concept inventory must equal production. */
|
||||
const nocase=(a,b)=>{const x=a.toLowerCase(),y=b.toLowerCase();return x<y?-1:x>y?1:a<b?-1:a>b?1:0;};
|
||||
|
||||
export function mapImagesConcept(reference,{manifest,now=new Date(manifest.now*1000)}) {
|
||||
export function mapImagesConcept(reference,{manifest,now=new Date(manifest.now*1000),imageCategories=[]}) {
|
||||
const paths=new Map(manifest.images.map(image=>[image.filename,image.path]));
|
||||
// D14: the sort appends production's two sorts. Save view is the concept's own (saved views, #249 D16 retired).
|
||||
const toolbar={...reference.toolbar};
|
||||
toolbar.items=toolbar.items.map(item=>item.id==='sort'?{...item,options:[...item.options,'Oldest first','Name Z–A']}:item);
|
||||
// D36: the category list is the Library's real list, sorted alphabetically (database/library.py `ORDER BY category
|
||||
// COLLATE NOCASE, category`); the concept lists its samples in prompt order (app.js 348). Only the order is mapped.
|
||||
// D141 (#332): the list also holds the listed images' own categories (`GET /prompt_manager/images/categories`),
|
||||
// sorted in with the Library's.
|
||||
toolbar.items=toolbar.items.map(item=>item.id==='category-filter'
|
||||
?{...item,options:[item.options[0],...item.options.slice(1).sort(nocase)]}:item);
|
||||
?{...item,options:[item.options[0],...[...item.options.slice(1),...imageCategories].sort(nocase)]}:item);
|
||||
// D29: no "Illustrative previews" sample tag.
|
||||
const filterRow=withoutItems(reference.filterRow,item=>String(item.className).includes('sample-tag'));
|
||||
filterRow.items=filterRow.items.map(item=>{
|
||||
@@ -163,9 +165,9 @@ const pick=inventory=>stripExact(inventory);
|
||||
// The concept's own sample categories, in its first-appearance order (app.js 348): what D36 reorders.
|
||||
const CONCEPT_CATEGORIES=['All categories','Landscapes','Architecture','Environments','Illustration'];
|
||||
|
||||
function compare(reference,actual,manifest,sections=SECTIONS) {
|
||||
function compare(reference,actual,manifest,sections=SECTIONS,imageCategories=[]) {
|
||||
const only=inventory=>Object.fromEntries(sections.map(key=>[key,inventory[key]]));
|
||||
return diffInventory(pick(only(mapImagesConcept(reference,{manifest}))),pick(only(actual)));
|
||||
return diffInventory(pick(only(mapImagesConcept(reference,{manifest,imageCategories}))),pick(only(actual)));
|
||||
}
|
||||
|
||||
/** The comparison as a gate: throws, naming every differing path, unless production equals the mapped concept. */
|
||||
@@ -470,6 +472,117 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`images selecti
|
||||
expect(compare(oracle,await inventoryOf(page,'#workspace-main'),manifest,['bulkbar']).map(item=>item.path)).toContain('bulkbar.bar.paddingTop');
|
||||
});
|
||||
|
||||
/** #332: an own category on one listed image, set through the real route (PUT /prompt_manager/images/category). */
|
||||
const IMAGE_CATEGORY='Portraits';
|
||||
async function setImageCategory(request,path,category) {
|
||||
const listing=await (await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();
|
||||
const root_scope=listing.images.find(item=>item.root_index===0).root_scope;
|
||||
const response=await request.put('/prompt_manager/images/category',{data:{items:[{root_index:0,root_scope,path}],category}});
|
||||
await expect(response).toBeOK();
|
||||
expect(await response.json()).toMatchObject({success:true,category,updated:1});
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs in the page: the open Set category dialog (the concept's `simpleInput`, app.js 1384-1390 and 1138): its title,
|
||||
* the body's tree, the field, the input and its suggestions (`datalist`), and the actions with every button's box,
|
||||
* all relative to the dialog, with the concept's geometry and tokens.
|
||||
*/
|
||||
function measureCategoryDialog() {
|
||||
const dialog=document.querySelector('dialog#modal[open]');
|
||||
const css=(node,...keys)=>node?Object.fromEntries(keys.map(key=>[key,getComputedStyle(node)[key]])):null;
|
||||
const name=node=>[node.localName,...[...node.classList].sort()].join('.');
|
||||
const text=node=>node?.textContent.trim()??null;
|
||||
const origin=dialog.getBoundingClientRect();
|
||||
const box=node=>{const rect=node.getBoundingClientRect();return {left:Math.round(rect.left-origin.left),top:Math.round(rect.top-origin.top),
|
||||
width:Math.round(rect.width),height:Math.round(rect.height)};};
|
||||
const body=dialog.querySelector('#modal-body');
|
||||
const field=body.querySelector(':scope > .field');
|
||||
const label=field?.querySelector(':scope > label'),input=field?.querySelector(':scope > input');
|
||||
const list=body.querySelector(':scope > datalist');
|
||||
const actions=body.querySelector(':scope > .dialog-actions');
|
||||
return {
|
||||
title:text(dialog.querySelector('#modal-title')),
|
||||
tree:[...body.children].map(name),
|
||||
field:field&&{tree:[...field.children].map(name),...css(field,'display','flexDirection','rowGap','marginBottom'),...box(field)},
|
||||
label:label&&{text:text(label),for:label.htmlFor,...css(label,'fontSize','fontWeight','color')},
|
||||
input:input&&{id:input.id,type:input.type,value:input.value,list:input.getAttribute('list'),
|
||||
...css(input,'height','paddingLeft','fontSize','color','backgroundColor','borderTopColor','borderTopLeftRadius'),...box(input)},
|
||||
suggestions:list&&{id:list.id,shown:list.getClientRects().length>0,options:[...list.options].map(option=>option.value)},
|
||||
actions:{tree:[...actions.children].map(name),...css(actions,'display','justifyContent','columnGap','rowGap','flexWrap','marginTop'),...box(actions),
|
||||
buttons:[...actions.children].map(button=>({text:text(button),className:button.className,
|
||||
...css(button,'backgroundColor','color','fontSize','paddingTop','paddingLeft','borderTopColor','minHeight'),...box(button)}))},
|
||||
width:Math.round(origin.width),height:Math.round(origin.height),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The concept's own open dialog as production records it, changed at runtime so the browser lays it out: D140 (#332)
|
||||
* empties the field (the concept pre-fills its demo "Landscapes"), lists the toolbar's categories as the field's
|
||||
* suggestions (`datalist#workspace-input-suggestions`) and adds "Use prompt category", a plain button of the concept's
|
||||
* own Cancel, between Cancel and the primary action; D8 names the primary action "Apply" ("Apply in prototype").
|
||||
*/
|
||||
async function categoryDialogAsRecorded(page,suggestions) {
|
||||
await mutate(page,{clone:[{selector:'dialog#modal[open] .dialog-actions > button:first-child',text:'Use prompt category'}]});
|
||||
await page.evaluate(suggestions=>{
|
||||
const body=document.querySelector('dialog#modal[open] #modal-body');
|
||||
const input=body.querySelector('#action-input');input.value='';input.setAttribute('list','workspace-input-suggestions');
|
||||
const list=document.createElement('datalist');list.id='workspace-input-suggestions';
|
||||
for(const value of suggestions){const option=document.createElement('option');option.value=value;list.append(option);}
|
||||
body.querySelector(':scope > .field').after(list);
|
||||
body.querySelector('.dialog-actions > .ca-primary').textContent='Apply';
|
||||
},suggestions);
|
||||
await settle(page);
|
||||
return page.evaluate(measureCategoryDialog);
|
||||
}
|
||||
|
||||
async function openCategoryDialog(page,scope) {
|
||||
await page.locator(scope).locator(CARDS).first().locator('.image-overlays > label > input[type="checkbox"]').check();
|
||||
await expect(page.locator('#bulkbar > .bulkbar > strong')).toHaveText('1 selected');
|
||||
await page.locator('#bulkbar').getByRole('button',{name:'Set category',exact:true}).click();
|
||||
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Categorize selected images');
|
||||
await page.mouse.move(0,0);await settle(page);
|
||||
return page.evaluate(measureCategoryDialog);
|
||||
}
|
||||
|
||||
// D140 and D141 (#332): with one image's own category set, the toolbar's category options and the Set category dialog
|
||||
// against the concept's (app.js 348 and 1384-1390). The dialog is compared with the concept page itself changed at
|
||||
// runtime as recorded (`categoryDialogAsRecorded`); unmapped, it differs only in the recorded parts.
|
||||
for(const [width,height] of SIZES)for(const theme of THEMES)test(`images category options and Set category dialog match the concept ${width}x${height} ${theme}`,async({page,request},testInfo)=>{
|
||||
await page.setViewportSize({width,height});
|
||||
const manifest=testInfo.manifest;
|
||||
await setImageCategory(request,manifest.images.at(-1).path,IMAGE_CATEGORY);
|
||||
await openConceptLoaded(page,theme,'no-preference');
|
||||
const reference=await inventoryOf(page,'#main');
|
||||
const referenceDialog=await openCategoryDialog(page,'#main');
|
||||
await captureCanonicalState(page,testInfo,`concept-images-category-${width}-${theme}`,'dialog#modal');
|
||||
const suggestions=[...CONCEPT_CATEGORIES.slice(1),IMAGE_CATEGORY].sort(nocase);
|
||||
const oracle=await categoryDialogAsRecorded(page,suggestions);
|
||||
await page.keyboard.press('Escape');await expect(page.locator('dialog#modal[open]')).toHaveCount(0);
|
||||
|
||||
await openProductionLoaded(page,theme,'no-preference');
|
||||
await expect(page.locator('#category-filter option')).toHaveText(['All categories',...suggestions]);
|
||||
const actual=await inventoryOf(page,'#workspace-main');
|
||||
const actualDialog=await openCategoryDialog(page,'#workspace-main');
|
||||
await captureCanonicalState(page,testInfo,`production-images-category-${width}-${theme}`,'dialog#modal');
|
||||
const path=testInfo.outputPath('images-category-geometry.json');
|
||||
await writeFile(path,JSON.stringify({referenceDialog,oracle,actualDialog,reference:reference.toolbar,actual:actual.toolbar},null,2));
|
||||
await testInfo.attach('images-category-geometry',{path,contentType:'application/json'});
|
||||
|
||||
// The concept itself: its sample categories and its pre-filled simpleInput with Cancel / Apply in prototype.
|
||||
expect.soft(reference.toolbar.items.find(item=>item.id==='category-filter').options,'concept categories').toEqual(CONCEPT_CATEGORIES);
|
||||
expect.soft(referenceDialog,'concept dialog').toMatchObject({title:'Categorize selected images',label:{text:'Category'},
|
||||
input:{id:'action-input',value:'Landscapes',list:null},suggestions:null});
|
||||
expect.soft(referenceDialog.actions.buttons.map(button=>button.text),'concept actions').toEqual(['Cancel','Apply in prototype']);
|
||||
// D141: the toolbar options add the image's own category, sorted in (D36).
|
||||
expect.soft(compare(reference,actual,manifest,['toolbar'],[IMAGE_CATEGORY]),'toolbar differences after D14, D36, D141').toEqual([]);
|
||||
// D140 and D8: the dialog equals the concept page laid out as recorded.
|
||||
expect.soft(diffInventory(oracle,actualDialog),'dialog differences after D8, D140').toEqual([]);
|
||||
// Unmapped, the concept differs only in the field's value and suggestions, the body's datalist and the actions.
|
||||
const recorded=/^(input\.(value|list)|tree\.|suggestions|actions\.)/;
|
||||
expect.soft(diffInventory(referenceDialog,actualDialog).map(item=>item.path).filter(item=>!recorded.test(item)),'unrecorded dialog differences').toEqual([]);
|
||||
expect.soft(actualDialog.suggestions,'suggestions').toEqual({id:'workspace-input-suggestions',shown:false,options:suggestions});
|
||||
});
|
||||
|
||||
// The comparison is only worth something if it notices a change (spec §7 mutation check): one CSS mutation (the
|
||||
// `.media-info` padding) and one DOM mutation (the Inspect buttons removed), each applied after an exact match.
|
||||
test('images comparison detects mutations',async({page},testInfo)=>{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
import {SIZES,THEMES,seedConcept,openConcept,openProduction,inspectorOf,diffInventory,mutate,countCopy,saveImageTags} from './concept-inventory.mjs';
|
||||
import {SIZES,THEMES,seedConcept,openConcept,openProduction,inspectorOf,diffInventory,mutate,countCopy,saveImageTags,withChipSource} from './concept-inventory.mjs';
|
||||
|
||||
// Task D4 of #249: the Images side panel (app.js imageDetails a400–414) and the viewer's full details (imageDetails(i,
|
||||
// true)) against the immutable concept, on the concept-shaped real fixture, at 1600x1100 and 390x844 in both themes.
|
||||
@@ -13,6 +13,8 @@ test.use({locale:'en-US',timezoneId:'UTC'});
|
||||
|
||||
const CARDS='.gallery-body > .image-grid > .media-card';
|
||||
const TITLE='Alpine observatory';
|
||||
// D146 (#332): alpine_00001.png's recipe has no category, so its chip shows the positive's, " · prompt" (D39).
|
||||
const ORIGIN='prompt';
|
||||
const FIXTURE_IMAGES=24;
|
||||
// The concept's sticky panel never scrolls; production's scrolls inside the window above 700px (D20/D21 side panel).
|
||||
const PANEL_INSET=40;
|
||||
@@ -38,14 +40,18 @@ function withFocusablePrompts(inventory) {
|
||||
return {...inventory,controls,sections:inventory.sections.map(section=>({...section,text:{...section.text,tabIndex:0}}))};
|
||||
}
|
||||
|
||||
/** D21: the category chip names its scope in a visually hidden prefix; here the linked positive's (D39 fallback). */
|
||||
/**
|
||||
* D21: the category chip names its scope in a visually hidden prefix; here the linked positive's (D39 fallback). Its
|
||||
* visible origin (D146, #332: " · prompt") is the concept page's own chip with `span.chip-source` added at runtime
|
||||
* (`withChipSource`), so `reference.panel` and `viewerPlain` already carry it.
|
||||
*/
|
||||
function withCategoryScope(inventory) {
|
||||
const [badge,category,...rest]=inventory.badges.chips;
|
||||
return {...inventory,badges:{...inventory.badges,chips:[badge,{...category,hidden:'Positive prompt category: '},...rest]}};
|
||||
}
|
||||
|
||||
/**
|
||||
* Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34, D38 and D117); everything else must
|
||||
* Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34, D38, D117 and D146); everything else must
|
||||
* equal production. `reference.viewerPlain` is the concept's own full details
|
||||
* with its tag chips made plain (D20), D117's summary button added and, under a coarse pointer, D34's targets, all at
|
||||
* runtime: the browser lays out what follows them, as the Images spec's removed toolbar items do.
|
||||
@@ -112,6 +118,7 @@ async function openProductionPanel(page,theme) {
|
||||
*/
|
||||
async function plainViewerChips(page) {
|
||||
const restoreTargets=await mutate(page,{css:D34_COARSE_TARGETS});
|
||||
const restoreSource=await withChipSource(page,'#viewer-details',ORIGIN);
|
||||
await page.evaluate(()=>{
|
||||
window.__plainChips=[...document.querySelectorAll('#viewer-details .tags > button.chip')].map(button=>{
|
||||
const span=document.createElement('span');span.className='chip';span.textContent=button.textContent;
|
||||
@@ -126,7 +133,7 @@ async function plainViewerChips(page) {
|
||||
return async()=>{
|
||||
await page.evaluate(()=>{for(const [span,button] of window.__plainChips)span.replaceWith(button);delete window.__plainChips;
|
||||
window.__summaryButton.remove();delete window.__summaryButton;});
|
||||
await restoreTargets();
|
||||
await restoreSource();await restoreTargets();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -162,7 +169,11 @@ async function viewerOf(page,concept,mutation=null) {
|
||||
return {viewerDetails,viewerPlain,fileControls};
|
||||
}
|
||||
async function inventory(page,concept=false) {
|
||||
// D146: the concept panel's category chip with its visible origin, laid out by the concept page itself.
|
||||
const restoreSource=concept?await withChipSource(page,'#image-inspector',ORIGIN):null;
|
||||
if(concept)await settle(page);
|
||||
const {panel}=await inspectorOf(page);
|
||||
await restoreSource?.();
|
||||
const {viewerDetails,viewerPlain,fileControls}=await viewerOf(page,concept);
|
||||
return concept?{panel,viewerDetails,viewerPlain,fileControls}:{panel,viewerDetails,fileControls};
|
||||
}
|
||||
@@ -288,7 +299,8 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`inspector pane
|
||||
expect.soft(panel.sections.map(section=>section.label.color)[0],'concept role colors').not.toBe(panel.sections[1].label.color);
|
||||
expect.soft(panel.facts,'concept facts').toMatchObject({columns:2,columnGap:'12px',rowGap:'12px',fontSize:width>700?'11px':'12px'});
|
||||
expect.soft(panel.footer,'concept footer').toMatchObject({paddingTop:'13px',paddingRight:'17px',paddingBottom:'13px',paddingLeft:'17px'});
|
||||
expect.soft(panel.badges.chips.map(chip=>chip.text),'concept badges').toEqual(['Execution recorded','Landscapes']);
|
||||
// The concept's bare "Landscapes" with the D146 origin added at runtime (`withChipSource`).
|
||||
expect.soft(panel.badges.chips.map(chip=>chip.text),'concept badges').toEqual(['Execution recorded','Landscapes · prompt']);
|
||||
expect.soft(reference.viewerDetails.structure.inner.slice(-2),'concept full details').toEqual(['details','details']);
|
||||
|
||||
expect.soft(diff,'differences after the recorded deviations').toEqual([]);
|
||||
@@ -322,8 +334,10 @@ test('a recipe-linked image without recipe metadata shows its positive prompt me
|
||||
await expect(panelOf(page).locator('.panel-heading h2')).toHaveText('Quiet geometry');
|
||||
const rows=panelOf(page).locator('.detail-inner > .tags');
|
||||
await expect(rows).toHaveCount(2);
|
||||
await expect(rows.first().locator('.chip')).toHaveText(['Execution recorded','Positive prompt category: Architecture']);
|
||||
await expect(rows.first().locator('.chip')).toHaveText(['Execution recorded','Positive prompt category: Architecture · prompt']);
|
||||
await expect(rows.first().locator('.chip .ca-visually-hidden')).toHaveText('Positive prompt category: ');
|
||||
// D146 (#332): the visible origin names where the value came from.
|
||||
await expect(rows.first().locator('.chip .chip-source')).toHaveText(' · prompt');
|
||||
await expect(rows.nth(1).locator('button.chip')).toHaveText(['minimal','warm_light','editorial']);
|
||||
await expect(rows.nth(1).locator('span.chip')).toHaveCount(0);
|
||||
await expect(panelOf(page).locator('.detail-footer button')).toHaveText(['Send to ComfyUI','Prompt history']);
|
||||
@@ -400,6 +414,13 @@ test('inspector comparison detects mutations',async({page})=>{
|
||||
expect(()=>assertInspectorMatch(reference,withoutSummary,viewport)).toThrow(/viewerDetails\.structure\.inner/);
|
||||
assertInspectorMatch(reference,await viewerMutated(),viewport);
|
||||
|
||||
// D146: the visible origin is part of the mapped concept, so losing it is a difference.
|
||||
const restoreSource=await mutate(page,{remove:['#image-inspector .chip-source']});
|
||||
const sourceMutated=await measure();
|
||||
expect(()=>assertInspectorMatch(reference,sourceMutated,viewport)).toThrow(/panel\.badges\.chips\.1\.source/);
|
||||
await restoreSource();await settle(page);
|
||||
assertInspectorMatch(reference,await measure(),viewport);
|
||||
|
||||
await mutate(page,{remove:['#image-inspector > .detail-footer']});
|
||||
const domMutated=await measure();
|
||||
expect(()=>assertInspectorMatch(reference,domMutated,viewport)).toThrow(/panel\.footer/);
|
||||
|
||||
@@ -75,6 +75,8 @@ class TestModel:
|
||||
loaded = None
|
||||
# Models the Tag image dialog workflows mark as not downloaded.
|
||||
unavailable: set = set()
|
||||
# Pip names per model whose backend packages "cannot be imported" (#424).
|
||||
missing_packages: dict = {}
|
||||
# Scored names the concept comparisons answer instead of the fixed list
|
||||
# (``/__test__/tagging/suggestions``); None keeps the fixed list.
|
||||
suggestions: list | None = None
|
||||
@@ -82,10 +84,13 @@ class TestModel:
|
||||
models_config: dict = {}
|
||||
|
||||
def get_models_status(self) -> dict:
|
||||
from py.autotag import package_fields
|
||||
|
||||
return {
|
||||
name: {
|
||||
"downloaded": name not in self.unavailable,
|
||||
"loaded": self.loaded == name,
|
||||
**package_fields(name, self.missing_packages.get(name, [])),
|
||||
}
|
||||
for name in self.models_config
|
||||
}
|
||||
@@ -262,6 +267,8 @@ def create_app(root: Path) -> web.Application:
|
||||
with target.db.model.get_connection() as conn:
|
||||
for table in (
|
||||
"saved_views",
|
||||
"image_collection_items",
|
||||
"image_collections",
|
||||
"image_favorites",
|
||||
"media_review_decisions",
|
||||
"media_review_items",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Concept-vs-production inventory for the native Tags & categories screen (concept renderTags, app.js:719-729).
|
||||
// pageInventoryOf runs in the page; dialogs reuse the editor's inventoryOf. The concept is never rewritten:
|
||||
// mapVocabularyConcept / mapVocabularyDialog apply only the recorded deviations, each named with its ruling.
|
||||
import {diffInventory} from './library-editor-canonical-comparison.mjs';
|
||||
export {inventoryOf, diffInventory} from './library-editor-canonical-comparison.mjs';
|
||||
|
||||
export function pageInventoryOf(main) {
|
||||
@@ -61,23 +62,51 @@ export function pageInventoryOf(main) {
|
||||
};
|
||||
}
|
||||
|
||||
const countOf=(count,noun)=>`${count} ${noun}${count===1?'':'s'}`;
|
||||
|
||||
/**
|
||||
* D143 (#332): a category row counts the images whose own category it is: "N positive prompts · M images" when
|
||||
* prompts hold it too, "M images" for a value only images hold. Image-only rows follow every prompt-backed row, before
|
||||
* the closing Manage category row, each drawn as the concept's last category row with its own text (vocabulary-visual
|
||||
* checks this against the concept page with those rows added at runtime). `images` is `{value: count}`; `only` lists
|
||||
* the image-only values in their order.
|
||||
*/
|
||||
export function withImageCounts(operations,{images={},only=[]}={}) {
|
||||
const rows=operations.map(row=>images[row.h3.text]&&!only.includes(row.h3.text)
|
||||
?{...row,p:{...row.p,text:`${row.p.text} · ${countOf(images[row.h3.text],'image')}`}}:row);
|
||||
const manage=rows.at(-1),last=rows.at(-2);
|
||||
const added=only.map(value=>({...last,h3:{...last.h3,text:value},p:{...last.p,text:countOf(images[value],'image')}}));
|
||||
return [...rows.slice(0,-1),...added,manage];
|
||||
}
|
||||
|
||||
// Recorded deviations only (plan "Mock inventory" + decisions D1-D15 + ledger rulings). Everything else must be equal.
|
||||
export function mapVocabularyConcept(reference) {
|
||||
// `imageCategories` (#332 D143) is the fixture's own image categories, none by default.
|
||||
export function mapVocabularyConcept(reference,imageCategories={}) {
|
||||
const operations=reference.operations.map(row=>({...row,p:{...row.p,
|
||||
// Ledger ruling: "1 positive prompts" -> singular, consistent with the shared plural helper (#238/#243).
|
||||
text:row.p.text.replace(/^1 positive prompts$/,'1 positive prompt')}}));
|
||||
return {...reference,
|
||||
table:{...reference.table,rows:reference.table.rows.map(row=>({...row,
|
||||
// Data, not a deviation: the concept tags each sample image with its prompt's tags; the vocabulary fixture
|
||||
// tags no image, so every Images count is 0 (#249 C2 retired #245 D3).
|
||||
images:'0'}))},
|
||||
operations:reference.operations.map(row=>({...row,p:{...row.p,
|
||||
// Ledger ruling: "1 positive prompts" -> singular, consistent with the shared plural helper (#238/#243).
|
||||
text:row.p.text.replace(/^1 positive prompts$/,'1 positive prompt')}})),
|
||||
operations:withImageCounts(operations,imageCategories),
|
||||
};
|
||||
}
|
||||
|
||||
/** The page comparison: concept rows are the concept's sample tags; production appends fixture-only tags (D4). */
|
||||
export function compareVocabularyPage(reference,actual,imageCategories={}) {
|
||||
const expected=mapVocabularyConcept(reference,imageCategories);
|
||||
const shared=expected.table.rows.length;
|
||||
return {diff:diffInventory(expected,{...actual,table:{...actual.table,rows:actual.table.rows.slice(0,shared)}}),
|
||||
extra:actual.table.rows.slice(shared).map(row=>row.tag)};
|
||||
}
|
||||
|
||||
// Concept dialogs are simpleInput (app.js:1138-1143). donors are concept inventories for controls the concept
|
||||
// shows elsewhere: a `.field select` and a `p.small` (Filters dialog, app.js:1351) and a danger dialog action
|
||||
// (Archive… in the editor, app.js:976), so added controls are held to concept tokens, never to production values.
|
||||
export function mapVocabularyDialog(mode,reference,{filters,editor,categories}) {
|
||||
// `preview` is the Manage categories sentence once Preview has been pressed (D9; D144, #332, adds its images).
|
||||
export function mapVocabularyDialog(mode,reference,{filters,editor,categories,preview=null}) {
|
||||
const fields=reference.fields.map(item=>({...item,
|
||||
// Inventory rows 27-29: the name is `required` (blank Apply is blocked natively).
|
||||
required:true}));
|
||||
@@ -98,9 +127,9 @@ export function mapVocabularyDialog(mode,reference,{filters,editor,categories})
|
||||
// D9 / Task 8: the proposed name offers a category datalist (no trailing commas: one value).
|
||||
...fields.map(item=>({...item,listed:true,commaOptions:false}))],
|
||||
// D9: the preview sentence is a `p.small`, empty and hidden until Preview.
|
||||
small:[{...filters.small[0],text:''}],
|
||||
small:[{...filters.small[0],text:preview??''}],
|
||||
// D9: the primary is `Preview` until a preview is shown, then `Apply`.
|
||||
actions:{...reference.actions,buttons:buttons.map(item=>item.text==='Apply'?{...item,text:'Preview'}:item)}};
|
||||
actions:{...reference.actions,buttons:preview===null?buttons.map(item=>item.text==='Apply'?{...item,text:'Preview'}:item):buttons}};
|
||||
}
|
||||
return {...reference,fields,actions:{...reference.actions,buttons}};
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import {test,expect} from './fixtures.mjs';
|
||||
import {captureCanonicalState} from './history-canonical-comparison.mjs';
|
||||
import {pageInventoryOf,inventoryOf,diffInventory,mapVocabularyConcept,mapVocabularyDialog,compareVocabularyPage} from './vocabulary-canonical-comparison.mjs';
|
||||
|
||||
// Artifact-only captures of Tags & categories states that have no concept counterpart: the Manage categories
|
||||
// preview, loading and 1,000-category cap notes, tag conflict and restore notes, the Delete tag confirmation (D14),
|
||||
// empty lists (D12), a failed read with Retry, and Load more (D5). The page and the three concept dialogs are captured
|
||||
// as concept/production pairs by workspace-vocabulary.spec.mjs. There are deliberately no pixel assertions or
|
||||
// baselines: the maintainer accepts these captures visually before any baseline is added.
|
||||
// baselines: the maintainer accepts these captures visually before any baseline is added. The last test (#332 D143,
|
||||
// D144) compares the category rows with image counts and the Manage category preview against the concept.
|
||||
test.use({locale:'en-US',timezoneId:'UTC',reducedMotion:'reduce'});
|
||||
const route='/prompt_manager/workspace#/tags';
|
||||
const main=page=>page.locator('#workspace-main');
|
||||
@@ -101,3 +104,87 @@ for(const [width,height] of [[1600,1100],[390,844]])for(const theme of ['dark','
|
||||
await testInfo.attach(`production-load-more-${width}-${theme}`,{path,contentType:'image/png'});
|
||||
await expect(rows(page)).toHaveCount(100);
|
||||
});
|
||||
|
||||
// D143 and D144 (#332): with own image categories seeded (`image_categories`: "Landscapes" on two images, "Portraits"
|
||||
// on one image only), the Categories panel and the Manage category preview against the concept (renderTags, app.js
|
||||
// 719-729; the Manage dialog is its simpleInput, 1138-1143). The page is compared with D143 mapped, a model checked
|
||||
// against the concept page with those rows added at runtime; the preview with D9's sentence and D144's images.
|
||||
const concept='/__test__/canonical/workspace';
|
||||
const IMAGE_CATEGORIES=Object.freeze({images:{Landscapes:2,Portraits:1},only:['Portraits']});
|
||||
// D9's sentence; D144 adds the images holding the value ("and 2 images").
|
||||
const PREVIEW='“Landscapes” is used by 3 positive prompts and 2 images. Apply renames it to “Scenery”.';
|
||||
const pageOf=(page,production)=>page.locator(production?'#workspace-main':'#main').evaluate(pageInventoryOf);
|
||||
async function openConceptRoute(page,route,theme) {
|
||||
await page.goto('about:blank');await page.goto(`${concept}#${route}`);
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
}
|
||||
/** The concept page as D143 records it: image counts after the prompt counts, image-only rows before Manage category. */
|
||||
function conceptWithImageCounts(page) {
|
||||
return page.evaluate(({images,only})=>{
|
||||
const surface=document.querySelectorAll('#main .two-col > section.surface')[1];
|
||||
const rows=[...surface.querySelectorAll('.operation')];
|
||||
const count=value=>`${images[value]} image${images[value]===1?'':'s'}`;
|
||||
for(const row of rows) {
|
||||
const value=row.querySelector('h3').textContent;
|
||||
if(images[value]&&!only.includes(value))row.querySelector('p').textContent+=` · ${count(value)}`;
|
||||
}
|
||||
const manage=rows.at(-1),last=rows.at(-2);
|
||||
for(const value of only) {
|
||||
const row=last.cloneNode(true);row.querySelector('h3').textContent=value;row.querySelector('p').textContent=count(value);manage.before(row);
|
||||
}
|
||||
},IMAGE_CATEGORIES);
|
||||
}
|
||||
/** Concept controls the Manage dialog adds (as in workspace-vocabulary.spec.mjs): the Filters field select and p.small. */
|
||||
async function conceptDonors(page,theme,categories) {
|
||||
await openConceptRoute(page,'images',theme);
|
||||
await page.locator('#main').getByRole('button',{name:'Filters',exact:true}).click();await expect(page.locator('#modal')).toBeVisible();
|
||||
const filters=await page.locator('#modal').evaluate(inventoryOf);
|
||||
await openConceptRoute(page,'prompt/alpine',theme);
|
||||
await page.getByRole('button',{name:'Edit metadata',exact:true}).click();await expect(page.locator('#modal')).toBeVisible();
|
||||
const editor=await page.locator('#modal').evaluate(inventoryOf);
|
||||
return {filters,editor,categories};
|
||||
}
|
||||
|
||||
for(const [width,height] of [[1600,1100],[390,844]])for(const theme of ['dark','light'])test(`category rows and the Manage category preview match the concept with image categories ${width}x${height} ${theme} (D143, D144)`,async({page,request},testInfo)=>{
|
||||
await page.setViewportSize({width,height});
|
||||
const manifest=await seed(request,{image_categories:true});
|
||||
expect(manifest.image_categories).toEqual(IMAGE_CATEGORIES.images);
|
||||
await openConceptRoute(page,'tags',theme);await expect(page.locator('#main h1')).toHaveText('Tags & categories');
|
||||
await page.mouse.move(0,0);
|
||||
const reference=await pageOf(page,false);
|
||||
await conceptWithImageCounts(page);
|
||||
const oracle=await pageOf(page,false);
|
||||
await captureCanonicalState(page,testInfo,`concept-image-categories-${width}-${theme}`,'#main');
|
||||
const donors=await conceptDonors(page,theme,['Landscapes','Architecture','Environments','Illustration','Portraits']);
|
||||
await openConceptRoute(page,'tags',theme);
|
||||
await page.locator('#main').getByRole('button',{name:'Review',exact:true}).click();await expect(page.locator('#modal')).toBeVisible();
|
||||
const referenceDialog=await page.locator('#modal').evaluate(inventoryOf);
|
||||
|
||||
await open(page,theme,manifest.tags.length);
|
||||
await expect(categoryOperation(page,'Portraits')).toHaveCount(1);
|
||||
// The pointer still rests where the concept's Review was pressed: park it so no row is hovered.
|
||||
await page.mouse.move(0,0);
|
||||
const actual=await pageOf(page,true);
|
||||
await captureCanonicalState(page,testInfo,`production-image-categories-${width}-${theme}`,'#workspace-main');
|
||||
const box=await openDialog(page,categoryOperation(page,'Manage category').getByRole('button',{name:'Review',exact:true}));
|
||||
await expect(box.getByLabel('Category',{exact:true})).toHaveValue('Landscapes');
|
||||
await box.getByLabel('Proposed new category name',{exact:true}).fill('Scenery');await button(box,'Preview').click();
|
||||
await expect(box.locator('p.small')).toHaveText(PREVIEW);
|
||||
// The pressed primary keeps the pointer and focus; the concept is measured as it opens (focus on ×, no hover),
|
||||
// which workspace-vocabulary.spec.mjs compares, so this state is restored before measuring the preview.
|
||||
await page.mouse.move(0,0);await box.locator('.dialog-header button').focus();
|
||||
const actualDialog=await box.evaluate(inventoryOf);
|
||||
await captureCanonicalState(page,testInfo,`production-image-categories-preview-${width}-${theme}`);
|
||||
const path=testInfo.outputPath('vocabulary-image-categories.json');
|
||||
await writeFile(path,JSON.stringify({reference,oracle,actual,referenceDialog,actualDialog},null,2));
|
||||
await testInfo.attach('vocabulary-image-categories',{path,contentType:'application/json'});
|
||||
|
||||
// The D143 model reproduces the concept page with the rows added at runtime.
|
||||
expect.soft(diffInventory(mapVocabularyConcept(reference,IMAGE_CATEGORIES).operations,mapVocabularyConcept(oracle).operations),'D143 model').toEqual([]);
|
||||
const {diff,extra}=compareVocabularyPage(reference,actual,IMAGE_CATEGORIES);
|
||||
expect.soft(actual.operations.map(row=>[row.h3.text,row.p.text]).slice(0,-1),'category rows').toEqual([['Landscapes','3 positive prompts · 2 images'],
|
||||
['Architecture','1 positive prompt'],['Environments','1 positive prompt'],['Illustration','1 positive prompt'],['Portraits','1 image']]);
|
||||
expect.soft(diff,'page differences after the recorded deviations and D143').toEqual([]);
|
||||
expect.soft(extra,'fixture-only rows').toEqual([manifest.pair_only_tag,manifest.archived_only_tag,manifest.negative_only_tag,manifest.unused_tag]);
|
||||
expect.soft(diffInventory(mapVocabularyDialog('manage',referenceDialog,{...donors,preview:PREVIEW}),actualDialog),'preview differences after D9 and D144').toEqual([]);
|
||||
});
|
||||
|
||||
@@ -495,6 +495,30 @@ test('the inline current file names its prompts apart from the frozen run fields
|
||||
expect(await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'))).toBe(before);
|
||||
});
|
||||
|
||||
// #332 CUJ 5 (MC8, §5.3): History's current-file inspector reads the file's own category with the Images key rule, so
|
||||
// the inline current file and its viewer show "Portraits · this image"; current.png has two saved links, so it inherits
|
||||
// nothing and shows no chip before.
|
||||
test('the current file shows its own category with its origin in the recorded run and its viewer',async({page,request})=>{
|
||||
const chip=scope=>scope.locator('span.chip:has(> span.chip-source)');
|
||||
const current=dialog(page).getByRole('region',{name:'Current reported file',exact:true});
|
||||
const viewCurrent=async()=>{await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();
|
||||
const details=page.locator('dialog#viewer-dialog #viewer-details');await expect(details.locator('.panel-heading')).toBeVisible();return details;};
|
||||
await open(page,page.fixture.detail_id);await marker(page,page.fixture.runs.shared).click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
|
||||
let details=await viewCurrent();await expect(details.locator('.panel-heading h2')).toBeVisible();await expect(chip(details)).toHaveCount(0);
|
||||
await page.keyboard.press('Escape');await expect(current).toBeVisible();await expect(chip(current)).toHaveCount(0);
|
||||
await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click();
|
||||
const {images}=await(await request.get('/prompt_manager/images/output?mode=workspace&subfolder=history&limit=40')).json();
|
||||
const file=images.find(image=>image.root_index===0&&image.path==='history/current.png');
|
||||
const put=await request.put('/prompt_manager/images/category',{data:{items:[{root_index:0,root_scope:file.root_scope,path:file.path}],category:'Portraits'}});
|
||||
await expect(put).toBeOK();expect((await put.json()).updated).toBe(1);
|
||||
await open(page,page.fixture.detail_id);await marker(page,page.fixture.runs.shared).click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
|
||||
details=await viewCurrent();
|
||||
await expect(chip(details)).toHaveText('Image category: Portraits · this image');
|
||||
await page.keyboard.press('Escape');await expect(current).toBeVisible();
|
||||
await expect(chip(current)).toHaveText('Image category: Portraits · this image');
|
||||
expect(await chip(current).locator('.chip-source').evaluate(node=>node.textContent)).toBe(' · this image');
|
||||
});
|
||||
|
||||
// LI-4 (D117): the recorded history's current-file viewer is the shared full inspector, so it copies the legacy summary.
|
||||
test('the current file viewer copies its settings summary',async({page})=>{
|
||||
await page.addInitScript(()=>{window.__copied=[];Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText:async text=>{window.__copied.push(text);}}});});
|
||||
|
||||
@@ -1988,70 +1988,286 @@ test('Export metadata downloads nothing when no image can be read', async ({ pag
|
||||
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('2 selected');
|
||||
});
|
||||
|
||||
// #249 D8 follow-up (image selection, Task 3): Set category sets the linked positives' category (app.js simpleInput).
|
||||
// #332 (D140, MC6, MC7): Set category writes each selected image's own category (PUT /prompt_manager/images/category);
|
||||
// "Use prompt category" removes it after a confirmation (DELETE). It never writes a prompt: Images no longer posts
|
||||
// /prompt_manager/bulk/category (#259's prompt action is retired from Images).
|
||||
const categoryButton = page => bulkbar(page).getByRole('button', { name: 'Set category', exact: true });
|
||||
const categoryDialog = page => page.getByRole('dialog', { name: 'Categorize selected images', exact: true });
|
||||
const categoryRoute = '**/prompt_manager/bulk/category';
|
||||
/** Unlink the input root's card by deleting its prompt; returns the output card's positive id. */
|
||||
async function unlinkInputCard(request) {
|
||||
const { images } = await (await request.get('/prompt_manager/images/output?mode=workspace')).json();
|
||||
const byKey = Object.fromEntries(images.map(image => [`${image.root_index}:${image.path}`, image]));
|
||||
const [output, input] = [byKey['0:shared/same.png'], byKey['1:shared/same.png']];
|
||||
expect([output.linked.status, input.linked.status]).toEqual(['linked', 'linked']);
|
||||
await expect(await request.delete(`/prompt_manager/delete/${input.linked.positive_id}`)).toBeOK();
|
||||
return output.linked.positive_id;
|
||||
const removeDialog = page => page.getByRole('dialog', { name: 'Use prompt category', exact: true });
|
||||
const categoryFilter = page => page.getByRole('combobox', { name: 'Filter by category', exact: true });
|
||||
const categoryRoute = '**/prompt_manager/images/category';
|
||||
const IMAGE_CATEGORY = '/prompt_manager/images/category';
|
||||
const cardOf = (page, key) => page.locator(`article[data-image-key="${key}"]`);
|
||||
const keyOf = image => `${image.root_index}:${image.path}`;
|
||||
const cardKeys = page => cards(page).evaluateAll(nodes => nodes.map(node => node.dataset.imageKey).sort());
|
||||
// The four outputs of "Cat portrait" (CUJ 1's batch); the input root's same.png is linked to "Dog landscape".
|
||||
const BATCH = ['0:bulk/0000.png', '0:bulk/0001.png', '0:bulk/0002.png', '0:shared/same.png'];
|
||||
/** Every category write the page sends, Images' own route and #259's prompt route alike. */
|
||||
function categoryWrites(page) {
|
||||
const writes = [];
|
||||
page.on('request', item => {
|
||||
const path = new URL(item.url()).pathname;
|
||||
if (item.method() === 'GET' || !['/prompt_manager/images/category', '/prompt_manager/bulk/category'].includes(path)) return;
|
||||
writes.push({ method: item.method(), path, body: item.postDataJSON() });
|
||||
});
|
||||
return writes;
|
||||
}
|
||||
async function listedImages(request, query = '') {
|
||||
const response = await request.get(`/prompt_manager/images/output?mode=workspace&limit=40${query}`);
|
||||
await expect(response).toBeOK();
|
||||
return (await response.json()).images;
|
||||
}
|
||||
async function targetsOf(request, keys) {
|
||||
const images = await listedImages(request);
|
||||
return keys.map(key => {
|
||||
const image = images.find(item => keyOf(item) === key);
|
||||
return { root_index: image.root_index, root_scope: image.root_scope, path: image.path };
|
||||
});
|
||||
}
|
||||
async function putOwnCategory(request, keys, category) {
|
||||
const response = await request.put(IMAGE_CATEGORY, { data: { items: await targetsOf(request, keys), category } });
|
||||
await expect(response).toBeOK();
|
||||
}
|
||||
const filteredKeys = async (request, category) => (await listedImages(request, `&category=${encodeURIComponent(category)}`)).map(keyOf).sort();
|
||||
async function positiveOf(request, id) {
|
||||
const response = await request.get(`/prompt_manager/library/${id}`);
|
||||
await expect(response).toBeOK();
|
||||
const { positive } = await response.json();
|
||||
return { id: positive.id, text: positive.text, category: positive.category };
|
||||
}
|
||||
/** CUJ 1's Given: four images linked to the positive "Cat portrait", whose category is "Landscapes". */
|
||||
async function seedBatch(request) {
|
||||
await expect(await request.post('/__test__/reset', { data: { extra_images: 3 } })).toBeOK();
|
||||
const response = await request.post('/prompt_manager/save', { data: { text: 'Cat portrait', category: 'Landscapes' } });
|
||||
await expect(response).toBeOK();
|
||||
const id = (await response.json()).prompt_id;
|
||||
expect(await filteredKeys(request, 'Landscapes')).toEqual(BATCH);
|
||||
return id;
|
||||
}
|
||||
async function openSetCategory(page) {
|
||||
await categoryButton(page).click();
|
||||
await expect(categoryDialog(page)).toBeVisible();
|
||||
return categoryDialog(page);
|
||||
}
|
||||
async function applyCategory(page, category) {
|
||||
const dialog = await openSetCategory(page);
|
||||
await dialog.getByLabel('Category', { exact: true }).fill(category);
|
||||
await dialog.getByRole('button', { name: 'Apply', exact: true }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
}
|
||||
|
||||
test('Set category applies the category to the linked positives and the category filter then matches', async ({ page, request }) => {
|
||||
const outputPositive = await unlinkInputCard(request);
|
||||
test('Set category overrides one image of a batch and never writes its prompt (#332 CUJ 1)', async ({ page, request }) => {
|
||||
const positiveId = await seedBatch(request);
|
||||
const before = await positiveOf(request, positiveId);
|
||||
const writes = categoryWrites(page);
|
||||
await page.goto(workspace);
|
||||
await checkBoth(page);
|
||||
const opener = categoryButton(page);
|
||||
await opener.click();
|
||||
const dialog = categoryDialog(page);
|
||||
await expect(dialog).toBeVisible();
|
||||
// The concept's simpleInput: one field and Cancel / Apply (primary).
|
||||
await expect(cards(page)).toHaveCount(5);
|
||||
await checkbox(cardOf(page, '0:bulk/0001.png'), '0001.png').check();
|
||||
const dialog = await openSetCategory(page);
|
||||
// The concept's simpleInput plus "Use prompt category" between Cancel and Apply (primary).
|
||||
await expect(dialog.locator('#modal-body > .field > label[for="action-input"]')).toHaveText('Category');
|
||||
await expect(dialog.locator('#modal-body > .field > input#action-input')).toHaveValue('');
|
||||
await expect(dialog.locator('#modal-body > .dialog-actions > button')).toHaveText(['Cancel', 'Apply']);
|
||||
await expect(dialog.locator('#modal-body > .dialog-actions > button')).toHaveText(['Cancel', 'Use prompt category', 'Apply']);
|
||||
await expect(dialog.getByRole('button', { name: 'Apply', exact: true })).toHaveClass(/ca-primary/);
|
||||
await expect(dialog.getByLabel('Category', { exact: true })).toBeFocused();
|
||||
await expect(dialog.getByLabel('Category', { exact: true })).toHaveAttribute('maxlength', '100');
|
||||
// An empty value applies nothing and keeps the dialog open.
|
||||
const posts = [];
|
||||
page.on('request', item => { if (item.method() === 'POST' && item.url().endsWith('/prompt_manager/bulk/category')) posts.push(item.postDataJSON()); });
|
||||
await dialog.getByRole('button', { name: 'Apply', exact: true }).click();
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByLabel('Category', { exact: true }).fill(' Landscapes ');
|
||||
await dialog.getByLabel('Category', { exact: true }).fill(' Portraits ');
|
||||
const listings = [];
|
||||
page.on('request', item => { if (imagePages(new URL(item.url()))) listings.push(item.url()); });
|
||||
await dialog.getByRole('button', { name: 'Apply', exact: true }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(toast(page)).toHaveText('Category set on 1 prompt. 1 selected image had no linked prompt and was skipped.');
|
||||
expect(posts).toEqual([{ prompt_ids: [outputPositive], category: 'Landscapes' }]);
|
||||
await expect(toast(page)).toHaveText('Category “Portraits” set on 1 image.');
|
||||
expect(writes).toEqual([{ method: 'PUT', path: IMAGE_CATEGORY,
|
||||
body: { items: await targetsOf(request, ['0:bulk/0001.png']), category: 'Portraits' } }]);
|
||||
// The listing and the category options reload; the reload clears the selection.
|
||||
await expect.poll(() => listings.length).toBeGreaterThan(0);
|
||||
await expect(bulkbar(page)).toBeEmpty();
|
||||
await expect(page.locator('#workspace-main h1')).toBeFocused();
|
||||
await expect(cards(page).getByRole('checkbox', { checked: true })).toHaveCount(0);
|
||||
const filter = page.getByRole('combobox', { name: 'Filter by category', exact: true });
|
||||
await expect(filter.locator('option')).toContainText(['Landscapes']);
|
||||
await filter.selectOption('Landscapes');
|
||||
await expect(categoryFilter(page).locator('option')).toHaveText(['All categories', 'Landscapes', 'Portraits']);
|
||||
await categoryFilter(page).selectOption('Portraits');
|
||||
await expect(cards(page)).toHaveCount(1);
|
||||
await expect(cards(page)).toHaveAttribute('data-image-key', '0:shared/same.png');
|
||||
await expect(cards(page)).toHaveAttribute('data-image-key', '0:bulk/0001.png');
|
||||
await categoryFilter(page).selectOption('Landscapes');
|
||||
await expect(cards(page)).toHaveCount(3);
|
||||
expect(await cardKeys(page)).toEqual(BATCH.filter(key => key !== '0:bulk/0001.png'));
|
||||
// The prompt keeps its category: only the image carries "Portraits".
|
||||
expect(await positiveOf(request, positiveId)).toEqual(before);
|
||||
expect(before.category).toBe('Landscapes');
|
||||
expect(writes.filter(write => write.path === '/prompt_manager/bulk/category')).toEqual([]);
|
||||
});
|
||||
|
||||
test('Set category without a linked image posts nothing and opens no dialog', async ({ page, request }) => {
|
||||
await unlinkInputCard(request);
|
||||
test('Set category categorizes unlinked images and lists the value in the toolbar and the suggestions (#332 CUJ 2)', async ({ page, request }) => {
|
||||
// Unlink both images by deleting their prompts.
|
||||
for (const image of await listedImages(request)) {
|
||||
await expect(await request.delete(`/prompt_manager/delete/${image.linked.positive_id}`)).toBeOK();
|
||||
}
|
||||
expect((await listedImages(request)).filter(image => image.linked.status === 'linked')).toEqual([]);
|
||||
const writes = categoryWrites(page);
|
||||
await page.goto(workspace);
|
||||
await checkBoth(page);
|
||||
await applyCategory(page, 'Scans');
|
||||
await expect(toast(page)).toHaveText('Category “Scans” set on 2 images.');
|
||||
expect(writes).toEqual([{ method: 'PUT', path: IMAGE_CATEGORY,
|
||||
body: { items: await targetsOf(request, ['0:shared/same.png', '1:shared/same.png']), category: 'Scans' } }]);
|
||||
await expect(categoryFilter(page).locator('option')).toHaveText(['All categories', 'Scans']);
|
||||
await categoryFilter(page).selectOption('Scans');
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
let posted = 0;
|
||||
await page.route(categoryRoute, route => { posted += 1; return route.continue(); });
|
||||
await checkbox(cards(page).last(), 'same.png').check();
|
||||
await categoryButton(page).click();
|
||||
await expect(toast(page)).toHaveText("None of the selected images is linked to a saved prompt, so there is no prompt category to set.");
|
||||
expect(await cardKeys(page)).toEqual(['0:shared/same.png', '1:shared/same.png']);
|
||||
// The field suggests the toolbar's categories through its own datalist.
|
||||
await checkbox(cards(page).first(), 'same.png').check();
|
||||
const dialog = await openSetCategory(page);
|
||||
await expect(dialog.getByLabel('Category', { exact: true })).toHaveAttribute('list', 'workspace-input-suggestions');
|
||||
expect(await dialog.locator('datalist#workspace-input-suggestions option').evaluateAll(nodes => nodes.map(node => node.value))).toEqual(['Scans']);
|
||||
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(page.locator('dialog[open]')).toHaveCount(0);
|
||||
expect(posted).toBe(0);
|
||||
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('1 selected');
|
||||
expect(writes).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('Use prompt category removes the own category only after Remove in its confirmation (#332 CUJ 3)', async ({ page, request }) => {
|
||||
const positiveId = await seedBatch(request);
|
||||
await putOwnCategory(request, ['0:bulk/0001.png'], 'Portraits');
|
||||
const writes = categoryWrites(page);
|
||||
await page.goto(workspace);
|
||||
await expect(cards(page)).toHaveCount(5);
|
||||
await expect(categoryFilter(page).locator('option')).toHaveText(['All categories', 'Landscapes', 'Portraits']);
|
||||
await checkbox(cardOf(page, '0:bulk/0001.png'), '0001.png').check();
|
||||
// Cancel in the confirmation sends nothing and keeps the selection.
|
||||
await (await openSetCategory(page)).getByRole('button', { name: 'Use prompt category', exact: true }).click();
|
||||
await expect(categoryDialog(page)).toHaveCount(0);
|
||||
const confirm = removeDialog(page);
|
||||
await expect(confirm).toBeVisible();
|
||||
await expect(confirm.locator('p').first()).toHaveText("Remove the image category from the 1 selected image? Each one then shows its prompt's category, or none if it has no linked prompt. This cannot be undone.");
|
||||
await expect(confirm.locator('.dialog-actions > button')).toHaveText(['Cancel', 'Remove']);
|
||||
await expect(confirm.getByRole('button', { name: 'Remove', exact: true })).toHaveClass(/danger/);
|
||||
await expect(confirm.getByRole('button', { name: 'Cancel', exact: true })).toBeFocused();
|
||||
await confirm.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(page.locator('dialog[open]')).toHaveCount(0);
|
||||
await expect(selectedCount(page)).toHaveText('1 selected');
|
||||
expect(writes).toEqual([]);
|
||||
expect(await filteredKeys(request, 'Portraits')).toEqual(['0:bulk/0001.png']);
|
||||
// Remove sends exactly one DELETE naming the selected image.
|
||||
await (await openSetCategory(page)).getByRole('button', { name: 'Use prompt category', exact: true }).click();
|
||||
await removeDialog(page).getByRole('button', { name: 'Remove', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('Removed the image category from 1 image.');
|
||||
expect(writes).toEqual([{ method: 'DELETE', path: IMAGE_CATEGORY, body: { items: await targetsOf(request, ['0:bulk/0001.png']) } }]);
|
||||
await expect(bulkbar(page)).toBeEmpty();
|
||||
// The image shows its prompt's category again: "Landscapes" lists all four, and "Portraits" is gone.
|
||||
await expect(categoryFilter(page).locator('option')).toHaveText(['All categories', 'Landscapes']);
|
||||
await categoryFilter(page).selectOption('Landscapes');
|
||||
await expect(cards(page)).toHaveCount(4);
|
||||
expect(await cardKeys(page)).toEqual(BATCH);
|
||||
expect((await positiveOf(request, positiveId)).category).toBe('Landscapes');
|
||||
});
|
||||
|
||||
test('a saved view filtered by a category lists an image categorized after it was saved (#332 CUJ 8)', async ({ page, request }) => {
|
||||
await seedBatch(request);
|
||||
await putOwnCategory(request, ['0:bulk/0001.png'], 'Portraits');
|
||||
await page.goto(workspace);
|
||||
await categoryFilter(page).selectOption('Portraits');
|
||||
await expect(cards(page)).toHaveCount(1);
|
||||
await page.locator('#workspace-main search.toolbar').getByRole('button', { name: 'Save view', exact: true }).click();
|
||||
const save = page.getByRole('dialog', { name: 'Save this view', exact: true });
|
||||
await save.getByRole('textbox', { name: 'View name', exact: true }).fill('Portraits');
|
||||
await save.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('Saved view added to the sidebar.');
|
||||
// Another image gets "Portraits" through Set category.
|
||||
await categoryFilter(page).selectOption('');
|
||||
await expect(cards(page)).toHaveCount(5);
|
||||
await checkbox(cardOf(page, '0:bulk/0002.png'), '0002.png').check();
|
||||
await applyCategory(page, 'Portraits');
|
||||
await expect(toast(page)).toHaveText('Category “Portraits” set on 1 image.');
|
||||
// Reopening the view lists both.
|
||||
await page.getByRole('navigation', { name: 'Saved views', exact: true }).getByRole('link', { name: 'Portraits', exact: true }).click();
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Portraits');
|
||||
await expect(categoryFilter(page)).toHaveValue('Portraits');
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
expect(await cardKeys(page)).toEqual(['0:bulk/0001.png', '0:bulk/0002.png']);
|
||||
});
|
||||
|
||||
test('at 390px in light theme the Set category dialog and the category chip fit (#332 CUJ 10)', async ({ page, request }) => {
|
||||
const LONG = 'Portraits taken on a phone at the end of a long walk through the old harbour at dusk';
|
||||
await page.addInitScript(() => { try { localStorage.setItem('pm_workspace_theme', 'light'); } catch { /* the default theme */ } });
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await seedBatch(request);
|
||||
await putOwnCategory(request, ['0:bulk/0001.png'], LONG);
|
||||
await page.goto(workspace);
|
||||
await expect(cards(page)).toHaveCount(5);
|
||||
await expect(page.locator('html')).toHaveCSS('color-scheme', 'light');
|
||||
const card = cardOf(page, '0:bulk/0001.png');
|
||||
// The chip wraps inside the side panel; its origin never splits.
|
||||
await card.getByRole('button', { name: 'Inspect 0001.png', exact: true }).click();
|
||||
const chip = details(page).locator('span.chip', { has: page.locator('span.chip-source') });
|
||||
await expect(chip).toHaveText(`Image category: ${LONG} · this image`);
|
||||
const fit = await chip.evaluate(node => {
|
||||
const panel = node.closest('aside, [role="complementary"]').getBoundingClientRect(), box = node.getBoundingClientRect();
|
||||
const source = node.querySelector('.chip-source');
|
||||
const line = parseFloat(getComputedStyle(source).lineHeight) || source.getBoundingClientRect().height;
|
||||
return { inside: box.left >= panel.left - 0.5 && box.right <= panel.right + 0.5, sourceLines: source.getClientRects().length,
|
||||
wrapped: box.height > line * 1.5, pageScroll: document.documentElement.scrollWidth - window.innerWidth };
|
||||
});
|
||||
expect(fit).toEqual({ inside: true, sourceLines: 1, wrapped: true, pageScroll: 0 });
|
||||
// The dialog fits the width with no horizontal scroll, and its three buttons wrap without overlap.
|
||||
await checkbox(card, '0001.png').check();
|
||||
const dialog = await openSetCategory(page);
|
||||
const geometry = await dialog.evaluate(node => {
|
||||
const box = node.getBoundingClientRect();
|
||||
const buttons = [...node.querySelectorAll('.dialog-actions > button')].map(button => button.getBoundingClientRect())
|
||||
.map(({ left, right, top, bottom }) => ({ left, right, top, bottom }));
|
||||
const overlaps = buttons.some((a, i) => buttons.some((b, j) => j > i && a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom));
|
||||
const scrollers = [node, ...node.querySelectorAll('*')].filter(item => item.scrollWidth > item.clientWidth + 1 && getComputedStyle(item).overflowX !== 'visible');
|
||||
return { fits: box.left >= 0 && box.right <= window.innerWidth, scrollers: scrollers.length, count: buttons.length, overlaps,
|
||||
inside: buttons.every(b => b.left >= box.left && b.right <= box.right), pageScroll: document.documentElement.scrollWidth - window.innerWidth };
|
||||
});
|
||||
expect(geometry).toEqual({ fits: true, scrollers: 0, count: 3, overlaps: false, inside: true, pageScroll: 0 });
|
||||
});
|
||||
|
||||
test('Set category of more than 500 images says so before any dialog or request (#332)', async ({ page, request }) => {
|
||||
await page.route(url => url.pathname === '/prompt_manager/images/output' && url.searchParams.get('limit') !== '1', async route => {
|
||||
const response = await route.fetch();
|
||||
const body = await response.json();
|
||||
await route.fulfill({ response, json: { ...body, total: 501 } });
|
||||
});
|
||||
await bulkReset(page, request);
|
||||
await cardAt(page, 0).getByRole('button', { name: /^Open / }).focus();
|
||||
await page.keyboard.press('ControlOrMeta+a');
|
||||
await bulkbar(page).getByRole('button', { name: 'Select all 501 matching', exact: true }).click();
|
||||
await expect(selectedCount(page)).toHaveText('All 501 matching selected');
|
||||
const writes = categoryWrites(page);
|
||||
await categoryButton(page).click();
|
||||
await expect(toast(page)).toHaveText('Select at most 500 images.');
|
||||
await expect(page.locator('dialog[open]')).toHaveCount(0);
|
||||
expect(writes).toEqual([]);
|
||||
await expect(selectedCount(page)).toHaveText('All 501 matching selected');
|
||||
});
|
||||
|
||||
test('a changed source says so and reloads the listing (#332)', async ({ page }) => {
|
||||
await page.goto(workspace);
|
||||
await checkBoth(page);
|
||||
await page.route(categoryRoute, route => route.fulfill({ status: 409,
|
||||
json: { success: false, code: 'source-changed', error: 'The output sources changed. Refresh the images and try again.' } }));
|
||||
const reloaded = page.waitForRequest(item => imagePages(new URL(item.url())));
|
||||
await applyCategory(page, 'Landscapes');
|
||||
await expect(toast(page)).toHaveText('The sources changed. Refresh the images and try again.');
|
||||
await reloaded;
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
// The reload hides the bar with Set category in it: focus moves to the page heading, as after a success.
|
||||
await expect(page.locator('#workspace-main h1')).toBeFocused();
|
||||
});
|
||||
|
||||
test('a recipe-linked image is listed under its recipe\'s category (#332 D142)', async ({ page, request }) => {
|
||||
await expect(await request.post('/__test__/reset', { data: { native_library: true } })).toBeOK();
|
||||
await page.goto(workspace);
|
||||
await expect(cards(page)).toHaveCount(3);
|
||||
await expect(categoryFilter(page).locator('option')).toHaveText(['All categories', 'Animals', 'Experiments', 'Portraits']);
|
||||
// The output same.png is linked to recipe "Portrait variant 1" (Portraits), the input copy to variant 2 (Experiments);
|
||||
// only the image without a recipe shows its positive's "Animals".
|
||||
for (const [category, keys] of [['Portraits', ['0:shared/same.png']], ['Experiments', ['1:shared/same.png']], ['Animals', ['0:legacy-unassigned.png']]]) {
|
||||
await categoryFilter(page).selectOption(category);
|
||||
await expect(cards(page)).toHaveCount(keys.length);
|
||||
expect(await cardKeys(page)).toEqual(keys);
|
||||
}
|
||||
});
|
||||
|
||||
test('Set category reports a failure, keeps the selection and ignores a second press while it runs', async ({ page }) => {
|
||||
@@ -2059,9 +2275,7 @@ test('Set category reports a failure, keeps the selection and ignores a second p
|
||||
await checkBoth(page);
|
||||
const held = [];
|
||||
await page.route(categoryRoute, route => { held.push(route); });
|
||||
await categoryButton(page).click();
|
||||
await categoryDialog(page).getByLabel('Category', { exact: true }).fill('Landscapes');
|
||||
await categoryDialog(page).getByRole('button', { name: 'Apply', exact: true }).click();
|
||||
await applyCategory(page, 'Landscapes');
|
||||
await expect.poll(() => held.length).toBe(1);
|
||||
await expect(categoryButton(page)).toHaveAttribute('aria-disabled', 'true');
|
||||
// Playwright waits on an aria-disabled control, so the second press is a dispatched click.
|
||||
@@ -2098,14 +2312,12 @@ test('leaving Images during Set category finishes the write, and the return relo
|
||||
await checkBoth(page);
|
||||
const held = [];
|
||||
await page.route(categoryRoute, route => { held.push(route); });
|
||||
await categoryButton(page).click();
|
||||
await categoryDialog(page).getByLabel('Category', { exact: true }).fill('Landscapes');
|
||||
await categoryDialog(page).getByRole('button', { name: 'Apply', exact: true }).click();
|
||||
await applyCategory(page, 'Landscapes');
|
||||
await expect.poll(() => held.length).toBe(1);
|
||||
await page.evaluate(() => { window.location.hash = '/overview'; });
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
|
||||
// The write is not aborted with the page: the server still receives and answers it.
|
||||
const answered = page.waitForResponse(response => response.url().endsWith('/prompt_manager/bulk/category'));
|
||||
const answered = page.waitForResponse(response => new URL(response.url()).pathname === IMAGE_CATEGORY);
|
||||
await held[0].continue();
|
||||
expect((await answered).ok()).toBe(true);
|
||||
await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 150)));
|
||||
@@ -2116,23 +2328,25 @@ test('leaving Images during Set category finishes the write, and the return relo
|
||||
await expect(cards(page)).toHaveCount(2);
|
||||
await expect(bulkbar(page)).toBeEmpty();
|
||||
await expect(cards(page).getByRole('checkbox', { checked: true })).toHaveCount(0);
|
||||
await expect(page.getByRole('combobox', { name: 'Filter by category', exact: true }).locator('option')).toContainText(['Landscapes']);
|
||||
await expect(categoryFilter(page).locator('option')).toContainText(['Landscapes']);
|
||||
});
|
||||
|
||||
test('Set category limits the field to the server length, shows the server count, and a rejection keeps the selection', async ({ page }) => {
|
||||
test('Set category shows the server counts and coded refusals, and a rejection keeps the selection', async ({ page }) => {
|
||||
await page.goto(workspace);
|
||||
await checkBoth(page);
|
||||
const answers = [
|
||||
{ status: 400, json: { success: false, error: 'Category cannot exceed 100 characters' } },
|
||||
{ status: 200, json: { success: true, updated_count: 0 } },
|
||||
{ status: 404, json: { success: false, code: 'files-not-found', error: 'None of the images could be found.' } },
|
||||
{ status: 200, json: { success: true, category: 'Landscapes', updated: 1, unchanged: 0, not_found: 1 } },
|
||||
];
|
||||
await page.route(categoryRoute, route => route.fulfill(answers.shift()));
|
||||
for (const expected of ['Category could not be set. Try again.', 'Category set on 0 prompts.']) {
|
||||
await categoryButton(page).click();
|
||||
const field = categoryDialog(page).getByLabel('Category', { exact: true });
|
||||
await expect(field).toHaveAttribute('maxlength', '100');
|
||||
await field.fill('Landscapes');
|
||||
await categoryDialog(page).getByRole('button', { name: 'Apply', exact: true }).click();
|
||||
for (const expected of ['Category could not be set. Try again.',
|
||||
'None of the selected images could be found. Refresh the images and try again.',
|
||||
'Category “Landscapes” set on 1 image. 1 could not be found.']) {
|
||||
const dialog = await openSetCategory(page);
|
||||
await expect(dialog.getByLabel('Category', { exact: true })).toHaveAttribute('maxlength', '100');
|
||||
await dialog.getByLabel('Category', { exact: true }).fill('Landscapes');
|
||||
await dialog.getByRole('button', { name: 'Apply', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText(expected);
|
||||
if (answers.length) await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('2 selected');
|
||||
}
|
||||
@@ -2381,24 +2595,20 @@ test('all matching sets the category through the listing filter and survives a r
|
||||
// Export metadata waits for loaded images: a press does nothing.
|
||||
const exportButton = bulkbar(page).getByRole('button', { name: 'Export metadata', exact: true });
|
||||
await expect(exportButton).toHaveAttribute('title', 'Export metadata reads each image in the browser, so it works on loaded images only.');
|
||||
// No match linked (a stubbed answer): the nothing-linked sentence, and the selection stays.
|
||||
await page.route('**/prompt_manager/bulk/category', route => route.fulfill({ json: { success: true, updated_count: 0, prompt_count: 0, skipped: 202 } }), { times: 1 });
|
||||
const categorize = page.getByRole('dialog', { name: 'Categorize selected images', exact: true });
|
||||
await bulkbar(page).getByRole('button', { name: 'Set category', exact: true }).click();
|
||||
await categorize.getByRole('textbox', { name: 'Category', exact: true }).fill('Bulk392');
|
||||
await categorize.getByRole('button', { name: 'Apply', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText("None of the selected images is linked to a saved prompt, so there is no prompt category to set.");
|
||||
// No match listed any more (a stubbed answer): the not-found sentence, and the selection stays.
|
||||
await page.route(categoryRoute, route => route.fulfill({ status: 404, json: { success: false, code: 'files-not-found', error: 'None of the images could be found.' } }), { times: 1 });
|
||||
await applyCategory(page, 'Bulk392');
|
||||
await expect(toast(page)).toHaveText('None of the selected images could be found. Refresh the images and try again.');
|
||||
await expect(selectedCount(page)).toHaveText('All 202 matching selected');
|
||||
// The real write: one request with the filter, every matching image's positive changes.
|
||||
await bulkbar(page).getByRole('button', { name: 'Set category', exact: true }).click();
|
||||
await categorize.getByRole('textbox', { name: 'Category', exact: true }).fill('Bulk392');
|
||||
const posted = page.waitForRequest(item => item.method() === 'POST' && new URL(item.url()).pathname === '/prompt_manager/bulk/category');
|
||||
await categorize.getByRole('button', { name: 'Apply', exact: true }).click();
|
||||
expect((await posted).postDataJSON()).toEqual({ images_filter: { sort: 'newest' }, category: 'Bulk392' });
|
||||
await expect(toast(page)).toHaveText(/^Category set on \d+ prompts?\.$/);
|
||||
// The real write: one PUT with the listing filter sets every matching image's own category; no prompt changes.
|
||||
const writes = categoryWrites(page);
|
||||
await applyCategory(page, 'Bulk392');
|
||||
await expect(toast(page)).toHaveText('Category “Bulk392” set on 202 images.');
|
||||
expect(writes).toEqual([{ method: 'PUT', path: IMAGE_CATEGORY, body: { filter: { sort: 'newest' }, category: 'Bulk392' } }]);
|
||||
await expect(bulkbar(page)).toBeEmpty();
|
||||
const filtered = await (await request.get('/prompt_manager/images/output?mode=workspace&limit=1&category=Bulk392')).json();
|
||||
expect(filtered.total).toBe(202);
|
||||
expect((await (await request.get('/prompt_manager/library/options')).json()).categories).not.toContain('Bulk392');
|
||||
// A return visit keeps "all matching" as the loaded images, and the offer shows again.
|
||||
await cardAt(page, 0).getByRole('button', { name: /^Open / }).focus();
|
||||
await page.keyboard.press('ControlOrMeta+a');
|
||||
@@ -2426,10 +2636,12 @@ test('all matching over 10,000 images says so before any dialog or request', asy
|
||||
await bulkbar(page).getByRole('button', { name: 'Select all 20,002 matching', exact: true }).click();
|
||||
await expect(selectedCount(page)).toHaveText('All 20,002 matching selected');
|
||||
let posted = 0;
|
||||
page.on('request', item => { if (item.method() === 'POST') posted += 1; });
|
||||
for (const action of ['Set category', 'Add tags', 'Auto-tag selected']) {
|
||||
page.on('request', item => { if (item.method() !== 'GET') posted += 1; });
|
||||
// Set category names at most 500 images (#332); the tagging jobs take up to 10,000.
|
||||
for (const [action, sentence] of [['Set category', 'Select at most 500 images.'], ['Add tags', 'Narrow the view to at most 10,000 images.'],
|
||||
['Auto-tag selected', 'Narrow the view to at most 10,000 images.']]) {
|
||||
await bulkbar(page).getByRole('button', { name: action, exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('Narrow the view to at most 10,000 images.');
|
||||
await expect(toast(page)).toHaveText(sentence);
|
||||
await expect(page.locator('dialog[open]')).toHaveCount(0);
|
||||
}
|
||||
expect(posted).toBe(0);
|
||||
|
||||
@@ -123,6 +123,32 @@ test('model options are the four real engines with size and the description show
|
||||
await expect(actionOf(page, 'Unload')).toBeDisabled();
|
||||
});
|
||||
|
||||
const NEEDS_LLAMA = 'GGUF JoyCaption needs llama-cpp-python. Install it into ComfyUI\'s Python: pip install llama-cpp-python. '
|
||||
+ 'For a CUDA build: CMAKE_ARGS="-DGGML_CUDA=on" pip install llama-cpp-python';
|
||||
|
||||
test('a downloaded model whose backend package is missing says so and is not offered as ready (#424)', async ({ page, request }) => {
|
||||
await expect(await request.post('/__test__/tagging/models', { data: { unavailable: ['wd14-vit'],
|
||||
missing_packages: { gguf: ['llama-cpp-python'], 'wd14-vit': ['onnxruntime'] } } })).toBeOK();
|
||||
await openTagging(page);
|
||||
const line = modelRow(page).locator('p').first();
|
||||
await page.locator('#tag-model').selectOption('gguf');
|
||||
await expect(line).toHaveText('Downloaded. Needs llama-cpp-python: pip install llama-cpp-python. '
|
||||
+ 'For a CUDA build: CMAKE_ARGS="-DGGML_CUDA=on" pip install llama-cpp-python');
|
||||
// Files and packages are separate: Download (again) stays offered.
|
||||
await expect(actionOf(page, 'Download again')).not.toHaveAttribute('aria-disabled', 'true');
|
||||
// Review explains instead of reviewing; nothing is previewed.
|
||||
await expect(reviewButton(page)).toHaveAttribute('aria-disabled', 'true');
|
||||
await reviewButton(page).dispatchEvent('click');
|
||||
await expect(toast(page)).toHaveText(NEEDS_LLAMA);
|
||||
expect(page.modelCalls.filter(call => call.endsWith('/autotag/batch/preview'))).toEqual([]);
|
||||
// Not downloaded reads differently and still names what it will need.
|
||||
await page.locator('#tag-model').selectOption('wd14-vit');
|
||||
await expect(line).toHaveText('Not downloaded. Download size ~400 MB. Also needs onnxruntime.');
|
||||
await page.locator('#tag-model').selectOption('wd14-swinv2');
|
||||
await expect(line).toHaveText('Downloaded. Not loaded.');
|
||||
await expect(reviewButton(page)).not.toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
|
||||
test('entering the section makes no model call besides status', async ({ page }) => {
|
||||
await openTagging(page);
|
||||
await page.locator('#workspace-main .section-nav').getByRole('button', { name: 'Integrations', exact: true }).click();
|
||||
|
||||
@@ -186,6 +186,26 @@ test('a model that is not downloaded keeps Start disabled and says why', async (
|
||||
expect(posted).toBe(0);
|
||||
});
|
||||
|
||||
const NEEDS_LLAMA = 'GGUF JoyCaption needs llama-cpp-python. Install it into ComfyUI\'s Python: pip install llama-cpp-python. '
|
||||
+ 'For a CUDA build: CMAKE_ARGS="-DGGML_CUDA=on" pip install llama-cpp-python';
|
||||
|
||||
test('a model missing its backend package keeps Start disabled and names the install command (#424)', async ({ page, request }) => {
|
||||
await expect(await request.post('/__test__/tagging/models', { data: { unavailable: [],
|
||||
missing_packages: { gguf: ['llama-cpp-python'] } } })).toBeOK();
|
||||
await select(page, FIRST);
|
||||
await barButton(page, 'Auto-tag selected').click();
|
||||
const status = modal(page).locator('#tag-generation-status');
|
||||
const start = actionButtons(page).last();
|
||||
await modal(page).getByRole('combobox', { name: 'Tagging model', exact: true }).selectOption('gguf');
|
||||
await expect(status).toHaveText(NEEDS_LLAMA);
|
||||
await expect(start).toHaveAttribute('aria-disabled', 'true');
|
||||
await modal(page).getByRole('combobox', { name: 'Tagging model', exact: true }).selectOption('wd14-vit');
|
||||
await expect(status).toHaveText('');
|
||||
await expect(start).not.toHaveAttribute('aria-disabled', 'true');
|
||||
await actionButtons(page).first().click();
|
||||
await expect(modal(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('Suggestions field prefills from the setting and caps chips at the entered count', async ({ page, request }) => {
|
||||
await expect(await request.post('/__test__/tagging/defaults', { data: { general: 0.5, character: 0.9, limit: 7 } })).toBeOK();
|
||||
await select(page, FIRST);
|
||||
|
||||
@@ -260,6 +260,18 @@ test('unavailable model shows inline status and never downloads', async ({ page,
|
||||
await expect(dialog(page).locator('.dialog-actions .ca-primary')).toBeFocused();
|
||||
});
|
||||
|
||||
test('a missing backend package is the server\'s install sentence, no traceback page (#424)', async ({ page, request }) => {
|
||||
await expect(await request.post('/__test__/tagging/models', { data: { unavailable: [],
|
||||
missing_packages: { 'wd14-swinv2': ['onnxruntime'] } } })).toBeOK();
|
||||
await openTag(page);
|
||||
const answer = await generated(page);
|
||||
expect(answer.status()).toBe(409);
|
||||
expect(await answer.json()).toMatchObject({ success: false, code: 'model-unavailable' });
|
||||
await expect(status(page)).toHaveText('WD14 SwinV2 needs onnxruntime. Install it into ComfyUI\'s Python: '
|
||||
+ 'pip install onnxruntime. Use onnxruntime-gpu for CUDA.');
|
||||
await expect(chips(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('video tag is manual only', async ({ page, request }) => {
|
||||
await openTag(page, VIDEO);
|
||||
const preview = dialog(page).locator('video.tag-image-preview');
|
||||
|
||||
@@ -383,6 +383,62 @@ test('manage categories previews real impact before applying',async({page,reques
|
||||
expect(after.prompts.filter(row=>row[2]==='Scenery')).toHaveLength(3);
|
||||
});
|
||||
|
||||
// #332 MC9, MC10: image categories (their own, set on Images) count beside the prompts. The vocabulary fixture has no
|
||||
// image files, so these tests add two committed files to the output folder and set their categories through the API.
|
||||
async function imageCategories(request,category) {
|
||||
for(const name of ['f02.png','f03.png']) await expect(await request.post('/__test__/add-local-inspect-file',{data:{name}})).toBeOK();
|
||||
const listing=await(await request.get('/prompt_manager/images/output?mode=workspace&subfolder=local-inspect&limit=40')).json();
|
||||
const items=listing.images.map(({root_index,root_scope,path})=>({root_index,root_scope,path}));expect(items).toHaveLength(2);
|
||||
const response=await request.put('/prompt_manager/images/category',{data:{items,category}});await expect(response).toBeOK();
|
||||
expect((await response.json()).updated).toBe(2);
|
||||
}
|
||||
const ownCategories=async request=>(await(await request.get('/prompt_manager/images/categories')).json()).categories;
|
||||
const IMAGE_KEYS=['0:local-inspect/f02.png','0:local-inspect/f03.png'];
|
||||
|
||||
test('manage categories renames the images\' own categories too and the preview counts them (#332 CUJ 6)',async({page,request})=>{
|
||||
await imageCategories(request,'Landscapes');
|
||||
const writes=trackWrites(page);await openPage(page);
|
||||
await expect(categoryRow(page,'Landscapes').locator('p')).toHaveText('3 positive prompts · 2 images');
|
||||
const box=await openManage(page);
|
||||
await expect(field(box,'Category')).toHaveValue('Landscapes');
|
||||
await field(box,'Proposed new category name').fill('Landscape');await button(box,'Preview').click();
|
||||
await expect(box.locator('p.small')).toHaveText('“Landscapes” is used by 3 positive prompts and 2 images. Apply renames it to “Landscape”.');
|
||||
await button(box,'Apply').click();
|
||||
await expect(box).toHaveCount(0);await expect(toast(page)).toHaveText('Category renamed.');
|
||||
expect(writes.at(-1)).toMatchObject({url:'/prompt_manager/vocabulary/categories/apply',body:{source:'Landscapes',target:'Landscape'}});
|
||||
await expect(categoryRow(page,'Landscapes')).toHaveCount(0);
|
||||
await expect(categoryRow(page,'Landscape').locator('p')).toHaveText('3 positive prompts · 2 images');
|
||||
// The prompts and both images carry the new name; nothing keeps the old one.
|
||||
expect((await snapshot(request)).prompts.filter(row=>row[2]==='Landscape')).toHaveLength(3);
|
||||
expect(await ownCategories(request)).toEqual(['Landscape']);
|
||||
for(const [category,total] of [['Landscape',2],['Landscapes',0]]) {
|
||||
const listing=await(await request.get(`/prompt_manager/images/output?mode=workspace&limit=40&category=${category}`)).json();
|
||||
expect(listing.images.map(image=>`${image.root_index}:${image.path}`).sort()).toEqual(total?IMAGE_KEYS:[]);
|
||||
}
|
||||
});
|
||||
|
||||
test('a category only images hold is listed after the prompt-backed ones and Browse opens Images (#332 CUJ 7)',async({page,request})=>{
|
||||
await imageCategories(request,'Scans');
|
||||
await openPage(page);
|
||||
const operations=surfaces(page).nth(1).locator('.operation');
|
||||
await expect(operations.locator('h3')).toHaveText([...CATEGORIES.map(([name])=>name),'Scans','Manage category']);
|
||||
await expect(categoryRow(page,'Scans').locator('p')).toHaveText('2 images');
|
||||
for(const [name,count] of CATEGORIES) await expect(categoryRow(page,name).locator('p')).toHaveText(`${count} positive prompt${count===1?'':'s'}`);
|
||||
// Manage category can rename it: its select lists it last.
|
||||
const box=await openManage(page);
|
||||
await expect(field(box,'Category').locator('option')).toHaveText([...CATEGORIES.map(([name])=>name),'Scans']);
|
||||
await button(box,'Cancel').click();await expect(box).toHaveCount(0);
|
||||
// Browse opens Images filtered by it (its Library would be empty).
|
||||
await categoryRow(page,'Scans').getByRole('button',{name:'Browse',exact:true}).click();
|
||||
await expect(page).toHaveURL(/#\/images$/);
|
||||
await expect(main(page).getByRole('heading',{level:1})).toHaveText('Images');
|
||||
await expect(page.getByRole('combobox',{name:'Filter by category',exact:true})).toHaveValue('Scans');
|
||||
const cards=page.locator('article[data-image-key]');await expect(cards).toHaveCount(2);
|
||||
expect((await cards.evaluateAll(nodes=>nodes.map(node=>node.dataset.imageKey))).sort()).toEqual(IMAGE_KEYS);
|
||||
await expect(page.getByRole('searchbox',{name:'Search workspace',exact:true})).toHaveValue('');
|
||||
await expect(page.locator('#workspace-main search.toolbar').getByRole('button',{name:'Filters',exact:true})).toBeVisible();
|
||||
});
|
||||
|
||||
test('an edit during a pending preview needs a new Preview; Apply never sends the old values',async({page,request})=>{
|
||||
const writes=trackWrites(page);await openPage(page);const before=await snapshot(request);
|
||||
let release;const held=new Promise(resolve=>{release=resolve;});
|
||||
|
||||
@@ -41,6 +41,18 @@ FILES = (
|
||||
("loose/unlinked.png", "unlinked"),
|
||||
)
|
||||
MODELS = ("wd14-swinv2", "wd14-vit", "gguf", "hf")
|
||||
|
||||
|
||||
def _valid_missing(value) -> bool:
|
||||
"""``{"<model>": ["<pip name>", ...]}`` with known models and string names."""
|
||||
return isinstance(value, dict) and all(
|
||||
name in MODELS
|
||||
and isinstance(names, list)
|
||||
and all(isinstance(pip, str) and pip for pip in names)
|
||||
for name, names in value.items()
|
||||
)
|
||||
|
||||
|
||||
# Unlinked copies enough to virtualize the Images grid (it renders all up to 120).
|
||||
MAX_EXTRA_IMAGES = 160
|
||||
_TABLES = (
|
||||
@@ -217,6 +229,7 @@ def register_tagging_commands(
|
||||
if not 0 <= extra <= MAX_EXTRA_IMAGES:
|
||||
raise web.HTTPBadRequest()
|
||||
model.unavailable = set()
|
||||
model.missing_packages = {}
|
||||
model.suggestions = None
|
||||
model.loaded = None
|
||||
restore_download()
|
||||
@@ -235,17 +248,25 @@ def register_tagging_commands(
|
||||
|
||||
@routes.post("/__test__/tagging/models")
|
||||
async def models(request: web.Request) -> web.Response:
|
||||
"""Mark fake models as not downloaded (``{"unavailable": [...]}``)."""
|
||||
"""Mark fake models as not downloaded (``{"unavailable": [...]}``) and,
|
||||
optionally, as missing backend packages (``"missing_packages":
|
||||
{"<model>": ["<pip name>", ...]}``, #424)."""
|
||||
data = await request.json() if request.can_read_body else None
|
||||
if (
|
||||
request.query
|
||||
or not isinstance(data, dict)
|
||||
or set(data) != {"unavailable"}
|
||||
or "unavailable" not in data
|
||||
or set(data) - {"unavailable", "missing_packages"}
|
||||
or not isinstance(data["unavailable"], list)
|
||||
or any(name not in MODELS for name in data["unavailable"])
|
||||
or not _valid_missing(data.get("missing_packages", {}))
|
||||
):
|
||||
raise web.HTTPBadRequest()
|
||||
model.unavailable = set(data["unavailable"])
|
||||
model.missing_packages = {
|
||||
name: list(names)
|
||||
for name, names in data.get("missing_packages", {}).items()
|
||||
}
|
||||
return web.json_response({"success": True})
|
||||
|
||||
@routes.post("/__test__/tagging/loaded")
|
||||
|
||||
@@ -72,6 +72,10 @@ CONCEPT_RECIPES = (
|
||||
"negative": "oversaturated, flat lighting, watermark, text",
|
||||
},
|
||||
)
|
||||
# #332 D143/D144 (opt-in, ``image_categories``): own image categories, one on
|
||||
# images only and one shared with prompts. Stored rows count whether or not the
|
||||
# files exist, so the keys name files the fixture never writes.
|
||||
IMAGE_CATEGORY_ROWS = (("Landscapes", 2), ("Portraits", 1))
|
||||
PAIR_ONLY_TAG = "pair_only_detail"
|
||||
ARCHIVED_ONLY_TAG = "archived_pair_detail"
|
||||
NEGATIVE_ONLY_TAG = "legacy_negative_detail"
|
||||
@@ -112,8 +116,26 @@ _SNAPSHOT = {
|
||||
}
|
||||
|
||||
|
||||
def _seed_image_categories(api: PromptManagerAPI) -> dict:
|
||||
"""Own image category rows keyed as Images keys them; ``{value: count}``."""
|
||||
from py.api.images import image_category_key
|
||||
|
||||
sources = list(api._get_all_output_dirs())
|
||||
for category, count in IMAGE_CATEGORY_ROWS:
|
||||
keys = [
|
||||
image_category_key(sources, str(Path(sources[0]) / f"{category}_{n}.png"))
|
||||
for n in range(count)
|
||||
]
|
||||
api.db.set_image_categories(keys, category)
|
||||
return dict(IMAGE_CATEGORY_ROWS)
|
||||
|
||||
|
||||
def seed_vocabulary(
|
||||
api: PromptManagerAPI, root: Path, extra_tags: int = 0, extra_categories: int = 0
|
||||
api: PromptManagerAPI,
|
||||
root: Path,
|
||||
extra_tags: int = 0,
|
||||
extra_categories: int = 0,
|
||||
image_categories: bool = False,
|
||||
) -> dict:
|
||||
"""Replace this server's disposable rows with the concept vocabulary."""
|
||||
db = api.db
|
||||
@@ -167,6 +189,7 @@ def seed_vocabulary(
|
||||
"category": f"bulk_category_{number:04}",
|
||||
},
|
||||
)
|
||||
images = _seed_image_categories(api) if image_categories else {}
|
||||
with db.model.get_connection() as conn:
|
||||
tags = [row[0] for row in conn.execute("SELECT name FROM tags ORDER BY id")]
|
||||
api.invalidate_gallery_cache()
|
||||
@@ -180,6 +203,7 @@ def seed_vocabulary(
|
||||
"archived_only_tag": ARCHIVED_ONLY_TAG,
|
||||
"negative_only_tag": NEGATIVE_ONLY_TAG,
|
||||
"unused_tag": UNUSED_TAG,
|
||||
"image_categories": images,
|
||||
}
|
||||
|
||||
|
||||
@@ -200,7 +224,14 @@ def register_vocabulary_commands(
|
||||
@routes.post("/__test__/vocabulary/reset")
|
||||
async def reset(request: web.Request) -> web.Response:
|
||||
data = await request.json() if request.can_read_body else {}
|
||||
if not isinstance(data, dict) or set(data) - {"extra_tags", "extra_categories"}:
|
||||
if not isinstance(data, dict) or set(data) - {
|
||||
"extra_tags",
|
||||
"extra_categories",
|
||||
"image_categories",
|
||||
}:
|
||||
raise web.HTTPBadRequest()
|
||||
images = data.get("image_categories", False)
|
||||
if not isinstance(images, bool):
|
||||
raise web.HTTPBadRequest()
|
||||
extra = data.get("extra_tags", 0)
|
||||
categories = data.get("extra_categories", 0)
|
||||
@@ -215,7 +246,7 @@ def register_vocabulary_commands(
|
||||
if request.query:
|
||||
raise web.HTTPBadRequest()
|
||||
manifest = await api._run_in_executor(
|
||||
seed_vocabulary, api, root, extra, categories
|
||||
seed_vocabulary, api, root, extra, categories, images
|
||||
)
|
||||
return web.json_response({"success": True, **manifest})
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"routes": [
|
||||
"DELETE /prompt_manager/collections/items",
|
||||
"DELETE /prompt_manager/collections/{collection_id}",
|
||||
"DELETE /prompt_manager/delete/{prompt_id}",
|
||||
"DELETE /prompt_manager/image-annotations/capture/{token}",
|
||||
"DELETE /prompt_manager/images/category",
|
||||
@@ -15,6 +17,8 @@
|
||||
"GET /prompt_manager/backup",
|
||||
"GET /prompt_manager/backup/status",
|
||||
"GET /prompt_manager/categories",
|
||||
"GET /prompt_manager/collections",
|
||||
"GET /prompt_manager/collections/membership",
|
||||
"GET /prompt_manager/diagnostics",
|
||||
"GET /prompt_manager/export",
|
||||
"GET /prompt_manager/gallery",
|
||||
@@ -92,6 +96,7 @@
|
||||
"GET /prompt_manager/vocabulary/tags/{tag_id}/impact",
|
||||
"GET /prompt_manager/web",
|
||||
"GET /prompt_manager/workspace",
|
||||
"PATCH /prompt_manager/collections/{collection_id}",
|
||||
"PATCH /prompt_manager/library/{positive_id}",
|
||||
"PATCH /prompt_manager/pairs/{pair_id}",
|
||||
"PATCH /prompt_manager/saved-views/{view_id}",
|
||||
@@ -106,6 +111,7 @@
|
||||
"POST /prompt_manager/bulk/delete",
|
||||
"POST /prompt_manager/bulk/tags",
|
||||
"POST /prompt_manager/cleanup",
|
||||
"POST /prompt_manager/collections/items",
|
||||
"POST /prompt_manager/delete_duplicate_images",
|
||||
"POST /prompt_manager/diagnostics/test-link",
|
||||
"POST /prompt_manager/gallery/rescan",
|
||||
|
||||
@@ -384,7 +384,7 @@ test('the D117 wrap rule targets only the settings summary button', async () =>
|
||||
assert.doesNotMatch(css, /#viewer-details > div > button/);
|
||||
});
|
||||
|
||||
test('category chip names its origin and shows an own category for any link status (#332)', async () => {
|
||||
test('category chip names its origin visibly and shows an own category for any link status (#332 D146)', async () => {
|
||||
const base = await localRecord(localPng('a.png'));
|
||||
const chipTexts = record => {
|
||||
const {f, done} = renderFull(record);
|
||||
@@ -393,11 +393,11 @@ test('category chip names its origin and shows an own category for any link stat
|
||||
} finally { done(); }
|
||||
};
|
||||
const cases = [
|
||||
[{status:'none', category:'Scans', category_scope:'image'}, 'Image category: Scans'],
|
||||
[{status:'ambiguous', category:'Scans', category_scope:'image'}, 'Image category: Scans'],
|
||||
[{status:'linked', category:'Portraits', category_scope:'image'}, 'Image category: Portraits'],
|
||||
[{status:'linked', category:'Landscapes', category_scope:'positive'}, 'Positive prompt category: Landscapes'],
|
||||
[{status:'linked', category:'Recipes', category_scope:'pair'}, 'Recipe category: Recipes'],
|
||||
[{status:'none', category:'Scans', category_scope:'image'}, 'Image category: Scans · this image'],
|
||||
[{status:'ambiguous', category:'Scans', category_scope:'image'}, 'Image category: Scans · this image'],
|
||||
[{status:'linked', category:'Portraits', category_scope:'image'}, 'Image category: Portraits · this image'],
|
||||
[{status:'linked', category:'Landscapes', category_scope:'positive'}, 'Positive prompt category: Landscapes · prompt'],
|
||||
[{status:'linked', category:'Recipes', category_scope:'pair'}, 'Recipe category: Recipes · recipe'],
|
||||
];
|
||||
for (const [library, text] of cases) {
|
||||
const chips = chipTexts({...base, library:{...base.library, tags:[], tags_scope:null, ...library}});
|
||||
|
||||
@@ -1,53 +1,61 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
// #332 D140: Set category writes each selected image's own category; Use prompt category removes it.
|
||||
const source = import('../../web/js/workspace/images-category.mjs');
|
||||
|
||||
const linked = (id, extra = {}) => ({path:`p${id}.png`, linked:{status:'linked', positive_id:id, ...extra}});
|
||||
|
||||
test('targets are the distinct linked positives, in selection order, and the rest count as skipped', async () => {
|
||||
const {categoryTargets} = await source;
|
||||
const images = [
|
||||
linked(7), linked(3), linked(7),
|
||||
{path:'u.png', linked:{status:'unlinked', positive_id:null}},
|
||||
{path:'a.png', linked:{status:'ambiguous', positive_id:4}},
|
||||
{path:'v.png', linked:{status:'unavailable', positive_id:null}},
|
||||
{path:'none.png'},
|
||||
{path:'bad.png', linked:{status:'linked', positive_id:'9'}},
|
||||
{path:'zero.png', linked:{status:'linked', positive_id:0}},
|
||||
{path:'frac.png', linked:{status:'linked', positive_id:1.5}},
|
||||
null,
|
||||
];
|
||||
assert.deepEqual(categoryTargets(images), {promptIds:[7, 3], skipped:8});
|
||||
assert.deepEqual(categoryTargets([]), {promptIds:[], skipped:0});
|
||||
assert.deepEqual(categoryTargets([linked(2)]), {promptIds:[2], skipped:0});
|
||||
});
|
||||
|
||||
test('the outcome toast counts the prompts and the skipped images', async () => {
|
||||
const {categoryOutcome} = await source;
|
||||
assert.equal(categoryOutcome(3, 0), 'Category set on 3 prompts.');
|
||||
assert.equal(categoryOutcome(1, 0), 'Category set on 1 prompt.');
|
||||
assert.equal(categoryOutcome(2, 4), 'Category set on 2 prompts. 4 selected images had no linked prompt and were skipped.');
|
||||
assert.equal(categoryOutcome(1, 1), 'Category set on 1 prompt. 1 selected image had no linked prompt and was skipped.');
|
||||
});
|
||||
|
||||
test('the dialog and failure copy is the plan\'s', async () => {
|
||||
const {CATEGORY_DIALOG, CATEGORY_NONE_LINKED, CATEGORY_FAILED} = await source;
|
||||
assert.deepEqual(CATEGORY_DIALOG, {title:'Categorize selected images', label:'Category', action:'Apply'});
|
||||
assert.equal(CATEGORY_NONE_LINKED, "None of the selected images is linked to a saved prompt, so there is no prompt category to set.");
|
||||
test('the dialog keeps the concept\'s simpleInput words and names the routes', async () => {
|
||||
const {CATEGORY_DIALOG, USE_PROMPT_CATEGORY, REMOVE_DIALOG, CATEGORY_URL, IMAGE_CATEGORIES_URL, CATEGORY_FAILED,
|
||||
CATEGORY_MAX_LENGTH, MAX_CATEGORY_IMAGES} = await source;
|
||||
assert.deepEqual({...CATEGORY_DIALOG}, {title:'Categorize selected images', label:'Category', action:'Apply'});
|
||||
assert.equal(USE_PROMPT_CATEGORY, 'Use prompt category');
|
||||
assert.deepEqual({...REMOVE_DIALOG}, {title:'Use prompt category', action:'Remove'});
|
||||
assert.equal(CATEGORY_URL, '/prompt_manager/images/category');
|
||||
assert.equal(IMAGE_CATEGORIES_URL, '/prompt_manager/images/categories');
|
||||
assert.equal(CATEGORY_FAILED, 'Category could not be set. Try again.');
|
||||
assert.ok(Object.isFrozen(CATEGORY_DIALOG));
|
||||
assert.equal(CATEGORY_MAX_LENGTH, 100);
|
||||
assert.equal(MAX_CATEGORY_IMAGES, 500);
|
||||
});
|
||||
|
||||
test('the updated count is the server\'s when it is a count, else the number of prompts posted', async () => {
|
||||
const {updatedCount} = await source;
|
||||
assert.equal(updatedCount({updated_count:0}, 3), 0);
|
||||
assert.equal(updatedCount({updated_count:2}, 3), 2);
|
||||
for (const result of [{}, {updated_count:-1}, {updated_count:1.5}, {updated_count:'2'}, null, undefined]) {
|
||||
assert.equal(updatedCount(result, 3), 3, JSON.stringify(result));
|
||||
test('a set counts unchanged files as set and names files that could not be found', async () => {
|
||||
const {setOutcome} = await source;
|
||||
assert.equal(setOutcome({updated:1, unchanged:0, not_found:0}, 'Portraits'), 'Category “Portraits” set on 1 image.');
|
||||
assert.equal(setOutcome({updated:1, unchanged:2, not_found:0}, 'Scans'), 'Category “Scans” set on 3 images.');
|
||||
assert.equal(setOutcome({updated:2, unchanged:0, not_found:1}, 'Scans'), 'Category “Scans” set on 2 images. 1 could not be found.');
|
||||
assert.equal(setOutcome({updated:0, unchanged:1, not_found:3}, 'Scans'), 'Category “Scans” set on 1 image. 3 could not be found.');
|
||||
assert.equal(setOutcome({}, 'Scans'), 'Category “Scans” set on 0 images.');
|
||||
});
|
||||
|
||||
test('a removal says how many lost their own category, or that none had one', async () => {
|
||||
const {clearOutcome, NONE_HAD_OWN} = await source;
|
||||
assert.equal(clearOutcome({cleared:1}), 'Removed the image category from 1 image.');
|
||||
assert.equal(clearOutcome({cleared:4}), 'Removed the image category from 4 images.');
|
||||
assert.equal(clearOutcome({cleared:0}), NONE_HAD_OWN);
|
||||
assert.equal(NONE_HAD_OWN, 'None of the selected images had its own category.');
|
||||
});
|
||||
|
||||
test('the confirmation states the count', async () => {
|
||||
const {removeQuestion} = await source;
|
||||
assert.equal(removeQuestion(1), "Remove the image category from the 1 selected image? Each one then shows its prompt's " +
|
||||
'category, or none if it has no linked prompt. This cannot be undone.');
|
||||
assert.match(removeQuestion(1200), /^Remove the image category from the 1,200 selected images\? /);
|
||||
});
|
||||
|
||||
test('coded failures have their own sentences, anything else the fixed one', async () => {
|
||||
const {failureMessage, NONE_FOUND, SOURCES_CHANGED, CATEGORY_FAILED} = await source;
|
||||
assert.equal(failureMessage({code:'files-not-found'}), NONE_FOUND);
|
||||
assert.equal(NONE_FOUND, 'None of the selected images could be found. Refresh the images and try again.');
|
||||
assert.equal(failureMessage({code:'source-changed'}), SOURCES_CHANGED);
|
||||
assert.equal(SOURCES_CHANGED, 'The sources changed. Refresh the images and try again.');
|
||||
assert.equal(failureMessage({code:'too-many-images', message:'Select at most 500 images.'}), 'Select at most 500 images.');
|
||||
for (const error of [null, undefined, new Error('x'), {code:'other', message:'secret'}]) {
|
||||
assert.equal(failureMessage(error), CATEGORY_FAILED);
|
||||
}
|
||||
});
|
||||
|
||||
test('the category field is limited to the server\'s 100 characters', async () => {
|
||||
const {CATEGORY_MAX_LENGTH} = await source;
|
||||
assert.equal(CATEGORY_MAX_LENGTH, 100);
|
||||
test('the toolbar list merges prompt and image categories once each', async () => {
|
||||
const {mergeCategories} = await source;
|
||||
assert.deepEqual(mergeCategories(['Landscapes', 'Portraits'], ['Scans', 'Landscapes']), ['Landscapes', 'Portraits', 'Scans']);
|
||||
assert.deepEqual(mergeCategories(['A', '', ' ', 7], null), ['A']);
|
||||
assert.deepEqual(mergeCategories(undefined, ['landscapes', 'Landscapes']), ['landscapes', 'Landscapes']);
|
||||
});
|
||||
|
||||
@@ -135,6 +135,41 @@ test('a model that is not downloaded is named with the server sentence', () => {
|
||||
assert.throws(() => modelUnavailable({}, 'nope'), TypeError);
|
||||
});
|
||||
|
||||
test('a downloaded model whose backend package is missing names the package and the command (#424)', () => {
|
||||
const { modelUnavailable, needsPackages } = state;
|
||||
const gguf = { downloaded: true, name: 'GGUF (Recommended)', label: 'GGUF JoyCaption', missing_packages: ['llama-cpp-python'],
|
||||
install_command: 'pip install llama-cpp-python',
|
||||
install_note: 'For a CUDA build: CMAKE_ARGS="-DGGML_CUDA=on" pip install llama-cpp-python' };
|
||||
const sentence = 'GGUF JoyCaption needs llama-cpp-python. Install it into ComfyUI\'s Python: pip install llama-cpp-python. '
|
||||
+ 'For a CUDA build: CMAKE_ARGS="-DGGML_CUDA=on" pip install llama-cpp-python';
|
||||
assert.equal(modelUnavailable({ gguf }, 'gguf'), sentence);
|
||||
// Not downloaded is named first, in its own sentence.
|
||||
assert.equal(modelUnavailable({ gguf: { ...gguf, downloaded: false } }, 'gguf'),
|
||||
'GGUF (Recommended) is not downloaded. Download it in System › Auto-tagging.');
|
||||
assert.equal(modelUnavailable({ gguf: { ...gguf, missing_packages: [] } }, 'gguf'), '');
|
||||
assert.equal(needsPackages('HuggingFace JoyCaption', { missing_packages: ['accelerate', 'bitsandbytes'] }),
|
||||
'HuggingFace JoyCaption needs accelerate and bitsandbytes. Install them into ComfyUI\'s Python: pip install accelerate bitsandbytes.');
|
||||
assert.equal(needsPackages('X', { missing_packages: ['a', 'b', 'c'], install_command: 'pip install a b c' }),
|
||||
'X needs a, b and c. Install them into ComfyUI\'s Python: pip install a b c.');
|
||||
assert.equal(needsPackages('X', null), '');
|
||||
assert.equal(needsPackages('X', { missing_packages: [1, ''] }), '');
|
||||
// Without the server's label the option's own name is used.
|
||||
assert.ok(modelUnavailable({ 'wd14-vit': { downloaded: true, missing_packages: ['onnxruntime'] } }, 'wd14-vit')
|
||||
.startsWith('WD14 ViT needs onnxruntime.'));
|
||||
});
|
||||
|
||||
test('the bulk dialog keeps Start disabled while the chosen model misses a package (#424)', async () => {
|
||||
const { createTaggingOptionsController } = state;
|
||||
const models = { 'wd14-vit': { downloaded: true, label: 'WD14 ViT', missing_packages: ['onnxruntime'],
|
||||
install_command: 'pip install onnxruntime' } };
|
||||
const options = createTaggingOptionsController({ api: { get: async () => ({ success: true, models, model_type: 'wd14-vit' }) } });
|
||||
await options.open();
|
||||
assert.equal(options.state.model, 'wd14-vit');
|
||||
assert.equal(options.state.canStart, false);
|
||||
assert.equal(options.state.unavailable, 'WD14 ViT needs onnxruntime. Install it into ComfyUI\'s Python: pip install onnxruntime.');
|
||||
options.dispose();
|
||||
});
|
||||
|
||||
test('the bulk options start from the server defaults and wait for them before Start', async () => {
|
||||
const { createTaggingOptionsController } = state;
|
||||
let answer;
|
||||
|
||||
@@ -63,9 +63,9 @@ test('status line shows only server-reported backups (D103)', async () => {
|
||||
|
||||
const PREVIEW = Object.freeze({
|
||||
success: true, token: 'r'.repeat(43), expires_at: '2026-10-25T12:15:00+00:00',
|
||||
summary: { prompts: 1234, positives: 900, pairs: 3, archived_pairs: 0, image_links: 56, runs: 1, saved_views: 2 },
|
||||
summary: { prompts: 1234, positives: 900, pairs: 3, archived_pairs: 0, image_links: 56, runs: 1, saved_views: 2, collections: 1 },
|
||||
schema: { migrated: true, library_state: true }, integrity: 'ok', documents: { repaired: 0, cleared: 0, images: 0 },
|
||||
live: { prompts: 1, positives: 1, pairs: 0, archived_pairs: 0, image_links: 0, runs: 0, saved_views: 0 }, size: 4096,
|
||||
live: { prompts: 1, positives: 1, pairs: 0, archived_pairs: 0, image_links: 0, runs: 0, saved_views: 0, collections: 0 }, size: 4096,
|
||||
safety_backup: { destination_label: 'prompts.db.backup_<date and time>' },
|
||||
});
|
||||
|
||||
|
||||
@@ -482,6 +482,60 @@ test('Tag output images › Review previews the drafts, else says why; a refused
|
||||
} finally { scope.dispose(); dom.restore(); }
|
||||
});
|
||||
|
||||
// #424: GGUF on disk without llama-cpp-python.
|
||||
const NEEDS_LLAMA = { missing_packages: ['llama-cpp-python'], install_command: 'pip install llama-cpp-python',
|
||||
install_note: 'For a CUDA build: CMAKE_ARGS="-DGGML_CUDA=on" pip install llama-cpp-python' };
|
||||
const NEEDS_SENTENCE = 'GGUF JoyCaption needs llama-cpp-python. Install it into ComfyUI\'s Python: pip install llama-cpp-python. '
|
||||
+ 'For a CUDA build: CMAKE_ARGS="-DGGML_CUDA=on" pip install llama-cpp-python';
|
||||
|
||||
test('a model on disk whose backend package is missing never reads as ready (#424)', async () => {
|
||||
const { modelDescription, readStatus, batchCloseNote, STATUS_COPY } = await tagging;
|
||||
const status = readStatus({ ...MODELS, models: { ...MODELS.models,
|
||||
gguf: entry('GGUF JoyCaption', '~4.5 GB', { ...NEEDS_LLAMA }),
|
||||
'wd14-vit': entry('WD14 ViT', '~400 MB', { downloaded: false, missing_packages: ['onnxruntime'],
|
||||
install_command: 'pip install onnxruntime', install_note: 'Use onnxruntime-gpu for CUDA.' }),
|
||||
hf: entry('HuggingFace JoyCaption', '~16 GB', { downloaded: false, model_exists: undefined,
|
||||
missing_packages: ['accelerate', 'bitsandbytes'], install_command: 'pip install accelerate bitsandbytes' }) } });
|
||||
const ready = modelDescription(status, 'gguf');
|
||||
assert.notEqual(ready, STATUS_COPY.downloaded);
|
||||
assert.equal(ready, 'Downloaded. Needs llama-cpp-python: pip install llama-cpp-python. '
|
||||
+ 'For a CUDA build: CMAKE_ARGS="-DGGML_CUDA=on" pip install llama-cpp-python');
|
||||
// Not downloaded reads differently, and still says what it will need.
|
||||
assert.equal(modelDescription(status, 'wd14-vit'), 'Not downloaded. Download size ~400 MB. Also needs onnxruntime.');
|
||||
assert.equal(modelDescription(status, 'hf'), 'Not downloaded. Download size ~16 GB. Also needs accelerate and bitsandbytes.');
|
||||
const partial = readStatus({ ...MODELS, models: { ...MODELS.models, gguf: entry('GGUF JoyCaption', '~4.5 GB',
|
||||
{ downloaded: false, model_exists: true, mmproj_exists: false, ...NEEDS_LLAMA }) } });
|
||||
assert.equal(modelDescription(partial, 'gguf'), 'Partly downloaded. Download again to finish. Also needs llama-cpp-python.');
|
||||
// Untrusted shapes are ignored; a missing command is built from the names.
|
||||
const odd = readStatus({ ...MODELS, models: { ...MODELS.models, gguf: entry('GGUF JoyCaption', '~4.5 GB',
|
||||
{ missing_packages: ['llama-cpp-python', 3, ''], install_command: 7, install_note: {} }) } });
|
||||
assert.equal(modelDescription(odd, 'gguf'), 'Downloaded. Needs llama-cpp-python: pip install llama-cpp-python.');
|
||||
const junk = readStatus({ ...MODELS, models: { ...MODELS.models, gguf: entry('GGUF JoyCaption', '~4.5 GB',
|
||||
{ missing_packages: 'llama-cpp-python' }) } });
|
||||
assert.equal(modelDescription(junk, 'gguf'), STATUS_COPY.downloaded);
|
||||
// The batch review says why it cannot start, in the server's sentence.
|
||||
assert.equal(batchCloseNote({ ...PLAN, model: { type: 'gguf', label: 'GGUF JoyCaption', downloaded: true, ...NEEDS_LLAMA } }),
|
||||
NEEDS_SENTENCE);
|
||||
assert.equal(batchCloseNote({ ...PLAN, model: { ...PLAN.model, downloaded: false, ...NEEDS_LLAMA } }),
|
||||
'WD14 SwinV2 is not downloaded. Download it first.');
|
||||
});
|
||||
|
||||
test('Review explains a missing backend package instead of reviewing (#424)', async () => {
|
||||
const models = { ...MODELS, models: { ...MODELS.models, gguf: entry('GGUF JoyCaption', '~4.5 GB', { ...NEEDS_LLAMA }) } };
|
||||
const { dom, scope, button, byId, calls } = await mounted({ models });
|
||||
try {
|
||||
byId('tag-model').value = 'gguf'; await byId('tag-model').dispatch('change');
|
||||
const review = button('Review');
|
||||
assert.equal(review.getAttribute('aria-disabled'), 'true');
|
||||
assert.equal(review.dataset.reason, NEEDS_SENTENCE);
|
||||
await review.dispatch('click');
|
||||
assert.equal(calls.toasts.at(-1), NEEDS_SENTENCE);
|
||||
assert.deepEqual(calls.post, []);
|
||||
// Download stays offered: files and packages are separate.
|
||||
assert.equal(button('Download again').getAttribute('aria-disabled'), null);
|
||||
} finally { scope.dispose(); dom.restore(); }
|
||||
});
|
||||
|
||||
test('settingRefusal marks only an Auto-tagging save\'s plain 400 as invalid-setting (CUJ 2a)', async () => {
|
||||
const { settingRefusal } = await import('../../web/js/workspace/system.mjs');
|
||||
const refusal = (extra = {}) => Object.assign(new Error('General-tag confidence must be between 0 and 1.'),
|
||||
|
||||
@@ -567,6 +567,43 @@ test('an input dialog with a fallback applies it for an empty value', async () =
|
||||
}
|
||||
});
|
||||
|
||||
// #332: Set category's field suggests the categories under its own datalist id, and "Use prompt category" sits
|
||||
// between Cancel and Apply, closing the dialog before it runs.
|
||||
test('an input dialog offers suggestions and a secondary action', async () => {
|
||||
const { openInputDialog, closeWorkspaceDialog, INPUT_SUGGESTIONS_ID } = await dialogsModule;
|
||||
const dom = fakeDOM();
|
||||
const scope = (await coreModule).createScope();
|
||||
try {
|
||||
const calls = [];
|
||||
openInputDialog({ scope, title: 'Categorize selected images', label: 'Category', action: 'Apply', maxLength: 100,
|
||||
suggestions: ['Landscapes', 'Scans'], secondary: { label: 'Use prompt category', onClick: () => calls.push('secondary') },
|
||||
onApply: value => calls.push(value) });
|
||||
assert.equal(INPUT_SUGGESTIONS_ID, 'workspace-input-suggestions');
|
||||
const body = byId(dom, 'modal-body');
|
||||
const [field, list, actions] = body.children;
|
||||
const input = field.children[1];
|
||||
assert.equal(input.getAttribute('list'), 'workspace-input-suggestions');
|
||||
assert.equal(list.tagName.toLowerCase(), 'datalist');
|
||||
assert.equal(list.id, 'workspace-input-suggestions');
|
||||
assert.deepEqual(list.children.map(option => option.value), ['Landscapes', 'Scans']);
|
||||
assert.deepEqual(actions.children.map(node => node.textContent), ['Cancel', 'Use prompt category', 'Apply']);
|
||||
actions.children[1].click();
|
||||
assert.deepEqual(calls, ['secondary']);
|
||||
// No suggestions: no datalist and no list attribute.
|
||||
openInputDialog({ scope, title: 'T', label: 'L', action: 'A', onApply() {} });
|
||||
const [plainField, plainActions] = byId(dom, 'modal-body').children;
|
||||
assert.equal(plainField.children[1].getAttribute('list'), null);
|
||||
assert.deepEqual(plainActions.children.map(node => node.textContent), ['Cancel', 'A']);
|
||||
assert.throws(() => openInputDialog({ scope, title: 'T', label: 'L', action: 'A', suggestions: [1], onApply() {} }), TypeError);
|
||||
assert.throws(() => openInputDialog({ scope, title: 'T', label: 'L', action: 'A', secondary: { label: '' }, onApply() {} }), TypeError);
|
||||
assert.throws(() => openInputDialog({ scope, title: 'T', label: 'L', action: 'A', secondary: { label: 'X' }, onApply() {} }), TypeError);
|
||||
} finally {
|
||||
closeWorkspaceDialog();
|
||||
scope.dispose();
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('dialogs restore focus to the trigger on Escape', async () => {
|
||||
const { openRefineDialog, openImportDialog, openCopyFallback, closeWorkspaceDialog } = await dialogsModule;
|
||||
const dom = fakeDOM();
|
||||
|
||||
@@ -553,3 +553,16 @@ test('delete text names the tag, its real links and what stays unchanged', async
|
||||
assert.equal(deleteTagText('unused', zero),
|
||||
'“unused” is not linked to any saved prompt or image. Deleting it removes only the name.');
|
||||
});
|
||||
|
||||
test('a category row counts prompts and images, and an image-only row browses Images (#332 D143)', async () => {
|
||||
const {categoryRowText, categoryBrowseTarget, categoryPreviewText} = await import('../../web/js/workspace/vocabulary-state.mjs');
|
||||
assert.equal(categoryRowText({value: 'L', prompt_count: 3}), '3 positive prompts');
|
||||
assert.equal(categoryRowText({value: 'L', prompt_count: 1, image_count: 0}), '1 positive prompt');
|
||||
assert.equal(categoryRowText({value: 'L', prompt_count: 3, image_count: 2}), '3 positive prompts · 2 images');
|
||||
assert.equal(categoryRowText({value: 'S', prompt_count: 0, image_count: 1}), '1 image');
|
||||
assert.equal(categoryBrowseTarget({value: 'L', prompt_count: 3, image_count: 2}), 'library');
|
||||
assert.equal(categoryBrowseTarget({value: 'S', prompt_count: 0, image_count: 2}), 'images');
|
||||
const impact = {positive_prompts: 3, other_prompts: 0, negative_variants: 0, archived_negative_variants: 0, images: 2};
|
||||
assert.equal(categoryPreviewText({source: 'Landscapes', target: 'Landscape', target_exists: false, impact}),
|
||||
'“Landscapes” is used by 3 positive prompts and 2 images. Apply renames it to “Landscape”.');
|
||||
});
|
||||
|
||||
+9
-15
@@ -2205,30 +2205,24 @@ class TestOutputScanInternals(AdminAPITestCase):
|
||||
self.assertEqual(self.api._ingest_scanned_file(f, meta), "unchanged")
|
||||
|
||||
def test_ingest_link_failures_are_tolerated(self):
|
||||
"""A failed link rolls the new prompt back: found, nothing left behind."""
|
||||
f = Path("gen.png")
|
||||
meta = {"prompt": json.dumps(_wired_prompt("a dog"))}
|
||||
self.api.db._insert_image_link = _raise
|
||||
with self.assertLogs("prompt_manager.database.scanned_prompts", "ERROR"):
|
||||
self.assertEqual(self.api._ingest_scanned_file(f, meta), "found")
|
||||
self.assertEqual(self.api._ingest_scanned_file(f, meta), "found")
|
||||
self.assertEqual(self.api.db.get_recent_prompts()["prompts"], [])
|
||||
self.api.db.link_image_to_prompt = _raise
|
||||
self.assertEqual(self.api._ingest_scanned_file(f, meta), "added")
|
||||
self.assertEqual(self.api._ingest_scanned_file(f, meta), "found")
|
||||
|
||||
def test_ingest_save_failure_counts_as_found(self):
|
||||
f = Path("gen.png")
|
||||
meta = {"prompt": json.dumps(_wired_prompt("a bird"))}
|
||||
with patch(
|
||||
"py.api.admin.store_scanned_prompt", return_value=("found", None)
|
||||
) as store:
|
||||
self.assertEqual(self.api._ingest_scanned_file(f, meta), "found")
|
||||
self.assertEqual(store.call_args.args[3], "a bird")
|
||||
self.api.db.save_prompt = lambda *a, **k: None
|
||||
self.assertEqual(self.api._ingest_scanned_file(f, meta), "found")
|
||||
|
||||
def test_batch_counts_and_per_file_errors(self):
|
||||
meta = {"prompt": json.dumps(_wired_prompt("a fish"))}
|
||||
with patch("py.api.admin.store_scanned_prompt", side_effect=_raise):
|
||||
counts = self.api._ingest_scan_batch_sync(
|
||||
[(Path("a.png"), meta), (Path("b.png"), {})]
|
||||
)
|
||||
self.api.db.get_prompt_by_hash = _raise
|
||||
counts = self.api._ingest_scan_batch_sync(
|
||||
[(Path("a.png"), meta), (Path("b.png"), {})]
|
||||
)
|
||||
self.assertEqual(
|
||||
counts,
|
||||
{
|
||||
|
||||
@@ -29,6 +29,7 @@ except ImportError:
|
||||
model_gate = None
|
||||
|
||||
from py import config as config_module
|
||||
from py.autotag import MissingBackendPackages, package_fields
|
||||
|
||||
AutotagConfig = getattr(config_module, "AutotagConfig", None)
|
||||
|
||||
@@ -37,6 +38,11 @@ WAIT = 30.0
|
||||
WD14 = {"general_threshold": 0.35, "character_threshold": 0.85}
|
||||
LLM = {"instructions": "Generate tags."}
|
||||
UNAVAILABLE = "Download it in System › Auto-tagging."
|
||||
NEEDS_LLAMA = (
|
||||
"GGUF JoyCaption needs llama-cpp-python. Install it into ComfyUI's Python: "
|
||||
'pip install llama-cpp-python. For a CUDA build: CMAKE_ARGS="-DGGML_CUDA=on" '
|
||||
"pip install llama-cpp-python"
|
||||
)
|
||||
|
||||
SCORED = [("sandstone", 0.98), ("desert", 0.96)] + [
|
||||
(f"tag_{i:03d}", round(0.9 - i * 0.005, 4)) for i in range(150)
|
||||
@@ -59,6 +65,8 @@ class FakeTagger:
|
||||
"hf": {},
|
||||
}
|
||||
self.downloaded = {name: True for name in self.models_config}
|
||||
# pip names of the backend packages that cannot be imported (#424)
|
||||
self.missing = {name: [] for name in self.models_config}
|
||||
self.loaded = None
|
||||
self.load_calls = []
|
||||
self.unload_calls = 0
|
||||
@@ -71,7 +79,11 @@ class FakeTagger:
|
||||
|
||||
def get_models_status(self):
|
||||
return {
|
||||
name: {"downloaded": self.downloaded[name], "model_path": None}
|
||||
name: {
|
||||
"downloaded": self.downloaded[name],
|
||||
"model_path": None,
|
||||
**package_fields(name, self.missing[name]),
|
||||
}
|
||||
for name in self.models_config
|
||||
}
|
||||
|
||||
@@ -315,6 +327,37 @@ class TestModelAvailability(SuggestTestCase):
|
||||
self.assertEqual(self.tagger.calls, [])
|
||||
self.assertIsNone(model_gate.holder())
|
||||
|
||||
async def test_missing_backend_package_is_refused_before_the_model(self):
|
||||
observed = await self.observed()
|
||||
self.tagger.missing["gguf"] = ["llama-cpp-python"]
|
||||
|
||||
status, body = await self.suggest(self.suggest_body(observed, "gguf"))
|
||||
|
||||
self.assertEqual((status, body["code"]), (409, "model-unavailable"))
|
||||
self.assertEqual(body["success"], False)
|
||||
self.assertEqual(body["error"], NEEDS_LLAMA)
|
||||
self.assertEqual(self.tagger.load_calls, [])
|
||||
self.assertIsNone(model_gate.holder())
|
||||
# Not downloaded still reads as before, whatever the packages.
|
||||
self.tagger.downloaded["gguf"] = False
|
||||
status, body = await self.suggest(self.suggest_body(observed, "gguf"))
|
||||
self.assertIn("is not downloaded", body["error"])
|
||||
|
||||
async def test_a_package_lost_after_the_check_is_the_same_409_without_a_traceback(
|
||||
self,
|
||||
):
|
||||
observed = await self.observed()
|
||||
self.tagger.load_error = MissingBackendPackages("gguf", ["llama-cpp-python"])
|
||||
|
||||
with self.assertNoLogs(self.api.logger, level="ERROR"):
|
||||
status, body = await self.suggest(self.suggest_body(observed, "gguf"))
|
||||
|
||||
self.assertEqual((status, body["code"]), (409, "model-unavailable"))
|
||||
self.assertEqual(body["error"], NEEDS_LLAMA)
|
||||
self.assertIsNone(model_gate.holder())
|
||||
entry = self.api._captures._entries[observed["capture"]["token"]]
|
||||
self.assertEqual(entry.uses, 0)
|
||||
|
||||
async def test_load_failure_is_a_generic_error_and_frees_everything(self):
|
||||
observed = await self.observed()
|
||||
self.tagger.load_error = RuntimeError(f"cannot load {self.output_dir}")
|
||||
|
||||
@@ -73,6 +73,7 @@ class FakeAutotagService:
|
||||
self.status_calls = 0
|
||||
self.status_threads = []
|
||||
self.status_paths = {}
|
||||
self.status_extra = {}
|
||||
self.suggest_calls = 0
|
||||
|
||||
def get_models_status(self):
|
||||
@@ -82,6 +83,7 @@ class FakeAutotagService:
|
||||
name: {
|
||||
"downloaded": self.downloaded,
|
||||
"model_path": self.status_paths.get(name),
|
||||
**self.status_extra.get(name, {}),
|
||||
}
|
||||
for name in self.models_config
|
||||
}
|
||||
@@ -1111,6 +1113,24 @@ class TestModelStatus(AutotagAPITestCase):
|
||||
leaked = [text for text in _strings(data) if _absolute(text)]
|
||||
self.assertEqual(leaked, [])
|
||||
|
||||
async def test_models_status_names_missing_backend_packages(self):
|
||||
fields = {
|
||||
"missing_packages": ["llama-cpp-python"],
|
||||
"install_command": "pip install llama-cpp-python",
|
||||
"install_note": "For a CUDA build: ...",
|
||||
}
|
||||
self.service.status_extra = {"gguf": fields}
|
||||
models = (await self._models())["models"]
|
||||
self.assertTrue(models["gguf"]["downloaded"])
|
||||
self.assertEqual(
|
||||
{key: models["gguf"][key] for key in fields},
|
||||
fields,
|
||||
)
|
||||
self.assertEqual(
|
||||
{key: models["wd14-vit"][key] for key in fields},
|
||||
{"missing_packages": [], "install_command": None, "install_note": None},
|
||||
)
|
||||
|
||||
async def test_models_status_adds_label_size_label_and_loaded(self):
|
||||
self.service._loaded_type = "wd14-vit"
|
||||
data = await self._models()
|
||||
@@ -1669,6 +1689,10 @@ class TestTagOutputImagesPreview(BatchJobTestCase):
|
||||
"label": "WD14 ViT",
|
||||
"size_label": "~400 MB",
|
||||
"downloaded": True,
|
||||
# #424: the backend packages it cannot import, none here.
|
||||
"missing_packages": [],
|
||||
"install_command": None,
|
||||
"install_note": None,
|
||||
},
|
||||
)
|
||||
self.assertEqual(skip["existing"], "skip")
|
||||
|
||||
@@ -0,0 +1,601 @@
|
||||
"""Collections (#331): the routes under /prompt_manager/collections."""
|
||||
|
||||
import json
|
||||
import ntpath
|
||||
import sqlite3
|
||||
from unittest.mock import patch
|
||||
|
||||
try:
|
||||
from test_api_images import ImageAPITestCase, make_png
|
||||
from open_handles import assert_closed
|
||||
except ImportError:
|
||||
from tests.test_api_images import ImageAPITestCase, make_png
|
||||
from tests.open_handles import assert_closed
|
||||
|
||||
BASE = "/prompt_manager/collections"
|
||||
ITEMS = BASE + "/items"
|
||||
MEMBERSHIP = BASE + "/membership"
|
||||
SOURCES_CHANGED = "The sources changed. Refresh the images and try again."
|
||||
|
||||
|
||||
class CollectionRoutesTestCase(ImageAPITestCase):
|
||||
async def tearDownAsync(self):
|
||||
self.api.db.close_all()
|
||||
assert_closed(self, self._temp_db.name, self._output_tmp.name)
|
||||
await super().tearDownAsync()
|
||||
|
||||
def make(self, *names, root=None):
|
||||
for name in names:
|
||||
make_png((root or self.output_dir) / name)
|
||||
|
||||
def scope(self, index=0):
|
||||
roots = list(self.api._get_all_output_dirs())
|
||||
return self.api._gallery_root_scope(roots[index], index)
|
||||
|
||||
def item(self, path, index=0, scope=None):
|
||||
return {
|
||||
"root_index": index,
|
||||
"root_scope": scope or self.scope(index),
|
||||
"path": path,
|
||||
}
|
||||
|
||||
def two_sources(self):
|
||||
second = self.comfy_dir / "second"
|
||||
second.mkdir()
|
||||
self.api._get_all_output_dirs = lambda: [self.output_dir, second]
|
||||
return second
|
||||
|
||||
async def call(self, method, url, body=None, status=200, **params):
|
||||
response = await self.client.request(method, url, json=body, params=params)
|
||||
text = await response.text()
|
||||
self.assertEqual(response.status, status, text)
|
||||
data = json.loads(text)
|
||||
self.assertNotIn(self.comfy_dir.as_posix(), text)
|
||||
self.assertNotIn(str(self.comfy_dir).replace("\\", "\\\\"), text)
|
||||
return data
|
||||
|
||||
async def add(self, status=200, **body):
|
||||
return await self.call("POST", ITEMS, body, status)
|
||||
|
||||
async def listed(self):
|
||||
return (await self.call("GET", BASE))["collections"]
|
||||
|
||||
|
||||
class TestCollectionRoutes(CollectionRoutesTestCase):
|
||||
async def test_each_route_succeeds(self):
|
||||
self.make("a.png", "b.png", "c.png")
|
||||
self.assertEqual(
|
||||
await self.call("GET", BASE), {"success": True, "collections": []}
|
||||
)
|
||||
made = await self.add(
|
||||
name=" LoRa testing ", items=[self.item("a.png"), self.item("b.png")]
|
||||
)
|
||||
grids = made["collection"]
|
||||
self.assertEqual(grids["name"], "LoRa testing")
|
||||
self.assertEqual(
|
||||
made,
|
||||
{
|
||||
"success": True,
|
||||
"collection": grids,
|
||||
"created": True,
|
||||
"added": 2,
|
||||
"already": 0,
|
||||
"not_found": 0,
|
||||
},
|
||||
)
|
||||
again = await self.add(
|
||||
collection_id=grids["id"], items=[self.item("b.png"), self.item("c.png")]
|
||||
)
|
||||
self.assertEqual(
|
||||
(again["created"], again["added"], again["already"]), (False, 1, 1)
|
||||
)
|
||||
self.assertEqual(
|
||||
await self.listed(),
|
||||
[{"id": grids["id"], "name": "LoRa testing", "count": 3, "missing": 0}],
|
||||
)
|
||||
membership = await self.call(
|
||||
"GET", MEMBERSHIP, root_index="0", root_scope=self.scope(), path="a.png"
|
||||
)
|
||||
self.assertEqual(membership, {"success": True, "collection_ids": [grids["id"]]})
|
||||
removed = await self.call(
|
||||
"DELETE",
|
||||
ITEMS,
|
||||
{"collection_id": grids["id"], "items": [self.item("a.png")]},
|
||||
)
|
||||
self.assertEqual(removed, {"success": True, "collection": grids, "removed": 1})
|
||||
renamed = await self.call(
|
||||
"PATCH", f"{BASE}/{grids['id']}", {"name": "LoRA grids"}
|
||||
)
|
||||
self.assertEqual(
|
||||
renamed,
|
||||
{"success": True, "collection": {"id": grids["id"], "name": "LoRA grids"}},
|
||||
)
|
||||
deleted = await self.call("DELETE", f"{BASE}/{grids['id']}")
|
||||
self.assertEqual(deleted, {"success": True, "deleted": True})
|
||||
self.assertEqual(await self.listed(), [])
|
||||
# Files are never touched.
|
||||
self.assertTrue((self.output_dir / "a.png").is_file())
|
||||
|
||||
async def test_names_are_unique_ignoring_case(self):
|
||||
self.make("a.png", "b.png")
|
||||
first = (await self.add(name="LoRa testing", items=[self.item("a.png")]))[
|
||||
"collection"
|
||||
]
|
||||
second = await self.add(name="lora TESTING", items=[self.item("b.png")])
|
||||
self.assertEqual(second["collection"], first)
|
||||
self.assertFalse(second["created"])
|
||||
self.assertEqual(len(await self.listed()), 1)
|
||||
|
||||
async def test_bad_requests_are_400(self):
|
||||
self.make("a.png")
|
||||
made = await self.add(name="Set", items=[self.item("a.png")])
|
||||
cid = made["collection"]["id"]
|
||||
good = [self.item("a.png")]
|
||||
for body in (
|
||||
{"items": good},
|
||||
{"name": "x", "collection_id": cid, "items": good},
|
||||
{"name": "x"},
|
||||
{"name": "x", "items": good, "extra": 1},
|
||||
{"collection_id": "1", "items": good},
|
||||
{"collection_id": True, "items": good},
|
||||
{"collection_id": 0, "items": good},
|
||||
{"collection_id": 10**10, "items": good},
|
||||
{"name": "", "items": good},
|
||||
{"name": "x" * 101, "items": good},
|
||||
{"name": "bell\x07", "items": good},
|
||||
{"name": 5, "items": good},
|
||||
{"name": "x", "items": []},
|
||||
{"name": "x", "items": "a.png"},
|
||||
{"name": "x", "items": [self.item("a.png")] * 501},
|
||||
{"name": "x", "items": [{"root_index": 0, "path": "a.png"}]},
|
||||
{"name": "x", "items": [{**self.item("a.png"), "root_index": "0"}]},
|
||||
{"name": "x", "items": [{**self.item("a.png"), "root_scope": "xyz"}]},
|
||||
):
|
||||
with self.subTest(body=body):
|
||||
data = await self.add(400, **body)
|
||||
self.assertFalse(data["success"])
|
||||
for body in ({"items": good}, {"collection_id": cid}):
|
||||
with self.subTest(remove=body):
|
||||
await self.call("DELETE", ITEMS, body, 400)
|
||||
for raw in ("abc", "0", "1234567890", "-1", "1.0"):
|
||||
with self.subTest(path_id=raw):
|
||||
await self.call("PATCH", f"{BASE}/{raw}", {"name": "x"}, 400)
|
||||
await self.call("DELETE", f"{BASE}/{raw}", None, 400)
|
||||
await self.call("PATCH", f"{BASE}/{cid}", {"name": ""}, 400)
|
||||
await self.call("PATCH", f"{BASE}/{cid}", {"label": "x"}, 400)
|
||||
for params in (
|
||||
{"root_index": "x", "root_scope": self.scope(), "path": "a.png"},
|
||||
{"root_index": "0", "path": "a.png"},
|
||||
{"root_index": "0", "root_scope": "nope", "path": "a.png"},
|
||||
):
|
||||
with self.subTest(membership=params):
|
||||
await self.call("GET", MEMBERSHIP, status=400, **params)
|
||||
self.assertEqual((await self.listed())[0]["count"], 1)
|
||||
|
||||
async def test_unsafe_paths_are_403(self):
|
||||
self.make("a.png")
|
||||
for path in (
|
||||
"../a.png",
|
||||
"sub/../../a.png",
|
||||
"C:/a.png",
|
||||
"a\\b.png",
|
||||
"/a.png",
|
||||
"notes.txt",
|
||||
):
|
||||
with self.subTest(path=path):
|
||||
await self.add(403, name="Set", items=[self.item(path)])
|
||||
await self.call(
|
||||
"GET",
|
||||
MEMBERSHIP,
|
||||
status=403,
|
||||
root_index="0",
|
||||
root_scope=self.scope(),
|
||||
path=path,
|
||||
)
|
||||
self.assertEqual(await self.listed(), [])
|
||||
|
||||
async def test_a_body_over_the_cap_is_413(self):
|
||||
response = await self.client.post(
|
||||
ITEMS, data=b'{"name":"x","items":[' + b" " * (1024 * 1024 + 1) + b"]}"
|
||||
)
|
||||
self.assertEqual(response.status, 413)
|
||||
|
||||
async def test_missing_collection_and_files_are_404(self):
|
||||
self.make("a.png")
|
||||
data = await self.add(404, collection_id=999, items=[self.item("a.png")])
|
||||
self.assertEqual(data["code"], "collection-not-found")
|
||||
data = await self.call(
|
||||
"DELETE", ITEMS, {"collection_id": 999, "items": [self.item("a.png")]}, 404
|
||||
)
|
||||
self.assertEqual(data["code"], "collection-not-found")
|
||||
data = await self.call("PATCH", f"{BASE}/999", {"name": "x"}, 404)
|
||||
self.assertEqual(data["code"], "collection-not-found")
|
||||
data = await self.call("DELETE", f"{BASE}/999", None, 404)
|
||||
self.assertEqual(data["code"], "collection-not-found")
|
||||
data = await self.add(404, name="New", items=[self.item("never.png")])
|
||||
self.assertEqual(data["code"], "files-not-found")
|
||||
self.assertEqual(await self.listed(), [])
|
||||
|
||||
async def test_unlisted_files_count_as_not_found(self):
|
||||
self.make("a.png")
|
||||
made = await self.add(
|
||||
name="Set", items=[self.item("a.png"), self.item("later.png")]
|
||||
)
|
||||
self.assertEqual((made["added"], made["not_found"]), (1, 1))
|
||||
again = await self.add(
|
||||
collection_id=made["collection"]["id"],
|
||||
items=[self.item("a.png"), self.item("later.png")],
|
||||
)
|
||||
self.assertEqual(
|
||||
(again["added"], again["already"], again["not_found"]), (0, 1, 1)
|
||||
)
|
||||
|
||||
async def test_name_taken_is_409(self):
|
||||
self.make("a.png")
|
||||
first = (await self.add(name="First", items=[self.item("a.png")]))["collection"]
|
||||
second = (await self.add(name="Second", items=[self.item("a.png")]))[
|
||||
"collection"
|
||||
]
|
||||
data = await self.call(
|
||||
"PATCH", f"{BASE}/{second['id']}", {"name": "FIRST"}, 409
|
||||
)
|
||||
self.assertEqual(
|
||||
(data["code"], data["collection_id"]), ("name-taken", first["id"])
|
||||
)
|
||||
renamed = await self.call("PATCH", f"{BASE}/{second['id']}", {"name": "SECOND"})
|
||||
self.assertEqual(renamed["collection"]["name"], "SECOND")
|
||||
|
||||
async def test_stale_scope_is_409_after_reordering_sources(self):
|
||||
second = self.two_sources()
|
||||
self.make("a.png")
|
||||
self.make("b.png", root=second)
|
||||
stale = self.item("b.png", 1)
|
||||
made = await self.add(name="Set", items=[self.item("a.png")])
|
||||
cid = made["collection"]["id"]
|
||||
self.api._get_all_output_dirs = lambda: [second, self.output_dir]
|
||||
for method, body in (
|
||||
("POST", {"collection_id": cid, "items": [self.item("b.png", 0), stale]}),
|
||||
("DELETE", {"collection_id": cid, "items": [stale]}),
|
||||
):
|
||||
with self.subTest(method=method):
|
||||
data = await self.call(method, ITEMS, body, 409)
|
||||
self.assertEqual(data["code"], "source-changed")
|
||||
self.assertEqual(data["error"], SOURCES_CHANGED)
|
||||
data = await self.call(
|
||||
"GET",
|
||||
MEMBERSHIP,
|
||||
status=409,
|
||||
root_index="1",
|
||||
root_scope=stale["root_scope"],
|
||||
path="b.png",
|
||||
)
|
||||
self.assertEqual(data["code"], "source-changed")
|
||||
# Nothing was written by the refused add; the member follows its folder.
|
||||
listed = await self.listed()
|
||||
self.assertEqual((listed[0]["count"], listed[0]["missing"]), (1, 0))
|
||||
membership = await self.call(
|
||||
"GET", MEMBERSHIP, root_index="1", root_scope=self.scope(1), path="a.png"
|
||||
)
|
||||
self.assertEqual(membership["collection_ids"], [cid])
|
||||
|
||||
async def test_counts_exclude_a_missing_member(self):
|
||||
second = self.two_sources()
|
||||
self.make("a.png", "b.png")
|
||||
self.make("c.png", root=second)
|
||||
made = await self.add(
|
||||
name="Grids",
|
||||
items=[self.item("a.png"), self.item("b.png"), self.item("c.png", 1)],
|
||||
)
|
||||
other = await self.add(name="Other", items=[self.item("c.png", 1)])
|
||||
(self.output_dir / "b.png").unlink()
|
||||
self.api.invalidate_gallery_cache()
|
||||
listed = await self.listed()
|
||||
self.assertEqual(
|
||||
listed,
|
||||
[
|
||||
{
|
||||
"id": made["collection"]["id"],
|
||||
"name": "Grids",
|
||||
"count": 2,
|
||||
"missing": 1,
|
||||
},
|
||||
{
|
||||
"id": other["collection"]["id"],
|
||||
"name": "Other",
|
||||
"count": 1,
|
||||
"missing": 0,
|
||||
},
|
||||
],
|
||||
)
|
||||
# The member is removable although it is no longer listed.
|
||||
removed = await self.call(
|
||||
"DELETE",
|
||||
ITEMS,
|
||||
{"collection_id": made["collection"]["id"], "items": [self.item("b.png")]},
|
||||
)
|
||||
self.assertEqual(removed["removed"], 1)
|
||||
|
||||
async def test_counts_are_null_when_a_source_scan_fails(self):
|
||||
self.make("a.png")
|
||||
await self.add(name="Set", items=[self.item("a.png")])
|
||||
|
||||
async def broken(_root):
|
||||
raise OSError("scan failed at /secret/place")
|
||||
|
||||
with patch.object(self.api, "_get_gallery_files", side_effect=broken):
|
||||
data = await self.call("GET", BASE)
|
||||
self.assertEqual(
|
||||
data["collections"][0] | {"id": 0},
|
||||
{"id": 0, "name": "Set", "count": None, "missing": None},
|
||||
)
|
||||
self.assertNotIn("secret", json.dumps(data))
|
||||
|
||||
async def test_windows_case_spellings_are_one_member(self):
|
||||
self.make("Sub/A.png")
|
||||
with patch("py.api.images._path_flavor", ntpath):
|
||||
made = await self.add(
|
||||
name="Set", items=[self.item("Sub/A.png"), self.item("SUB/a.PNG")]
|
||||
)
|
||||
self.assertEqual((made["added"], made["already"]), (1, 0))
|
||||
(self.output_dir / "Sub" / "A.png").rename(
|
||||
self.output_dir / "Sub" / "a.png"
|
||||
)
|
||||
(self.output_dir / "Sub").rename(self.output_dir / "sub")
|
||||
self.api.invalidate_gallery_cache()
|
||||
listed = await self.listed()
|
||||
self.assertEqual((listed[0]["count"], listed[0]["missing"]), (1, 0))
|
||||
membership = await self.call(
|
||||
"GET",
|
||||
MEMBERSHIP,
|
||||
root_index="0",
|
||||
root_scope=self.scope(),
|
||||
path="sub/a.png",
|
||||
)
|
||||
self.assertEqual(membership["collection_ids"], [made["collection"]["id"]])
|
||||
response = await self.client.get(
|
||||
"/prompt_manager/images/output",
|
||||
params={
|
||||
"mode": "workspace",
|
||||
"collection": str(made["collection"]["id"]),
|
||||
},
|
||||
)
|
||||
page = await response.json()
|
||||
self.assertEqual([i["path"] for i in page["images"]], ["sub/a.png"])
|
||||
|
||||
async def test_posix_case_distinct_files_stay_two_members(self):
|
||||
self.make("A.png", "a.png")
|
||||
if len(list(self.output_dir.iterdir())) != 2:
|
||||
self.skipTest("case-insensitive filesystem")
|
||||
made = await self.add(
|
||||
name="Set", items=[self.item("A.png"), self.item("a.png")]
|
||||
)
|
||||
self.assertEqual(made["added"], 2)
|
||||
|
||||
async def test_a_500_never_carries_the_exception_text(self):
|
||||
self.make("a.png")
|
||||
made = await self.add(name="Set", items=[self.item("a.png")])
|
||||
cid = made["collection"]["id"]
|
||||
|
||||
def broken(*_args, **_kwargs):
|
||||
raise sqlite3.OperationalError("disk I/O error at /secret/prompts.db")
|
||||
|
||||
cases = (
|
||||
(
|
||||
"list_collections",
|
||||
"GET",
|
||||
BASE,
|
||||
None,
|
||||
{},
|
||||
"Collections could not be read.",
|
||||
),
|
||||
(
|
||||
"file_collections",
|
||||
"GET",
|
||||
MEMBERSHIP,
|
||||
None,
|
||||
{"root_index": "0", "root_scope": self.scope(), "path": "a.png"},
|
||||
"Collections could not be read.",
|
||||
),
|
||||
(
|
||||
"add_to_collection",
|
||||
"POST",
|
||||
ITEMS,
|
||||
{"collection_id": cid, "items": [self.item("a.png")]},
|
||||
{},
|
||||
"The collection could not be changed.",
|
||||
),
|
||||
(
|
||||
"remove_from_collection",
|
||||
"DELETE",
|
||||
ITEMS,
|
||||
{"collection_id": cid, "items": [self.item("a.png")]},
|
||||
{},
|
||||
"The collection could not be changed.",
|
||||
),
|
||||
(
|
||||
"rename_collection",
|
||||
"PATCH",
|
||||
f"{BASE}/{cid}",
|
||||
{"name": "x"},
|
||||
{},
|
||||
"The collection could not be changed.",
|
||||
),
|
||||
(
|
||||
"delete_collection",
|
||||
"DELETE",
|
||||
f"{BASE}/{cid}",
|
||||
None,
|
||||
{},
|
||||
"The collection could not be changed.",
|
||||
),
|
||||
)
|
||||
for method_name, method, url, body, params, message in cases:
|
||||
with self.subTest(method=method_name):
|
||||
with patch.object(self.api.db, method_name, side_effect=broken):
|
||||
data = await self.call(method, url, body, 500, **params)
|
||||
self.assertEqual(data, {"success": False, "error": message})
|
||||
|
||||
|
||||
class TestCollectionListingFilter(CollectionRoutesTestCase):
|
||||
"""#392's "all N matching" filter carries ``collection`` like the listing."""
|
||||
|
||||
async def test_a_filter_with_a_collection_resolves_only_members(self):
|
||||
second = self.two_sources()
|
||||
self.make("a.png", "b.png")
|
||||
self.make("c.png", root=second)
|
||||
made = await self.add(
|
||||
name="Set", items=[self.item("a.png"), self.item("c.png", 1)]
|
||||
)
|
||||
cid = str(made["collection"]["id"])
|
||||
selection = await self.api.resolve_listing_filter({"collection": cid})
|
||||
self.assertEqual(
|
||||
sorted((index, path) for index, _scope, path in selection.targets),
|
||||
[(0, "a.png"), (1, "c.png")],
|
||||
)
|
||||
selection = await self.api.resolve_listing_filter(
|
||||
{"collection": cid, "root": "1", "root_scope": self.scope(1)}
|
||||
)
|
||||
self.assertEqual([t[2] for t in selection.targets], ["c.png"])
|
||||
for bad in ("0", "x", "1234567890"):
|
||||
with self.subTest(bad=bad):
|
||||
with self.assertRaises(ValueError):
|
||||
from py.api.images import parse_listing_filter
|
||||
|
||||
parse_listing_filter({"collection": bad})
|
||||
|
||||
async def test_bulk_category_acts_on_collection_members_only(self):
|
||||
self.make("a.png", "b.png")
|
||||
inside = self._save_prompt("Inside", category="Old")
|
||||
outside = self._save_prompt("Outside", category="Old")
|
||||
self.api.db.link_image_to_prompt(inside, str(self.output_dir / "a.png"))
|
||||
self.api.db.link_image_to_prompt(outside, str(self.output_dir / "b.png"))
|
||||
made = await self.add(name="Set", items=[self.item("a.png")])
|
||||
cid = str(made["collection"]["id"])
|
||||
data = await self.call(
|
||||
"POST",
|
||||
"/prompt_manager/bulk/category",
|
||||
{"images_filter": {"collection": cid}, "category": "New"},
|
||||
)
|
||||
self.assertEqual((data["updated_count"], data["prompt_count"]), (1, 1))
|
||||
self.assertEqual(self.api.db.get_prompt_by_id(inside)["category"], "New")
|
||||
self.assertEqual(self.api.db.get_prompt_by_id(outside)["category"], "Old")
|
||||
|
||||
async def test_a_deleted_collection_in_a_bulk_filter_is_a_404(self):
|
||||
self.make("a.png")
|
||||
data = await self.call(
|
||||
"POST",
|
||||
"/prompt_manager/bulk/category",
|
||||
{"images_filter": {"collection": "77"}, "category": "New"},
|
||||
status=404,
|
||||
)
|
||||
self.assertEqual(data["code"], "collection-not-found")
|
||||
with self.assertRaises(Exception) as caught:
|
||||
await self.api.resolve_listing_filter({"collection": "77"})
|
||||
self.assertEqual(type(caught.exception).__name__, "CollectionMissing")
|
||||
|
||||
|
||||
class TestReviewFindings(CollectionRoutesTestCase):
|
||||
"""PR #400 review: loop work, counts snapshot, id and query rules."""
|
||||
|
||||
async def test_no_realpath_runs_on_the_event_loop(self):
|
||||
import os
|
||||
import threading
|
||||
|
||||
names = [f"img{n}.png" for n in range(20)]
|
||||
self.make(*names)
|
||||
await self.call("GET", BASE) # warm the listing cache
|
||||
loop_thread = threading.current_thread()
|
||||
on_loop = []
|
||||
real = os.path.realpath
|
||||
|
||||
def watched(*args, **kwargs):
|
||||
if threading.current_thread() is loop_thread:
|
||||
on_loop.append(args[0])
|
||||
return real(*args, **kwargs)
|
||||
|
||||
items = [self.item(name) for name in names]
|
||||
cid = None
|
||||
with patch("os.path.realpath", side_effect=watched):
|
||||
made = await self.add(name="Set", items=items)
|
||||
cid = made["collection"]["id"]
|
||||
await self.call("DELETE", ITEMS, {"collection_id": cid, "items": items[:5]})
|
||||
await self.call(
|
||||
"GET",
|
||||
MEMBERSHIP,
|
||||
root_index="0",
|
||||
root_scope=items[0]["root_scope"],
|
||||
path="img9.png",
|
||||
)
|
||||
await self.call("GET", BASE)
|
||||
self.assertEqual(on_loop, [])
|
||||
|
||||
async def test_missing_is_never_negative(self):
|
||||
self.make("a.png", "b.png")
|
||||
made = await self.add(
|
||||
name="Set", items=[self.item("a.png"), self.item("b.png")]
|
||||
)
|
||||
cid = made["collection"]["id"]
|
||||
real = self.api.db.collection_count_snapshot
|
||||
|
||||
def raced(root_keys):
|
||||
members, stored = real(root_keys)
|
||||
return members, {cid: 1} # a remove landed between two reads
|
||||
|
||||
with patch.object(self.api.db, "collection_count_snapshot", side_effect=raced):
|
||||
listed = await self.listed()
|
||||
self.assertEqual((listed[0]["count"], listed[0]["missing"]), (2, 0))
|
||||
|
||||
async def test_path_ids_follow_the_listing_rule(self):
|
||||
self.make("a.png")
|
||||
made = await self.add(name="Set", items=[self.item("a.png")])
|
||||
cid = made["collection"]["id"]
|
||||
for raw in ("0", "007", f"0{cid}"):
|
||||
with self.subTest(raw=raw):
|
||||
await self.call("PATCH", f"{BASE}/{raw}", {"name": "x"}, 400)
|
||||
await self.call("DELETE", f"{BASE}/{raw}", None, 400)
|
||||
self.assertEqual(len(await self.listed()), 1)
|
||||
|
||||
async def test_membership_refuses_repeated_query_keys(self):
|
||||
self.make("a.png", "b.png")
|
||||
response = await self.client.get(
|
||||
MEMBERSHIP
|
||||
+ f"?root_index=0&root_scope={self.scope()}&path=a.png&path=b.png"
|
||||
)
|
||||
self.assertEqual(response.status, 400)
|
||||
response = await self.client.get(
|
||||
MEMBERSHIP
|
||||
+ f"?root_index=0&root_index=0&root_scope={self.scope()}&path=a.png"
|
||||
)
|
||||
self.assertEqual(response.status, 400)
|
||||
|
||||
async def test_deleting_a_collection_leaves_everything_else(self):
|
||||
from py.api.images import _favorite_root_key
|
||||
|
||||
try:
|
||||
from tests.test_annotation_schema import insert_group_rows, table_rows
|
||||
except ImportError:
|
||||
from test_annotation_schema import insert_group_rows, table_rows
|
||||
|
||||
self.make("a.png")
|
||||
db = self.api.db
|
||||
db.set_image_favorite(_favorite_root_key(self.output_dir), "a.png")
|
||||
prompt = self._save_prompt("Linked")
|
||||
db.link_image_to_prompt(prompt, str(self.output_dir / "a.png"))
|
||||
with db.model.get_connection() as conn:
|
||||
insert_group_rows(conn, locator=str(self.output_dir / "a.png"))
|
||||
made = await self.add(name="Set", items=[self.item("a.png")])
|
||||
tables = (
|
||||
"image_favorites",
|
||||
"generated_images",
|
||||
"prompts",
|
||||
"tags",
|
||||
"media_annotation_files",
|
||||
"media_annotation_bytes",
|
||||
"media_image_tags",
|
||||
)
|
||||
before = table_rows(db.model.get_connection(), tables)
|
||||
file_bytes = (self.output_dir / "a.png").read_bytes()
|
||||
await self.call("DELETE", f"{BASE}/{made['collection']['id']}")
|
||||
self.assertEqual(table_rows(db.model.get_connection(), tables), before)
|
||||
self.assertEqual((self.output_dir / "a.png").read_bytes(), file_bytes)
|
||||
self.assertTrue(all(before[t] for t in tables))
|
||||
@@ -693,6 +693,39 @@ class TestCleanupJob(DuplicateFilesTestCase):
|
||||
"Moved 2 database links to the kept copies", "\n".join(cleanup.log)
|
||||
)
|
||||
|
||||
async def test_duplicate_removal_moves_collection_memberships(self):
|
||||
self.trio()
|
||||
db = self.api.db
|
||||
root_key = duplicate_files.favorite_root_key(self.output_dir)
|
||||
grids = db.add_to_collection(name="LoRA grids", items=[(root_key, "b.png")])
|
||||
grids = grids["collection"]["id"]
|
||||
other = db.add_to_collection(
|
||||
name="Other", items=[(root_key, "sub/c.png"), (root_key, "d.png")]
|
||||
)["collection"]["id"]
|
||||
db.add_to_collection(collection_id=grids, items=[(root_key, "sub/c.png")])
|
||||
_plan, cleanup = await self.remove_all_but("a.png")
|
||||
# The union: a.png joins both; c.png's second grids row was a repeat.
|
||||
self.assertEqual(db.collection_members(grids, root_key), {"a.png"})
|
||||
self.assertEqual(db.collection_members(other, root_key), {"a.png", "d.png"})
|
||||
self.assertEqual(sorted(db.file_collections(root_key, "a.png")), [grids, other])
|
||||
self.assertEqual(db.file_collections(root_key, "b.png"), [])
|
||||
self.assertEqual(db.file_collections(root_key, "sub/c.png"), [])
|
||||
self.assertIn(
|
||||
"Moved 2 collection memberships to the kept copies", "\n".join(cleanup.log)
|
||||
)
|
||||
|
||||
def test_one_collection_membership_is_singular(self):
|
||||
job = type(
|
||||
"Job", (), {"log": [], "log_line": lambda s, line: s.log.append(line)}
|
||||
)()
|
||||
totals = dict.fromkeys(
|
||||
("moved", "merged", "favorites", "tags", "review", "collections"), 0
|
||||
)
|
||||
duplicate_files.DuplicateFilesMixin._log_moved_records(
|
||||
job, {**totals, "collections": 1}
|
||||
)
|
||||
self.assertEqual(job.log, ["Moved 1 collection membership to the kept copies"])
|
||||
|
||||
async def test_a_failed_removal_leaves_its_links_on_the_kept_copy(self):
|
||||
self.trio()
|
||||
prompt = self.save("Stays on c")
|
||||
@@ -849,8 +882,11 @@ class TestLinkQueries(DuplicateFilesTestCase):
|
||||
with self.subTest(sql=sql):
|
||||
self.assertNotIn("SCAN generated_images", plan)
|
||||
self.assertNotIn("SCAN image_favorites", plan)
|
||||
self.assertNotIn("SCAN image_collection_items", plan)
|
||||
finally:
|
||||
conn.close()
|
||||
statements = duplicate_files.query_plan_sql()
|
||||
self.assertTrue(any("image_collection_items" in sql for sql in statements))
|
||||
|
||||
|
||||
class TestReviewHelpers(DuplicateFilesTestCase):
|
||||
|
||||
@@ -2550,3 +2550,207 @@ class TestListingFilterSelection(ImageAPITestCase):
|
||||
|
||||
self.assertEqual(response.status, 409)
|
||||
self.assertEqual((await response.json())["code"], "source-changed")
|
||||
|
||||
|
||||
class TestWorkspaceCollectionFilter(ImageAPITestCase):
|
||||
"""#331: ``collection=<id>`` keeps only that collection's listed members."""
|
||||
|
||||
media = TestWorkspaceImages.media
|
||||
get = TestWorkspaceImages.get
|
||||
_names = TestWorkspaceImages._names
|
||||
|
||||
async def tearDownAsync(self):
|
||||
self.api.db.close_all()
|
||||
assert_closed(self, self._temp_db.name, self._output_tmp.name)
|
||||
await super().tearDownAsync()
|
||||
|
||||
def keys(self, root, relative):
|
||||
from py.api.images import _favorite_relative_key, _favorite_root_key
|
||||
|
||||
return (_favorite_root_key(root), _favorite_relative_key(relative))
|
||||
|
||||
def collect(self, name, *members):
|
||||
"""A collection holding ``(root, relative)`` members; its id."""
|
||||
result = self.api.db.add_to_collection(
|
||||
name=name, items=[self.keys(root, rel) for root, rel in members]
|
||||
)
|
||||
return result["collection"]["id"]
|
||||
|
||||
def two_sources(self):
|
||||
second = self.comfy_dir / "second"
|
||||
second.mkdir()
|
||||
self.api._get_all_output_dirs = lambda: [self.output_dir, second]
|
||||
return second
|
||||
|
||||
async def test_listing_keeps_only_collection_members(self):
|
||||
second = self.two_sources()
|
||||
self.media("a.png", 300)
|
||||
self.media("b.png", 200)
|
||||
self.media("sub/c.png", 100, root=second)
|
||||
self.media("d.png", 50, root=second)
|
||||
grids = self.collect(
|
||||
"Grids",
|
||||
(self.output_dir, "a.png"),
|
||||
(second, "sub/c.png"),
|
||||
(self.output_dir, "gone.png"), # stored, never listed
|
||||
)
|
||||
self.collect("Other", (self.output_dir, "b.png"))
|
||||
response, data = await self.get(collection=str(grids))
|
||||
self.assertEqual(response.status, 200, data)
|
||||
self.assertEqual(self._names(data), ["a.png", "c.png"])
|
||||
self.assertEqual(data["total"], 2)
|
||||
_, data = await self.get(collection=str(grids), root="1")
|
||||
self.assertEqual(self._names(data), ["c.png"])
|
||||
# Without the filter the listing is unchanged.
|
||||
_, data = await self.get()
|
||||
self.assertEqual(data["total"], 4)
|
||||
|
||||
async def test_collection_filter_combines_with_the_other_filters(self):
|
||||
night = self._save_prompt("Neon zebra", category="Night", tags=["a"])
|
||||
day = self._save_prompt("Morning field", category="Day", tags=["a"])
|
||||
files = {
|
||||
name: self.media(name, 100 + index)
|
||||
for index, name in enumerate(
|
||||
("night.png", "day.png", "zebra_plain.png", "clip.mp4", "out.png")
|
||||
)
|
||||
}
|
||||
self.api.db.link_image_to_prompt(night, str(files["night.png"]))
|
||||
self.api.db.link_image_to_prompt(day, str(files["day.png"]))
|
||||
members = [(self.output_dir, name) for name in files if name != "out.png"]
|
||||
grids = str(self.collect("Grids", *members))
|
||||
cases = (
|
||||
({}, ["clip.mp4", "day.png", "night.png", "zebra_plain.png"]),
|
||||
({"q": "zebra"}, ["night.png", "zebra_plain.png"]),
|
||||
({"category": "Night"}, ["night.png"]),
|
||||
({"tags": "a"}, ["day.png", "night.png"]),
|
||||
({"media": "video"}, ["clip.mp4"]),
|
||||
({"media": "image", "q": "day"}, ["day.png"]),
|
||||
)
|
||||
for params, expected in cases:
|
||||
with self.subTest(**params):
|
||||
response, data = await self.get(collection=grids, sort="name", **params)
|
||||
self.assertEqual(response.status, 200, data)
|
||||
self.assertEqual(self._names(data), expected)
|
||||
self.assertEqual(data["total"], len(expected))
|
||||
|
||||
async def test_collection_filter_combines_with_an_images_own_category(self):
|
||||
"""#331 with #332: a member is admitted by its effective category."""
|
||||
from py.api.images import image_category_key
|
||||
|
||||
night = self._save_prompt("Neon zebra", category="Night")
|
||||
files = {
|
||||
name: self.media(name, 100 + index)
|
||||
for index, name in enumerate(
|
||||
("linked.png", "overridden.png", "own.png", "outside.png")
|
||||
)
|
||||
}
|
||||
for name in ("linked.png", "overridden.png"):
|
||||
self.api.db.link_image_to_prompt(night, str(files[name]))
|
||||
sources = self.api._get_all_output_dirs()
|
||||
|
||||
def own(name, value):
|
||||
key = image_category_key(sources, files[name])
|
||||
self.api.db.set_image_categories([key], value)
|
||||
|
||||
own("overridden.png", "Portraits")
|
||||
own("own.png", "Night") # unlinked: only its own category
|
||||
own("outside.png", "Night") # not a member
|
||||
members = [(self.output_dir, name) for name in files if name != "outside.png"]
|
||||
grids = str(self.collect("Grids", *members))
|
||||
for category, expected in (
|
||||
("Night", ["linked.png", "own.png"]),
|
||||
("Portraits", ["overridden.png"]),
|
||||
):
|
||||
with self.subTest(category=category):
|
||||
response, data = await self.get(
|
||||
collection=grids, category=category, sort="name"
|
||||
)
|
||||
self.assertEqual(response.status, 200, data)
|
||||
self.assertEqual(self._names(data), expected)
|
||||
self.assertEqual(data["total"], len(expected))
|
||||
|
||||
async def test_invalid_collection_is_a_400(self):
|
||||
for value in ("x", "0", "-1", "1.5", "1234567890", " 1"):
|
||||
with self.subTest(value=value):
|
||||
response, data = await self.get(collection=value)
|
||||
self.assertEqual(response.status, 400, data)
|
||||
|
||||
async def test_unknown_collection_is_a_404(self):
|
||||
self.media("a.png")
|
||||
response, data = await self.get(collection="77")
|
||||
self.assertEqual(response.status, 404)
|
||||
self.assertEqual(data["code"], "collection-not-found")
|
||||
self.assertNotIn("images", data)
|
||||
self.api._get_all_output_dirs = lambda: []
|
||||
response, data = await self.get(collection="77")
|
||||
self.assertEqual((response.status, data["code"]), (404, "collection-not-found"))
|
||||
|
||||
async def test_a_collection_deleted_during_the_read_is_a_404(self):
|
||||
self.media("a.png")
|
||||
grids = self.collect("Grids", (self.output_dir, "a.png"))
|
||||
real = self.api.db.collection_members
|
||||
|
||||
def deleted_meanwhile(collection_id, root_key):
|
||||
self.api.db.delete_collection(collection_id)
|
||||
return real(collection_id, root_key)
|
||||
|
||||
with patch.object(
|
||||
self.api.db, "collection_members", side_effect=deleted_meanwhile
|
||||
):
|
||||
response, data = await self.get(collection=str(grids))
|
||||
self.assertEqual((response.status, data["code"]), (404, "collection-not-found"))
|
||||
|
||||
async def test_a_failed_members_read_is_a_500_never_an_unfiltered_page(self):
|
||||
self.media("a.png")
|
||||
self.media("b.png")
|
||||
grids = self.collect("Grids", (self.output_dir, "a.png"))
|
||||
|
||||
def broken(*_args, **_kwargs):
|
||||
raise sqlite3.OperationalError("database is locked")
|
||||
|
||||
with patch.object(self.api.db, "collection_members", side_effect=broken):
|
||||
response, data = await self.get(collection=str(grids))
|
||||
self.assertEqual(response.status, 500)
|
||||
self.assertNotIn("images", data)
|
||||
self.assertNotIn("locked", json.dumps(data))
|
||||
|
||||
async def test_reordering_sources_keeps_members(self):
|
||||
second = self.two_sources()
|
||||
self.media("a.png", root=second)
|
||||
self.media("b.png")
|
||||
grids = str(self.collect("Grids", (second, "a.png")))
|
||||
_, data = await self.get(collection=grids)
|
||||
self.assertEqual(
|
||||
[(i["root_index"], i["path"]) for i in data["images"]], [(1, "a.png")]
|
||||
)
|
||||
self.api._get_all_output_dirs = lambda: [second, self.output_dir]
|
||||
_, data = await self.get(collection=grids)
|
||||
self.assertEqual(
|
||||
[(i["root_index"], i["path"]) for i in data["images"]], [(0, "a.png")]
|
||||
)
|
||||
|
||||
async def test_cursor_from_another_collection_is_refused(self):
|
||||
for index, name in enumerate(("a.png", "b.png", "c.png")):
|
||||
self.media(name, 100 + index)
|
||||
everything = [(self.output_dir, name) for name in ("a.png", "b.png", "c.png")]
|
||||
first = str(self.collect("First", *everything))
|
||||
second = str(self.collect("Second", *everything))
|
||||
_, page = await self.get(collection=first, limit=1)
|
||||
token = page["next_cursor"]
|
||||
self.assertIsInstance(token, str)
|
||||
response, _ = await self.get(collection=first, limit=1, cursor=token)
|
||||
self.assertEqual(response.status, 200)
|
||||
for params in ({"collection": second}, {}, {"favorites": "1"}):
|
||||
with self.subTest(**params):
|
||||
response, _ = await self.get(cursor=token, limit=1, **params)
|
||||
self.assertEqual(response.status, 400)
|
||||
|
||||
async def test_collection_wins_over_favorites(self):
|
||||
self.media("a.png")
|
||||
self.media("b.png")
|
||||
grids = str(self.collect("Grids", (self.output_dir, "a.png")))
|
||||
from py.api.images import _favorite_root_key
|
||||
|
||||
self.api.db.set_image_favorite(_favorite_root_key(self.output_dir), "b.png")
|
||||
_, data = await self.get(collection=grids, favorites="1")
|
||||
self.assertEqual(self._names(data), ["a.png"])
|
||||
|
||||
@@ -553,6 +553,35 @@ class TestStartRefusals(BatchTestCase):
|
||||
self.assertEqual(self.api._jobs.list(), [])
|
||||
self.assertIsNone(model_gate.holder())
|
||||
|
||||
async def test_a_missing_backend_package_refuses_the_start(self):
|
||||
self.tagger.missing["gguf"] = ["llama-cpp-python"]
|
||||
status, data = await self.start_batch(model_type="gguf")
|
||||
self.assertEqual((status, data["code"]), (409, "model-unavailable"))
|
||||
self.assertEqual(data["success"], False)
|
||||
self.assertTrue(
|
||||
data["error"].startswith("GGUF JoyCaption needs llama-cpp-python."), data
|
||||
)
|
||||
self.assertIn("pip install llama-cpp-python", data["error"])
|
||||
self.assertEqual(self.api._jobs.list(), [])
|
||||
self.assertEqual(self.tagger.load_calls, [])
|
||||
self.assertIsNone(model_gate.holder())
|
||||
|
||||
async def test_a_package_lost_after_the_start_fails_the_job_without_a_traceback(
|
||||
self,
|
||||
):
|
||||
from py.autotag import MissingBackendPackages
|
||||
|
||||
self.image("a.png", (1, 1, 1))
|
||||
self.tagger.load_error = MissingBackendPackages("gguf", ["llama-cpp-python"])
|
||||
with self.assertNoLogs(self.api.logger, level="ERROR"):
|
||||
job = await self.run_batch(model_type="gguf")
|
||||
self.assertEqual(job.state, "failed")
|
||||
self.assertTrue(
|
||||
job.status_line.startswith("GGUF JoyCaption needs"), job.status_line
|
||||
)
|
||||
self.assertTrue(any("pip install llama-cpp-python" in line for line in job.log))
|
||||
self.assertIsNone(model_gate.holder())
|
||||
|
||||
async def test_model_busy_when_the_gate_is_held(self):
|
||||
owner = model_gate.new_owner("image-annotations")
|
||||
self.assertTrue(model_gate.try_acquire(owner))
|
||||
@@ -658,6 +687,20 @@ class TestPreview(BatchTestCase):
|
||||
self.assertIsNone(llm["estimate_device"])
|
||||
self.assertEqual([s for s in _strings(skip) if _absolute(s)], [])
|
||||
|
||||
async def test_preview_names_missing_backend_packages(self):
|
||||
self.image("a.png", (1, 1, 1))
|
||||
data = (await self.preview(model_type="gguf"))[1]
|
||||
self.assertEqual(data["model"]["missing_packages"], [])
|
||||
self.assertIsNone(data["model"]["install_command"])
|
||||
self.tagger.missing["gguf"] = ["llama-cpp-python"]
|
||||
data = (await self.preview(model_type="gguf"))[1]
|
||||
self.assertTrue(data["model"]["downloaded"])
|
||||
self.assertEqual(data["model"]["missing_packages"], ["llama-cpp-python"])
|
||||
self.assertEqual(
|
||||
data["model"]["install_command"], "pip install llama-cpp-python"
|
||||
)
|
||||
self.assertIn("DGGML_CUDA", data["model"]["install_note"])
|
||||
|
||||
async def test_preview_uses_the_graphics_card_rate_when_wd14_runs_on_cuda(self):
|
||||
self.image("a.png", (1, 1, 1))
|
||||
self.tagger.wd14_device = lambda: "gpu"
|
||||
|
||||
@@ -800,8 +800,12 @@ class TestDefaultLoaders(EngineTestCase):
|
||||
transformers, model, processor, inputs = self._fake_transformers()
|
||||
pixel = inputs["pixel_values"]
|
||||
service = AutoTagService(models_dir=self.models_dir)
|
||||
# The 8-bit load also needs these two (#424); stand-ins satisfy the check.
|
||||
stubs = {
|
||||
name: types.ModuleType(name) for name in ("accelerate", "bitsandbytes")
|
||||
}
|
||||
|
||||
with _patch_modules({"torch": torch, "transformers": transformers}):
|
||||
with _patch_modules({"torch": torch, "transformers": transformers, **stubs}):
|
||||
service.load_model("hf")
|
||||
tags = service.generate_tags(str(self.image_path))
|
||||
|
||||
|
||||
@@ -161,6 +161,39 @@ class TestLegacyBatch(LegacyTestCase):
|
||||
self.assertEqual(self.tagger.calls[-1]["prompt"], AutotagConfig.TEMPLATE)
|
||||
self.assertEqual(self.tagger.custom_prompt, "legacy prompt")
|
||||
|
||||
async def test_a_missing_backend_package_is_one_error_frame(self):
|
||||
self.tagger.missing["gguf"] = ["llama-cpp-python"]
|
||||
_, frames = await self.legacy("model_type=gguf")
|
||||
self.assertEqual(len(frames), 1)
|
||||
self.assertEqual(frames[0]["type"], "error")
|
||||
self.assertTrue(
|
||||
frames[0]["message"].startswith("GGUF JoyCaption needs llama-cpp-python.")
|
||||
)
|
||||
self.assertEqual(self.tagger.load_calls, [])
|
||||
|
||||
async def test_legacy_single_refuses_a_missing_backend_package(self):
|
||||
from py.autotag import MissingBackendPackages
|
||||
|
||||
self.image("a.png", (1, 1, 1))
|
||||
await self.relist()
|
||||
self.tagger.missing["gguf"] = ["llama-cpp-python"]
|
||||
status, data = await self.single(
|
||||
{"path": "a.png", "root_index": 0, "model_type": "gguf"}
|
||||
)
|
||||
self.assertEqual((status, data["code"]), (409, "model-unavailable"))
|
||||
self.assertIn("pip install llama-cpp-python", data["error"])
|
||||
self.assertEqual(self.tagger.load_calls, [])
|
||||
# Lost after the check: the same 409, no ERROR record, nothing held.
|
||||
self.tagger.missing["gguf"] = []
|
||||
self.tagger.load_error = MissingBackendPackages("gguf", ["llama-cpp-python"])
|
||||
with self.assertNoLogs(self.api.logger, level="ERROR"):
|
||||
status, data = await self.single(
|
||||
{"path": "a.png", "root_index": 0, "model_type": "gguf"}
|
||||
)
|
||||
self.assertEqual((status, data["code"]), (409, "model-unavailable"))
|
||||
self.assertIn("pip install llama-cpp-python", data["error"])
|
||||
self.assertIsNone(model_gate.holder())
|
||||
|
||||
async def test_each_refusal_is_one_error_frame(self):
|
||||
self.tagger.downloaded["wd14-vit"] = False
|
||||
_, frames = await self.legacy()
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
"""#424: each tagging backend reports the Python packages it cannot import.
|
||||
|
||||
``find_spec`` is patched throughout: no ML backend is installed or imported.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
try:
|
||||
from test_autotag_engine import (
|
||||
EngineTestCase,
|
||||
FakeGgufModel,
|
||||
_patch_modules,
|
||||
_touch_model_files,
|
||||
)
|
||||
except ImportError: # pragma: no cover - run as a package
|
||||
from tests.test_autotag_engine import (
|
||||
EngineTestCase,
|
||||
FakeGgufModel,
|
||||
_patch_modules,
|
||||
_touch_model_files,
|
||||
)
|
||||
|
||||
from py import autotag # noqa: E402
|
||||
from py.autotag import ( # noqa: E402
|
||||
BACKEND_PACKAGES,
|
||||
MODELS,
|
||||
AutoTagService,
|
||||
MissingBackendPackages,
|
||||
missing_packages,
|
||||
package_fields,
|
||||
)
|
||||
|
||||
GGUF_CUDA = 'For a CUDA build: CMAKE_ARGS="-DGGML_CUDA=on" pip install llama-cpp-python'
|
||||
|
||||
|
||||
def _find_spec_without(*absent):
|
||||
"""A ``find_spec`` that knows every module except *absent*."""
|
||||
|
||||
def find_spec(name, *args):
|
||||
return None if name in absent else object()
|
||||
|
||||
return find_spec
|
||||
|
||||
|
||||
def _missing_from(*absent):
|
||||
return patch.object(
|
||||
autotag.importlib.util, "find_spec", side_effect=_find_spec_without(*absent)
|
||||
)
|
||||
|
||||
|
||||
class TestMissingPackages(unittest.TestCase):
|
||||
|
||||
def test_every_backend_names_its_modules_and_pip_names(self):
|
||||
self.assertEqual(BACKEND_PACKAGES["gguf"], (("llama_cpp", "llama-cpp-python"),))
|
||||
self.assertEqual(
|
||||
[pip for _module, pip in BACKEND_PACKAGES["hf"]],
|
||||
["torch", "transformers", "accelerate", "bitsandbytes"],
|
||||
)
|
||||
for model in ("wd14-swinv2", "wd14-vit"):
|
||||
self.assertEqual(BACKEND_PACKAGES[model], (("onnxruntime", "onnxruntime"),))
|
||||
self.assertEqual(set(BACKEND_PACKAGES), set(MODELS))
|
||||
|
||||
def test_reports_pip_names_of_modules_find_spec_cannot_find(self):
|
||||
with _missing_from("llama_cpp", "accelerate", "bitsandbytes"):
|
||||
self.assertEqual(missing_packages("gguf"), ["llama-cpp-python"])
|
||||
self.assertEqual(missing_packages("hf"), ["accelerate", "bitsandbytes"])
|
||||
self.assertEqual(missing_packages("wd14-vit"), [])
|
||||
|
||||
def test_find_spec_errors_count_as_missing(self):
|
||||
for error in (ValueError("no spec"), ImportError("broken"), OSError("io")):
|
||||
with self.subTest(error=type(error).__name__):
|
||||
with patch.object(
|
||||
autotag.importlib.util, "find_spec", side_effect=error
|
||||
):
|
||||
self.assertEqual(missing_packages("wd14-vit"), ["onnxruntime"])
|
||||
|
||||
def test_a_module_already_imported_is_present_without_a_lookup(self):
|
||||
with _patch_modules({"llama_cpp": types.ModuleType("llama_cpp")}):
|
||||
with patch.object(
|
||||
autotag.importlib.util, "find_spec", side_effect=AssertionError
|
||||
):
|
||||
self.assertEqual(missing_packages("gguf"), [])
|
||||
|
||||
def test_the_check_imports_nothing(self):
|
||||
before = set(sys.modules)
|
||||
missing_packages("gguf")
|
||||
missing_packages("hf")
|
||||
missing_packages("wd14-vit")
|
||||
imported = set(sys.modules) - before
|
||||
heavy = {"llama_cpp", "torch", "transformers", "onnxruntime"}
|
||||
self.assertFalse(imported & heavy, imported)
|
||||
|
||||
def test_unknown_model_has_no_packages(self):
|
||||
self.assertEqual(missing_packages("nope"), [])
|
||||
|
||||
def test_package_fields_and_the_one_sentence(self):
|
||||
self.assertEqual(
|
||||
package_fields("gguf", []),
|
||||
{"missing_packages": [], "install_command": None, "install_note": None},
|
||||
)
|
||||
fields = package_fields("gguf", ["llama-cpp-python"])
|
||||
self.assertEqual(fields["missing_packages"], ["llama-cpp-python"])
|
||||
self.assertEqual(fields["install_command"], "pip install llama-cpp-python")
|
||||
self.assertEqual(fields["install_note"], GGUF_CUDA)
|
||||
self.assertEqual(
|
||||
package_fields("hf", ["torch", "bitsandbytes"])["install_command"],
|
||||
"pip install torch bitsandbytes",
|
||||
)
|
||||
self.assertIsNone(package_fields("hf", ["torch"])["install_note"])
|
||||
self.assertIn(
|
||||
"onnxruntime-gpu",
|
||||
package_fields("wd14-vit", ["onnxruntime"])["install_note"],
|
||||
)
|
||||
error = MissingBackendPackages("gguf", ["llama-cpp-python"])
|
||||
self.assertIsInstance(error, RuntimeError)
|
||||
self.assertEqual(
|
||||
str(error),
|
||||
"GGUF JoyCaption needs llama-cpp-python. Install it into ComfyUI's "
|
||||
"Python: pip install llama-cpp-python. " + GGUF_CUDA,
|
||||
)
|
||||
self.assertEqual(
|
||||
str(MissingBackendPackages("hf", ["accelerate", "bitsandbytes"])),
|
||||
"HuggingFace JoyCaption needs accelerate and bitsandbytes. Install them "
|
||||
"into ComfyUI's Python: pip install accelerate bitsandbytes.",
|
||||
)
|
||||
|
||||
|
||||
class TestStatusReportsPackages(EngineTestCase):
|
||||
|
||||
def test_downloaded_keeps_its_files_only_meaning(self):
|
||||
_touch_model_files(self.models_dir, "gguf")
|
||||
_touch_model_files(self.models_dir, "wd14-vit")
|
||||
service = AutoTagService(models_dir=self.models_dir)
|
||||
|
||||
with _missing_from("llama_cpp"):
|
||||
status = service.get_models_status()
|
||||
|
||||
self.assertTrue(status["gguf"]["downloaded"])
|
||||
self.assertEqual(status["gguf"]["missing_packages"], ["llama-cpp-python"])
|
||||
self.assertEqual(
|
||||
status["gguf"]["install_command"], "pip install llama-cpp-python"
|
||||
)
|
||||
self.assertEqual(status["gguf"]["install_note"], GGUF_CUDA)
|
||||
self.assertEqual(status["wd14-vit"]["missing_packages"], [])
|
||||
self.assertIsNone(status["wd14-vit"]["install_command"])
|
||||
# A model not on disk still says which packages it will need.
|
||||
self.assertFalse(status["wd14-swinv2"]["downloaded"])
|
||||
self.assertIn("missing_packages", status["hf"])
|
||||
|
||||
def test_an_injected_loader_brings_its_own_backend(self):
|
||||
service = self._service(("gguf", FakeGgufModel("x")))
|
||||
with _missing_from("llama_cpp"):
|
||||
self.assertEqual(
|
||||
service.get_models_status()["gguf"]["missing_packages"], []
|
||||
)
|
||||
|
||||
def test_an_injected_probe_is_used_for_status_and_load(self):
|
||||
_touch_model_files(self.models_dir, "gguf")
|
||||
service = AutoTagService(
|
||||
models_dir=self.models_dir,
|
||||
model_loader=lambda *_: ("gguf", FakeGgufModel("x")),
|
||||
package_probe=lambda model_type: ["llama-cpp-python"],
|
||||
)
|
||||
self.assertEqual(
|
||||
service.get_models_status()["gguf"]["missing_packages"],
|
||||
["llama-cpp-python"],
|
||||
)
|
||||
with self.assertRaises(MissingBackendPackages):
|
||||
service.load_model("gguf")
|
||||
|
||||
|
||||
class TestLoadFailsFast(EngineTestCase):
|
||||
|
||||
def test_missing_package_refuses_before_the_loader_without_an_error_log(self):
|
||||
_touch_model_files(self.models_dir, "gguf")
|
||||
service = AutoTagService(models_dir=self.models_dir)
|
||||
calls = []
|
||||
service._model_loader = lambda *args: calls.append(args)
|
||||
|
||||
with (
|
||||
_missing_from("llama_cpp"),
|
||||
self.assertLogs(service.logger, level="DEBUG") as logs,
|
||||
):
|
||||
with self.assertRaises(MissingBackendPackages) as ctx:
|
||||
service.load_model("gguf")
|
||||
|
||||
self.assertEqual(calls, [])
|
||||
self.assertEqual(ctx.exception.model_type, "gguf")
|
||||
self.assertEqual(ctx.exception.missing, ["llama-cpp-python"])
|
||||
self.assertIn("pip install llama-cpp-python", str(ctx.exception))
|
||||
self.assertFalse(service.is_model_loaded())
|
||||
levels = {record.levelname for record in logs.records}
|
||||
self.assertNotIn("ERROR", levels)
|
||||
self.assertIn("WARNING", levels)
|
||||
self.assertTrue(all(record.exc_info is None for record in logs.records))
|
||||
|
||||
def test_not_downloaded_is_reported_before_packages(self):
|
||||
service = AutoTagService(models_dir=self.models_dir)
|
||||
with _missing_from("llama_cpp"):
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
service.load_model("gguf")
|
||||
self.assertNotIsInstance(ctx.exception, MissingBackendPackages)
|
||||
|
||||
def test_a_module_not_found_from_the_loader_becomes_the_same_refusal(self):
|
||||
_touch_model_files(self.models_dir, "gguf")
|
||||
|
||||
def loader(model_type, use_gpu):
|
||||
raise ModuleNotFoundError("No module named 'llama_cpp'", name="llama_cpp")
|
||||
|
||||
service = AutoTagService(
|
||||
models_dir=self.models_dir,
|
||||
model_loader=loader,
|
||||
package_probe=lambda _model: [],
|
||||
)
|
||||
with self.assertLogs(service.logger, level="DEBUG") as logs:
|
||||
with self.assertRaises(MissingBackendPackages) as ctx:
|
||||
service.load_model("gguf")
|
||||
self.assertEqual(ctx.exception.missing, ["llama-cpp-python"])
|
||||
self.assertNotIn("ERROR", {record.levelname for record in logs.records})
|
||||
self.assertFalse(service.is_model_loaded())
|
||||
|
||||
def test_an_unknown_module_not_found_names_the_module(self):
|
||||
def loader(model_type, use_gpu):
|
||||
raise ModuleNotFoundError(
|
||||
"No module named 'sentencepiece'", name="sentencepiece"
|
||||
)
|
||||
|
||||
hf_dir = self.models_dir / MODELS["hf"]["subdir"]
|
||||
hf_dir.mkdir(parents=True, exist_ok=True)
|
||||
(hf_dir / "config.json").write_text("{}")
|
||||
service = AutoTagService(
|
||||
models_dir=self.models_dir,
|
||||
model_loader=loader,
|
||||
package_probe=lambda _model: [],
|
||||
)
|
||||
with self.assertRaises(MissingBackendPackages) as ctx:
|
||||
service.load_model("hf")
|
||||
self.assertEqual(ctx.exception.missing, ["sentencepiece"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -614,6 +614,50 @@ class TestRestoreFromFile(BackupTestCase):
|
||||
)
|
||||
assert_closed(self, str(backups[0]))
|
||||
|
||||
def test_backup_and_restore_keep_collections(self):
|
||||
with self.db.model.get_connection() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO image_collections (id, name, name_key, created_at) "
|
||||
"VALUES (4, 'LoRa testing', 'lora testing', '2026-10-27T00:00:00Z')"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO image_collection_items VALUES "
|
||||
"(4, 'root', 'grid.png', '2026-10-27T00:00:00Z')"
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
# The rows still sit in the WAL; a backup must carry them.
|
||||
backup = os.path.join(directory, "backup.db")
|
||||
self.assertTrue(self.db.model.backup_database(backup))
|
||||
self.assertEqual(
|
||||
PromptModel.verify_database_file(backup, require_library_state=True),
|
||||
(True, "ok"),
|
||||
)
|
||||
with self.db.model.get_connection() as conn:
|
||||
conn.execute("DELETE FROM image_collections")
|
||||
|
||||
self.db.model.restore_from_file(backup)
|
||||
|
||||
conn = self.db.model.get_connection()
|
||||
self.assertEqual(
|
||||
[
|
||||
tuple(r)
|
||||
for r in conn.execute("SELECT id, name FROM image_collections")
|
||||
],
|
||||
[(4, "LoRa testing")],
|
||||
)
|
||||
self.assertEqual(
|
||||
[
|
||||
tuple(r)
|
||||
for r in conn.execute(
|
||||
"SELECT collection_id, root_key, relative_path "
|
||||
"FROM image_collection_items"
|
||||
)
|
||||
],
|
||||
[(4, "root", "grid.png")],
|
||||
)
|
||||
PromptModel.close_all_instances()
|
||||
assert_closed(self, directory)
|
||||
|
||||
def test_restore_without_a_live_database_returns_empty_backup_path(self):
|
||||
src = self._make_source_db(["fresh"])
|
||||
self.db.close_all()
|
||||
|
||||
@@ -149,3 +149,36 @@ class TestDuplicateCleanupKeepsCategories(DuplicateFilesTestCase):
|
||||
kept = image_category_key([link], link / "a.png")
|
||||
self.assertFalse((self.output_dir / "b.png").exists())
|
||||
self.assertEqual(self.api.db.image_category(*kept), "Portraits")
|
||||
|
||||
async def test_a_copy_with_a_collection_and_a_category_moves_both(self):
|
||||
# #331 + #332: one removed copy carries both records; one cleanup
|
||||
# moves its membership and copies its category onto the kept copy.
|
||||
self.trio()
|
||||
db = self.api.db
|
||||
root_key = duplicate_files.favorite_root_key(self.output_dir)
|
||||
b = self.output_dir / "b.png"
|
||||
grids = db.add_to_collection(name="LoRA grids", items=[(root_key, "b.png")])
|
||||
grids = grids["collection"]["id"]
|
||||
self.set_category(b, "Portraits")
|
||||
seen = {}
|
||||
real_log = duplicate_files.DuplicateFilesMixin._log_moved_records
|
||||
|
||||
def log_moved_records(job, totals):
|
||||
seen.update(totals)
|
||||
real_log(job, totals)
|
||||
|
||||
with patch.object(
|
||||
duplicate_files.DuplicateFilesMixin,
|
||||
"_log_moved_records",
|
||||
staticmethod(log_moved_records),
|
||||
):
|
||||
cleanup = await self.run_cleanup()
|
||||
self.assertFalse(b.exists())
|
||||
self.assertEqual(db.collection_members(grids, root_key), {"a.png"})
|
||||
self.assertEqual(db.file_collections(root_key, "b.png"), [])
|
||||
self.assertEqual(self.category(self.output_dir / "a.png"), "Portraits")
|
||||
self.assertEqual(seen["collections"], 1)
|
||||
self.assertEqual(seen["categories"], 1)
|
||||
log = "\n".join(cleanup.log)
|
||||
self.assertIn("Moved 1 collection membership to the kept copies", log)
|
||||
self.assertIn("Kept 1 image category on the kept copies", log)
|
||||
|
||||
@@ -1,705 +0,0 @@
|
||||
"""The per-file prompt decision shared by the scan and the monitor (#247).
|
||||
|
||||
One test per case, named by the ladder step of the design
|
||||
(``docs/egm/247-foreign-workflows``). The ladder does no file or database
|
||||
I/O: history comes in through ``lookup``.
|
||||
"""
|
||||
|
||||
import json
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from database.run_attribution import RecordedOutput
|
||||
from tests.test_pair_scan import flux_graph, sampled_graph
|
||||
from tests.test_prompt_attribution import issue_graph, pair_evidence
|
||||
from tests import test_save_to_library as save_tests
|
||||
from utils import file_prompt
|
||||
from utils.file_prompt import Policy, decide_file_prompt, junk_text
|
||||
from utils.prompt_inspector import InspectionResult
|
||||
|
||||
try:
|
||||
from tests import inspection_parity as parity
|
||||
from tests import saver_anchor_graphs as shapes
|
||||
except ImportError: # pragma: no cover - discover -s tests puts tests/ first
|
||||
import inspection_parity as parity
|
||||
import saver_anchor_graphs as shapes
|
||||
|
||||
SHARED_NAME = "IMG_GEN_00371_.png"
|
||||
LORA = "<lora:style:1.00>"
|
||||
|
||||
|
||||
def _decide(
|
||||
metadata,
|
||||
*,
|
||||
policy=Policy.SCAN,
|
||||
lookup=None,
|
||||
in_output=True,
|
||||
file_name=SHARED_NAME,
|
||||
file_size=None,
|
||||
):
|
||||
return decide_file_prompt(
|
||||
metadata,
|
||||
file_name=file_name,
|
||||
file_size=file_size,
|
||||
in_output=in_output,
|
||||
lookup=lookup,
|
||||
policy=policy,
|
||||
)
|
||||
|
||||
|
||||
def _graph(graph):
|
||||
return {"prompt": json.dumps(graph)}
|
||||
|
||||
|
||||
class _Lookup:
|
||||
"""A history lookup that counts its calls."""
|
||||
|
||||
def __init__(self, saver=None):
|
||||
self.calls = 0
|
||||
self.found = RecordedOutput(saver, {}) if saver is not None else None
|
||||
|
||||
def __call__(self):
|
||||
self.calls += 1
|
||||
return self.found
|
||||
|
||||
|
||||
def _inspected(state, malformed=False):
|
||||
resolution = {"state": state, "positive": None, "negative": None}
|
||||
return InspectionResult(resolution, malformed=malformed)
|
||||
|
||||
|
||||
def _untraced_by_both():
|
||||
"""A sampled graph whose positive no profile traces; the legacy parser does."""
|
||||
return mock.patch.object(
|
||||
file_prompt, "inspection_resolution", return_value=_inspected("unresolved")
|
||||
)
|
||||
|
||||
|
||||
def _gated(graph):
|
||||
"""``graph`` past the default profile's node gate, so it is unresolved."""
|
||||
graph = dict(graph)
|
||||
for index in range(510):
|
||||
graph[f"pad{index}"] = {"class_type": "Note", "inputs": {}}
|
||||
return graph
|
||||
|
||||
|
||||
def _prompt_manager_branches(prepend="PRE", append="APP"):
|
||||
"""Two KSampler branches, each fed by a PromptManagerText string."""
|
||||
graph = sampled_graph()
|
||||
del graph["1"]
|
||||
sampler = graph.pop("3")
|
||||
for index, text in enumerate(("a cat", "a dog"), start=1):
|
||||
base = index * 10
|
||||
graph[str(base)] = {
|
||||
"class_type": "PromptManagerText",
|
||||
"inputs": {"text": text, "prepend_text": prepend, "append_text": append},
|
||||
}
|
||||
graph[str(base + 1)] = {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": [str(base), 0]},
|
||||
}
|
||||
graph[str(base + 2)] = {
|
||||
**sampler,
|
||||
"inputs": {**sampler["inputs"], "positive": [str(base + 1), 0]},
|
||||
}
|
||||
return graph
|
||||
|
||||
|
||||
def _numeric_first_branches():
|
||||
"""Three branches; the legacy parser's answer (the first) is a number."""
|
||||
graph = sampled_graph("2048")
|
||||
for index, text in ((5, "a cat"), (6, "a dog")):
|
||||
graph[str(index * 10)] = {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": text},
|
||||
}
|
||||
graph[str(index * 10 + 1)] = {
|
||||
**graph["3"],
|
||||
"inputs": {**graph["3"]["inputs"], "positive": [str(index * 10), 0]},
|
||||
}
|
||||
return graph
|
||||
|
||||
|
||||
class TestStepsBeforeResolving(unittest.TestCase):
|
||||
def test_step_1_no_metadata_tries_the_recorded_run(self):
|
||||
for metadata in ({}, None):
|
||||
decision = _decide(metadata)
|
||||
self.assertEqual(decision.kind, "never")
|
||||
self.assertTrue(decision.try_recorded_run)
|
||||
self.assertIsNone(decision.unlinked_status)
|
||||
self.assertEqual(decision.canonical, {})
|
||||
|
||||
def test_step_3_neither_prompt_nor_parameters_tries_the_recorded_run(self):
|
||||
decision = _decide({"workflow": json.dumps({"nodes": []})})
|
||||
self.assertEqual(decision.kind, "never")
|
||||
self.assertTrue(decision.try_recorded_run)
|
||||
self.assertIsNone(decision.unlinked_status)
|
||||
self.assertEqual(decision.canonical, {"workflow": {"nodes": []}})
|
||||
|
||||
def test_strings_are_parsed_and_malformed_ones_kept(self):
|
||||
decision = _decide({"prompt": "{not json", "workflow": "{}"})
|
||||
self.assertEqual(decision.canonical["prompt"], "{not json")
|
||||
self.assertEqual(decision.canonical["workflow"], {})
|
||||
|
||||
def test_the_file_size_is_recorded_unless_the_metadata_has_one(self):
|
||||
meta = _graph(sampled_graph())
|
||||
self.assertEqual(
|
||||
_decide(meta, file_size=42).canonical["file_info"], {"size": 42}
|
||||
)
|
||||
meta["file_info"] = {"size": 7, "width": 3}
|
||||
self.assertEqual(
|
||||
_decide(meta, file_size=42).canonical["file_info"], {"size": 7, "width": 3}
|
||||
)
|
||||
self.assertNotIn("file_info", _decide(_graph(sampled_graph())).canonical)
|
||||
|
||||
def test_a_file_size_reader_is_called_only_for_prompt_metadata(self):
|
||||
reads = []
|
||||
|
||||
def size():
|
||||
reads.append(1)
|
||||
return 9
|
||||
|
||||
for metadata in ({}, {"workflow": "{}"}):
|
||||
_decide(metadata, file_size=size)
|
||||
self.assertEqual(reads, [])
|
||||
decision = _decide(_graph(sampled_graph()), file_size=size)
|
||||
self.assertEqual((reads, decision.canonical["file_info"]), ([1], {"size": 9}))
|
||||
|
||||
|
||||
def _off_behind(middle, key):
|
||||
"""A switched-off PromptManagerText reaching the encoder through
|
||||
``middle``'s ``key`` input."""
|
||||
graph = save_tests.run_graph()
|
||||
graph["1"] = {
|
||||
"class_type": "PromptManagerText",
|
||||
"inputs": {"text": "SECRET OFF TEXT", "save_to_library": False},
|
||||
}
|
||||
graph["7"] = {"class_type": middle, "inputs": {key: ["1", 0]}}
|
||||
graph["8"] = {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": ["7", 0], "clip": ["4", 1]},
|
||||
}
|
||||
graph["3"]["inputs"]["positive"] = ["8", 0]
|
||||
return graph
|
||||
|
||||
|
||||
class TestSaveToLibrarySteps(unittest.TestCase):
|
||||
"""#90: a PromptManager node with Save to library off stores nothing."""
|
||||
|
||||
def test_step_2_an_opted_out_file_is_not_saved(self):
|
||||
lookup = _Lookup("9")
|
||||
graph = save_tests.run_graph(save=False)
|
||||
graph["5"] = {**graph["3"], "inputs": {**graph["3"]["inputs"]}}
|
||||
graph["5"]["inputs"]["positive"] = ["2", 0] # ambiguous otherwise
|
||||
for policy in Policy:
|
||||
decision = _decide(_graph(graph), policy=policy, lookup=lookup)
|
||||
self.assertEqual(decision.kind, "not_saved")
|
||||
self.assertFalse(decision.try_recorded_run)
|
||||
self.assertIsNone(decision.review)
|
||||
self.assertEqual(lookup.calls, 0)
|
||||
|
||||
def test_step_2_a_wired_switch_is_not_saved_wired(self):
|
||||
graph = save_tests.run_graph(save=["9", 0])
|
||||
graph["9"] = {"class_type": "SomeCustomSwitch", "inputs": {}}
|
||||
lookup = _Lookup("9")
|
||||
decision = _decide(_graph(graph), lookup=lookup)
|
||||
self.assertEqual(decision.kind, "not_saved_wired")
|
||||
self.assertFalse(decision.try_recorded_run)
|
||||
self.assertIsNone(decision.review)
|
||||
self.assertEqual(lookup.calls, 0)
|
||||
|
||||
def test_step_8_1_an_off_node_behind_a_pass_through_is_not_saved(self):
|
||||
"""The #90 graph walk does not follow ``anything`` or ``value``; the
|
||||
inspector does, so its positive is checked against the switch."""
|
||||
for middle, key in (("easy showAnything", "anything"), ("MyNode", "value")):
|
||||
with self.subTest(middle=middle):
|
||||
graph = _off_behind(middle, key)
|
||||
for policy in Policy:
|
||||
decision = _decide(_graph(graph), policy=policy, lookup=_Lookup())
|
||||
self.assertEqual(decision.kind, "not_saved")
|
||||
self.assertIsNone(decision.text)
|
||||
|
||||
def test_step_8_4_a_legacy_answer_from_an_off_node_is_not_saved(self):
|
||||
graph = _off_behind("MyNode", "value")
|
||||
graph["9"] = {"class_type": "CLIPTextEncode", "inputs": {"text": "on text"}}
|
||||
graph["5"] = {**graph["3"], "inputs": {**graph["3"]["inputs"]}}
|
||||
graph["5"]["inputs"]["positive"] = ["9", 0]
|
||||
decision = _decide(_graph(graph), lookup=_Lookup())
|
||||
self.assertEqual(decision.kind, "not_saved")
|
||||
self.assertIsNone(decision.text)
|
||||
|
||||
def test_step_2_an_off_node_on_any_positive_input_is_not_saved(self):
|
||||
"""Any input name upstream of a positive, not only prompt-like ones."""
|
||||
guider = _off_behind("MyNode", "value")
|
||||
guider["3"] = {
|
||||
"class_type": "BasicGuider",
|
||||
"inputs": {"model": ["4", 0], "conditioning": ["8", 0]},
|
||||
}
|
||||
prepended = _off_behind("MyNode", "value")
|
||||
prepended["1"]["inputs"]["prepend_text"] = "pre"
|
||||
pair = _off_behind("MyNode", "value")
|
||||
pair["1"] = {
|
||||
"class_type": "PromptManagerPair",
|
||||
"inputs": {"text": "x", "negative_text": "y", "save_to_library": False},
|
||||
}
|
||||
pair["2"]["inputs"]["text"] = ["1", 1]
|
||||
cases = {"sampler-less guider": guider, "prepend": prepended, "pair": pair}
|
||||
for name, graph in cases.items():
|
||||
with self.subTest(name=name):
|
||||
lookup = _Lookup("9")
|
||||
decision = _decide(_graph(graph), lookup=lookup)
|
||||
self.assertEqual(decision.kind, "not_saved")
|
||||
self.assertFalse(decision.try_recorded_run)
|
||||
self.assertEqual(lookup.calls, 0)
|
||||
|
||||
def test_step_2_a_wired_switch_behind_a_pass_through_is_not_saved_wired(self):
|
||||
graph = _off_behind("MyNode", "value")
|
||||
graph["1"]["inputs"]["save_to_library"] = ["20", 0]
|
||||
graph["20"] = {"class_type": "SomeBoolNode", "inputs": {}}
|
||||
self.assertEqual(_decide(_graph(graph)).kind, "not_saved_wired")
|
||||
|
||||
def test_a_switched_on_node_behind_a_pass_through_is_stored(self):
|
||||
"""Regression guard: on, or no switch at all (older files)."""
|
||||
for value in (True, None):
|
||||
with self.subTest(value=value):
|
||||
graph = _off_behind("MyNode", "value")
|
||||
if value is None:
|
||||
del graph["1"]["inputs"]["save_to_library"]
|
||||
else:
|
||||
graph["1"]["inputs"]["save_to_library"] = value
|
||||
decision = _decide(_graph(graph), lookup=_Lookup())
|
||||
self.assertEqual(decision.kind, "positive")
|
||||
self.assertEqual(decision.text, "SECRET OFF TEXT")
|
||||
|
||||
def test_an_off_node_on_a_negative_input_name_never_blocks(self):
|
||||
"""The walk skips negative inputs: #90's own rule (``neg``,
|
||||
``neg_text``, ``text_neg``) and any name containing "negative"."""
|
||||
for key in (
|
||||
"neg",
|
||||
"neg_text",
|
||||
"text_neg",
|
||||
"negative_text",
|
||||
"negative2",
|
||||
"negativeprompt",
|
||||
"NegativePrompt",
|
||||
):
|
||||
with self.subTest(key=key):
|
||||
graph = {
|
||||
"1": {
|
||||
"class_type": "PromptManagerText",
|
||||
"inputs": {"text": "bad hands", "save_to_library": False},
|
||||
},
|
||||
"9": {
|
||||
"class_type": "PromptManagerText",
|
||||
"inputs": {"text": "a cat", "save_to_library": True},
|
||||
},
|
||||
"7": {
|
||||
"class_type": "PipeEncode",
|
||||
"inputs": {"pos_text": ["9", 0], key: ["1", 0]},
|
||||
},
|
||||
"4": {"class_type": "CheckpointLoaderSimple", "inputs": {}},
|
||||
"3": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"model": ["4", 0],
|
||||
"positive": ["7", 0],
|
||||
"negative": ["7", 1],
|
||||
},
|
||||
},
|
||||
}
|
||||
self.assertIsNone(file_prompt._switched_off_upstream(graph))
|
||||
self.assertNotIn(
|
||||
_decide(_graph(graph)).kind, ("not_saved", "not_saved_wired")
|
||||
)
|
||||
|
||||
def test_the_guards_accept_malformed_nodes(self):
|
||||
graph = {
|
||||
"1": {"class_type": "PromptManagerText", "inputs": None},
|
||||
"2": {"class_type": "PromptManagerText"},
|
||||
}
|
||||
self.assertIsNone(file_prompt._off_text(graph, "x"))
|
||||
side = {"provenance": ["not", "a", "dict"]}
|
||||
with mock.patch.object(file_prompt, "side_opted_out", return_value=True):
|
||||
self.assertIsNone(file_prompt._side_switch(graph, side))
|
||||
|
||||
def test_the_off_text_guard_reads_a_nodes_resolved_text(self):
|
||||
graph = {
|
||||
"1": {
|
||||
"class_type": "PromptManagerText",
|
||||
"inputs": {"text": ["30", 0], "save_to_library": False},
|
||||
},
|
||||
"30": {
|
||||
"class_type": "PrimitiveStringMultiline",
|
||||
"inputs": {"value": "SECRET OFF TEXT"},
|
||||
},
|
||||
"2": {
|
||||
"class_type": "PromptManagerText",
|
||||
"inputs": {"text": "WIRED", "save_to_library": ["9", 0]},
|
||||
},
|
||||
"9": {"class_type": "SomeBoolNode", "inputs": {}},
|
||||
}
|
||||
self.assertEqual(file_prompt._off_text(graph, " SECRET OFF TEXT"), "not_saved")
|
||||
self.assertEqual(file_prompt._off_text(graph, "WIRED"), "not_saved_wired")
|
||||
self.assertIsNone(file_prompt._off_text(graph, "other"))
|
||||
|
||||
def test_steps_5_and_7_check_the_resolved_positive_too(self):
|
||||
"""A resolved positive through an off node the #90 walk missed."""
|
||||
cases = {
|
||||
"pair": save_tests.run_graph(save=False),
|
||||
"negative off": save_tests.run_graph(save=False, negative_save=False),
|
||||
}
|
||||
for name, graph in cases.items():
|
||||
with (
|
||||
self.subTest(name=name),
|
||||
mock.patch.object(
|
||||
file_prompt, "metadata_opted_out", return_value=False
|
||||
),
|
||||
):
|
||||
self.assertEqual(_decide(_graph(graph)).kind, "not_saved")
|
||||
|
||||
def test_step_5_a_junk_positive_is_never_stored(self):
|
||||
"""Pinned: the positive alone still passes the junk gate, and a blank
|
||||
positive (never storable) stores nothing either."""
|
||||
graph = save_tests.run_graph(save=True, negative_save=False)
|
||||
graph["1"]["inputs"]["text"] = "2048"
|
||||
decision = _decide(_graph(graph))
|
||||
self.assertEqual((decision.kind, decision.source), ("never", "junk:number"))
|
||||
self.assertEqual(decision.unlinked_status, "found")
|
||||
graph["1"]["inputs"]["text"] = ""
|
||||
decision = _decide(_graph(graph))
|
||||
self.assertEqual((decision.kind, decision.text), ("never", None))
|
||||
|
||||
def test_step_2_a_workflow_only_opt_out_comes_before_step_3(self):
|
||||
workflow = {
|
||||
"nodes": [{"id": 1, "type": "PromptManager", "widgets_values": [False]}]
|
||||
}
|
||||
decision = _decide({"workflow": json.dumps(workflow)})
|
||||
self.assertEqual(decision.kind, "not_saved")
|
||||
|
||||
def test_step_5_an_opted_out_negative_stores_the_positive_alone(self):
|
||||
graph = save_tests.run_graph(save=True, negative_save=False)
|
||||
decision = _decide(_graph(graph))
|
||||
self.assertEqual(decision.kind, "positive")
|
||||
self.assertEqual(decision.source, "opted-out-negative")
|
||||
self.assertEqual(decision.text, save_tests.POSITIVE)
|
||||
|
||||
|
||||
class TestResolvedSteps(unittest.TestCase):
|
||||
def test_step_6_a_blank_positive_counts_as_unresolved(self):
|
||||
meta = {
|
||||
"prompt": json.dumps(issue_graph()),
|
||||
"comfyassets_prompt_pairs": json.dumps(pair_evidence()),
|
||||
}
|
||||
decision = _decide(meta, policy=Policy.MONITOR, in_output=False)
|
||||
self.assertEqual(decision.kind, "never")
|
||||
self.assertEqual(decision.resolution["state"], "unresolved")
|
||||
|
||||
def test_step_7_a_resolved_pair(self):
|
||||
lookup = _Lookup("9")
|
||||
decision = _decide(_graph(sampled_graph("cat", "blur")), lookup=lookup)
|
||||
self.assertEqual(decision.kind, "pair")
|
||||
self.assertEqual(decision.source, "resolver")
|
||||
self.assertEqual(decision.text, "cat")
|
||||
self.assertEqual(decision.resolution["negative"]["text"], "blur")
|
||||
self.assertIsNone(decision.review)
|
||||
self.assertEqual(lookup.calls, 0, "decided before step 8: history not asked")
|
||||
|
||||
def test_step_7_a_blank_negative_stays_a_pair(self):
|
||||
decision = _decide(_graph(sampled_graph("cat", "")))
|
||||
self.assertEqual(decision.kind, "pair")
|
||||
self.assertEqual(decision.resolution["negative"]["text"], "")
|
||||
|
||||
|
||||
class TestUndecidedGraph(unittest.TestCase):
|
||||
def test_step_8_1_the_inspector_alone_decides(self):
|
||||
decision = _decide({"prompt": parity.build("f03")["prompt"]})
|
||||
self.assertEqual(decision.kind, "positive")
|
||||
self.assertEqual(decision.source, "inspector")
|
||||
self.assertEqual(decision.text, parity.POS)
|
||||
self.assertEqual(decision.resolution["state"], "unresolved")
|
||||
|
||||
def test_step_8_1_the_recorded_saver_names_its_branch(self):
|
||||
lookup = _Lookup("23")
|
||||
decision = _decide(_graph(shapes.shared_prefix()), lookup=lookup)
|
||||
self.assertEqual(decision.kind, "positive")
|
||||
self.assertEqual(decision.source, "recorded-run")
|
||||
self.assertEqual(decision.text, shapes.BRANCH_TEXTS[1])
|
||||
self.assertEqual(decision.review, "ambiguous")
|
||||
self.assertEqual(lookup.calls, 1)
|
||||
|
||||
def test_step_8_1_stores_the_storage_text_without_prepend_or_append(self):
|
||||
graph = shapes.shared_prefix()
|
||||
graph["20"]["inputs"]["text"] = ["25", 1]
|
||||
graph["25"] = {
|
||||
"class_type": "PromptManagerSplit",
|
||||
"inputs": {
|
||||
"text": shapes.BRANCH_TEXTS[1],
|
||||
"prepend_text": "trigger",
|
||||
"append_text": "detail",
|
||||
},
|
||||
}
|
||||
decision = _decide(_graph(graph), lookup=_Lookup("23"))
|
||||
self.assertEqual(decision.source, "recorded-run")
|
||||
self.assertEqual(decision.text, shapes.BRANCH_TEXTS[1])
|
||||
|
||||
def test_step_8_1_a_file_name_naming_another_saver_anchors_nothing(self):
|
||||
graph = shapes.branch_bench()
|
||||
for index in (1, 3, 4):
|
||||
graph[str(index * 10 + 3)]["inputs"]["filename_prefix"] = "bench/shared"
|
||||
decision = _decide(
|
||||
_graph(graph), lookup=_Lookup("43"), file_name="s45_branch_2_00001_.png"
|
||||
)
|
||||
# The name contradicts history's branch 4: no recorded anchor.
|
||||
self.assertEqual(decision.source, "legacy-parser")
|
||||
|
||||
def test_step_8_1_the_file_name_anchor_alone_never_decides(self):
|
||||
"""#390: the file-name anchor never decides a link. The scan's legacy
|
||||
floor may still link the file (as before #247), and a file nothing
|
||||
links is queued with the anchor's choice per #390; the monitor
|
||||
stores nothing."""
|
||||
name = "s45_branch_2_00001_.png"
|
||||
scan = _decide(_graph(shapes.branch_bench()), lookup=_Lookup(), file_name=name)
|
||||
self.assertEqual((scan.kind, scan.source), ("positive", "legacy-parser"))
|
||||
self.assertEqual(scan.review, "ambiguous")
|
||||
monitor = _decide(
|
||||
_graph(shapes.branch_bench()),
|
||||
policy=Policy.MONITOR,
|
||||
lookup=_Lookup(),
|
||||
file_name=name,
|
||||
)
|
||||
self.assertEqual(monitor.kind, "never")
|
||||
recorded = _decide(
|
||||
_graph(shapes.branch_bench()), lookup=_Lookup("43"), file_name=name
|
||||
)
|
||||
# The recorded saver still decides: history says branch 4, but the
|
||||
# file name names branch 2, which contradicts it.
|
||||
self.assertNotEqual(recorded.source, "recorded-run")
|
||||
recorded = _decide(_graph(shapes.branch_bench()), lookup=_Lookup("43"))
|
||||
self.assertEqual(recorded.source, "recorded-run")
|
||||
self.assertEqual(recorded.text, shapes.BRANCH_TEXTS[3])
|
||||
|
||||
def test_step_8_2_the_monitor_waits_for_history_inside_the_output_folder(self):
|
||||
decision = _decide(_graph(shapes.shared_prefix()), policy=Policy.MONITOR)
|
||||
self.assertEqual(decision.kind, "history")
|
||||
|
||||
def test_step_8_2_outside_the_output_folder_steps_8_3_and_8_4_apply(self):
|
||||
decision = _decide(
|
||||
_graph(shapes.shared_prefix()), policy=Policy.MONITOR, in_output=False
|
||||
)
|
||||
self.assertEqual(decision.kind, "never")
|
||||
with _untraced_by_both():
|
||||
decision = _decide(
|
||||
_graph(_gated(sampled_graph())), policy=Policy.MONITOR, in_output=False
|
||||
)
|
||||
self.assertEqual(
|
||||
(decision.kind, decision.source), ("positive", "legacy-parser")
|
||||
)
|
||||
|
||||
def test_step_8_3_an_ambiguous_file_is_left_unlinked_by_the_monitor(self):
|
||||
lookup = _Lookup("missing")
|
||||
decision = _decide(
|
||||
_graph(shapes.shared_prefix()), policy=Policy.MONITOR, lookup=lookup
|
||||
)
|
||||
self.assertEqual(decision.kind, "never")
|
||||
self.assertEqual(decision.unlinked_status, "ambiguous")
|
||||
self.assertFalse(decision.try_recorded_run)
|
||||
self.assertEqual(lookup.calls, 1)
|
||||
|
||||
def test_step_8_4_joined_prompt_manager_text_is_never_the_legacy_answer(self):
|
||||
"""The legacy parser returns prepend + text + append; the stored text
|
||||
of a PromptManager node cannot be told from it."""
|
||||
decision = _decide(_graph(_prompt_manager_branches()), lookup=_Lookup())
|
||||
self.assertEqual(decision.kind, "never")
|
||||
self.assertEqual(decision.unlinked_status, "ambiguous")
|
||||
self.assertEqual(decision.review, "ambiguous")
|
||||
self.assertTrue(decision.try_recorded_run)
|
||||
# A linked part the parser does not follow: its answer is the node's
|
||||
# own text, which is what the node stores (#170 parity).
|
||||
linked = _prompt_manager_branches(prepend=["99", 0], append="")
|
||||
decision = _decide(_graph(linked), lookup=_Lookup())
|
||||
self.assertEqual((decision.kind, decision.text), ("positive", "a cat"))
|
||||
|
||||
def test_step_8_4_prompt_manager_text_without_parts_keeps_the_floor(self):
|
||||
"""Regression guard (passes on the base): no prepend or append."""
|
||||
graph = _prompt_manager_branches(prepend="", append=" ")
|
||||
decision = _decide(_graph(graph), lookup=_Lookup())
|
||||
self.assertEqual((decision.kind, decision.text), ("positive", "a cat"))
|
||||
|
||||
def test_step_8_4_an_ambiguous_file_gets_the_legacy_answer_in_the_scan(self):
|
||||
decision = _decide(_graph(shapes.shared_prefix()), lookup=_Lookup())
|
||||
self.assertEqual(decision.kind, "positive")
|
||||
self.assertEqual(decision.source, "legacy-parser")
|
||||
self.assertEqual(decision.text, shapes.BRANCH_TEXTS[0])
|
||||
self.assertEqual(decision.review, "ambiguous")
|
||||
|
||||
def test_step_8_4_an_untraceable_file_gets_the_legacy_answer_in_both(self):
|
||||
for policy in Policy:
|
||||
for inspected in (_inspected("unresolved"), _inspected("x", True)):
|
||||
with (
|
||||
self.subTest(policy=policy, malformed=inspected.malformed),
|
||||
mock.patch.object(
|
||||
file_prompt, "inspection_resolution", return_value=inspected
|
||||
),
|
||||
):
|
||||
decision = _decide(
|
||||
_graph(_gated(sampled_graph("cat"))),
|
||||
policy=policy,
|
||||
lookup=_Lookup(),
|
||||
)
|
||||
self.assertEqual(decision.kind, "positive")
|
||||
self.assertEqual(decision.source, "legacy-parser")
|
||||
self.assertEqual(decision.text, "cat")
|
||||
|
||||
def test_step_8_4_nothing_traced_tries_the_recorded_run(self):
|
||||
graph = sampled_graph()
|
||||
graph["5"] = {**graph["3"]}
|
||||
for sampler in ("3", "5"):
|
||||
graph[sampler]["inputs"]["positive"] = ["9", 0]
|
||||
graph["9"] = {"class_type": "BasicGuider", "inputs": {"width": 2048}}
|
||||
decision = _decide(_graph(graph), lookup=_Lookup())
|
||||
self.assertEqual(decision.kind, "never")
|
||||
self.assertTrue(decision.try_recorded_run)
|
||||
# Ambiguous without a choice is reported unresolved.
|
||||
self.assertEqual(decision.unlinked_status, "unresolved")
|
||||
self.assertEqual(decision.review, "untraced")
|
||||
|
||||
|
||||
class TestStepsWithoutAnUndecidedGraph(unittest.TestCase):
|
||||
def test_step_9_ambiguous_parameters_only(self):
|
||||
meta = {
|
||||
"parameters": (
|
||||
"cat\nNegative prompt: blur\nNegative prompt: noise"
|
||||
"\nSteps: 20, Sampler: Euler"
|
||||
)
|
||||
}
|
||||
decision = _decide(meta)
|
||||
self.assertEqual(decision.kind, "never")
|
||||
self.assertFalse(decision.try_recorded_run)
|
||||
self.assertEqual(decision.unlinked_status, "unresolved")
|
||||
|
||||
def test_step_10_unresolved_without_a1111_text(self):
|
||||
decision = _decide({"prompt": {}, "parameters": 5})
|
||||
self.assertEqual(decision.kind, "never")
|
||||
self.assertFalse(decision.try_recorded_run)
|
||||
self.assertEqual(decision.unlinked_status, "unresolved")
|
||||
|
||||
def test_step_11_sampler_less_with_one_text(self):
|
||||
decision = _decide(_graph(flux_graph()))
|
||||
self.assertEqual((decision.kind, decision.source), ("positive", "sampler-less"))
|
||||
self.assertEqual(decision.text, "a red fox")
|
||||
|
||||
def test_step_11_sampler_less_with_several_texts(self):
|
||||
graph = flux_graph()
|
||||
graph["50"] = {
|
||||
"class_type": "Lora Loader (LoraManager)",
|
||||
"inputs": {"model": ["1", 0], "clip": ["2", 0], "text": "a lora note"},
|
||||
}
|
||||
graph["22"]["inputs"]["model"] = ["50", 0]
|
||||
graph["6"]["inputs"]["clip"] = ["50", 1]
|
||||
decision = _decide(_graph(graph))
|
||||
self.assertEqual(decision.kind, "never")
|
||||
self.assertTrue(decision.try_recorded_run)
|
||||
self.assertEqual(decision.unlinked_status, "unresolved")
|
||||
meta = {**_graph(graph), "parameters": "a red fox\nNegative prompt: x"}
|
||||
decision = _decide(meta)
|
||||
self.assertEqual((decision.kind, decision.source), ("positive", "a1111"))
|
||||
self.assertEqual(decision.text, "a red fox")
|
||||
|
||||
def test_step_11_sampler_less_without_a_text(self):
|
||||
graph = {"40": {"class_type": "easy showAnything", "inputs": {"text": "x"}}}
|
||||
decision = _decide(_graph(graph))
|
||||
self.assertEqual(decision.kind, "never")
|
||||
self.assertTrue(decision.try_recorded_run)
|
||||
self.assertEqual(decision.unlinked_status, "found")
|
||||
|
||||
def test_step_12_a1111_only(self):
|
||||
decision = _decide({"parameters": "a cat"})
|
||||
self.assertEqual((decision.kind, decision.source), ("positive", "a1111"))
|
||||
self.assertEqual(decision.text, "a cat")
|
||||
decision = _decide({"parameters": "Negative prompt: blur"})
|
||||
self.assertEqual((decision.kind, decision.unlinked_status), ("never", "found"))
|
||||
self.assertFalse(decision.try_recorded_run)
|
||||
|
||||
def test_step_12_parsed_metadata_never_raises(self):
|
||||
# The monitor hands parsed values: a dict prompt, non-string parameters.
|
||||
decision = _decide({"prompt": {}, "parameters": "a cat"})
|
||||
self.assertEqual(decision.text, "a cat")
|
||||
decision = _decide({"parameters": 5})
|
||||
self.assertEqual((decision.kind, decision.unlinked_status), ("never", "found"))
|
||||
|
||||
|
||||
class TestJunkGate(unittest.TestCase):
|
||||
JUNK = {
|
||||
"blank": " ",
|
||||
"number": "2048",
|
||||
"lora-only": f"{LORA}, <lora:detail:0.5> |",
|
||||
"model-file": "models/checkpoints/Flux-Dev.SafeTensors",
|
||||
}
|
||||
|
||||
def test_each_junk_class_is_named(self):
|
||||
for name, text in self.JUNK.items():
|
||||
with self.subTest(name=name):
|
||||
self.assertEqual(junk_text(text), name)
|
||||
for ending in ("ckpt", "pt", "pth", "bin", "gguf", "sft"):
|
||||
self.assertEqual(junk_text(f"model.{ending}"), "model-file")
|
||||
|
||||
def test_near_misses_are_not_junk(self):
|
||||
for text in (
|
||||
f"a red fox {LORA}",
|
||||
"a.safetensors b.safetensors",
|
||||
"nan",
|
||||
"a portrait saved as final.safetensors",
|
||||
"4k",
|
||||
"C:\\My Models\\x.safetensors",
|
||||
):
|
||||
with self.subTest(text=text):
|
||||
self.assertIsNone(junk_text(text))
|
||||
|
||||
def test_step_13_junk_from_any_step_is_never_stored(self):
|
||||
cases = {
|
||||
"resolver": _graph(sampled_graph("2048", "blur")),
|
||||
"sampler-less": _graph(flux_graph(text=LORA)),
|
||||
"a1111": {"parameters": "model.safetensors"},
|
||||
}
|
||||
for step, meta in cases.items():
|
||||
with self.subTest(step=step):
|
||||
decision = _decide(meta)
|
||||
self.assertEqual(decision.kind, "never")
|
||||
self.assertTrue(decision.source.startswith("junk:"), decision.source)
|
||||
self.assertTrue(decision.try_recorded_run)
|
||||
self.assertEqual(decision.unlinked_status, "found")
|
||||
|
||||
def test_step_13_a_junk_legacy_answer_is_never_stored(self):
|
||||
with _untraced_by_both():
|
||||
decision = _decide(_graph(_gated(sampled_graph(LORA))), lookup=_Lookup())
|
||||
self.assertEqual(decision.source, "junk:lora-only")
|
||||
self.assertEqual(decision.kind, "never")
|
||||
self.assertEqual(decision.unlinked_status, "unresolved")
|
||||
|
||||
def test_step_13_junk_at_step_8_keeps_that_steps_status_and_review(self):
|
||||
# An ambiguous file whose legacy answer is a number is still a
|
||||
# Needs review case: its status stays "ambiguous".
|
||||
decision = _decide(_graph(_numeric_first_branches()), lookup=_Lookup())
|
||||
self.assertEqual((decision.kind, decision.source), ("never", "junk:number"))
|
||||
self.assertEqual(decision.unlinked_status, "ambiguous")
|
||||
self.assertEqual(decision.review, "ambiguous")
|
||||
self.assertTrue(decision.try_recorded_run)
|
||||
|
||||
|
||||
class TestParseComfyuiPrompt(unittest.TestCase):
|
||||
def test_a_parsed_prompt_and_non_string_values_are_kept(self):
|
||||
parsed = file_prompt.parse_comfyui_prompt(
|
||||
{"prompt": {"1": {}}, "workflow": {"nodes": []}, "steps": 20}
|
||||
)
|
||||
self.assertEqual(parsed["prompt"], {"1": {}})
|
||||
self.assertEqual(parsed["workflow"], {"nodes": []})
|
||||
self.assertEqual(parsed["parameters"]["steps"], 20)
|
||||
self.assertIsNone(parsed["positive_prompt"])
|
||||
|
||||
def test_non_string_parameters_are_ignored(self):
|
||||
parsed = file_prompt.parse_comfyui_prompt({"parameters": {"a": 1}})
|
||||
self.assertIsNone(parsed["positive_prompt"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,268 @@
|
||||
"""Collections (#331): the database operations."""
|
||||
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
try:
|
||||
from tests.open_handles import assert_closed
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from open_handles import assert_closed
|
||||
|
||||
from database.models import PromptModel
|
||||
from database.operations import PromptDatabase
|
||||
|
||||
try: # guarded so a missing module fails assertions, not test loading
|
||||
from database.image_collections import (
|
||||
CollectionFilesMissing,
|
||||
CollectionInvalid,
|
||||
CollectionMissing,
|
||||
CollectionNameTaken,
|
||||
READ_SQL,
|
||||
)
|
||||
except ImportError:
|
||||
CollectionFilesMissing = CollectionMissing = CollectionNameTaken = None
|
||||
CollectionInvalid = ValueError
|
||||
READ_SQL = None
|
||||
|
||||
ROOT = "/real/output"
|
||||
OTHER = "/real/other"
|
||||
|
||||
|
||||
class ImageCollectionOperationsTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.addCleanup(assert_closed, self, self.temp.name)
|
||||
self.addCleanup(PromptModel.reset_schema_cache)
|
||||
self.db = PromptDatabase(str(Path(self.temp.name) / "prompts.db"))
|
||||
self.addCleanup(self.db.close_all)
|
||||
|
||||
def require(self):
|
||||
self.assertTrue(
|
||||
hasattr(self.db, "add_to_collection"),
|
||||
"ImageCollectionOperations is not implemented",
|
||||
)
|
||||
|
||||
def add(self, items, **target):
|
||||
return self.db.add_to_collection(items=items, **target)
|
||||
|
||||
def test_add_creates_a_collection_by_name(self):
|
||||
self.require()
|
||||
result = self.add(
|
||||
[(ROOT, "a.png"), (ROOT, "b.png"), (OTHER, "a.png")], name="LoRa testing"
|
||||
)
|
||||
collection = result["collection"]
|
||||
self.assertEqual(collection["name"], "LoRa testing")
|
||||
self.assertEqual(
|
||||
result,
|
||||
{"collection": collection, "created": True, "added": 3, "already": 0},
|
||||
)
|
||||
self.assertEqual(self.db.list_collections(), [collection])
|
||||
self.assertTrue(self.db.collection_exists(collection["id"]))
|
||||
self.assertEqual(self.db.get_collection(collection["id"]), collection)
|
||||
self.assertEqual(
|
||||
self.db.collection_members(collection["id"], ROOT), {"a.png", "b.png"}
|
||||
)
|
||||
self.assertEqual(
|
||||
self.db.root_collection_members(OTHER), {collection["id"]: {"a.png"}}
|
||||
)
|
||||
self.assertEqual(self.db.collection_counts(), {collection["id"]: 3})
|
||||
self.assertEqual(self.db.file_collections(ROOT, "a.png"), [collection["id"]])
|
||||
self.assertEqual(self.db.file_collections(ROOT, "zzz.png"), [])
|
||||
|
||||
def test_add_by_name_ignores_case_and_unicode_form(self):
|
||||
self.require()
|
||||
first = self.add([(ROOT, "a.png")], name="Café grids")["collection"]
|
||||
# NFD spelling (macOS) in another case joins the same collection.
|
||||
second = self.add([(ROOT, "b.png")], name="CAFÉ GRIDS")
|
||||
self.assertEqual(second["collection"], first)
|
||||
self.assertFalse(second["created"])
|
||||
self.assertEqual(len(self.db.list_collections()), 1)
|
||||
self.assertEqual(
|
||||
self.db.collection_members(first["id"], ROOT), {"a.png", "b.png"}
|
||||
)
|
||||
|
||||
def test_add_counts_already_present(self):
|
||||
self.require()
|
||||
made = self.add([(ROOT, "a.png"), (ROOT, "b.png")], name="Set")
|
||||
result = self.add(
|
||||
[(ROOT, "b.png"), (ROOT, "c.png"), (ROOT, "c.png")],
|
||||
collection_id=made["collection"]["id"],
|
||||
)
|
||||
self.assertEqual(
|
||||
(result["created"], result["added"], result["already"]), (False, 1, 1)
|
||||
)
|
||||
self.assertEqual(self.db.collection_counts(), {made["collection"]["id"]: 3})
|
||||
|
||||
def test_add_with_a_new_name_and_nothing_inserted_creates_nothing(self):
|
||||
self.require()
|
||||
with self.assertRaises(CollectionFilesMissing):
|
||||
self.add([], name="Empty")
|
||||
self.assertEqual(self.db.list_collections(), [])
|
||||
# An existing name with nothing to add is not an error.
|
||||
made = self.add([(ROOT, "a.png")], name="Kept")["collection"]
|
||||
self.assertEqual(
|
||||
self.add([], name="kept"),
|
||||
{"collection": made, "created": False, "added": 0, "already": 0},
|
||||
)
|
||||
|
||||
def test_add_to_a_deleted_collection_raises_missing(self):
|
||||
self.require()
|
||||
made = self.add([(ROOT, "a.png")], name="Gone")["collection"]
|
||||
self.db.delete_collection(made["id"])
|
||||
with self.assertRaises(CollectionMissing):
|
||||
self.add([(ROOT, "b.png")], collection_id=made["id"])
|
||||
with self.assertRaises(CollectionMissing):
|
||||
self.db.remove_from_collection(made["id"], [(ROOT, "a.png")])
|
||||
with self.assertRaises(CollectionMissing):
|
||||
self.db.rename_collection(made["id"], "Back")
|
||||
self.assertFalse(self.db.collection_exists(made["id"]))
|
||||
self.assertIsNone(self.db.get_collection(made["id"]))
|
||||
|
||||
def test_remove_returns_the_count(self):
|
||||
self.require()
|
||||
made = self.add([(ROOT, "a.png"), (ROOT, "b.png")], name="Set")["collection"]
|
||||
result = self.db.remove_from_collection(
|
||||
made["id"], [(ROOT, "a.png"), (ROOT, "a.png"), (ROOT, "never.png")]
|
||||
)
|
||||
self.assertEqual(result, {"collection": made, "removed": 1})
|
||||
self.assertEqual(self.db.collection_members(made["id"], ROOT), {"b.png"})
|
||||
# Removing the last member keeps the (now empty) collection.
|
||||
self.db.remove_from_collection(made["id"], [(ROOT, "b.png")])
|
||||
self.assertEqual(self.db.list_collections(), [made])
|
||||
self.assertEqual(self.db.collection_counts(), {made["id"]: 0})
|
||||
|
||||
def test_rename_refuses_a_taken_name(self):
|
||||
self.require()
|
||||
first = self.add([(ROOT, "a.png")], name="First")["collection"]
|
||||
second = self.add([(ROOT, "a.png")], name="Second")["collection"]
|
||||
with self.assertRaises(CollectionNameTaken) as caught:
|
||||
self.db.rename_collection(second["id"], " first ")
|
||||
self.assertEqual(caught.exception.collection_id, first["id"])
|
||||
self.assertEqual(self.db.get_collection(second["id"]), second)
|
||||
|
||||
def test_rename_accepts_a_case_variant_of_its_own_name(self):
|
||||
self.require()
|
||||
made = self.add([(ROOT, "a.png")], name="lora grids")["collection"]
|
||||
renamed = self.db.rename_collection(made["id"], "LoRA grids ")
|
||||
self.assertEqual(renamed, {"id": made["id"], "name": "LoRA grids"})
|
||||
self.assertEqual(self.db.list_collections(), [renamed])
|
||||
other = self.db.rename_collection(made["id"], "Portraits")
|
||||
self.assertEqual(other["name"], "Portraits")
|
||||
|
||||
def test_delete_unknown_raises_missing(self):
|
||||
self.require()
|
||||
made = self.add([(ROOT, "a.png")], name="Doomed")["collection"]
|
||||
self.assertTrue(self.db.delete_collection(made["id"]))
|
||||
self.assertEqual(self.db.root_collection_members(ROOT), {})
|
||||
with self.assertRaises(CollectionMissing):
|
||||
self.db.delete_collection(made["id"])
|
||||
with self.assertRaises(CollectionMissing):
|
||||
self.db.delete_collection(999)
|
||||
|
||||
def test_list_is_in_name_key_order(self):
|
||||
self.require()
|
||||
for name in ("beta", "Alpha", "été", "Zeta", "alpha two"):
|
||||
self.add([(ROOT, "a.png")], name=name)
|
||||
self.assertEqual(
|
||||
[row["name"] for row in self.db.list_collections()],
|
||||
["Alpha", "alpha two", "beta", "Zeta", "été"],
|
||||
)
|
||||
|
||||
def test_names_are_trimmed_and_collapsed(self):
|
||||
self.require()
|
||||
made = self.add([(ROOT, "a.png")], name=" LoRa \t testing\n ")["collection"]
|
||||
self.assertEqual(made["name"], "LoRa testing")
|
||||
for bad in ("", " ", "x" * 101, "bell\x07", "c1\x81", "del\x7f", 5, None):
|
||||
with self.subTest(name=bad):
|
||||
with self.assertRaises(CollectionInvalid):
|
||||
self.add([(ROOT, "a.png")], name=bad)
|
||||
self.assertEqual(len(self.db.list_collections()), 1)
|
||||
|
||||
def test_targets_and_items_are_validated(self):
|
||||
self.require()
|
||||
made = self.add([(ROOT, "a.png")], name="Set")["collection"]
|
||||
for target in (
|
||||
{},
|
||||
{"name": "x", "collection_id": made["id"]},
|
||||
{"collection_id": True},
|
||||
{"collection_id": 0},
|
||||
{"collection_id": "1"},
|
||||
):
|
||||
with self.subTest(target=target):
|
||||
with self.assertRaises(CollectionInvalid):
|
||||
self.add([(ROOT, "a.png")], **target)
|
||||
for items in ([("root",)], [(ROOT, 5)], "a.png", [(ROOT, "x" * 4097)]):
|
||||
with self.subTest(items=items):
|
||||
with self.assertRaises(CollectionInvalid):
|
||||
self.add(items, collection_id=made["id"])
|
||||
|
||||
def test_a_failure_after_the_membership_move_rolls_it_back(self):
|
||||
self.require()
|
||||
made = self.add([(ROOT, "b.png")], name="Grids")["collection"]
|
||||
move = {
|
||||
"removed": [],
|
||||
"kept": [],
|
||||
"targets": {},
|
||||
"kept_path": "/real/output/a.png",
|
||||
"favorites": [(ROOT, "b.png", ROOT, "a.png")],
|
||||
"removed_locator": "/real/output/b.png",
|
||||
"kept_locator": "/real/output/a.png",
|
||||
"sha256": "0" * 64,
|
||||
"kept_size": 1,
|
||||
"kept_mtime_ns": 1,
|
||||
}
|
||||
with patch.object(
|
||||
type(self.db), "_move_copy_tags", side_effect=RuntimeError("boom")
|
||||
):
|
||||
with self.assertRaises(RuntimeError):
|
||||
self.db.move_copy_records(move)
|
||||
self.assertEqual(self.db.file_collections(ROOT, "b.png"), [made["id"]])
|
||||
self.assertEqual(self.db.file_collections(ROOT, "a.png"), [])
|
||||
# Without the failure the same move succeeds.
|
||||
self.assertEqual(self.db.move_copy_records(move)["collections"], 1)
|
||||
self.assertEqual(self.db.file_collections(ROOT, "a.png"), [made["id"]])
|
||||
|
||||
def test_count_snapshot_reads_in_one_transaction(self):
|
||||
self.require()
|
||||
made = self.add([(ROOT, "a.png"), (OTHER, "b.png")], name="Set")["collection"]
|
||||
statements = []
|
||||
conn = self.db.model.get_connection()
|
||||
conn.set_trace_callback(statements.append)
|
||||
try:
|
||||
members, stored = self.db.collection_count_snapshot([ROOT, OTHER, ROOT])
|
||||
finally:
|
||||
conn.set_trace_callback(None)
|
||||
self.assertEqual(
|
||||
members, {ROOT: {made["id"]: {"a.png"}}, OTHER: {made["id"]: {"b.png"}}}
|
||||
)
|
||||
self.assertEqual(stored, {made["id"]: 2})
|
||||
self.assertEqual(statements[0], "BEGIN")
|
||||
self.assertEqual(statements[-1], "ROLLBACK")
|
||||
self.assertFalse(conn.in_transaction)
|
||||
|
||||
def test_reads_use_an_index(self):
|
||||
self.require()
|
||||
self.add([(ROOT, "a.png")], name="Set")
|
||||
conn = sqlite3.connect(self.db.model.db_path)
|
||||
try:
|
||||
for sql in READ_SQL:
|
||||
plan = " ".join(
|
||||
row[3]
|
||||
for row in conn.execute(
|
||||
"EXPLAIN QUERY PLAN " + sql, [None] * sql.count("?")
|
||||
)
|
||||
)
|
||||
with self.subTest(sql=sql):
|
||||
self.assertNotIn("SCAN image_collection_items", plan)
|
||||
if "?" in sql: # a lookup, not the whole list
|
||||
self.assertNotIn("SCAN image_collections", plan)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,521 @@
|
||||
"""Collections schema (#331): install, validation and restore safety."""
|
||||
|
||||
import re
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
import unicodedata
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
try:
|
||||
from tests.open_handles import assert_closed
|
||||
from tests.test_annotation_schema import LEGACY_TABLES, table_rows
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from open_handles import assert_closed
|
||||
from test_annotation_schema import LEGACY_TABLES, table_rows
|
||||
|
||||
from database.models import PromptModel
|
||||
|
||||
try: # guarded so a missing module fails assertions, not test loading
|
||||
from database import image_collections
|
||||
from database.image_collections import (
|
||||
image_collections_schema_error,
|
||||
migrate_image_collections,
|
||||
)
|
||||
except ImportError:
|
||||
image_collections = None
|
||||
image_collections_schema_error = None
|
||||
migrate_image_collections = None
|
||||
|
||||
COLLECTIONS = "image_collections"
|
||||
ITEMS = "image_collection_items"
|
||||
INDEX = "idx_image_collection_items_file"
|
||||
TABLES = (COLLECTIONS, ITEMS)
|
||||
DDL = {
|
||||
COLLECTIONS: (
|
||||
f"CREATE TABLE {COLLECTIONS} (id INTEGER PRIMARY KEY AUTOINCREMENT, "
|
||||
"name TEXT NOT NULL, name_key TEXT NOT NULL UNIQUE, "
|
||||
"created_at TEXT NOT NULL)"
|
||||
),
|
||||
ITEMS: (
|
||||
f"CREATE TABLE {ITEMS} (collection_id INTEGER NOT NULL REFERENCES "
|
||||
f"{COLLECTIONS}(id) ON DELETE CASCADE, root_key TEXT NOT NULL, "
|
||||
"relative_path TEXT NOT NULL, added_at TEXT NOT NULL, "
|
||||
"PRIMARY KEY (collection_id, root_key, relative_path))"
|
||||
),
|
||||
}
|
||||
INDEX_DDL = f"CREATE INDEX {INDEX} ON {ITEMS}(root_key, relative_path)"
|
||||
NOW = "2026-10-27T00:00:00.000000+00:00"
|
||||
IMAGES = 23_000
|
||||
|
||||
|
||||
def _compact(sql):
|
||||
return re.sub(r"\s+", "", sql or "").lower()
|
||||
|
||||
|
||||
def expected_catalog():
|
||||
return {
|
||||
("table", COLLECTIONS, _compact(DDL[COLLECTIONS])),
|
||||
("table", ITEMS, _compact(DDL[ITEMS])),
|
||||
("index", INDEX, _compact(INDEX_DDL)),
|
||||
("index", f"sqlite_autoindex_{COLLECTIONS}_1", ""),
|
||||
("index", f"sqlite_autoindex_{ITEMS}_1", ""),
|
||||
}
|
||||
|
||||
|
||||
def catalog(conn):
|
||||
return {
|
||||
(kind, name, _compact(sql))
|
||||
for kind, name, sql in conn.execute(
|
||||
"SELECT type,name,sql FROM sqlite_master "
|
||||
"WHERE name IN (?,?,?) OR tbl_name IN (?,?)",
|
||||
(*TABLES, INDEX, *TABLES),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def key(name):
|
||||
return unicodedata.normalize("NFC", unicodedata.normalize("NFC", name).casefold())
|
||||
|
||||
|
||||
def schema(conn, exclude=()):
|
||||
return sorted(
|
||||
tuple(row)
|
||||
for row in conn.execute(
|
||||
"SELECT type,name,tbl_name,sql FROM sqlite_master ORDER BY name"
|
||||
)
|
||||
if row[1] not in exclude and row[2] not in exclude
|
||||
)
|
||||
|
||||
|
||||
def row_counts(conn):
|
||||
tables = [
|
||||
name
|
||||
for (name,) in conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
|
||||
)
|
||||
]
|
||||
return {
|
||||
table: conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] # nosec
|
||||
for table in tables
|
||||
}
|
||||
|
||||
|
||||
class ImageCollectionsSchemaTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.addCleanup(assert_closed, self, self.temp.name)
|
||||
self.addCleanup(PromptModel.reset_schema_cache)
|
||||
self.models = []
|
||||
self.addCleanup(self.close_models)
|
||||
self.path = str(Path(self.temp.name) / "live.db")
|
||||
|
||||
def close_models(self):
|
||||
for model in self.models:
|
||||
model.close_all()
|
||||
|
||||
def model(self, path=None):
|
||||
model = PromptModel(path or self.path)
|
||||
self.models.append(model)
|
||||
return model
|
||||
|
||||
def require_module(self):
|
||||
self.assertIsNotNone(
|
||||
image_collections_schema_error,
|
||||
"database.image_collections is not implemented",
|
||||
)
|
||||
|
||||
def closed_current(self, name="current.db", *statements):
|
||||
"""A closed current database, altered afterwards by *statements*."""
|
||||
path = str(Path(self.temp.name) / name)
|
||||
model = self.model(path)
|
||||
model.close_all()
|
||||
PromptModel.reset_schema_cache()
|
||||
with closing(sqlite3.connect(path)) as raw, raw:
|
||||
raw.execute("PRAGMA foreign_keys=OFF")
|
||||
for statement in statements:
|
||||
if isinstance(statement, tuple):
|
||||
raw.execute(*statement)
|
||||
else:
|
||||
raw.execute(statement)
|
||||
return path
|
||||
|
||||
@staticmethod
|
||||
def drop_group(path):
|
||||
with closing(sqlite3.connect(path)) as raw, raw:
|
||||
raw.execute(f"DROP TABLE {ITEMS}")
|
||||
raw.execute(f"DROP TABLE {COLLECTIONS}")
|
||||
|
||||
def test_migration_creates_tables_and_index(self):
|
||||
self.require_module()
|
||||
model = self.model()
|
||||
with model.get_connection() as conn:
|
||||
self.assertEqual(catalog(conn), expected_catalog())
|
||||
self.assertIsNone(image_collections_schema_error(conn, allow_missing=False))
|
||||
self.assertEqual(
|
||||
conn.execute(f"SELECT COUNT(*) FROM {COLLECTIONS}").fetchone()[0], 0
|
||||
)
|
||||
self.assertEqual(
|
||||
conn.execute(f"SELECT COUNT(*) FROM {ITEMS}").fetchone()[0], 0
|
||||
)
|
||||
self.assertEqual(
|
||||
PromptModel.verify_database_file(self.path, require_library_state=True),
|
||||
(True, "ok"),
|
||||
)
|
||||
|
||||
def test_migration_is_idempotent(self):
|
||||
self.require_module()
|
||||
model = self.model()
|
||||
with model.get_connection() as conn:
|
||||
conn.execute(
|
||||
f"INSERT INTO {COLLECTIONS} (name, name_key, created_at) "
|
||||
"VALUES ('Kept', 'kept', ?)",
|
||||
(NOW,),
|
||||
)
|
||||
conn.execute(f"INSERT INTO {ITEMS} VALUES (1, 'root', 'a.png', ?)", (NOW,))
|
||||
before = schema(conn)
|
||||
model.close_all()
|
||||
for _ in range(2):
|
||||
PromptModel.reset_schema_cache()
|
||||
model = self.model()
|
||||
with model.get_connection() as conn:
|
||||
migrate_image_collections(conn)
|
||||
self.assertEqual(schema(conn), before)
|
||||
self.assertEqual(
|
||||
[tuple(r) for r in conn.execute(f"SELECT * FROM {ITEMS}")],
|
||||
[(1, "root", "a.png", NOW)],
|
||||
)
|
||||
model.close_all()
|
||||
|
||||
def test_migration_reads_only_its_own_tables(self):
|
||||
self.require_module()
|
||||
model = self.model()
|
||||
with model.get_connection() as conn:
|
||||
conn.execute("INSERT INTO prompts(id,text,hash) VALUES(1,'positive','p')")
|
||||
conn.executemany(
|
||||
"INSERT INTO generated_images(prompt_id,image_path,filename,"
|
||||
"file_path) VALUES(1,?,?,?)",
|
||||
((f"/out/{n}.png", f"{n}.png", f"/out/{n}.png") for n in range(IMAGES)),
|
||||
)
|
||||
conn.execute("INSERT INTO image_favorites VALUES('root','a.png',?)", (NOW,))
|
||||
model.close_all()
|
||||
self.drop_group(self.path)
|
||||
with closing(sqlite3.connect(self.path)) as raw:
|
||||
raw.execute("PRAGMA foreign_keys=ON")
|
||||
counts_before = row_counts(raw)
|
||||
schema_before = schema(raw)
|
||||
legacy_before = table_rows(raw, LEGACY_TABLES)
|
||||
self.assertEqual(counts_before["generated_images"], IMAGES)
|
||||
touched = []
|
||||
|
||||
def record(action, arg1, arg2, _db, _trigger):
|
||||
touched.append((action, arg1, arg2))
|
||||
return sqlite3.SQLITE_OK
|
||||
|
||||
raw.set_authorizer(record)
|
||||
try:
|
||||
migrate_image_collections(raw)
|
||||
finally:
|
||||
raw.set_authorizer(None)
|
||||
raw.commit()
|
||||
reads = {
|
||||
arg1 for action, arg1, _arg2 in touched if action == sqlite3.SQLITE_READ
|
||||
}
|
||||
self.assertTrue(reads)
|
||||
self.assertLessEqual(reads, {"sqlite_master", COLLECTIONS, ITEMS})
|
||||
writes = {
|
||||
arg1
|
||||
for action, arg1, _arg2 in touched
|
||||
if action
|
||||
in (sqlite3.SQLITE_INSERT, sqlite3.SQLITE_UPDATE, sqlite3.SQLITE_DELETE)
|
||||
}
|
||||
self.assertLessEqual(writes, {"sqlite_master"})
|
||||
self.assertEqual(catalog(raw), expected_catalog())
|
||||
counts_after = row_counts(raw)
|
||||
self.assertEqual(counts_after.pop(COLLECTIONS), 0)
|
||||
self.assertEqual(counts_after.pop(ITEMS), 0)
|
||||
self.assertEqual(counts_after, counts_before)
|
||||
self.assertEqual(schema(raw, exclude=TABLES), schema_before)
|
||||
self.assertEqual(table_rows(raw, LEGACY_TABLES), legacy_before)
|
||||
# A second run is a no-op.
|
||||
after_first = schema(raw)
|
||||
changes = raw.total_changes
|
||||
migrate_image_collections(raw)
|
||||
raw.commit()
|
||||
self.assertEqual(schema(raw), after_first)
|
||||
self.assertEqual(raw.total_changes, changes)
|
||||
|
||||
def test_migration_refuses_a_foreign_object_of_the_same_name(self):
|
||||
self.require_module()
|
||||
for statements in (
|
||||
(f"CREATE TABLE {COLLECTIONS} (x)",),
|
||||
(f"CREATE VIEW {COLLECTIONS} AS SELECT 1",),
|
||||
(f"CREATE INDEX {INDEX} ON prompts(text)",),
|
||||
):
|
||||
with self.subTest(statements=statements):
|
||||
path = str(Path(self.temp.name) / f"foreign{len(self.models)}.db")
|
||||
self.model(path).close_all()
|
||||
PromptModel.reset_schema_cache()
|
||||
self.drop_group(path)
|
||||
with closing(sqlite3.connect(path)) as raw, raw:
|
||||
for statement in statements:
|
||||
raw.execute(statement)
|
||||
with closing(sqlite3.connect(path)) as raw:
|
||||
before = schema(raw)
|
||||
self.assertIsNotNone(image_collections_schema_error(raw))
|
||||
with self.assertRaises(sqlite3.DatabaseError):
|
||||
self.model(path).get_connection()
|
||||
PromptModel.close_all_instances()
|
||||
PromptModel.reset_schema_cache()
|
||||
with closing(sqlite3.connect(path)) as raw:
|
||||
self.assertEqual(schema(raw), before)
|
||||
self.assertIsNone(
|
||||
raw.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE name=?", (ITEMS,)
|
||||
).fetchone()
|
||||
)
|
||||
self.assertFalse(PromptModel.verify_database_file(path)[0])
|
||||
|
||||
def test_migration_rolls_back_on_error(self):
|
||||
self.require_module()
|
||||
self.model().close_all()
|
||||
PromptModel.reset_schema_cache()
|
||||
self.drop_group(self.path)
|
||||
with closing(sqlite3.connect(self.path)) as raw:
|
||||
with patch.object(
|
||||
image_collections,
|
||||
"image_collections_schema_error",
|
||||
side_effect=[None, "Image collections went wrong"],
|
||||
):
|
||||
with self.assertRaisesRegex(sqlite3.DatabaseError, "went wrong"):
|
||||
migrate_image_collections(raw)
|
||||
raw.commit()
|
||||
self.assertEqual(catalog(raw), set())
|
||||
|
||||
def test_startup_accepts_a_value_saved_under_older_rules(self):
|
||||
self.require_module()
|
||||
path = self.closed_current(
|
||||
"older.db",
|
||||
(
|
||||
f"INSERT INTO {COLLECTIONS} (name, name_key, created_at) "
|
||||
"VALUES (?, 'not the key', ?)",
|
||||
(" " + "x" * 150 + " ", NOW),
|
||||
),
|
||||
)
|
||||
model = self.model(path)
|
||||
with model.get_connection() as conn:
|
||||
self.assertIsNone(image_collections_schema_error(conn, check_values=False))
|
||||
self.assertIsNotNone(image_collections_schema_error(conn))
|
||||
self.assertEqual(
|
||||
conn.execute(f"SELECT COUNT(*) FROM {COLLECTIONS}").fetchone()[0], 1
|
||||
)
|
||||
model.close_all()
|
||||
self.assertFalse(
|
||||
PromptModel.verify_database_file(path, require_library_state=True)[0]
|
||||
)
|
||||
|
||||
def test_restore_accepts_an_old_backup_without_collections(self):
|
||||
self.require_module()
|
||||
path = self.closed_current("old.db")
|
||||
self.drop_group(path)
|
||||
with closing(sqlite3.connect(path)) as raw:
|
||||
self.assertIsNone(image_collections_schema_error(raw))
|
||||
self.assertEqual(
|
||||
image_collections_schema_error(raw, allow_missing=False),
|
||||
"Missing image collections schema",
|
||||
)
|
||||
self.assertEqual(PromptModel.verify_database_file(path), (True, "ok"))
|
||||
model = self.model(path)
|
||||
with model.get_connection() as conn:
|
||||
self.assertEqual(catalog(conn), expected_catalog())
|
||||
model.close_all()
|
||||
self.assertEqual(
|
||||
PromptModel.verify_database_file(path, require_library_state=True),
|
||||
(True, "ok"),
|
||||
)
|
||||
|
||||
def test_restore_requires_collections_on_a_current_stage(self):
|
||||
self.require_module()
|
||||
path = self.closed_current("stage.db")
|
||||
self.drop_group(path)
|
||||
self.assertEqual(
|
||||
PromptModel.verify_database_file(path, require_library_state=True),
|
||||
(False, "Missing image collections schema"),
|
||||
)
|
||||
# One table without the other is never acceptable.
|
||||
partial = self.closed_current("partial.db", f"DROP TABLE {ITEMS}")
|
||||
self.assertFalse(PromptModel.verify_database_file(partial)[0])
|
||||
|
||||
def assert_refused(self, name, *statements):
|
||||
self.require_module()
|
||||
path = self.closed_current(
|
||||
f"{name}.db",
|
||||
(
|
||||
f"INSERT INTO {COLLECTIONS} (id, name, name_key, created_at) "
|
||||
"VALUES (1, 'Good', 'good', ?)",
|
||||
(NOW,),
|
||||
),
|
||||
(f"INSERT INTO {ITEMS} VALUES (1, 'root', 'a.png', ?)", (NOW,)),
|
||||
*statements,
|
||||
)
|
||||
with closing(sqlite3.connect(path)) as raw:
|
||||
self.assertIsNone(image_collections_schema_error(raw, check_values=False))
|
||||
error = image_collections_schema_error(raw)
|
||||
self.assertIsNotNone(error)
|
||||
self.assertIn("collections", error.lower())
|
||||
ok, reason = PromptModel.verify_database_file(path)
|
||||
self.assertFalse(ok)
|
||||
self.assertEqual(reason, error)
|
||||
self.assertFalse(
|
||||
PromptModel.verify_database_file(path, require_library_state=True)[0]
|
||||
)
|
||||
|
||||
def test_a_valid_current_database_is_accepted(self):
|
||||
self.require_module()
|
||||
path = self.closed_current(
|
||||
"valid.db",
|
||||
(
|
||||
f"INSERT INTO {COLLECTIONS} (id, name, name_key, created_at) "
|
||||
"VALUES (1, ?, ?, ?)",
|
||||
("LoRa testing", key("LoRa testing"), NOW),
|
||||
),
|
||||
(f"INSERT INTO {ITEMS} VALUES (1, 'root', ?, ?)", ("é" * 4096, NOW)),
|
||||
)
|
||||
self.assertEqual(
|
||||
PromptModel.verify_database_file(path, require_library_state=True),
|
||||
(True, "ok"),
|
||||
)
|
||||
|
||||
def test_restore_refuses_a_non_text_name(self):
|
||||
# Column affinity turns a number into text; a blob stays a blob.
|
||||
self.assert_refused("blob-name", f"UPDATE {COLLECTIONS} SET name = x'61'")
|
||||
|
||||
def test_restore_refuses_an_empty_name(self):
|
||||
self.assert_refused("empty", f"UPDATE {COLLECTIONS} SET name = '', name_key=''")
|
||||
|
||||
def test_restore_refuses_a_name_over_100_characters(self):
|
||||
name = "é" * 101
|
||||
self.assert_refused(
|
||||
"long",
|
||||
(f"UPDATE {COLLECTIONS} SET name = ?, name_key = ?", (name, key(name))),
|
||||
)
|
||||
|
||||
def test_restore_refuses_a_control_character(self):
|
||||
name = "a\x07b"
|
||||
self.assert_refused(
|
||||
"control",
|
||||
(f"UPDATE {COLLECTIONS} SET name = ?, name_key = ?", (name, key(name))),
|
||||
)
|
||||
|
||||
def test_restore_refuses_a_leading_or_trailing_space(self):
|
||||
for name in (" Good", "Good "):
|
||||
with self.subTest(name=name):
|
||||
self.assert_refused(
|
||||
f"padded{len(name)}{name[0] == ' '}",
|
||||
(
|
||||
f"UPDATE {COLLECTIONS} SET name = ?, name_key = ?",
|
||||
(name, key(name)),
|
||||
),
|
||||
)
|
||||
|
||||
def test_restore_accepts_a_name_key_from_other_case_tables(self):
|
||||
# Python versions fold some characters differently; a key written
|
||||
# by another version must never make a backup unrestorable.
|
||||
self.require_module()
|
||||
path = self.closed_current(
|
||||
"other-key.db",
|
||||
(
|
||||
f"INSERT INTO {COLLECTIONS} (id, name, name_key, created_at) "
|
||||
"VALUES (1, 'Good', 'Good', ?)",
|
||||
(NOW,),
|
||||
),
|
||||
)
|
||||
self.assertEqual(
|
||||
PromptModel.verify_database_file(path, require_library_state=True),
|
||||
(True, "ok"),
|
||||
)
|
||||
|
||||
def test_restore_refuses_an_empty_or_non_text_name_key(self):
|
||||
self.assert_refused("empty-key", f"UPDATE {COLLECTIONS} SET name_key = ''")
|
||||
self.assert_refused(
|
||||
"blob-name-key", f"UPDATE {COLLECTIONS} SET name_key = x'61'"
|
||||
)
|
||||
self.assert_refused(
|
||||
"long-name-key",
|
||||
(f"UPDATE {COLLECTIONS} SET name_key = ?", ("k" * 1201,)),
|
||||
)
|
||||
|
||||
def test_restore_refuses_a_c1_control_character(self):
|
||||
for name in ("a\x81b", "a\x7fb"):
|
||||
with self.subTest(name=repr(name)):
|
||||
self.assert_refused(
|
||||
f"c1-{ord(name[1])}",
|
||||
(
|
||||
f"UPDATE {COLLECTIONS} SET name = ?, name_key = ?",
|
||||
(name, key(name)),
|
||||
),
|
||||
)
|
||||
|
||||
def test_restore_refuses_a_non_text_key(self):
|
||||
# Column affinity turns a number into text; a blob stays a blob.
|
||||
self.assert_refused("blob-key", f"UPDATE {ITEMS} SET relative_path = x'61'")
|
||||
|
||||
def test_restore_refuses_a_key_over_4096_characters(self):
|
||||
self.assert_refused(
|
||||
"long-key", (f"UPDATE {ITEMS} SET root_key = ?", ("é" * 4097,))
|
||||
)
|
||||
|
||||
def test_restore_refuses_a_timestamp_over_64_bytes(self):
|
||||
self.assert_refused(
|
||||
"stamp", (f"UPDATE {COLLECTIONS} SET created_at = ?", ("t" * 65,))
|
||||
)
|
||||
self.assert_refused(
|
||||
"item-stamp", (f"UPDATE {ITEMS} SET added_at = ?", ("t" * 65,))
|
||||
)
|
||||
|
||||
def test_restore_refuses_an_item_of_a_missing_collection(self):
|
||||
self.assert_refused("orphan", f"INSERT INTO {ITEMS} VALUES (9, 'r', 'b', 'n')")
|
||||
|
||||
def test_restore_refuses_a_non_integer_collection_id(self):
|
||||
self.assert_refused("text-id", f"UPDATE {ITEMS} SET collection_id = 'one'")
|
||||
|
||||
def test_restore_refuses_a_trigger_or_extra_index(self):
|
||||
self.require_module()
|
||||
for name, statement in (
|
||||
(
|
||||
"trigger",
|
||||
f"CREATE TRIGGER t AFTER INSERT ON {ITEMS} BEGIN SELECT 1; END",
|
||||
),
|
||||
("extra", f"CREATE INDEX extra ON {COLLECTIONS}(name)"),
|
||||
):
|
||||
with self.subTest(name=name):
|
||||
path = self.closed_current(f"{name}.db", statement)
|
||||
with closing(sqlite3.connect(path)) as raw:
|
||||
self.assertIsNotNone(image_collections_schema_error(raw))
|
||||
self.assertFalse(PromptModel.verify_database_file(path)[0])
|
||||
|
||||
def test_deleting_a_collection_deletes_its_members(self):
|
||||
self.require_module()
|
||||
model = self.model()
|
||||
with model.get_connection() as conn:
|
||||
conn.execute(
|
||||
f"INSERT INTO {COLLECTIONS} (id, name, name_key, created_at) "
|
||||
"VALUES (1, 'A', 'a', ?), (2, 'B', 'b', ?)",
|
||||
(NOW, NOW),
|
||||
)
|
||||
conn.executemany(
|
||||
f"INSERT INTO {ITEMS} VALUES (?, 'root', ?, ?)",
|
||||
((1, "a.png", NOW), (1, "b.png", NOW), (2, "a.png", NOW)),
|
||||
)
|
||||
conn.execute(f"DELETE FROM {COLLECTIONS} WHERE id = 1")
|
||||
self.assertEqual(
|
||||
[tuple(r) for r in conn.execute(f"SELECT collection_id FROM {ITEMS}")],
|
||||
[(2,)],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -299,6 +299,19 @@ class TestAutoTag(ImageTagJobTestCase):
|
||||
self.tagger.download_model.assert_not_called()
|
||||
self.assertIsNone(model_gate.holder())
|
||||
|
||||
async def test_a_missing_backend_package_fails_the_job_before_the_first_file(self):
|
||||
self.png("a.png")
|
||||
self.tagger.missing["wd14-vit"] = ["onnxruntime"]
|
||||
|
||||
with self.assertNoLogs(self.api.logger, level="ERROR"):
|
||||
job = await self.run_job(self.auto_body(["a.png"]))
|
||||
|
||||
self.assertEqual(job.state, "failed")
|
||||
self.assertEqual(job.processed, 0)
|
||||
self.assertTrue(job.status_line.startswith("WD14 ViT needs onnxruntime."))
|
||||
self.assertIn("pip install onnxruntime", job.status_line)
|
||||
self.assertEqual(self.tagger.load_calls, [])
|
||||
|
||||
async def test_model_unavailable_fails_the_job_before_the_first_file(self):
|
||||
self.png("a.png")
|
||||
self.tagger.downloaded["wd14-vit"] = False
|
||||
@@ -884,6 +897,16 @@ class TestFilterStart(ImageTagJobTestCase):
|
||||
self.assertEqual((status, data["code"]), (400, "no-images"))
|
||||
self.assertEqual(self.api._jobs.list(), [])
|
||||
|
||||
async def test_filter_with_a_deleted_collection_is_a_404_without_a_job(self):
|
||||
self.png("a.png")
|
||||
|
||||
status, data = await self.start(
|
||||
{"mode": "add", "filter": {"collection": "77"}, "tags": ["x"]}
|
||||
)
|
||||
|
||||
self.assertEqual((status, data["code"]), (404, "collection-not-found"))
|
||||
self.assertEqual(self.api._jobs.list(), [])
|
||||
|
||||
async def test_filter_over_the_cap_is_refused_without_a_job(self):
|
||||
from py.api import image_tag_jobs
|
||||
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
"""Ingestion decides with the default resolver profile first (D1, #247 D-Q1).
|
||||
"""Ingestion keeps the default resolver profile; only inspection routes moved (D1).
|
||||
|
||||
The live monitor and the admin scan write pairs and links to the user's
|
||||
database, so the default profile decides every file it can, byte-for-byte as
|
||||
before. f03 (506 executable nodes) separates the profiles: the default
|
||||
profile's 500-node gate leaves it unresolved, while the inspection profile
|
||||
resolves it. Only such an undecided file reaches the inspector's resolve
|
||||
(``inspection_resolution``), and only its positive is stored (#247).
|
||||
database, so their resolver semantics stay byte-for-byte today's. f03 (506
|
||||
executable nodes) separates the profiles: the default profile's 500-node gate
|
||||
leaves it unresolved, while the inspection profile resolves it.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -70,26 +68,24 @@ class TestIngestionKeepsDefaultProfile(unittest.TestCase):
|
||||
spy.assert_default_and_gated(self)
|
||||
handler.db_manager.link_image_to_prompt_or_raise.assert_called_once()
|
||||
|
||||
def test_admin_scan_ingestion_uses_the_default_profile_first(self):
|
||||
def test_admin_scan_ingestion_uses_the_default_profile(self):
|
||||
from py.api import admin
|
||||
from utils import file_prompt
|
||||
|
||||
mixin = admin.AdminRoutesMixin.__new__(admin.AdminRoutesMixin)
|
||||
mixin.db = Mock()
|
||||
mixin.db.get_prompt_by_hash.return_value = None
|
||||
mixin.db.save_prompt.return_value = 7
|
||||
spy = _Spy()
|
||||
with (
|
||||
patch.object(file_prompt, "resolve_prompt_pair", spy),
|
||||
patch.object(
|
||||
admin, "store_scanned_prompt", return_value=("added", 7)
|
||||
) as store,
|
||||
):
|
||||
with patch.object(admin, "resolve_prompt_pair", spy):
|
||||
status = mixin._ingest_scanned_file(Path("/out/f03.png"), _f03_metadata())
|
||||
spy.assert_default_and_gated(self)
|
||||
# The default profile leaves f03 unresolved; the inspector's resolve
|
||||
# names the prompt the inspector shows, and the scan stores it.
|
||||
# The resolver leaves f03 unresolved; the scan links the prompt the
|
||||
# browser parser traces, as the inspector shows it.
|
||||
self.assertEqual(status, "added")
|
||||
self.assertEqual(store.call_args.args[3], "a red fox in snow, detailed fur")
|
||||
mixin.db.link_image_to_prompt.assert_not_called()
|
||||
self.assertEqual(
|
||||
mixin.db.save_prompt.call_args.args[0], "a red fox in snow, detailed fur"
|
||||
)
|
||||
mixin.db.link_image_to_prompt.assert_called_once()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
"""Metadata scanning enriches known pairs and conservatively leaves conflicts."""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from tests.test_api_admin import AdminAPITestCase
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
|
||||
def graph(positive="cat", negative="blur"):
|
||||
@@ -452,159 +449,3 @@ class TestSamplerLessScan(AdminAPITestCase):
|
||||
path = self.output_dir / "saver.png"
|
||||
self.assertEqual(self.api._ingest_scanned_file(path, meta), "added")
|
||||
self.assertEqual(self._texts(), ["a red fox"])
|
||||
|
||||
|
||||
class TestScanDecision(AdminAPITestCase):
|
||||
"""The scan applies the shared per-file decision (#247): one prompt per
|
||||
file, no junk text, marks on every prompt it creates."""
|
||||
|
||||
def _path(self, name):
|
||||
path = self.output_dir / name
|
||||
path.write_bytes(b"png")
|
||||
return path
|
||||
|
||||
def _links(self, path):
|
||||
rows = self.api.db.model.get_connection().execute(
|
||||
"SELECT prompt_id FROM generated_images WHERE image_path=?", (str(path),)
|
||||
)
|
||||
return [row[0] for row in rows]
|
||||
|
||||
def _prompt(self, text):
|
||||
return self.api.db.get_prompt_by_hash(generate_prompt_hash(text))
|
||||
|
||||
def test_a_file_linked_to_another_prompt_is_a_conflict(self):
|
||||
path = self._path("fox.png")
|
||||
other = self.api.db.save_prompt(
|
||||
"a dog", prompt_hash=generate_prompt_hash("a dog")
|
||||
)
|
||||
self.api.db.link_image_to_prompt(other, str(path))
|
||||
meta = {"prompt": json.dumps(flux_graph())}
|
||||
self.assertEqual(self.api._ingest_scanned_file(path, meta), "conflict")
|
||||
self.assertEqual(self._links(path), [other])
|
||||
self.assertIsNone(self._prompt("a red fox"))
|
||||
|
||||
def test_junk_text_is_never_stored(self):
|
||||
cases = {
|
||||
"lora.png": {"prompt": json.dumps(flux_graph(text="<lora:style:1.00>"))},
|
||||
"model.png": {"parameters": "models/flux-dev.safetensors"},
|
||||
}
|
||||
for name, meta in cases.items():
|
||||
with self.subTest(name=name):
|
||||
path = self._path(name)
|
||||
self.assertEqual(self.api._ingest_scanned_file(path, meta), "found")
|
||||
self.assertEqual(self._links(path), [])
|
||||
self.assertEqual(self.api.db.get_recent_prompts()["prompts"], [])
|
||||
|
||||
def test_created_prompts_carry_the_scan_marks(self):
|
||||
path = self._path("pair.png")
|
||||
meta = {"prompt": json.dumps(sampled_graph("cat", "blur"))}
|
||||
self.assertEqual(self.api._ingest_scanned_file(path, meta), "added")
|
||||
for text in ("cat", "blur"):
|
||||
prompt = self._prompt(text)
|
||||
self.assertEqual(prompt["category"], "scanned")
|
||||
self.assertEqual(prompt["tags"], ["auto-scanned"])
|
||||
self.assertEqual(prompt["notes"], "Auto-scanned from pair.png")
|
||||
|
||||
def test_outcome_of_a_positive_reports_its_title(self):
|
||||
"""Regression guard (passes on the base): the detailed positive row."""
|
||||
path = self._path("fox.png")
|
||||
meta = {"prompt": json.dumps(flux_graph())}
|
||||
first = self.api._ingest_scanned_file(path, meta, detailed=True)
|
||||
self.assertEqual(first, {"status": "added", "title": "a red fox"})
|
||||
again = self.api._ingest_scanned_file(path, meta, detailed=True)
|
||||
self.assertEqual(again["status"], "unchanged")
|
||||
other = self._path("fox2.png")
|
||||
linked = self.api._ingest_scanned_file(other, meta, detailed=True)
|
||||
self.assertEqual(linked["status"], "linked")
|
||||
|
||||
def test_outcome_of_a_pair_reports_its_counts(self):
|
||||
"""Regression guard (passes on the base): the detailed pair row."""
|
||||
path = self._path("pair.png")
|
||||
meta = {"prompt": json.dumps(sampled_graph("cat", "blur"))}
|
||||
result = self.api._ingest_scanned_file(path, meta, detailed=True)
|
||||
self.assertEqual(result["status"], "added")
|
||||
self.assertEqual((result["pairs_added"], result["links_added"]), (1, 1))
|
||||
self.assertEqual(result["title"], "cat")
|
||||
|
||||
def test_outcome_without_a_store_reports_the_unlinked_status(self):
|
||||
"""Regression guard (passes on the base): never-stored rows."""
|
||||
path = self._path("notes.png")
|
||||
meta = {"prompt": json.dumps({"40": {"class_type": "Textbox", "inputs": {}}})}
|
||||
self.assertEqual(self.api._ingest_scanned_file(path, meta), "found")
|
||||
self.assertIsNone(self.api._ingest_scanned_file(path, {}))
|
||||
|
||||
def test_review_lists_get_the_default_profiles_ambiguous_files(self):
|
||||
"""Regression guard (passes on the base): Needs review inputs."""
|
||||
g = sampled_graph()
|
||||
g["6"] = {"class_type": "CLIPTextEncode", "inputs": {"text": "dog"}}
|
||||
g["12"] = {**g["3"], "inputs": {**g["3"]["inputs"], "positive": ["6", 0]}}
|
||||
ambiguous, untraced, records = [], [], {}
|
||||
path = self._path("two.png")
|
||||
status = self.api._ingest_scanned_file(
|
||||
path,
|
||||
{"prompt": json.dumps(g)},
|
||||
ambiguous=ambiguous,
|
||||
untraced=untraced,
|
||||
records=records,
|
||||
)
|
||||
# Linked by the legacy floor, still listed (queued only if unlinked).
|
||||
self.assertEqual(status, "added")
|
||||
self.assertEqual((ambiguous, untraced), ([path], []))
|
||||
self.assertEqual(records["canonical"]["prompt"], g)
|
||||
|
||||
def test_an_ambiguous_file_with_a_junk_answer_is_still_queued(self):
|
||||
from tests.test_file_prompt import _numeric_first_branches
|
||||
|
||||
path = self._path("numbers.png")
|
||||
meta = {"prompt": json.dumps(_numeric_first_branches())}
|
||||
with patch.object(
|
||||
type(self.api), "_record_scanned_review", autospec=True
|
||||
) as record:
|
||||
counts = self.api._ingest_scan_batch_sync([(path, meta)])
|
||||
self.assertEqual(counts["ambiguous"], 1, counts)
|
||||
record.assert_called_once()
|
||||
self.assertEqual(self._links(path), [])
|
||||
|
||||
def test_a_conflict_row_says_the_link_was_kept(self):
|
||||
from py.api.admin import _scan_outcome
|
||||
|
||||
_group, row, _line = _scan_outcome(self._path("c.png"), [], "conflict")
|
||||
self.assertEqual(row["detail"], "Kept: already linked to a different prompt")
|
||||
|
||||
def test_a_library_move_reruns_the_scan_writes(self):
|
||||
from py.api import admin
|
||||
|
||||
real = admin.ingest_resolved_pair
|
||||
model = self.api.db.model
|
||||
|
||||
def moved_once(*args, **kwargs):
|
||||
if not calls:
|
||||
calls.append(1)
|
||||
model._generation += 1
|
||||
raise sqlite3.OperationalError("closed")
|
||||
return real(*args, **kwargs)
|
||||
|
||||
calls = []
|
||||
path = self._path("pair.png")
|
||||
with patch.object(admin, "ingest_resolved_pair", side_effect=moved_once):
|
||||
meta = {"prompt": json.dumps(sampled_graph("cat", "blur"))}
|
||||
self.assertEqual(self.api._ingest_scanned_file(path, meta), "added")
|
||||
self.assertEqual(calls, [1])
|
||||
|
||||
def test_an_off_node_behind_a_pass_through_stores_nothing(self):
|
||||
"""#90 regression guard for the scan (main stored nothing here)."""
|
||||
from tests.test_save_to_library import run_graph
|
||||
|
||||
g = run_graph(save=False)
|
||||
g["1"]["class_type"] = "PromptManagerText"
|
||||
g["7"] = {"class_type": "easy showAnything", "inputs": {"anything": ["1", 0]}}
|
||||
g["8"] = {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": ["7", 0], "clip": ["4", 1]},
|
||||
}
|
||||
g["3"]["inputs"]["positive"] = ["8", 0]
|
||||
path = self._path("off.png")
|
||||
meta = {"prompt": json.dumps(g)}
|
||||
self.assertEqual(self.api._ingest_scanned_file(path, meta), "not_saved")
|
||||
self.assertEqual(self.api.db.get_recent_prompts()["prompts"], [])
|
||||
self.assertEqual(self._links(path), [])
|
||||
|
||||
@@ -375,89 +375,5 @@ class TestRecordedTexts(unittest.TestCase):
|
||||
self.assertEqual(evidence["resolution"]["sampler_id"], sampler)
|
||||
|
||||
|
||||
class TestInspectionResolution(unittest.TestCase):
|
||||
"""``inspection_resolution``: the inspector's resolve and recorded anchor,
|
||||
shared with ingestion (#247), so the two cannot drift."""
|
||||
|
||||
def _result(self, graph, saver=None, *, as_text=True, name="IMG_GEN_00371_.png"):
|
||||
from utils.prompt_inspector import inspection_resolution
|
||||
|
||||
prompt = json.dumps(graph) if as_text else graph
|
||||
return inspection_resolution({"prompt": prompt}, name, saver)
|
||||
|
||||
def test_the_recorded_saver_names_its_branch_by_record(self):
|
||||
result = self._result(shapes.shared_prefix(), _saver(2))
|
||||
self.assertTrue(result.by_record)
|
||||
self.assertFalse(result.malformed)
|
||||
self.assertEqual(result.resolution["state"], "resolved")
|
||||
self.assertEqual(_positive(result.resolution), shapes.BRANCH_TEXTS[1])
|
||||
self.assertIs(result.anchored, result.resolution)
|
||||
|
||||
def test_without_a_saver_nothing_is_anchored(self):
|
||||
result = self._result(shapes.shared_prefix())
|
||||
self.assertEqual(result.resolution["state"], "ambiguous")
|
||||
self.assertIsNone(result.anchored)
|
||||
self.assertFalse(result.by_record)
|
||||
|
||||
def test_a_saver_that_names_nothing_keeps_the_open_result(self):
|
||||
result = self._result(shapes.shared_prefix(), "missing")
|
||||
self.assertEqual(result.resolution["state"], "ambiguous")
|
||||
self.assertIsNotNone(result.anchored)
|
||||
self.assertFalse(result.by_record)
|
||||
|
||||
def test_a_file_that_resolves_alone_is_never_anchored(self):
|
||||
result = self._result(
|
||||
shapes.branch_bench(), _saver(4), name="s45_branch_2_00001_.png"
|
||||
)
|
||||
self.assertEqual(_positive(result.resolution), shapes.BRANCH_TEXTS[1])
|
||||
self.assertIsNone(result.anchored)
|
||||
self.assertFalse(result.by_record)
|
||||
|
||||
def test_parsed_metadata_resolves_as_the_file_text_does(self):
|
||||
# The monitor hands parsed dicts; the scan hands the file's strings.
|
||||
parsed = self._result(shapes.shared_prefix(), _saver(3), as_text=False)
|
||||
text = self._result(shapes.shared_prefix(), _saver(3))
|
||||
self.assertEqual(parsed.resolution, text.resolution)
|
||||
|
||||
def test_malformed_metadata_is_reported_as_malformed(self):
|
||||
from utils.prompt_inspector import inspection_resolution
|
||||
|
||||
result = inspection_resolution({"prompt": "{not json"}, None, None)
|
||||
self.assertTrue(result.malformed)
|
||||
self.assertEqual(result.resolution["state"], "unresolved")
|
||||
self.assertEqual(result.resolution["reason_code"], "malformed")
|
||||
|
||||
def test_metadata_without_a_role_record_is_absent(self):
|
||||
from utils.prompt_inspector import inspection_resolution
|
||||
|
||||
result = inspection_resolution({"workflow": "{}"}, None, "9")
|
||||
self.assertFalse(result.malformed)
|
||||
self.assertEqual(result.resolution["state"], "absent")
|
||||
|
||||
def test_without_the_name_anchor_a_file_name_names_no_branch(self):
|
||||
from utils.prompt_inspector import inspection_resolution
|
||||
|
||||
graph = {"prompt": json.dumps(shapes.branch_bench())}
|
||||
name = "s45_branch_2_00001_.png"
|
||||
named = inspection_resolution(graph, name, None)
|
||||
self.assertEqual(_positive(named.resolution), shapes.BRANCH_TEXTS[1])
|
||||
blind = inspection_resolution(graph, name, None, name_anchor=False)
|
||||
self.assertEqual(blind.resolution["state"], "ambiguous")
|
||||
# The recorded saver still decides, and the name still contradicts it.
|
||||
recorded = inspection_resolution(graph, name, _saver(2), name_anchor=False)
|
||||
self.assertTrue(recorded.by_record)
|
||||
shared = {"prompt": json.dumps(shapes.shared_prefix())}
|
||||
bench = shapes.branch_bench()
|
||||
for index in (1, 3, 4):
|
||||
bench[_saver(index)]["inputs"]["filename_prefix"] = "bench/shared"
|
||||
contradicted = inspection_resolution(
|
||||
{"prompt": json.dumps(bench)}, name, _saver(4), name_anchor=False
|
||||
)
|
||||
self.assertFalse(contradicted.by_record)
|
||||
self.assertTrue(
|
||||
inspection_resolution(shared, name, _saver(4), name_anchor=False).by_record
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -49,6 +49,7 @@ SUMMARY_KEYS = {
|
||||
"image_links",
|
||||
"runs",
|
||||
"saved_views",
|
||||
"collections",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -226,6 +226,7 @@ class TestStageAndPublish(StageTestCase):
|
||||
for key in ("pairs", "archived_pairs", "image_links", "runs"):
|
||||
self.assertEqual(staged.summary[key], 0, key)
|
||||
self.assertEqual(staged.summary["saved_views"], 0)
|
||||
self.assertEqual(staged.summary["collections"], 0)
|
||||
self.assertEqual(staged.schema, {"migrated": False, "library_state": True})
|
||||
self.assertEqual(
|
||||
staged.digest,
|
||||
@@ -239,6 +240,48 @@ class TestStageAndPublish(StageTestCase):
|
||||
staged.discard()
|
||||
|
||||
|
||||
class TestCollectionsSummary(StageTestCase):
|
||||
"""#331: collections join the review's summary and the live fingerprint."""
|
||||
|
||||
def add_collection(self, db, name):
|
||||
with db.model.get_connection() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO image_collections (name, name_key, created_at) "
|
||||
"VALUES (?, ?, '2026-10-27T00:00:00+00:00')",
|
||||
(name, name.casefold()),
|
||||
)
|
||||
|
||||
def test_summary_counts_collections(self):
|
||||
src_path = os.path.join(self.uploads, "with-collections.db")
|
||||
src = PromptDatabase(src_path)
|
||||
try:
|
||||
self.save(src, "kept")
|
||||
self.add_collection(src, "First")
|
||||
self.add_collection(src, "Second")
|
||||
finally:
|
||||
src.close_all()
|
||||
staged = self.db.model.stage_restore(src_path)
|
||||
try:
|
||||
self.assertEqual(staged.summary["collections"], 2)
|
||||
finally:
|
||||
staged.discard()
|
||||
self.assertIn("collections", restore_stage.SUMMARY_KEYS)
|
||||
|
||||
def test_adding_a_collection_changes_the_fingerprint(self):
|
||||
conn = self.db.model.get_connection()
|
||||
summary, before = restore_stage.live_fingerprint(conn)
|
||||
self.assertEqual(summary["collections"], 0)
|
||||
self.add_collection(self.db, "After the review")
|
||||
summary, after = restore_stage.live_fingerprint(conn)
|
||||
self.assertEqual(summary["collections"], 1)
|
||||
self.assertNotEqual(after, before)
|
||||
# A delete and a re-add with the same count still changes it (rowid).
|
||||
with self.db.model.get_connection() as write:
|
||||
write.execute("DELETE FROM image_collections")
|
||||
self.add_collection(self.db, "Replacement")
|
||||
self.assertNotEqual(restore_stage.live_fingerprint(conn)[1], after)
|
||||
|
||||
|
||||
class TestOlderBackups(StageTestCase):
|
||||
"""Rollback-journal and older-schema backups restore exactly as before."""
|
||||
|
||||
|
||||
@@ -17,8 +17,6 @@ from database.run_attribution import (
|
||||
from tests import test_execution_history_database as history_tests
|
||||
from tests.background_stops import close_handler
|
||||
from tests.test_api_admin import AdminAPITestCase
|
||||
from tests import saver_anchor_graphs as shapes
|
||||
from tests.test_pair_scan import sampled_graph
|
||||
from tests.test_execution_context_database import known, snapshot
|
||||
from tests.test_prompt_attribution import issue_graph, llm_text_graph, make_tracker
|
||||
from utils.execution_contexts import (
|
||||
@@ -328,7 +326,7 @@ class TestRecordedOutputNode(RecordedRuns, unittest.TestCase):
|
||||
self.assertIsNone(recorded_output_lookup(broken)(self.image))
|
||||
with (
|
||||
mock.patch(
|
||||
"database.run_attribution.recorded_runs",
|
||||
"database.run_attribution._recorded_runs",
|
||||
side_effect=sqlite3.OperationalError("locked"),
|
||||
),
|
||||
self.assertLogs("prompt_manager.run_attribution", "WARNING"),
|
||||
@@ -453,86 +451,6 @@ class TestScanUsesHistory(RecordedRuns, AdminAPITestCase):
|
||||
self.assertIsNone(self.linked_prompt(path))
|
||||
self.assertEqual(db.list_prompt_pairs()["pairs"], [])
|
||||
|
||||
def scan_dataset(self, names, nodes):
|
||||
"""Two branches of the shared-prefix dataset graph, one run, then a scan."""
|
||||
paths = [self.write(name) for name in names]
|
||||
self.record_run(
|
||||
self.api.db,
|
||||
str(self.output_dir),
|
||||
[("", path.name) for path in paths],
|
||||
[],
|
||||
nodes=nodes,
|
||||
)
|
||||
meta = {"prompt": json.dumps(shapes.shared_prefix())}
|
||||
return paths, self.api._ingest_scan_batch_sync([(p, meta) for p in paths])
|
||||
|
||||
def test_the_recorded_saver_links_each_dataset_file_to_its_branch(self):
|
||||
"""#247: before, the legacy parser gave every branch the first prompt."""
|
||||
paths, counts = self.scan_dataset(
|
||||
["IMG_GEN_00001_.png", "IMG_GEN_00002_.png"], ["13", "23"]
|
||||
)
|
||||
self.assertEqual(counts["added"], 2, counts)
|
||||
for path, text in zip(paths, shapes.BRANCH_TEXTS):
|
||||
prompt = self.api.db.get_prompt_by_hash(generate_prompt_hash(text))
|
||||
self.assertEqual(self.linked_prompt(path), prompt["id"])
|
||||
self.assertEqual(prompt["category"], "scanned")
|
||||
|
||||
def test_a_file_the_legacy_answer_linked_is_left_as_it_is(self):
|
||||
"""#247 Q5: a rescan never adds a second link to the branch prompt."""
|
||||
db = self.api.db
|
||||
legacy = shapes.BRANCH_TEXTS[0]
|
||||
legacy_id = db.save_prompt(legacy, prompt_hash=generate_prompt_hash(legacy))
|
||||
path = self.write("IMG_GEN_00002_.png")
|
||||
db.link_image_to_prompt(legacy_id, str(path))
|
||||
self.record_run(db, str(self.output_dir), [("", path.name)], [], nodes=["23"])
|
||||
meta = {"prompt": json.dumps(shapes.shared_prefix())}
|
||||
self.assertEqual(self.api._ingest_scanned_file(path, meta), "conflict")
|
||||
self.assertEqual(self.linked_prompt(path), legacy_id)
|
||||
self.assertIsNone(
|
||||
db.get_prompt_by_hash(generate_prompt_hash(shapes.BRANCH_TEXTS[1]))
|
||||
)
|
||||
|
||||
def test_a_recorded_run_never_adds_a_second_link(self):
|
||||
db = self.api.db
|
||||
db.save_prompt(text=LLM_TEXT, prompt_hash=generate_prompt_hash(LLM_TEXT))
|
||||
other = db.save_prompt(text="a dog", prompt_hash=generate_prompt_hash("a dog"))
|
||||
path = self.write("bare_00003_.png")
|
||||
db.link_image_to_prompt(other, str(path))
|
||||
self.record_run(db, str(self.output_dir), [("", path.name)], [LLM_TEXT])
|
||||
self.assertEqual(self.api._ingest_scanned_file(path, {}), "conflict")
|
||||
rows = db.model.get_connection().execute(
|
||||
"SELECT prompt_id FROM generated_images WHERE image_path=?", (str(path),)
|
||||
)
|
||||
self.assertEqual([row[0] for row in rows], [other])
|
||||
|
||||
def test_a_single_file_asks_history_once(self):
|
||||
db = self.api.db
|
||||
db.save_prompt(text=LLM_TEXT, prompt_hash=generate_prompt_hash(LLM_TEXT))
|
||||
path = self.write("llm_00002_.png")
|
||||
self.record_run(db, str(self.output_dir), [("", path.name)], [LLM_TEXT])
|
||||
meta = {"prompt": json.dumps(llm_text_graph())}
|
||||
statements = []
|
||||
conn = db.model.get_connection()
|
||||
conn.set_trace_callback(statements.append)
|
||||
try:
|
||||
self.assertEqual(self.api._ingest_scanned_file(path, meta), "linked")
|
||||
finally:
|
||||
conn.set_trace_callback(None)
|
||||
lookups = [s for s in statements if "FROM execution_run_outputs" in s]
|
||||
self.assertEqual(len(lookups), 1)
|
||||
|
||||
def test_a_file_the_recorded_saver_links_is_not_queued(self):
|
||||
"""A default-ambiguous file linked at the inspector's step is no
|
||||
Needs review case."""
|
||||
with mock.patch.object(
|
||||
type(self.api), "_record_scanned_review", autospec=True
|
||||
) as record:
|
||||
_paths, counts = self.scan_dataset(
|
||||
["IMG_GEN_00003_.png", "IMG_GEN_00004_.png"], ["13", "23"]
|
||||
)
|
||||
self.assertEqual(counts["added"], 2, counts)
|
||||
record.assert_not_called()
|
||||
|
||||
|
||||
class TestHostHelpers(unittest.TestCase):
|
||||
"""The host lookups degrade to "no proof", never to an error."""
|
||||
@@ -834,17 +752,13 @@ class TestScanBatchLookup(RecordedRuns, AdminAPITestCase):
|
||||
"""M5: a scan batch asks history once per chunk of names, not per file."""
|
||||
|
||||
def test_a_batch_its_metadata_links_never_asks_history(self):
|
||||
"""R3: the batch lookup is made only when a file needs it.
|
||||
|
||||
Since #247 every file the default profile leaves undecided asks (the
|
||||
recorded saver may name its branch), so these files resolve as pairs.
|
||||
"""
|
||||
"""R3: the batch lookup is made only when a file needs it."""
|
||||
files = []
|
||||
for n in range(3):
|
||||
path = self.output_dir / f"typed_{n:05}_.png"
|
||||
path.write_bytes(b"png")
|
||||
files.append(path)
|
||||
meta = {"prompt": json.dumps(sampled_graph("typed mountains"))}
|
||||
meta = {"prompt": json.dumps(issue_graph(text="typed mountains"))}
|
||||
statements = []
|
||||
conn = self.api.db.model.get_connection()
|
||||
conn.set_trace_callback(statements.append)
|
||||
@@ -888,72 +802,6 @@ class TestScanBatchLookup(RecordedRuns, AdminAPITestCase):
|
||||
}
|
||||
self.assertEqual(linked, {f.name for f in files[:4]})
|
||||
|
||||
def test_the_batch_saver_equals_the_files_recorded_output(self):
|
||||
from py.api.admin import _BatchHistory
|
||||
|
||||
db = self.api.db
|
||||
names = ["one_node.png", "two_nodes.png", "two_runs.png", "unrecorded.png"]
|
||||
files = []
|
||||
for name in names:
|
||||
path = self.output_dir / name
|
||||
path.write_bytes(b"png")
|
||||
files.append(path)
|
||||
root = str(self.output_dir)
|
||||
self.record_run(db, root, [("", "one_node.png")], [], nodes=["13"])
|
||||
self.record_run(
|
||||
db, root, [("", "two_nodes.png")] * 2, [], item=1, nodes=["13", "23"]
|
||||
)
|
||||
self.record_run(db, root, [("", "two_runs.png")], [], item=2)
|
||||
self.record_run(db, root, [("", "two_runs.png")], [], item=3)
|
||||
history = _BatchHistory(self.api, files)
|
||||
with mock.patch(
|
||||
"database.run_attribution.host_output_directory", return_value=root
|
||||
):
|
||||
for path in files:
|
||||
with self.subTest(path=path.name):
|
||||
expected = recorded_output(db, str(path), root)
|
||||
found = history.recorded(str(path))
|
||||
if expected is None:
|
||||
self.assertIsNone(found)
|
||||
else:
|
||||
self.assertEqual(found.saver_id, expected.saver_id)
|
||||
self.assertEqual(history.recorded(str(files[0])).saver_id, "13")
|
||||
self.assertIsNone(history.recorded(str(files[1])).saver_id)
|
||||
|
||||
def test_a_failed_batch_lookup_is_logged_and_names_nothing(self):
|
||||
from py.api.admin import _BatchHistory
|
||||
|
||||
path = self.output_dir / "x.png"
|
||||
history = _BatchHistory(self.api, [path])
|
||||
with (
|
||||
mock.patch(
|
||||
"py.api.admin.recorded_runs", side_effect=sqlite3.OperationalError("x")
|
||||
),
|
||||
self.assertLogs("prompt_manager", "WARNING"),
|
||||
):
|
||||
self.assertIsNone(history.recorded(str(path)))
|
||||
self.assertIsNone(history.get(str(path)))
|
||||
|
||||
def test_a_failed_prompt_lookup_is_logged_and_names_nothing(self):
|
||||
from py.api.admin import _BatchHistory
|
||||
|
||||
path = self.output_dir / "one.png"
|
||||
path.write_bytes(b"png")
|
||||
root = str(self.output_dir)
|
||||
self.record_run(self.api.db, root, [("", path.name)], [LLM_TEXT])
|
||||
history = _BatchHistory(self.api, [path])
|
||||
with (
|
||||
mock.patch(
|
||||
"database.run_attribution.host_output_directory", return_value=root
|
||||
),
|
||||
mock.patch(
|
||||
"py.api.admin.named_prompt", side_effect=sqlite3.OperationalError("x")
|
||||
),
|
||||
self.assertLogs("prompt_manager", "WARNING"),
|
||||
):
|
||||
self.assertIsNone(history.get(str(path)))
|
||||
self.assertEqual(history.recorded(str(path)).saver_id, "9")
|
||||
|
||||
|
||||
class TestLinkedOutputRoot(RecordedRuns, unittest.TestCase):
|
||||
"""R2: ComfyUI's output folder reached through a symlink or junction."""
|
||||
|
||||
@@ -1,274 +0,0 @@
|
||||
"""Storing a file's prompt from its text: one transaction, one prompt each (#247)."""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
try:
|
||||
from tests.open_handles import assert_closed
|
||||
from tests import test_execution_history_database as history_tests
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from open_handles import assert_closed
|
||||
import test_execution_history_database as history_tests
|
||||
|
||||
from database.operations import PromptDatabase
|
||||
from database.pair_ingestion import (
|
||||
RUN_MARKS,
|
||||
SCAN_MARKS,
|
||||
Marks,
|
||||
ingest_resolved_pair,
|
||||
)
|
||||
from database.scanned_prompts import store_scanned_prompt, store_scanned_prompt_or_raise
|
||||
from utils.hashing import generate_prompt_hash
|
||||
|
||||
META = {"prompt": {"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "x"}}}}
|
||||
|
||||
|
||||
def resolved(positive="cat", negative="blur"):
|
||||
return {
|
||||
"state": "resolved",
|
||||
"positive": {"text": positive, "provenance": {"source": "api"}},
|
||||
"negative": {"text": negative, "provenance": {"source": "api"}},
|
||||
}
|
||||
|
||||
|
||||
class StoreCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.db = PromptDatabase(str(Path(self.temp.name) / "test.db"))
|
||||
self.path = str(Path(self.temp.name) / "gen.png")
|
||||
|
||||
def tearDown(self):
|
||||
self.db.close_all()
|
||||
assert_closed(self, self.temp.name)
|
||||
self.temp.cleanup()
|
||||
|
||||
def prompts(self):
|
||||
rows = self.db.model.get_connection().execute(
|
||||
"SELECT id, text FROM prompts ORDER BY id"
|
||||
)
|
||||
return [tuple(row) for row in rows]
|
||||
|
||||
def links(self):
|
||||
rows = self.db.model.get_connection().execute(
|
||||
"SELECT prompt_id, image_path FROM generated_images ORDER BY id"
|
||||
)
|
||||
return [tuple(row) for row in rows]
|
||||
|
||||
def save(self, text, **fields):
|
||||
return self.db.save_prompt(
|
||||
text, prompt_hash=generate_prompt_hash(text), **fields
|
||||
)
|
||||
|
||||
|
||||
class TestStoreScannedPrompt(StoreCase):
|
||||
def test_a_new_text_is_created_with_the_scan_marks_and_linked(self):
|
||||
status, prompt_id = store_scanned_prompt_or_raise(
|
||||
self.db, self.path, META, " a cat ", SCAN_MARKS
|
||||
)
|
||||
self.assertEqual(status, "added")
|
||||
prompt = self.db.get_prompt_by_id(prompt_id)
|
||||
self.assertEqual(prompt["text"], "a cat")
|
||||
self.assertEqual(prompt["category"], "scanned")
|
||||
self.assertEqual(prompt["tags"], ["auto-scanned"])
|
||||
self.assertEqual(prompt["notes"], "Auto-scanned from gen.png")
|
||||
self.assertEqual(prompt["run_count"], 0)
|
||||
self.assertEqual(self.links(), [(prompt_id, self.path)])
|
||||
|
||||
def test_a_run_created_prompt_names_the_workflow_run(self):
|
||||
_status, prompt_id = store_scanned_prompt_or_raise(
|
||||
self.db, self.path, META, "a cat", RUN_MARKS
|
||||
)
|
||||
prompt = self.db.get_prompt_by_id(prompt_id)
|
||||
self.assertEqual(prompt["notes"], "Saved from a workflow run: gen.png")
|
||||
self.assertEqual(prompt["tags"], ["auto-scanned"])
|
||||
|
||||
def test_a_stored_text_is_reused_by_hash_and_keeps_its_fields(self):
|
||||
existing = self.save("A Cat", category="mine", notes="kept")
|
||||
status, prompt_id = store_scanned_prompt_or_raise(
|
||||
self.db, self.path, META, "a cat ", SCAN_MARKS
|
||||
)
|
||||
self.assertEqual((status, prompt_id), ("linked", existing))
|
||||
prompt = self.db.get_prompt_by_id(existing)
|
||||
self.assertEqual((prompt["category"], prompt["notes"]), ("mine", "kept"))
|
||||
self.assertEqual(prompt["tags"], [])
|
||||
self.assertEqual(len(self.prompts()), 1)
|
||||
|
||||
def test_a_repeat_is_unchanged(self):
|
||||
first = store_scanned_prompt_or_raise(
|
||||
self.db, self.path, META, "a cat", SCAN_MARKS
|
||||
)
|
||||
again = store_scanned_prompt_or_raise(
|
||||
self.db, self.path, META, "a cat", SCAN_MARKS
|
||||
)
|
||||
self.assertEqual(again, ("unchanged", first[1]))
|
||||
self.assertEqual(len(self.links()), 1)
|
||||
|
||||
def test_a_file_linked_to_another_prompt_is_a_conflict(self):
|
||||
other = self.save("a dog")
|
||||
# Linked under another spelling of the same file.
|
||||
spelled = os.path.join(self.temp.name, ".", "gen.png")
|
||||
self.db.link_image_to_prompt(other, spelled)
|
||||
result = store_scanned_prompt_or_raise(
|
||||
self.db, self.path, META, "a cat", SCAN_MARKS
|
||||
)
|
||||
self.assertEqual(result, ("conflict", None))
|
||||
self.assertEqual(self.prompts(), [(other, "a dog")])
|
||||
self.assertEqual(self.links(), [(other, spelled)])
|
||||
|
||||
def test_a_failed_link_raises_and_leaves_no_prompt_behind(self):
|
||||
with mock.patch.object(
|
||||
self.db, "_insert_image_link", side_effect=sqlite3.OperationalError("x")
|
||||
):
|
||||
with self.assertRaises(sqlite3.OperationalError):
|
||||
store_scanned_prompt_or_raise(
|
||||
self.db, self.path, META, "a cat", SCAN_MARKS
|
||||
)
|
||||
with self.assertLogs("prompt_manager", "ERROR"):
|
||||
result = store_scanned_prompt(
|
||||
self.db, self.path, META, "a cat", SCAN_MARKS
|
||||
)
|
||||
self.assertEqual(result, ("found", None))
|
||||
self.assertEqual(self.prompts(), [])
|
||||
self.assertEqual(self.links(), [])
|
||||
|
||||
def test_two_concurrent_writers_store_one_prompt_and_one_link(self):
|
||||
hold = history_tests.FirstWriterHold()
|
||||
outcome = self.db._pair_component_outcome
|
||||
started = threading.Barrier(2)
|
||||
|
||||
def held_open(*args, **kwargs):
|
||||
hold()
|
||||
return outcome(*args, **kwargs)
|
||||
|
||||
def write(_index):
|
||||
started.wait(timeout=30)
|
||||
try:
|
||||
return store_scanned_prompt_or_raise(
|
||||
self.db, self.path, META, "a cat", SCAN_MARKS
|
||||
)[0]
|
||||
finally:
|
||||
self.db.model.close()
|
||||
|
||||
with (
|
||||
mock.patch.object(
|
||||
self.db, "_pair_component_outcome", side_effect=held_open
|
||||
),
|
||||
ThreadPoolExecutor(max_workers=2) as pool,
|
||||
):
|
||||
statuses = sorted(pool.map(write, range(2)))
|
||||
self.assertEqual(statuses, ["added", "unchanged"])
|
||||
self.assertEqual(len(self.prompts()), 1)
|
||||
self.assertEqual(len(self.links()), 1)
|
||||
|
||||
def test_a_library_move_reruns_the_store(self):
|
||||
from database import scanned_prompts
|
||||
|
||||
real = scanned_prompts.store_scanned_prompt_or_raise
|
||||
calls = []
|
||||
|
||||
def moved_once(*args, **kwargs):
|
||||
if not calls:
|
||||
calls.append(1)
|
||||
self.db.model._generation += 1
|
||||
raise sqlite3.OperationalError("closed")
|
||||
return real(*args, **kwargs)
|
||||
|
||||
with mock.patch.object(
|
||||
scanned_prompts, "store_scanned_prompt_or_raise", side_effect=moved_once
|
||||
):
|
||||
status, _id = store_scanned_prompt(
|
||||
self.db, self.path, META, "a cat", SCAN_MARKS
|
||||
)
|
||||
self.assertEqual((status, calls), ("added", [1]))
|
||||
|
||||
def _linked_folder(self):
|
||||
"""``(out_real, out_link)``: a symlink to the same output folder."""
|
||||
from tests.test_api_duplicate_files import TestReview
|
||||
|
||||
if not TestReview._can_symlink(self.temp.name): # pragma: no cover
|
||||
self.skipTest("symlinks are not available")
|
||||
real = Path(self.temp.name) / "out_real"
|
||||
real.mkdir()
|
||||
link = Path(self.temp.name) / "out_link"
|
||||
os.symlink(str(real), str(link))
|
||||
(real / "a.png").write_bytes(b"png")
|
||||
return os.path.realpath(str(real)), str(link)
|
||||
|
||||
def test_a_file_linked_through_its_real_folder_is_found_through_a_link(self):
|
||||
"""The scan stores realpath spellings, the monitor the watcher's."""
|
||||
real, link = self._linked_folder()
|
||||
other = self.save("a dog")
|
||||
self.db.link_image_to_prompt(other, os.path.join(real, "a.png"))
|
||||
through_link = os.path.join(link, "a.png")
|
||||
self.assertEqual(
|
||||
store_scanned_prompt_or_raise(
|
||||
self.db, through_link, META, "a cat", SCAN_MARKS
|
||||
),
|
||||
("conflict", None),
|
||||
)
|
||||
self.assertEqual(
|
||||
store_scanned_prompt_or_raise(
|
||||
self.db, through_link, META, "a dog", SCAN_MARKS
|
||||
),
|
||||
("unchanged", other),
|
||||
)
|
||||
pair = ingest_resolved_pair(self.db, through_link, META, resolved())
|
||||
self.assertEqual(pair["status"], "conflict")
|
||||
self.assertEqual(len(self.links()), 1)
|
||||
|
||||
|
||||
class TestPairMarks(StoreCase):
|
||||
def test_marks_reach_only_created_components(self):
|
||||
kept = self.save("cat", category="mine")
|
||||
result = ingest_resolved_pair(
|
||||
self.db, self.path, META, resolved(), marks=RUN_MARKS
|
||||
)
|
||||
self.assertEqual(result["status"], "added")
|
||||
positive = self.db.get_prompt_by_id(kept)
|
||||
self.assertEqual((positive["category"], positive["tags"]), ("mine", []))
|
||||
negative_id = next(pid for pid, text in self.prompts() if text == "blur")
|
||||
negative = self.db.get_prompt_by_id(negative_id)
|
||||
self.assertEqual(negative["category"], "scanned")
|
||||
self.assertEqual(negative["tags"], ["auto-scanned"])
|
||||
self.assertEqual(negative["notes"], "Saved from a workflow run: gen.png")
|
||||
|
||||
def test_without_marks_created_components_stay_bare(self):
|
||||
"""Regression guard (passes on the base): the node path is unchanged."""
|
||||
ingest_resolved_pair(self.db, self.path, META, resolved())
|
||||
for prompt_id, _text in self.prompts():
|
||||
prompt = self.db.get_prompt_by_id(prompt_id)
|
||||
self.assertIsNone(prompt["category"])
|
||||
self.assertEqual(prompt["tags"], [])
|
||||
|
||||
def test_a_blank_negative_creates_only_the_positive(self):
|
||||
result = ingest_resolved_pair(
|
||||
self.db, self.path, META, resolved(negative=""), marks=SCAN_MARKS
|
||||
)
|
||||
self.assertEqual(result["status"], "added")
|
||||
self.assertEqual([text for _id, text in self.prompts()], ["cat"])
|
||||
|
||||
def test_marks_are_a_frozen_value(self):
|
||||
self.assertEqual(
|
||||
SCAN_MARKS, Marks("scanned", ("auto-scanned",), SCAN_MARKS.note)
|
||||
)
|
||||
with self.assertRaises(AttributeError):
|
||||
SCAN_MARKS.category = "x"
|
||||
|
||||
|
||||
class TestPublicRunAttribution(unittest.TestCase):
|
||||
def test_recorded_runs_and_named_prompt_are_public(self):
|
||||
from database import run_attribution
|
||||
|
||||
self.assertTrue(callable(run_attribution.recorded_runs))
|
||||
self.assertTrue(callable(run_attribution.named_prompt))
|
||||
self.assertFalse(hasattr(run_attribution, "_recorded_runs"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+20
@@ -27,3 +27,23 @@ test('browser coverage maps nested JS/MJS and excludes unrelated and traversal p
|
||||
const root = fileURLToPath(new URL('../../web/js/', import.meta.url));
|
||||
assert.deepEqual(report.result.map(entry => fileURLToPath(entry.url)), [join(root, 'gallery.js'), join(root, 'shared/controls/button.mjs'), join(root, 'shared/panel.js')]);
|
||||
});
|
||||
|
||||
// #432: every raw file repeated each script's full source (about 84% of its bytes) and the coverage merge ran out of
|
||||
// memory. c8 reads the source from the mapped file on disk, so the entries keep only the URL, id and functions.
|
||||
test('browser coverage writes no script source, only what c8 maps', async t => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'pm-browser-coverage-'));
|
||||
const previous = process.env.PM_JS_COVERAGE;
|
||||
process.env.PM_JS_COVERAGE = directory;
|
||||
t.after(() => {
|
||||
if (previous === undefined) delete process.env.PM_JS_COVERAGE;
|
||||
else process.env.PM_JS_COVERAGE = previous;
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
const functions = [{ functionName: 'f', isBlockCoverage: true, ranges: [{ startOffset: 0, endOffset: 9, count: 1 }] }];
|
||||
await saveCoverage({ coverage: { stopJSCoverage: async () => [{ url: 'http://127.0.0.1:8765/prompt_manager/js/gallery.js',
|
||||
scriptId: '7', source: 'x'.repeat(100_000), functions }] } }, 'chromium');
|
||||
const report = JSON.parse(readFileSync(join(directory, readdirSync(directory)[0]), 'utf8'));
|
||||
assert.equal(report.result.length, 1);
|
||||
assert.deepEqual(Object.keys(report.result[0]).sort(), ['functions', 'scriptId', 'url']);
|
||||
assert.deepEqual(report.result[0].functions, functions);
|
||||
});
|
||||
|
||||
Vendored
+4
-1
@@ -12,7 +12,10 @@ export async function startCoverage(page, browserName) {
|
||||
export async function saveCoverage(page, browserName) {
|
||||
if (!process.env.PM_JS_COVERAGE || browserName !== 'chromium') return;
|
||||
const entries = await page.coverage.stopJSCoverage();
|
||||
const result = mapBrowserCoverage(entries, { urlPrefix: '/prompt_manager/js/', sourceRoot: join(repositoryRoot, 'web/js') });
|
||||
// The source is left out (#432): c8 reads it from the mapped file on disk, and repeating every script's text in every
|
||||
// raw file made the coverage merge run out of memory.
|
||||
const result = mapBrowserCoverage(entries, { urlPrefix: '/prompt_manager/js/', sourceRoot: join(repositoryRoot, 'web/js') })
|
||||
.map(({ source: _source, ...entry }) => entry);
|
||||
mkdirSync(process.env.PM_JS_COVERAGE, { recursive: true });
|
||||
writeFileSync(join(process.env.PM_JS_COVERAGE, `browser-${randomUUID()}.json`), JSON.stringify({ result }));
|
||||
}
|
||||
|
||||
@@ -1,634 +0,0 @@
|
||||
"""The per-file prompt decision shared by the output scan and the monitor (#247).
|
||||
|
||||
``decide_file_prompt`` is the ladder: which prompt one output file names,
|
||||
from its metadata alone plus, when the graph cannot decide, the save node
|
||||
recorded history says wrote it. It does no file or database I/O; the caller
|
||||
reads the metadata, the file size and history (through ``lookup``) and
|
||||
stores the ``Decision``.
|
||||
|
||||
The default resolver profile decides first, as ingestion always has. Only
|
||||
for a file it leaves ambiguous or unresolved does the ladder ask the
|
||||
inspector's resolve (``inspection_resolution``: the inspection profile, then
|
||||
the recorded saver anchor; never the file-name saver anchor, whose files go
|
||||
to Needs review by the #390 ruling), then the #170 browser parser (the legacy floor).
|
||||
The policies differ in one place: for a file the inspector calls ambiguous,
|
||||
the scan keeps the legacy floor and the monitor stores nothing. Junk text
|
||||
(``junk_text``) is never stored, whichever step found it.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Callable, Mapping, Optional, Union
|
||||
|
||||
from .generation_params import (
|
||||
PLACEHOLDERS,
|
||||
GenerationParamsError,
|
||||
extract_generation_params,
|
||||
parse_page_json,
|
||||
)
|
||||
from .prompt_graph import (
|
||||
PROMPT_MANAGER_TYPES,
|
||||
_positive_roots,
|
||||
is_negative_input,
|
||||
metadata_opted_out,
|
||||
resolve_text,
|
||||
save_setting,
|
||||
side_opted_out,
|
||||
)
|
||||
from .prompt_inspector import inspection_resolution
|
||||
from .prompt_pair_resolver import _numeric, resolve_prompt_pair, sampler_less_prompt
|
||||
from .review_capture import reviewable
|
||||
|
||||
try:
|
||||
from ..database.pair_ingestion import blank_positive
|
||||
except ImportError:
|
||||
from database.pair_ingestion import blank_positive
|
||||
|
||||
|
||||
class Policy(Enum):
|
||||
SCAN = "scan"
|
||||
MONITOR = "monitor"
|
||||
|
||||
|
||||
PAIR = "pair"
|
||||
POSITIVE = "positive"
|
||||
HISTORY = "history"
|
||||
NOT_SAVED = "not_saved"
|
||||
NOT_SAVED_WIRED = "not_saved_wired"
|
||||
NEVER = "never"
|
||||
KINDS = (PAIR, POSITIVE, HISTORY, NOT_SAVED, NOT_SAVED_WIRED, NEVER)
|
||||
|
||||
_UNDECIDED = ("ambiguous", "unresolved")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Decision:
|
||||
"""What to store for one file (see the design's "The ladder").
|
||||
|
||||
``kind``: one of ``KINDS``. ``text``: the storage text of the positive.
|
||||
``canonical``: the parsed metadata stored with the link. ``resolution``:
|
||||
the resolution that decided a ``pair``, else the default profile's
|
||||
result. ``source``: which step named the text (log lines and the scan's
|
||||
outcome detail). ``review``: ``ambiguous`` or ``untraced`` when the
|
||||
default profile was ambiguous (the scan's Needs review lists).
|
||||
``try_recorded_run``: the scan tries the recorded run's stored prompt
|
||||
when nothing else links. ``unlinked_status``: the scan status when
|
||||
nothing links (None: no prompt metadata).
|
||||
"""
|
||||
|
||||
kind: str
|
||||
text: Optional[str] = None
|
||||
canonical: dict = field(default_factory=dict)
|
||||
resolution: Optional[dict] = None
|
||||
source: str = "none"
|
||||
review: Optional[str] = None
|
||||
try_recorded_run: bool = False
|
||||
unlinked_status: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _File:
|
||||
"""The ladder's inputs beside the metadata."""
|
||||
|
||||
metadata: Mapping
|
||||
canonical: dict
|
||||
resolution: dict
|
||||
review: Optional[str]
|
||||
file_name: Optional[str]
|
||||
in_output: bool
|
||||
lookup: Optional[Callable[[], Any]]
|
||||
policy: Policy
|
||||
|
||||
|
||||
def decide_file_prompt(
|
||||
metadata: Any,
|
||||
*,
|
||||
file_name: Optional[str],
|
||||
file_size: Union[None, int, Callable[[], Optional[int]]],
|
||||
in_output: bool,
|
||||
lookup: Optional[Callable[[], Any]],
|
||||
policy: Policy,
|
||||
) -> Decision:
|
||||
"""The ladder for one file's raw ``metadata``.
|
||||
|
||||
``prompt``/``workflow`` may be JSON strings (the scan) or parsed values
|
||||
(the monitor). ``lookup`` returns the file's ``RecordedOutput`` or None;
|
||||
it is None when history must not be asked yet. It is called at most
|
||||
once, and only for a graph the default profile leaves undecided.
|
||||
``file_size`` may be a callable: it is read only for a file with prompt
|
||||
metadata whose ``file_info`` has no size.
|
||||
"""
|
||||
if not metadata:
|
||||
return Decision(NEVER, try_recorded_run=True)
|
||||
canonical = _parsed(metadata)
|
||||
if metadata_opted_out(canonical):
|
||||
# Save to library was off for the run's prompt: nothing is stored,
|
||||
# no recorded-run fallback, no Needs review item (#90)
|
||||
return Decision(NOT_SAVED, canonical=canonical)
|
||||
if metadata_opted_out(canonical, unreadable=True):
|
||||
# The switch is wired from a node the file cannot show: only the
|
||||
# live run (the node saw the value) can link it
|
||||
return Decision(NOT_SAVED_WIRED, canonical=canonical)
|
||||
switched = _switched_off_upstream(canonical.get("prompt"))
|
||||
if switched is not None:
|
||||
# The #90 walk follows only prompt-like inputs; any input name
|
||||
# upstream of a positive can carry a switched-off node's text
|
||||
return Decision(switched, canonical=canonical)
|
||||
if not canonical.get("prompt") and not canonical.get("parameters"):
|
||||
return Decision(NEVER, canonical=canonical, try_recorded_run=True)
|
||||
_with_size(canonical, file_size)
|
||||
resolution = resolve_prompt_pair(canonical)
|
||||
switched = (
|
||||
_side_switch(canonical.get("prompt"), resolution["positive"])
|
||||
if resolution["state"] == "resolved"
|
||||
else None
|
||||
)
|
||||
if switched is not None:
|
||||
# The positive passes a node that is off, though the #90 graph walk
|
||||
# did not reach it (steps 5 and 7)
|
||||
return Decision(switched, canonical=canonical)
|
||||
if resolution["state"] == "resolved" and side_opted_out(
|
||||
canonical.get("prompt"), resolution["negative"]
|
||||
):
|
||||
# Only the negative's node is off: the positive alone, never a pair
|
||||
positive = resolution["positive"]
|
||||
text = positive.get("storage_text", positive["text"]).strip()
|
||||
return _gated(
|
||||
Decision(
|
||||
POSITIVE,
|
||||
text=text,
|
||||
canonical=canonical,
|
||||
resolution=resolution,
|
||||
source="opted-out-negative",
|
||||
),
|
||||
None,
|
||||
)
|
||||
if blank_positive(resolution):
|
||||
# Only prepend/append text: no stored prompt can own it (#342)
|
||||
resolution = {**resolution, "state": "unresolved"}
|
||||
found = _File(
|
||||
metadata,
|
||||
canonical,
|
||||
resolution,
|
||||
_review(resolution),
|
||||
file_name,
|
||||
in_output,
|
||||
lookup,
|
||||
policy,
|
||||
)
|
||||
return _gated(_decide(found), found)
|
||||
|
||||
|
||||
def _decide(found: _File) -> Decision:
|
||||
state = found.resolution["state"]
|
||||
graph = found.canonical.get("prompt")
|
||||
if state == "resolved":
|
||||
positive = found.resolution["positive"]
|
||||
text = positive.get("storage_text", positive["text"])
|
||||
return _decision(found, PAIR, text, "resolver")
|
||||
if graph and state in _UNDECIDED:
|
||||
return _undecided_graph(found)
|
||||
if state == "ambiguous":
|
||||
return _never(found, _unlinked(found))
|
||||
if state == "unresolved" and not isinstance(found.canonical.get("parameters"), str):
|
||||
return _never(found, "unresolved")
|
||||
if graph:
|
||||
return _sampler_less(found, graph)
|
||||
text = _a1111_positive(found.metadata)
|
||||
if text is None:
|
||||
return _never(found, "found")
|
||||
return _decision(found, POSITIVE, text, "a1111")
|
||||
|
||||
|
||||
def _undecided_graph(found: _File) -> Decision:
|
||||
recorded = found.lookup() if found.lookup is not None else None
|
||||
saver = recorded.saver_id if recorded is not None else None
|
||||
inspected = inspection_resolution(
|
||||
found.metadata, found.file_name, saver, name_anchor=False
|
||||
)
|
||||
text = _inspected_text(inspected.resolution)
|
||||
switched = (
|
||||
_side_switch(found.canonical["prompt"], inspected.resolution["positive"])
|
||||
if text is not None
|
||||
else None
|
||||
)
|
||||
if switched is not None:
|
||||
# The inspector follows pass-throughs the #90 graph walk does not
|
||||
return Decision(switched, canonical=found.canonical)
|
||||
if text is not None:
|
||||
source = "recorded-run" if inspected.by_record else "inspector"
|
||||
return _decision(found, POSITIVE, text, source)
|
||||
if found.policy is Policy.MONITOR:
|
||||
if found.lookup is None and found.in_output:
|
||||
return _decision(found, HISTORY, None, "none")
|
||||
if not inspected.malformed and inspected.resolution["state"] == "ambiguous":
|
||||
return _never(found, _unlinked(found))
|
||||
text = graph_prompt_text(found.metadata)
|
||||
switched = _off_text(found.canonical["prompt"], text) if text else None
|
||||
if switched is not None:
|
||||
# The legacy answer is a switched-off node's own text (#90)
|
||||
return Decision(switched, canonical=found.canonical)
|
||||
if text is not None and _joined_text(found.canonical["prompt"], text):
|
||||
# The legacy parser joins prepend + text + append: the text a
|
||||
# PromptManager node stores cannot be told from it (#343).
|
||||
return _never(found, _unlinked(found), try_recorded_run=True)
|
||||
if text is not None:
|
||||
return _decision(found, POSITIVE, text, "legacy-parser")
|
||||
return _never(found, _unlinked(found), try_recorded_run=True)
|
||||
|
||||
|
||||
def _sampler_less(found: _File, graph: Any) -> Decision:
|
||||
# No node takes a positive or negative input (a guider or wrapper graph):
|
||||
# the one wired text, never a guess (#252).
|
||||
wired = sampler_less_prompt(graph)
|
||||
if wired["text"] is not None:
|
||||
return _decision(found, POSITIVE, wired["text"], "sampler-less")
|
||||
if isinstance(found.canonical.get("parameters"), str):
|
||||
# A metadata saver also wrote A1111 text: its positive prompt.
|
||||
text = _a1111_positive(found.metadata)
|
||||
if text is not None:
|
||||
return _decision(found, POSITIVE, text, "a1111")
|
||||
status = "unresolved" if wired["state"] == "ambiguous" else "found"
|
||||
return _never(found, status, try_recorded_run=True)
|
||||
|
||||
|
||||
_UNDECIDED_SOURCES = ("inspector", "recorded-run", "legacy-parser")
|
||||
|
||||
|
||||
def _switch_kind(settings: Any) -> Optional[str]:
|
||||
"""``not_saved`` when a setting is off, ``not_saved_wired`` when one is
|
||||
unknowable (wired from a node the graph cannot read), else None."""
|
||||
settings = list(settings)
|
||||
if False in settings:
|
||||
return NOT_SAVED
|
||||
if None in settings:
|
||||
return NOT_SAVED_WIRED
|
||||
return None
|
||||
|
||||
|
||||
def _prompt_manager_ids(graph: Any) -> list:
|
||||
if not isinstance(graph, dict):
|
||||
return []
|
||||
return [
|
||||
str(node_id)
|
||||
for node_id, node in graph.items()
|
||||
if isinstance(node, dict) and node.get("class_type") in PROMPT_MANAGER_TYPES
|
||||
]
|
||||
|
||||
|
||||
def _side_switch(graph: Any, side: Any) -> Optional[str]:
|
||||
"""The switch outcome of the PromptManager nodes on a side's path."""
|
||||
if not isinstance(graph, dict) or not side_opted_out(graph, side):
|
||||
return None
|
||||
provenance = side.get("provenance")
|
||||
path = provenance.get("path") if isinstance(provenance, dict) else None
|
||||
path = path if isinstance(path, list) else []
|
||||
nodes = {str(step.get("node_id")) for step in path if isinstance(step, dict)} & set(
|
||||
_prompt_manager_ids(graph)
|
||||
)
|
||||
return _switch_kind(save_setting(graph, node_id) for node_id in nodes)
|
||||
|
||||
|
||||
# Inputs that never carry prompt text or positive conditioning.
|
||||
_NOT_TEXT_INPUTS = frozenset(
|
||||
{
|
||||
"model",
|
||||
"clip",
|
||||
"vae",
|
||||
"latent_image",
|
||||
"samples",
|
||||
"pixels",
|
||||
"image",
|
||||
"images",
|
||||
"mask",
|
||||
"noise",
|
||||
"sigmas",
|
||||
"sampler",
|
||||
"control_net",
|
||||
"upscale_model",
|
||||
}
|
||||
)
|
||||
_MAX_SWITCH_VISITS = 5000
|
||||
|
||||
|
||||
def _negative_name(key: Any) -> bool:
|
||||
"""#90's negative rule, plus any name containing "negative"
|
||||
(``negative2``, ``NegativePrompt``)."""
|
||||
return is_negative_input(key) or "negative" in str(key).lower()
|
||||
|
||||
|
||||
def _switched_off_upstream(graph: Any) -> Optional[str]:
|
||||
"""The switch outcome of every PromptManager node upstream of a positive.
|
||||
|
||||
Follows every input except negative and non-text ones (#90's walk follows
|
||||
only prompt-like names). A graph without a recognisable sampler checks
|
||||
every PromptManager node, as #90 does.
|
||||
"""
|
||||
if not isinstance(graph, dict):
|
||||
return None
|
||||
roots = _positive_roots(graph)
|
||||
if not roots:
|
||||
ids = _prompt_manager_ids(graph)
|
||||
return _switch_kind(save_setting(graph, node_id) for node_id in ids)
|
||||
seen, found = set(), set()
|
||||
stack = [str(link[0]) for link in roots]
|
||||
while stack and len(seen) < _MAX_SWITCH_VISITS:
|
||||
node_id = stack.pop()
|
||||
if node_id in seen:
|
||||
continue
|
||||
seen.add(node_id)
|
||||
node = graph.get(node_id)
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
if node.get("class_type") in PROMPT_MANAGER_TYPES:
|
||||
found.add(node_id)
|
||||
inputs = node.get("inputs")
|
||||
for key, value in (inputs.items() if isinstance(inputs, dict) else ()):
|
||||
if key in _NOT_TEXT_INPUTS or _negative_name(key):
|
||||
continue
|
||||
if isinstance(value, (list, tuple)) and len(value) == 2:
|
||||
if isinstance(value[0], (str, int)) and isinstance(value[1], int):
|
||||
stack.append(str(value[0]))
|
||||
return _switch_kind(save_setting(graph, node_id) for node_id in found)
|
||||
|
||||
|
||||
def _off_text(graph: Any, text: str) -> Optional[str]:
|
||||
"""The switch outcome when ``text`` is the text (resolved, else literal)
|
||||
of a PromptManager-family node whose Save to library is not on."""
|
||||
wanted = text.strip()
|
||||
matched = []
|
||||
for node_id in _prompt_manager_ids(graph):
|
||||
node_text = resolve_text(graph, node_id)
|
||||
if node_text is None:
|
||||
inputs = graph[node_id].get("inputs")
|
||||
literal = inputs.get("text") if isinstance(inputs, dict) else None
|
||||
node_text = literal if isinstance(literal, str) else None
|
||||
if node_text is not None and node_text.strip() == wanted:
|
||||
matched.append(save_setting(graph, node_id))
|
||||
return _switch_kind(matched)
|
||||
|
||||
|
||||
def _joined_text(graph: Any, text: str) -> bool:
|
||||
"""Whether ``text`` may hold a PromptManager node's prepend or append.
|
||||
|
||||
True when a PromptManager-family node has a prepend or append part and
|
||||
``text`` is not the literal ``text`` of one such node (the legacy
|
||||
parser's answer when it does not follow a linked part).
|
||||
"""
|
||||
own = _prompt_manager_parts(graph)
|
||||
return own is not None and text.strip() not in own
|
||||
|
||||
|
||||
def _prompt_manager_parts(graph: Any) -> Optional[set]:
|
||||
"""The literal texts of PromptManager-family nodes with a prepend or
|
||||
append part, or None when no such node exists."""
|
||||
if not isinstance(graph, dict):
|
||||
return None
|
||||
found = None
|
||||
for node in graph.values():
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
name, inputs = node.get("class_type"), node.get("inputs")
|
||||
if not isinstance(name, str) or "promptmanager" not in name.lower():
|
||||
continue
|
||||
if not isinstance(inputs, dict) or not _has_part(inputs):
|
||||
continue
|
||||
found = found if found is not None else set()
|
||||
if isinstance(inputs.get("text"), str):
|
||||
found.add(inputs["text"].strip())
|
||||
return found
|
||||
|
||||
|
||||
def _has_part(inputs: dict) -> bool:
|
||||
for key in ("prepend_text", "append_text"):
|
||||
part = inputs.get(key)
|
||||
if part is not None and not (isinstance(part, str) and not part.strip()):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _gated(decision: Decision, found: Optional[_File]) -> Decision:
|
||||
"""Junk text is never stored (step 13).
|
||||
|
||||
A junk answer of the undecided-graph step keeps that step's unlinked
|
||||
status, so an ambiguous file is still a Needs review case; any other
|
||||
step reports ``found``.
|
||||
"""
|
||||
if decision.kind not in (PAIR, POSITIVE):
|
||||
return decision
|
||||
junk = junk_text(decision.text)
|
||||
if junk is None:
|
||||
return decision
|
||||
return Decision(
|
||||
NEVER,
|
||||
canonical=decision.canonical,
|
||||
resolution=decision.resolution,
|
||||
source=f"junk:{junk}",
|
||||
review=decision.review,
|
||||
try_recorded_run=True,
|
||||
unlinked_status=(
|
||||
_unlinked(found)
|
||||
if found is not None and decision.source in _UNDECIDED_SOURCES
|
||||
else "found"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _decision(found: _File, kind: str, text: Optional[str], source: str) -> Decision:
|
||||
return Decision(
|
||||
kind,
|
||||
text=text,
|
||||
canonical=found.canonical,
|
||||
resolution=found.resolution,
|
||||
source=source,
|
||||
review=found.review,
|
||||
)
|
||||
|
||||
|
||||
def _never(found: _File, status: str, *, try_recorded_run: bool = False) -> Decision:
|
||||
return Decision(
|
||||
NEVER,
|
||||
canonical=found.canonical,
|
||||
resolution=found.resolution,
|
||||
review=found.review,
|
||||
try_recorded_run=try_recorded_run,
|
||||
unlinked_status=status,
|
||||
)
|
||||
|
||||
|
||||
def _review(resolution: dict) -> Optional[str]:
|
||||
if resolution["state"] != "ambiguous":
|
||||
return None
|
||||
return "ambiguous" if reviewable(resolution) else "untraced"
|
||||
|
||||
|
||||
def _unlinked(found: _File) -> str:
|
||||
"""Ambiguous without a choice is reported unresolved."""
|
||||
if found.review == "untraced":
|
||||
return "unresolved"
|
||||
return found.resolution["state"]
|
||||
|
||||
|
||||
def _inspected_text(resolution: dict) -> Optional[str]:
|
||||
"""The storage text of a positive the inspector shows, else None."""
|
||||
if resolution.get("state") in _UNDECIDED:
|
||||
return None
|
||||
positive = resolution.get("positive")
|
||||
if not isinstance(positive, dict):
|
||||
return None
|
||||
text = positive.get("storage_text", positive.get("text"))
|
||||
return text if isinstance(text, str) and text.strip() else None
|
||||
|
||||
|
||||
def _parsed(metadata: Mapping) -> dict:
|
||||
canonical = dict(metadata)
|
||||
for key in ("prompt", "workflow"):
|
||||
if isinstance(canonical.get(key), str):
|
||||
try:
|
||||
canonical[key] = json.loads(canonical[key])
|
||||
except (ValueError, TypeError):
|
||||
# Preserve malformed metadata for conservative resolver rejection.
|
||||
pass
|
||||
return canonical
|
||||
|
||||
|
||||
def _with_size(canonical: dict, file_size: Any) -> None:
|
||||
"""Record the file's size in ``file_info`` unless the metadata carries one.
|
||||
|
||||
Stored as ``generated_images.file_size``; the "new" scan scope skips a
|
||||
known file only while its size is unchanged.
|
||||
"""
|
||||
info = canonical.get("file_info")
|
||||
info = dict(info) if isinstance(info, dict) else {}
|
||||
if info.get("size") is None:
|
||||
size = file_size() if callable(file_size) else file_size
|
||||
if size is not None:
|
||||
info["size"] = size
|
||||
if info:
|
||||
canonical["file_info"] = info
|
||||
|
||||
|
||||
def _a1111_positive(metadata: Mapping) -> Optional[str]:
|
||||
text = parse_comfyui_prompt(metadata)["positive_prompt"]
|
||||
return text.strip() if text and text.strip() else None
|
||||
|
||||
|
||||
_LORA_TAG = re.compile(r"<lora:[^>]*>")
|
||||
_LORA_SEPARATORS = " \t\r\n\f\v,;|"
|
||||
_MODEL_FILE = re.compile(r"\S*\.(?:safetensors|ckpt|pt|pth|bin|gguf|sft)", re.I)
|
||||
|
||||
|
||||
def junk_text(text: Any) -> Optional[str]:
|
||||
"""The #252 junk class of ``text``, or None when it may be a prompt."""
|
||||
stripped = text.strip() if isinstance(text, str) else ""
|
||||
if not stripped:
|
||||
return "blank"
|
||||
if _numeric(stripped):
|
||||
return "number"
|
||||
if not _LORA_TAG.sub("", stripped).strip(_LORA_SEPARATORS):
|
||||
return "lora-only"
|
||||
if _MODEL_FILE.fullmatch(stripped):
|
||||
return "model-file"
|
||||
return None
|
||||
|
||||
|
||||
def graph_prompt_text(metadata: Mapping) -> Optional[str]:
|
||||
"""The positive prompt the browser parser traces from the graphs, or None.
|
||||
|
||||
Records parse as the legacy pages parsed them (NaN reads as null).
|
||||
"""
|
||||
records = {}
|
||||
for key in ("prompt", "workflow"):
|
||||
value = metadata.get(key)
|
||||
records[key] = parse_page_json(value) if isinstance(value, str) else value
|
||||
try:
|
||||
positive = extract_generation_params(records)["positivePrompt"]
|
||||
except GenerationParamsError:
|
||||
return None
|
||||
if not isinstance(positive, str) or positive == PLACEHOLDERS["positivePrompt"]:
|
||||
return None
|
||||
return positive.strip() or None
|
||||
|
||||
|
||||
_COMMON_FIELDS = (
|
||||
"positive",
|
||||
"negative",
|
||||
"steps",
|
||||
"cfg",
|
||||
"sampler",
|
||||
"scheduler",
|
||||
"seed",
|
||||
)
|
||||
|
||||
|
||||
def _json_or_raw(value: Any) -> Any:
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
try:
|
||||
return json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return value
|
||||
|
||||
|
||||
def parse_comfyui_prompt(metadata: Mapping) -> dict:
|
||||
"""Parse ComfyUI and A1111 prompt data from metadata.
|
||||
|
||||
Accepts the file's strings or parsed values: a parsed ``prompt`` or
|
||||
``workflow`` is kept as it is and a non-string ``parameters`` is ignored.
|
||||
"""
|
||||
result = {
|
||||
"prompt": None,
|
||||
"workflow": None,
|
||||
"parameters": {},
|
||||
"positive_prompt": None,
|
||||
"negative_prompt": None,
|
||||
}
|
||||
params = metadata.get("parameters")
|
||||
if isinstance(params, str):
|
||||
_parse_a1111(params, result)
|
||||
if result["positive_prompt"] is None:
|
||||
for key in ("prompt", "workflow"):
|
||||
if key in metadata:
|
||||
result[key] = _json_or_raw(metadata[key])
|
||||
for name in _COMMON_FIELDS:
|
||||
if name in metadata:
|
||||
result["parameters"][name] = _json_or_raw(metadata[name])
|
||||
return result
|
||||
|
||||
|
||||
_SETTINGS_FIELDS = ("Steps", "Sampler", "Schedule type", "CFG scale", "Seed")
|
||||
|
||||
|
||||
def _parse_a1111(params: str, result: dict) -> None:
|
||||
"""A1111/Forge ``parameters``: a possibly multi-line positive, then a
|
||||
"Negative prompt:" section, then a trailing "Steps: ..." settings line.
|
||||
Taking the first line as the positive would truncate a multi-line prompt,
|
||||
so the text is split on the settings line instead.
|
||||
"""
|
||||
body = params
|
||||
settings = ""
|
||||
found = re.search(r"\nSteps:\s", body)
|
||||
if found:
|
||||
settings = body[found.start() + 1 :].strip()
|
||||
body = body[: found.start()]
|
||||
negative = None
|
||||
found = re.search(r"(?im)^\s*negative prompt:\s*", body)
|
||||
if found:
|
||||
negative = body[found.end() :].strip()
|
||||
body = body[: found.start()]
|
||||
elif body.lower().lstrip().startswith("negative prompt:"):
|
||||
negative = body.lower().lstrip()[len("negative prompt:") :].strip()
|
||||
body = ""
|
||||
result["positive_prompt"] = body.strip() or None
|
||||
if negative:
|
||||
result["negative_prompt"] = negative
|
||||
result["parameters"]["parameters"] = params
|
||||
for name in _SETTINGS_FIELDS if settings else ():
|
||||
found = re.search(
|
||||
r"(?i)(?:^|,\s*)" + re.escape(name) + r":\s*([^,]+)", settings
|
||||
)
|
||||
if found:
|
||||
key = name.lower().replace(" ", "_")
|
||||
result["parameters"][key] = found.group(1).strip()
|
||||
@@ -41,14 +41,9 @@ _TEXT_KEY = re.compile(r"^(text|string|prompt)(_?[a-z0-9]+)?$", re.IGNORECASE)
|
||||
_NEGATIVE_KEY = re.compile(r"(^|_)neg(ative)?($|_)", re.IGNORECASE)
|
||||
|
||||
|
||||
def is_negative_input(key: Any) -> bool:
|
||||
"""Whether an input name carries a negative prompt (#90's one rule)."""
|
||||
return isinstance(key, str) and bool(_NEGATIVE_KEY.search(key))
|
||||
|
||||
|
||||
def follows_input(key: Any) -> bool:
|
||||
"""Whether an input name carries positive conditioning or prompt text."""
|
||||
if not isinstance(key, str) or is_negative_input(key):
|
||||
if not isinstance(key, str) or _NEGATIVE_KEY.search(key):
|
||||
return False
|
||||
return key == "positive" or bool(
|
||||
_CONDITIONING_KEY.match(key) or _TEXT_KEY.match(key)
|
||||
|
||||
+64
-131
@@ -1,10 +1,4 @@
|
||||
"""Read prompt evidence without inference, indexing, or decoding image pixels.
|
||||
|
||||
``inspection_resolution`` is the inspector's resolve (inspection profile, then
|
||||
the recorded saver anchor, #386). Ingestion (the output scan, through
|
||||
``utils.file_prompt``) calls it too, only for files the default profile
|
||||
cannot decide (#247), so the inspector and ingestion cannot drift.
|
||||
"""
|
||||
"""Read prompt evidence without inference, indexing, or decoding image pixels."""
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
@@ -577,102 +571,6 @@ def _recorded_execution(resolved: dict, texts: Mapping) -> dict | None:
|
||||
return {"prompts": prompts, "resolution": resolution}
|
||||
|
||||
|
||||
_ABSENT_RESOLUTION = {
|
||||
"state": "absent",
|
||||
"sampler_id": None,
|
||||
"reason": "No parsed prompt metadata",
|
||||
"reason_code": None,
|
||||
"node": None,
|
||||
}
|
||||
_MALFORMED_RESOLUTION = {
|
||||
"state": "unresolved",
|
||||
"sampler_id": None,
|
||||
"reason": MALFORMED_EVIDENCE_REASON,
|
||||
"reason_code": "malformed",
|
||||
"node": None,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InspectionResult:
|
||||
"""The inspector's resolution of one file's metadata (#386, #247).
|
||||
|
||||
``resolution`` is the result to use. ``anchored`` is the result anchored
|
||||
on the recorded saver, kept or not (None when no anchored resolve ran).
|
||||
``by_record`` says the anchored result was kept: history's saver chose
|
||||
the samplers and the result names the positive. ``malformed`` marks
|
||||
metadata the inspector cannot read as a graph.
|
||||
"""
|
||||
|
||||
resolution: dict
|
||||
anchored: dict | None = None
|
||||
by_record: bool = False
|
||||
malformed: bool = False
|
||||
|
||||
|
||||
def inspection_resolution(
|
||||
metadata: Mapping,
|
||||
file_name: str | None,
|
||||
recorded_saver_id: str | None,
|
||||
*,
|
||||
profile: ResolverProfile = INSPECTION_PROFILE,
|
||||
name_anchor: bool = True,
|
||||
) -> InspectionResult:
|
||||
"""Resolve raw file metadata as the inspector does.
|
||||
|
||||
Keeps only the role records, parses a string ``prompt`` and attaches the
|
||||
parsed ``workflow`` only when the graph may hold a PromptManager node.
|
||||
Resolves with ``profile``; when that leaves the prompt open and
|
||||
``recorded_saver_id`` is given, resolves again anchored on that saver
|
||||
and keeps the anchored result only when it names the positive.
|
||||
|
||||
``name_anchor=False`` (ingestion, #247) resolves the unanchored pass
|
||||
without ``file_name``, so the saver its file name names anchors nothing:
|
||||
such a file goes to Needs review, never linked (#390 ruling). The
|
||||
recorded-saver pass still gets ``file_name``, only to refuse a saver the
|
||||
name contradicts.
|
||||
"""
|
||||
workflow = metadata.get("workflow")
|
||||
canonical = {
|
||||
key: value for key, value in metadata.items() if key in _ROLE_METADATA_KEYS
|
||||
}
|
||||
if not canonical:
|
||||
return InspectionResult(dict(_ABSENT_RESOLUTION))
|
||||
malformed = InspectionResult(dict(_MALFORMED_RESOLUTION), malformed=True)
|
||||
if "prompt" not in canonical and "parameters" not in canonical:
|
||||
return malformed
|
||||
if isinstance(canonical.get("prompt"), str):
|
||||
try:
|
||||
canonical["prompt"] = json.loads(canonical["prompt"])
|
||||
except (ValueError, RecursionError):
|
||||
return malformed
|
||||
workflow = (
|
||||
_parsed_workflow(workflow) if _may_hold_prompt_manager(canonical) else None
|
||||
)
|
||||
if workflow is not None and "prompt" in canonical:
|
||||
# Only to recognize overwritten PromptManager text (R1); never a role.
|
||||
canonical["workflow"] = workflow
|
||||
resolved = resolve_prompt_pair(
|
||||
canonical, profile=profile, file_name=file_name if name_anchor else None
|
||||
)
|
||||
if resolved["state"] not in _UNDECIDED or recorded_saver_id is None:
|
||||
return InspectionResult(resolved)
|
||||
anchored = resolve_prompt_pair(
|
||||
canonical,
|
||||
profile=profile,
|
||||
file_name=file_name,
|
||||
recorded_saver_id=recorded_saver_id,
|
||||
)
|
||||
# Only a result whose samplers history's saver chose is about this file.
|
||||
if (
|
||||
anchored.get("recorded_anchor") is True
|
||||
and anchored["state"] not in _UNDECIDED
|
||||
and isinstance(anchored["positive"], dict)
|
||||
):
|
||||
return InspectionResult(anchored, anchored, True)
|
||||
return InspectionResult(resolved, anchored)
|
||||
|
||||
|
||||
def inspect_prompt_evidence(
|
||||
metadata: dict,
|
||||
saved: dict | None = None,
|
||||
@@ -695,13 +593,22 @@ def inspect_prompt_evidence(
|
||||
``execution``.
|
||||
|
||||
Inspection routes resolve with ``INSPECTION_PROFILE``: bounded work, not
|
||||
graph size. The resolve itself is ``inspection_resolution``, which
|
||||
ingestion also calls for files its default profile cannot decide (#247).
|
||||
``resolution`` carries the closed ``reason_code`` and, for a blocking
|
||||
node, ``node: {id, class_type}``.
|
||||
graph size. Ingestion (scan, live monitor, admin import) calls the resolver
|
||||
directly with its default profile. ``resolution`` carries the closed
|
||||
``reason_code`` and, for a blocking node, ``node: {id, class_type}``.
|
||||
"""
|
||||
roles = {key for key in metadata if key in _ROLE_METADATA_KEYS}
|
||||
if not roles:
|
||||
workflow = metadata.get("workflow")
|
||||
metadata = {
|
||||
key: value for key, value in metadata.items() if key in _ROLE_METADATA_KEYS
|
||||
}
|
||||
resolution = {
|
||||
"state": "unresolved",
|
||||
"sampler_id": None,
|
||||
"reason": MALFORMED_EVIDENCE_REASON,
|
||||
"reason_code": "malformed",
|
||||
"node": None,
|
||||
}
|
||||
if not metadata:
|
||||
result = {role: _side("absent") for role in ("positive", "negative")}
|
||||
if saved:
|
||||
context = saved.get("pair_context")
|
||||
@@ -714,30 +621,56 @@ def inspect_prompt_evidence(
|
||||
"verified against this file."
|
||||
),
|
||||
}
|
||||
return {"prompts": result, "resolution": dict(_ABSENT_RESOLUTION)}
|
||||
inspected = inspection_resolution(
|
||||
metadata, file_name, recorded_saver_id, profile=profile
|
||||
)
|
||||
if inspected.malformed:
|
||||
resolution = inspected.resolution
|
||||
return {
|
||||
"prompts": _explained(
|
||||
{role: _side("unresolved") for role in ("positive", "negative")},
|
||||
resolution,
|
||||
),
|
||||
"resolution": resolution,
|
||||
"prompts": result,
|
||||
"resolution": {
|
||||
"state": "absent",
|
||||
"sampler_id": None,
|
||||
"reason": "No parsed prompt metadata",
|
||||
"reason_code": None,
|
||||
"node": None,
|
||||
},
|
||||
}
|
||||
resolved, recorded = inspected.resolution, inspected.by_record
|
||||
anchored = inspected.anchored
|
||||
if (
|
||||
not recorded
|
||||
and recorded_texts
|
||||
and anchored is not None
|
||||
and anchored.get("recorded_anchor") is True
|
||||
):
|
||||
executed = _recorded_execution(anchored, recorded_texts)
|
||||
if executed is not None:
|
||||
return executed
|
||||
canonical = dict(metadata)
|
||||
malformed = {
|
||||
"prompts": _explained(
|
||||
{role: _side("unresolved") for role in ("positive", "negative")},
|
||||
resolution,
|
||||
),
|
||||
"resolution": resolution,
|
||||
}
|
||||
if "prompt" not in canonical and "parameters" not in canonical:
|
||||
return malformed
|
||||
if isinstance(canonical.get("prompt"), str):
|
||||
try:
|
||||
canonical["prompt"] = json.loads(canonical["prompt"])
|
||||
except (ValueError, RecursionError):
|
||||
return malformed
|
||||
workflow = (
|
||||
_parsed_workflow(workflow) if _may_hold_prompt_manager(canonical) else None
|
||||
)
|
||||
if workflow is not None and "prompt" in canonical:
|
||||
# Only to recognize overwritten PromptManager text (R1); never a role.
|
||||
canonical["workflow"] = workflow
|
||||
resolved = resolve_prompt_pair(canonical, profile=profile, file_name=file_name)
|
||||
recorded = False
|
||||
if resolved["state"] in _UNDECIDED and recorded_saver_id is not None:
|
||||
anchored = resolve_prompt_pair(
|
||||
canonical,
|
||||
profile=profile,
|
||||
file_name=file_name,
|
||||
recorded_saver_id=recorded_saver_id,
|
||||
)
|
||||
# Only a result whose samplers history's saver chose is about this file.
|
||||
if anchored.get("recorded_anchor") is True:
|
||||
if anchored["state"] not in _UNDECIDED and isinstance(
|
||||
anchored["positive"], dict
|
||||
):
|
||||
resolved, recorded = anchored, True
|
||||
elif recorded_texts:
|
||||
executed = _recorded_execution(anchored, recorded_texts)
|
||||
if executed is not None:
|
||||
return executed
|
||||
state = resolved["state"]
|
||||
result = {}
|
||||
for role in ("positive", "negative"):
|
||||
|
||||
@@ -20,8 +20,9 @@ const REPRESENTATIONS = {
|
||||
limited: 'Record exceeds inspection limits; text is unavailable',
|
||||
};
|
||||
const TEXTLESS = new Set(['invalid', 'limited']);
|
||||
// #332: the hidden prefix that names where a category chip's value came from.
|
||||
// #332: the hidden prefix and the visible origin word that name where a category chip's value came from (D146).
|
||||
const CATEGORY_PREFIX = {image: 'Image category: ', positive: 'Positive prompt category: ', pair: 'Recipe category: '};
|
||||
const CATEGORY_ORIGIN = {image: 'this image', positive: 'prompt', pair: 'recipe'};
|
||||
const REVEAL_URL = '/prompt_manager/images/reveal';
|
||||
// D38 Show in folder: the route's answers, in the maintainer's words.
|
||||
const REVEAL_OPENED = 'Opened the folder on this computer.';
|
||||
@@ -193,6 +194,17 @@ export function inspectorTagChips(record, {filterable}) {
|
||||
return chips;
|
||||
}
|
||||
/** A concept chip whose visible text carries a visually hidden prefix naming what it is. */
|
||||
/**
|
||||
* The category chip (#332 D146): the hidden prefix, the category, then `span.chip-source` " · this image", " · prompt"
|
||||
* or " · recipe" (visible, never split), so the chip names where its value came from.
|
||||
*/
|
||||
export function categoryChip(library) {
|
||||
const scope = CATEGORY_ORIGIN[library.category_scope] ? library.category_scope : 'positive';
|
||||
const chip = prefixedChip(CATEGORY_PREFIX[scope], library.category);
|
||||
chip.append(el('span', ` · ${CATEGORY_ORIGIN[scope]}`, 'chip-source'));
|
||||
return chip;
|
||||
}
|
||||
|
||||
function prefixedChip(prefix, text) {
|
||||
const chip = el('span', undefined, 'chip');
|
||||
chip.append(el('span', prefix, 'ca-visually-hidden'), text);
|
||||
@@ -268,9 +280,7 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
|
||||
const badges = el('div', undefined, 'tags'), badge = evidenceBadge(record.prompts);
|
||||
if (badge) badges.append(el('span', badge, 'chip blue'));
|
||||
// #332: an image's own category shows for any link status; an inherited one only when linked.
|
||||
if (library?.category && (library.category_scope === 'image' || linked)) {
|
||||
badges.append(prefixedChip(CATEGORY_PREFIX[library.category_scope] ?? CATEGORY_PREFIX.positive, library.category));
|
||||
}
|
||||
if (library?.category && (library.category_scope === 'image' || linked)) badges.append(categoryChip(library));
|
||||
if (badges.childNodes.length) inner.append(badges);
|
||||
const prompts = el('div'); inner.append(prompts);
|
||||
const blocks = mountPromptBlocks(prompts, {sender, parentScope:contentScope, includePairAction:false, contextLabel:promptContext, title});
|
||||
|
||||
@@ -203,17 +203,48 @@ async function readDefaults(api, signal) {
|
||||
}
|
||||
|
||||
export const UNAVAILABLE_HINT = 'Download it in System › Auto-tagging.';
|
||||
const nonEmpty = value => typeof value === 'string' && value.trim() !== '';
|
||||
const joined = names => (names.length > 1 ? `${names.slice(0, -1).join(', ')} and ${names.at(-1)}` : names.join(''));
|
||||
|
||||
/** The pip names a `/autotag/models` entry says its backend cannot import (#424); only non-empty strings count. */
|
||||
export const missingPackages = entry => (object(entry) && Array.isArray(entry.missing_packages)
|
||||
? entry.missing_packages.filter(nonEmpty) : []);
|
||||
|
||||
/** `pip install …` for `entry`'s missing packages: the server's command, else built from the names; '' when none. */
|
||||
export function installCommand(entry) {
|
||||
const missing = missingPackages(entry);
|
||||
if (!missing.length) return '';
|
||||
return nonEmpty(entry.install_command) ? entry.install_command : `pip install ${missing.join(' ')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The server's missing-package sentence (`missing_packages_message`, #424) for `name`, else '' (nothing missing):
|
||||
* "<name> needs llama-cpp-python. Install it into ComfyUI's Python: pip install llama-cpp-python. <note>".
|
||||
*/
|
||||
export function needsPackages(name, entry) {
|
||||
const missing = missingPackages(entry);
|
||||
if (!missing.length) return '';
|
||||
const note = nonEmpty(entry.install_note) ? ` ${entry.install_note}` : '';
|
||||
return `${name} needs ${joined(missing)}. Install ${missing.length === 1 ? 'it' : 'them'} into ComfyUI's Python: `
|
||||
+ `${installCommand(entry)}.${note}`;
|
||||
}
|
||||
|
||||
/** "a, b and c": the missing package names as the sentences list them. */
|
||||
export const packageList = entry => joined(missingPackages(entry));
|
||||
|
||||
/**
|
||||
* Why `model` cannot run, from the `/autotag/models` status (the server's own `model-unavailable` sentence), else ''
|
||||
* (downloaded, or the status is unknown: the job then reports a missing model itself).
|
||||
* (ready, or the status is unknown: the job then reports a missing model itself). Not downloaded is named first, then
|
||||
* a backend package that cannot be imported (#424).
|
||||
*/
|
||||
export function modelUnavailable(models, model) {
|
||||
const option = modelOf(model);
|
||||
requireValue(option, 'Unknown tagging model');
|
||||
const status = object(models) && object(models[model]) ? models[model] : null;
|
||||
if (!status || status.downloaded !== false) return '';
|
||||
const name = typeof status.name === 'string' && status.name.trim() ? status.name : option.label.split(' · ')[0];
|
||||
return `${name} is not downloaded. ${UNAVAILABLE_HINT}`;
|
||||
if (!status) return '';
|
||||
const fallback = option.label.split(' · ')[0];
|
||||
if (status.downloaded === false) return `${nonEmpty(status.name) ? status.name : fallback} is not downloaded. ${UNAVAILABLE_HINT}`;
|
||||
return needsPackages(nonEmpty(status.label) ? status.label : fallback.replace(' (Recommended)', ''), status);
|
||||
}
|
||||
|
||||
function initialState(source, preset) {
|
||||
|
||||
@@ -1,42 +1,62 @@
|
||||
import { countOf, noun } from './plural.mjs';
|
||||
import { countOf } from './plural.mjs';
|
||||
|
||||
/**
|
||||
* The Images selection bar's "Set category" (#249 D8). Images have no category of their own: the listing's category
|
||||
* filter matches the linked positive prompt's, so the action sets the category on those prompts.
|
||||
* The Images selection bar's "Set category" (#332, D140; the concept's bulk-category → simpleInput). Apply sets each
|
||||
* selected image's own category (`PUT /prompt_manager/images/category`); "Use prompt category" removes it after a
|
||||
* confirmation (`DELETE`), so the image shows its prompt's category again. It never writes a prompt: prompt
|
||||
* categories are set in the Prompt library editor and on the legacy admin page's "Set Category".
|
||||
*/
|
||||
export const CATEGORY_URL = '/prompt_manager/images/category';
|
||||
export const IMAGE_CATEGORIES_URL = '/prompt_manager/images/categories';
|
||||
export const CATEGORY_DIALOG = Object.freeze({title:'Categorize selected images', label:'Category', action:'Apply'});
|
||||
export const CATEGORY_NONE_LINKED = "None of the selected images is linked to a saved prompt, so there is no prompt category to set.";
|
||||
export const USE_PROMPT_CATEGORY = 'Use prompt category';
|
||||
export const REMOVE_DIALOG = Object.freeze({title:'Use prompt category', action:'Remove'});
|
||||
export const CATEGORY_FAILED = 'Category could not be set. Try again.';
|
||||
export const NONE_FOUND = 'None of the selected images could be found. Refresh the images and try again.';
|
||||
export const NONE_HAD_OWN = 'None of the selected images had its own category.';
|
||||
export const SOURCES_CHANGED = 'The sources changed. Refresh the images and try again.';
|
||||
// The server's limit (`validate_category`): the field stops there instead of failing on Apply.
|
||||
export const CATEGORY_MAX_LENGTH = 100;
|
||||
// One request names at most 500 images (DC2), as "Add tags" does.
|
||||
export const MAX_CATEGORY_IMAGES = 500;
|
||||
|
||||
function linkedPositive(image) {
|
||||
const link = image?.linked;
|
||||
if (link?.status !== 'linked') return null;
|
||||
return Number.isSafeInteger(link.positive_id) && link.positive_id > 0 ? link.positive_id : null;
|
||||
/** The success toast of a set: unchanged files count as set; files no source lists are named after. */
|
||||
export function setOutcome(result, category) {
|
||||
const updated = Number(result?.updated) || 0, unchanged = Number(result?.unchanged) || 0;
|
||||
const missing = Number(result?.not_found) || 0;
|
||||
const done = `Category “${category}” set on ${countOf(updated + unchanged, 'image')}.`;
|
||||
return missing ? `${done} ${missing.toLocaleString()} could not be found.` : done;
|
||||
}
|
||||
|
||||
/** The distinct linked positives of `images`, first-seen order; `skipped` counts the images without one. */
|
||||
export function categoryTargets(images) {
|
||||
const ids = new Set();
|
||||
let skipped = 0;
|
||||
for (const image of images) {
|
||||
const id = linkedPositive(image);
|
||||
if (id === null) skipped += 1;
|
||||
else ids.add(id);
|
||||
/** The toast of a removal. */
|
||||
export function clearOutcome(result) {
|
||||
const cleared = Number(result?.cleared) || 0;
|
||||
return cleared ? `Removed the image category from ${countOf(cleared, 'image')}.` : NONE_HAD_OWN;
|
||||
}
|
||||
|
||||
/** The confirmation sentence before "Use prompt category" removes the own category of `count` images. */
|
||||
export function removeQuestion(count) {
|
||||
const which = count === 1 ? 'the 1 selected image' : `the ${count.toLocaleString()} selected images`;
|
||||
return `Remove the image category from ${which}? Each one then shows its prompt's category, or none if it has no ` +
|
||||
'linked prompt. This cannot be undone.';
|
||||
}
|
||||
|
||||
/** The toast for a failed write: the coded answers the routes give, else the fixed failure. */
|
||||
export function failureMessage(error) {
|
||||
if (error?.code === 'files-not-found') return NONE_FOUND;
|
||||
if (error?.code === 'source-changed') return SOURCES_CHANGED;
|
||||
if (error?.code === 'too-many-images' && typeof error.message === 'string' && error.message) return error.message;
|
||||
return CATEGORY_FAILED;
|
||||
}
|
||||
|
||||
/** The toolbar's category list: the prompt and recipe categories plus the listed images' own ones, each once. */
|
||||
export function mergeCategories(promptCategories, imageCategories) {
|
||||
const merged = [];
|
||||
const seen = new Set();
|
||||
for (const value of [...(promptCategories ?? []), ...(imageCategories ?? [])]) {
|
||||
if (typeof value !== 'string' || !value.trim() || seen.has(value)) continue;
|
||||
seen.add(value);
|
||||
merged.push(value);
|
||||
}
|
||||
return {promptIds:[...ids], skipped};
|
||||
}
|
||||
|
||||
/** The success toast: the prompts changed, then any selected images that had no linked prompt. */
|
||||
export function categoryOutcome(prompts, skipped) {
|
||||
const done = `Category set on ${countOf(prompts, 'prompt')}.`;
|
||||
if (!skipped) return done;
|
||||
return `${done} ${countOf(skipped, 'selected image')} had no linked prompt and ${noun(skipped, 'was', 'were')} skipped.`;
|
||||
}
|
||||
|
||||
/** The prompts the server reports changed; without a count, the number of prompts posted. */
|
||||
export function updatedCount(result, posted) {
|
||||
const count = result?.updated_count;
|
||||
return Number.isSafeInteger(count) && count >= 0 ? count : posted;
|
||||
return merged;
|
||||
}
|
||||
|
||||
@@ -2,16 +2,16 @@ import { createButton } from '../shared/toolkit/core.js';
|
||||
import { imageKey, toggleSelection, selectionLabel } from './images-state.mjs';
|
||||
import { inspectPath, toInspectionRecord } from './inspection-state.mjs';
|
||||
import { BUNDLE_FILENAME, metadataBundle, collectImagesMetadata, exportOutcome } from './images-export.mjs';
|
||||
import { CATEGORY_DIALOG, CATEGORY_FAILED, CATEGORY_MAX_LENGTH, CATEGORY_NONE_LINKED, categoryOutcome, categoryTargets, updatedCount } from './images-category.mjs';
|
||||
import { openInputDialog } from './workspace-dialogs.mjs';
|
||||
import { MAX_FILTER_IMAGES, TOO_MANY_MATCHING, openAddTags, openAutoTag } from './images-tagging-bulk.mjs';
|
||||
import { CATEGORY_DIALOG, CATEGORY_MAX_LENGTH, CATEGORY_URL, MAX_CATEGORY_IMAGES, REMOVE_DIALOG, USE_PROMPT_CATEGORY,
|
||||
NONE_FOUND, clearOutcome, failureMessage, removeQuestion, setOutcome } from './images-category.mjs';
|
||||
import { closeWorkspaceDialog, openContentDialog, openInputDialog } from './workspace-dialogs.mjs';
|
||||
import { TOO_MANY, bulkTargets, openAddTags, openAutoTag } from './images-tagging-bulk.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
import { applyKeys, createLongPress, matchingLabel, matchingOffer, offersAllMatching, rangeKeys } from './images-multiselect.mjs';
|
||||
|
||||
const CHECKBOX = '.image-overlays input[type="checkbox"]';
|
||||
const CHECKBOX_LABEL = '.image-overlays > label';
|
||||
const CARD = 'article[data-image-key]';
|
||||
const CATEGORY_URL = '/prompt_manager/bulk/category';
|
||||
// D135: Export metadata reads every image's inspection in the browser, so it waits for loaded images.
|
||||
export const EXPORT_LOADED_ONLY = 'Export metadata reads each image in the browser, so it works on loaded images only.';
|
||||
|
||||
@@ -29,8 +29,9 @@ function el(tag, text, className) {
|
||||
* checkbox from `isChecked`; this module follows the boxes through the grid's `change` events.
|
||||
*
|
||||
* - `getItems()` is the listing in its order; `checked` restores a saved visit's selection.
|
||||
* - `onCategorySet()` reloads the categories and the listing after a successful Set category (the reload clears the
|
||||
* selection through `clear`); focus then moves to `focusFallback` if it was on the hidden action.
|
||||
* - `onCategorySet()` reloads the categories and the listing after a successful Set category or Use prompt category
|
||||
* (the reload clears the selection through `clear`); focus then moves to `focusFallback` if it was on the hidden
|
||||
* action. `getCategories()` gives the toolbar's categories, the field's suggestions.
|
||||
* - `retain(kept)` drops the checked files a refreshed listing no longer holds (a tag save under a filter, I7).
|
||||
* - With the Activity store `activity`, Add tags and Auto-tag selected (D85) follow the count, as the concept's Add tags
|
||||
* leads its actions; `onTagged(keys)` re-reads the files a finished tagging job tagged. The selection stays.
|
||||
@@ -43,7 +44,7 @@ function el(tag, text, className) {
|
||||
* matching", whose actions send that filter to the server.
|
||||
*/
|
||||
export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, onCategorySet, activity = null, onTagged,
|
||||
getListing, getFilter, checked: initial = Object.freeze(new Set()) }) {
|
||||
getListing, getFilter, getCategories, checked: initial = Object.freeze(new Set()) }) {
|
||||
let checked = initial;
|
||||
let exporting = false, categorizing = false;
|
||||
// D135: the range anchor, "all N matching" mode, touch selection mode and a pending Shift+click on a checkbox.
|
||||
@@ -261,42 +262,65 @@ export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, o
|
||||
timer = setTimeout(release, 1000);
|
||||
}
|
||||
/**
|
||||
* Set category (app.js bulk-category → simpleInput): images have no category, so Apply sets it on the selection's
|
||||
* distinct linked positives, taken when the (modal) dialog opens. With no linked image there is nothing to change:
|
||||
* a toast says so and no dialog opens.
|
||||
* Set category (app.js bulk-category → simpleInput, #332 D140): Apply sets each selected image's own category, linked
|
||||
* or not; "Use prompt category" removes it after a confirmation that states the count. Up to 500 images (DC2): an
|
||||
* explicit selection or "all N matching" (the listing filter, resolved by the server); more opens nothing.
|
||||
*/
|
||||
function openCategory(button) {
|
||||
if (categorizing) return;
|
||||
// All matching (D135): the listing's positive count says whether any match is linked; the server resolves.
|
||||
if (allMatching && getListing().total > MAX_FILTER_IMAGES) { showWorkspaceToast(TOO_MANY_MATCHING); return; }
|
||||
const targets = allMatching ? {filter: getFilter()} : categoryTargets(checkedImages());
|
||||
const linked = allMatching ? getListing().positiveCount !== 0 : targets.promptIds.length > 0;
|
||||
if (!linked) { showWorkspaceToast(CATEGORY_NONE_LINKED); return; }
|
||||
const count = allMatching ? getListing().total : checked.size;
|
||||
const targets = allMatching ? {filter: getFilter()} : {items: bulkTargets(checkedImages())};
|
||||
if (count > MAX_CATEGORY_IMAGES) { showWorkspaceToast(TOO_MANY); return; }
|
||||
// Checked files the listing no longer holds (a refresh dropped them): nothing to send.
|
||||
if (!allMatching && !targets.items?.length) { showWorkspaceToast(NONE_FOUND); return; }
|
||||
openInputDialog({ scope, trigger: button, title: CATEGORY_DIALOG.title, label: CATEGORY_DIALOG.label,
|
||||
action: CATEGORY_DIALOG.action, maxLength: CATEGORY_MAX_LENGTH, onApply: category => applyCategory(button, targets, category) });
|
||||
action: CATEGORY_DIALOG.action, maxLength: CATEGORY_MAX_LENGTH, suggestions: getCategories(),
|
||||
secondary: { label: USE_PROMPT_CATEGORY, onClick: () => confirmRemove(button, targets, count) },
|
||||
onApply: category => writeCategory(button, () => api.put(CATEGORY_URL, {...targets, category}),
|
||||
result => setOutcome(result, category)) });
|
||||
}
|
||||
/** "Use prompt category": nothing is sent until Remove; Cancel leaves every category as it was. */
|
||||
function confirmRemove(button, targets, count) {
|
||||
const text = el('p', removeQuestion(count));
|
||||
const actions = el('div', undefined, 'dialog-actions');
|
||||
const cancel = createButton('Cancel'), remove = createButton(REMOVE_DIALOG.action, 'danger');
|
||||
actions.append(cancel, remove);
|
||||
cancel.addEventListener('click', () => closeWorkspaceDialog());
|
||||
remove.addEventListener('click', () => {
|
||||
closeWorkspaceDialog();
|
||||
writeCategory(button, () => api.del(CATEGORY_URL, {body: targets}), clearOutcome);
|
||||
});
|
||||
openContentDialog({ scope, trigger: button, title: REMOVE_DIALOG.title, nodes: [text, actions] });
|
||||
cancel.focus();
|
||||
}
|
||||
/**
|
||||
* The write carries no page signal: once sent it finishes even if the page is left (`writing` then tells the page
|
||||
* not to save a visit that predates it). A failure keeps the selection; a success reloads through `onCategorySet`.
|
||||
* not to save a visit that predates it). A failure keeps the selection, except a changed source, which reloads the
|
||||
* listing as a success does (`onCategorySet`).
|
||||
*/
|
||||
async function applyCategory(button, { promptIds, skipped, filter }, category) {
|
||||
async function writeCategory(button, send, outcome) {
|
||||
categorizing = true;
|
||||
const release = busy(button);
|
||||
let result = null;
|
||||
let result = null, failure = null;
|
||||
try {
|
||||
result = await api.post(CATEGORY_URL, filter ? { images_filter: filter, category } : { prompt_ids: promptIds, category });
|
||||
} catch {
|
||||
// Reported below; the selection stays as it was.
|
||||
result = await send();
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
} finally {
|
||||
categorizing = false;
|
||||
release();
|
||||
}
|
||||
if (scope.signal.aborted) return;
|
||||
if (!result) { showWorkspaceToast(CATEGORY_FAILED); return; }
|
||||
if (filter && !result.prompt_count) { showWorkspaceToast(CATEGORY_NONE_LINKED); return; }
|
||||
showWorkspaceToast(filter ? categoryOutcome(updatedCount(result, result.prompt_count), Number(result.skipped) || 0)
|
||||
: categoryOutcome(updatedCount(result, promptIds.length), skipped));
|
||||
const focused = document.activeElement === button;
|
||||
if (failure !== null || !result) {
|
||||
showWorkspaceToast(failureMessage(failure));
|
||||
// A changed source reloads the listing (clearing the selection); any other failure keeps it.
|
||||
if (failure?.code !== 'source-changed') return;
|
||||
onCategorySet();
|
||||
if (focused) focusFallback.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
showWorkspaceToast(outcome(result));
|
||||
onCategorySet();
|
||||
// The reload hides the bar with the focused action in it, as Clear does: focus moves to the page heading.
|
||||
if (focused) focusFallback.focus({ preventScroll: true });
|
||||
|
||||
+39
-15
@@ -16,6 +16,7 @@ import { RESCAN_FAILED, RESCAN_STARTED, openImportDialog, openRefineDialog, resc
|
||||
import { openMetadataScan } from './activity-scan.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
import { imagesFilters, imagesViewState } from './saved-views.mjs';
|
||||
import { IMAGE_CATEGORIES_URL, mergeCategories } from './images-category.mjs';
|
||||
import { displayPreferences } from './preferences.mjs';
|
||||
import { onImageTagsSaved } from './image-tagging.mjs';
|
||||
import { followImageTagSaves, openCardTagging } from './images-tagging.mjs';
|
||||
@@ -81,6 +82,17 @@ setFavoriteVisitTarget((key, favorite) => {
|
||||
|
||||
// A tag save made while Images is left (Tags › Auto-tag images) may change what a filtered visit lists: it reloads (M6).
|
||||
onImageTagsSaved(() => { if (lastVisit?.filters.tags.length || lastVisit?.filters.q) lastVisit = {...lastVisit, stale: true}; });
|
||||
// The filter state a Tags & categories Browse hands to the next Images mount (#332 MC10).
|
||||
let browseState = null;
|
||||
/**
|
||||
* Open Images filtered by `category` only (every other filter at its default), as Browse opens the Library: Tags &
|
||||
* categories' Browse of a category only images use (#332 MC10, D143).
|
||||
*/
|
||||
export function browseImages({category} = {}) {
|
||||
if (typeof category !== 'string' || !category) throw new TypeError('browseImages needs a category');
|
||||
browseState = {category};
|
||||
window.location.hash = '#/images';
|
||||
}
|
||||
/** Forget the last Images visit (its listing, scroll and selection): the next visit reads everything again (S9). */
|
||||
export function forgetImagesVisit() {
|
||||
lastVisit = null;
|
||||
@@ -113,8 +125,12 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
scope.cleanup(inspection.subscribe(next => detailInspector?.setState(next)));
|
||||
scope.cleanup(() => inspection.dispose());
|
||||
scope.cleanup(() => detailInspector?.dispose());
|
||||
// A Tags & categories Browse (browseImages) is handed to this mount once: a fresh Images visit filtered by its
|
||||
// category, which becomes the Images return state like any plain visit (as Browse does for the Library).
|
||||
const handed = !embedded && !favorites && !savedState ? browseState : null;
|
||||
if (!embedded && !favorites) browseState = null;
|
||||
const saved = embedded || !savedState ? null : imagesFilters(savedState);
|
||||
const prior = embedded || saved || favorites ? null : lastVisit;
|
||||
const prior = embedded || saved || favorites || handed ? null : lastVisit;
|
||||
// A stale visit keeps its filters, layout and size, and lists again instead of restoring (M6).
|
||||
const visit = prior?.stale ? null : prior;
|
||||
// The layout and size defaults are the Images page's: an embedded gallery (a prompt history's, its dialogs) has no
|
||||
@@ -126,11 +142,14 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
let state, geometry = { positions: [], columns: 1, height: 0, maxHeight: 0 }, galleryWidth = 0, optionsRequest, categoryRequest, recovering = false, rescanning = false;
|
||||
// Discovered sources (each with its own folders) and every folder, for "Refine this view" and the scope chip.
|
||||
let roots = [], folders = [];
|
||||
// The toolbar's current category list (loadCategories), also the Set category suggestions.
|
||||
let categoryNames = [];
|
||||
// The open "Refine this view", so a discovery that settles after it opened still lists its sources and folders.
|
||||
let refine = null;
|
||||
const rendered = new Map();
|
||||
const itemIndexes = new Map();
|
||||
const filters = { root: 'all', q: '', category: '', tags: [], subfolder: '', media: 'all', sort: 'newest', ...prior?.filters, ...saved,
|
||||
...(handed ? {category: handed.category} : {}),
|
||||
favorites: favorites || saved?.favorites === true };
|
||||
filters.tags = [...filters.tags];
|
||||
if (!IMAGE_SORT_OPTIONS.some(([value]) => value === filters.sort)) filters.sort = 'newest';
|
||||
@@ -256,6 +275,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
// selection is kept across appended batches and cleared with the collection; the Library-embedded gallery has none.
|
||||
const bar = embedded ? null : mountSelectionBar({scope, api, grid, getItems:() => state?.items ?? [], focusFallback,
|
||||
checked:visit?.checked, onCategorySet:() => { loadCategories(); reset(); }, activity, onTagged:keys => refreshTagged(keys),
|
||||
// The Set category field suggests the toolbar's categories (#332 MC13).
|
||||
getCategories:() => categoryNames,
|
||||
// #392 "Select all N matching": the listing's total and its filter, as the server lists them.
|
||||
getListing:() => ({total:state?.total ?? 0, hasMore:Boolean(state?.hasMore), positiveCount:state?.positiveCount ?? null}),
|
||||
// A Source filter carries its root's scope, so a renumbered source is refused (source-changed), not acted on.
|
||||
@@ -712,24 +733,27 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
return false;
|
||||
}
|
||||
}
|
||||
/** The toolbar's categories are the Library's (the linked positive's category, D13). */
|
||||
/**
|
||||
* The toolbar's categories: the Library's (prompts and recipes) and the listed images' own ones (D141, #332), read
|
||||
* in parallel. If only the image list fails, the prompt categories show and the categories alert is raised.
|
||||
*/
|
||||
async function loadCategories() {
|
||||
if (!toolbar) return;
|
||||
categoryRequest?.abort();
|
||||
const request = new AbortController(); categoryRequest = request;
|
||||
try {
|
||||
const data = await api.get('/prompt_manager/library/options', {signal:request.signal});
|
||||
if (scope.signal.aborted || request !== categoryRequest) return;
|
||||
if (!Array.isArray(data?.categories)) throw new TypeError('Invalid categories');
|
||||
// D36: alphabetical (case-insensitive) whatever order the server sends, a restored category among them.
|
||||
const names = new Set([...data.categories.filter(value => typeof value === 'string' && value), filters.category]);
|
||||
names.delete('');
|
||||
toolbar.setCategories([...names].sort((a, b) => a.localeCompare(b, 'en-US', {sensitivity:'base'}) || (a < b ? -1 : a > b ? 1 : 0)));
|
||||
categoriesFailed = false;
|
||||
} catch {
|
||||
if (scope.signal.aborted || request !== categoryRequest) return;
|
||||
categoriesFailed = true;
|
||||
}
|
||||
const [library, images] = await Promise.allSettled([
|
||||
api.get('/prompt_manager/library/options', {signal:request.signal}),
|
||||
api.get(IMAGE_CATEGORIES_URL, {signal:request.signal}),
|
||||
]);
|
||||
if (scope.signal.aborted || request !== categoryRequest) return;
|
||||
const valid = result => result.status === 'fulfilled' && Array.isArray(result.value?.categories);
|
||||
if (!valid(library)) { categoriesFailed = true; return; }
|
||||
// D36: alphabetical (case-insensitive) whatever order the server sends, a restored category among them.
|
||||
const names = new Set(mergeCategories(library.value.categories, valid(images) ? images.value.categories : []));
|
||||
if (filters.category) names.add(filters.category);
|
||||
categoryNames = [...names].sort((a, b) => a.localeCompare(b, 'en-US', {sensitivity:'base'}) || (a < b ? -1 : a > b ? 1 : 0));
|
||||
toolbar.setCategories(categoryNames);
|
||||
categoriesFailed = !valid(images);
|
||||
}
|
||||
scope.cleanup(() => { optionsRequest?.abort(); categoryRequest?.abort(); });
|
||||
updateScope();
|
||||
|
||||
@@ -29,7 +29,7 @@ import { HOST_UNAVAILABLE } from './system-appearance.mjs';
|
||||
import { openCloseOnlyReview, openReviewDialog } from './workspace-dialogs.mjs';
|
||||
import { BACKUP_COPY, backupText, holding, reviewRefusal } from './maintenance-review.mjs';
|
||||
import { busyToast, downloadDatabaseBackup } from './activity-scan.mjs';
|
||||
import { MODEL_OPTIONS } from './image-tagging-state.mjs';
|
||||
import { MODEL_OPTIONS, installCommand, missingPackages, needsPackages, packageList } from './image-tagging-state.mjs';
|
||||
import { openImageTagging } from './image-tagging.mjs';
|
||||
import { selectedImage } from './images.mjs';
|
||||
import { NO_SELECTION } from './vocabulary.mjs';
|
||||
@@ -80,6 +80,7 @@ export const STATUS_COPY = Object.freeze({
|
||||
loaded: 'Downloaded and loaded.',
|
||||
partial: 'Partly downloaded. Download again to finish.',
|
||||
missing: size => `Not downloaded. Download size ${size}.`,
|
||||
needs: (names, command, note) => `Downloaded. Needs ${names}: ${command}.${typeof note === 'string' && note.trim() ? ` ${note}` : ''}`,
|
||||
});
|
||||
|
||||
/** The messages that name a setting the section cannot use (the server's own wording). */
|
||||
@@ -140,15 +141,21 @@ export function modelOptions(models) {
|
||||
|
||||
/**
|
||||
* D132: the Tagging model description for `status` (`{phase: loading|loaded|failed, models}`) and the chosen `model`.
|
||||
* GGUF with only one of its two files on disk is partly downloaded.
|
||||
* GGUF with only one of its two files on disk is partly downloaded. A backend package the server cannot import (#424)
|
||||
* turns "Downloaded" into "Downloaded. Needs <pkg>: pip install <pkg>." and adds "Also needs <pkg>." otherwise.
|
||||
*/
|
||||
export function modelDescription(status, model) {
|
||||
if (status?.phase === 'loading') return STATUS_COPY.loading;
|
||||
const entry = status?.models?.[model];
|
||||
if (status?.phase !== 'loaded' || !object(entry)) return STATUS_COPY.failed;
|
||||
if (entry.downloaded === true) return entry.loaded === true ? STATUS_COPY.loaded : STATUS_COPY.downloaded;
|
||||
if (partlyDownloaded(model, entry)) return STATUS_COPY.partial;
|
||||
return STATUS_COPY.missing(modelSize(model, entry));
|
||||
// #424: a backend package that cannot be imported; a model on disk without it never reads as ready.
|
||||
const needs = missingPackages(entry).length ? packageList(entry) : '';
|
||||
if (entry.downloaded === true) {
|
||||
if (needs) return STATUS_COPY.needs(needs, installCommand(entry), entry.install_note);
|
||||
return entry.loaded === true ? STATUS_COPY.loaded : STATUS_COPY.downloaded;
|
||||
}
|
||||
const files = partlyDownloaded(model, entry) ? STATUS_COPY.partial : STATUS_COPY.missing(modelSize(model, entry));
|
||||
return needs ? `${files} Also needs ${needs}.` : files;
|
||||
}
|
||||
|
||||
/** GGUF with only one of its two files on disk (a download that did not finish). */
|
||||
@@ -316,6 +323,7 @@ export function diskShortNote(disk) {
|
||||
export function batchCloseNote(plan) {
|
||||
if (plan.review_required === true) return REPLACE_NOTE;
|
||||
if (plan.model?.downloaded === false) return notDownloaded(modelLabel(plan.model.type, plan.model));
|
||||
if (missingPackages(plan.model).length) return needsPackages(modelLabel(plan.model.type, plan.model), plan.model);
|
||||
if (plan.disk?.sufficient === false) return diskShortNote(plan.disk);
|
||||
if (!Number.isSafeInteger(plan.images) || plan.images === 0) return NO_IMAGES;
|
||||
if (plan.to_tag === 0) return NOTHING_TO_TAG;
|
||||
@@ -484,8 +492,9 @@ export function mountTagging(container, { api, activity, scope, drafts, notify =
|
||||
unload.disabled = !(status.phase === 'loaded' && status.loadedType);
|
||||
if (review.textContent === 'Reviewing…') return;
|
||||
const batchProblem = known() ? draftProblem(values, { limit: false }) : unknown;
|
||||
setAvailable(review, entry?.downloaded === false ? notDownloaded(modelLabel(values.autotag_model_type, entry))
|
||||
: batchProblem?.message);
|
||||
const label = modelLabel(values.autotag_model_type, entry);
|
||||
setAvailable(review, entry?.downloaded === false ? notDownloaded(label)
|
||||
: needsPackages(label, entry) || batchProblem?.message);
|
||||
};
|
||||
|
||||
let reads = 0;
|
||||
|
||||
@@ -254,6 +254,23 @@ export function browseFilters(kind, value) {
|
||||
: {text: '', category: value, tags: [], sort: 'newest'};
|
||||
}
|
||||
|
||||
/**
|
||||
* A category row's count (#332 D143): "N positive prompts" when no image holds it as its own category, "N positive
|
||||
* prompts · M images" when both do, "M images" when only images do (stored rows, files on disk or not).
|
||||
*/
|
||||
export function categoryRowText(category) {
|
||||
const prompts = count(category?.prompt_count) ? category.prompt_count : 0;
|
||||
const images = count(category?.image_count) ? category.image_count : 0;
|
||||
if (!images) return countOf(prompts, 'positive prompt');
|
||||
if (!prompts) return countOf(images, 'image');
|
||||
return `${countOf(prompts, 'positive prompt')} · ${countOf(images, 'image')}`;
|
||||
}
|
||||
|
||||
/** Where a category's Browse opens: the Library while prompts hold it, else Images (MC10, its Library would be empty). */
|
||||
export function categoryBrowseTarget(category) {
|
||||
return count(category?.prompt_count) && category.prompt_count > 0 ? 'library' : 'images';
|
||||
}
|
||||
|
||||
/** The exact tag's current owner counts, for the delete confirmation. Throws on any unconfirmed shape. */
|
||||
export function readTagImpact(api, row, {signal} = {}) {
|
||||
requireRow(row);
|
||||
|
||||
@@ -9,12 +9,11 @@
|
||||
* constraint to the existing Library filters.
|
||||
*/
|
||||
import { createButton } from '../shared/toolkit/core.js';
|
||||
import { browseFilters, createVocabularyCommand, createVocabularyStream } from './vocabulary-state.mjs';
|
||||
import { countOf } from './plural.mjs';
|
||||
import { browseFilters, categoryBrowseTarget, categoryRowText, createVocabularyCommand, createVocabularyStream } from './vocabulary-state.mjs';
|
||||
import { browseLibrary, invalidateLibraryAfterWrite } from './library.mjs';
|
||||
import { createVocabularyDialog } from './vocabulary-dialog.mjs';
|
||||
import { pageHeading } from './heading.mjs';
|
||||
import { selectedImage } from './images.mjs';
|
||||
import { browseImages, selectedImage } from './images.mjs';
|
||||
import { onImageTagsSaved, openImageTagging } from './image-tagging.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
|
||||
@@ -64,7 +63,7 @@ function tagRow(tag) {
|
||||
}
|
||||
|
||||
function categoryRow(category) {
|
||||
const {row, button} = operation(category.value, countOf(category.prompt_count, 'positive prompt'), 'Browse');
|
||||
const {row, button} = operation(category.value, categoryRowText(category), 'Browse');
|
||||
button.dataset.action = 'browse';
|
||||
return row;
|
||||
}
|
||||
@@ -211,7 +210,10 @@ export function mountVocabulary(main, api, scope) {
|
||||
scope.listen(categoryRows, 'click', event => {
|
||||
const button = event.target.closest('button[data-action="browse"]');
|
||||
const category = button && categories.itemOf(button.closest('.operation'));
|
||||
if (category) browseLibrary(browseFilters('categories', category.value));
|
||||
if (!category) return;
|
||||
// MC10: a category only images hold opens Images filtered by it; the Library would be empty.
|
||||
if (categoryBrowseTarget(category) === 'images') browseImages({category: category.value});
|
||||
else browseLibrary(browseFilters('categories', category.value));
|
||||
});
|
||||
|
||||
let settled = Promise.resolve();
|
||||
|
||||
@@ -316,18 +316,29 @@ export function openCopyFallback(text, { scope, trigger = null, title = 'Copy pr
|
||||
area.select();
|
||||
}
|
||||
|
||||
export const INPUT_SUGGESTIONS_ID = 'workspace-input-suggestions';
|
||||
|
||||
/**
|
||||
* The concept's `simpleInput`: one labelled `input#action-input` (starting at `value`), then Cancel and the primary
|
||||
* `action`. The action reports the trimmed value and closes; an empty value keeps the dialog open on its field, or
|
||||
* reports `fallback` when one is given (Save view's "Saved view", a1339). A positive `maxLength` limits the field.
|
||||
* `validate(value)` may refuse a value with a message: the dialog then stays open with the typed text and the field
|
||||
* reports the message (its native validity) until it is edited.
|
||||
* reports the message (its native validity) until it is edited. `suggestions` (text list) offers values in a
|
||||
* `<datalist>` under the field; `secondary` (`{label, onClick}`) adds a button between Cancel and the action that
|
||||
* closes the dialog and runs `onClick` (#332).
|
||||
*/
|
||||
export function openInputDialog({ scope, title, label, action, value = '', maxLength, fallback, onApply, trigger = null,
|
||||
validate = null }) {
|
||||
validate = null, suggestions = null, secondary = null }) {
|
||||
requireScope(scope);
|
||||
requireCallbacks({ onApply });
|
||||
if (validate !== null) requireCallbacks({ validate });
|
||||
if (suggestions !== null && (!Array.isArray(suggestions) || suggestions.some(item => typeof item !== 'string'))) {
|
||||
throw new TypeError('The input dialog suggestions must be text');
|
||||
}
|
||||
if (secondary !== null && (typeof secondary?.label !== 'string' || !secondary.label)) {
|
||||
throw new TypeError('The input dialog secondary action needs a label');
|
||||
}
|
||||
if (secondary !== null) requireCallbacks({ onClick: secondary.onClick });
|
||||
for (const [name, text] of Object.entries({ title, label, action })) {
|
||||
if (typeof text !== 'string' || !text) throw new TypeError(`The input dialog needs a ${name}`);
|
||||
}
|
||||
@@ -355,8 +366,25 @@ export function openInputDialog({ scope, title, label, action, value = '', maxLe
|
||||
onApply(entered);
|
||||
});
|
||||
const actions = el('div', undefined, 'dialog-actions');
|
||||
actions.append(cancel, apply);
|
||||
show(title, [field(label, input, 'action-input'), actions], { scope, trigger, focusTarget: input });
|
||||
actions.append(cancel);
|
||||
if (secondary !== null) {
|
||||
// Between Cancel and the primary action; it closes the dialog first (#332 "Use prompt category").
|
||||
const other = createButton(secondary.label);
|
||||
other.addEventListener('click', () => { finish(); secondary.onClick(); });
|
||||
actions.append(other);
|
||||
}
|
||||
actions.append(apply);
|
||||
const nodes = [field(label, input, 'action-input')];
|
||||
if (suggestions?.length) {
|
||||
// Its own id: `datalist#categories` and `#vocabulary-category-options` belong to other pages' fields.
|
||||
const list = el('datalist');
|
||||
list.id = INPUT_SUGGESTIONS_ID;
|
||||
for (const value of suggestions) { const option = el('option'); option.value = value; list.append(option); }
|
||||
input.setAttribute('list', INPUT_SUGGESTIONS_ID);
|
||||
nodes.push(list);
|
||||
}
|
||||
nodes.push(actions);
|
||||
show(title, nodes, { scope, trigger, focusTarget: input });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -446,3 +446,7 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc
|
||||
outside 1–500 is highlighted as a refused setting is (D92). */
|
||||
.toolbar #log-limit { inline-size: 6em; min-height: 36px; font-size: 11px; /* D109 */ }
|
||||
.toolbar #log-limit[aria-invalid="true"] { outline: 2px solid var(--red); outline-offset: 2px; /* D109 */ }
|
||||
|
||||
/* #332 D146: the category chip names its origin (" · this image", " · prompt", " · recipe") in muted text that never
|
||||
splits; at 390px the chip may wrap between the category and its origin. */
|
||||
.chip .chip-source { color: var(--ca-muted); white-space: nowrap; }
|
||||
|
||||
Reference in New Issue
Block a user