feat(annotations): Tag image dialog beside Info on every image card
Every Images and Library-embedded gallery card shows the concept's Tag after Info. Tag opens the concept's Tag image dialog (image-tagging.mjs over the C1 controller) in the shared modal shell with the sticky tagging-modal footer: capture preview, manual tags, the four legacy engines with real defaults, explicit Generate, removable suggestion chips (D83), legacy model labels (D84), a Suggestions count (D91) and Retry where the controller allows it. The capture is released on close, route change and pagehide. The Tags page gains the concept's Auto-tag images for the image selected in Images (aria-disabled without one, D115) and real Images counts that follow every save. Retires D9, #245 D2 and #245 D3. The toast now uses the mock's copy verbatim ("tags" even for one), C1's D60/D61/D68 markers are renumbered to D83/D84/D91, and at extreme zoom a card's info row stacks (and wraps Info and Tag) when its row is narrower than the narrowest bounded card, list rows included (D31).
This commit is contained in:
+75
-9
@@ -769,8 +769,12 @@ Recorded shell deviations:
|
||||
"Save", not "Apply in prototype", and its toast drops the demo's word "Sample".
|
||||
- **D31**: at `max-width: 950px` the overlay caps the one-column shell at
|
||||
`minmax(0, 1fr)`, so extreme zoom cannot widen it to the sidebar's min-content.
|
||||
- **#245 D2**: no "Auto-tag images" on the Tags heading until the image-annotation
|
||||
slice.
|
||||
On the Images cards (#249 C2) a card's info row stacks its text above Info and Tag
|
||||
once the row is narrower than the narrowest bounded card (120px; a list row's info
|
||||
is what its art leaves), and the two buttons wrap when even that row cannot hold
|
||||
them (`stacksCardInfo`, `tests/js/workspace-image-layout.test.js`).
|
||||
- **#245 D2 (retired, #249 C2)**: the Tags heading shows the concept's "Auto-tag images";
|
||||
it opens the Tag image dialog (D115 when nothing is selected).
|
||||
- **Brand name**: not a deviation. The brand link's accessible name matches the
|
||||
concept's, mark included, because production's `.brand-mark` is not `aria-hidden`
|
||||
either. The inventory compares `brand.name` and `brand.mark.hidden`.
|
||||
@@ -793,7 +797,7 @@ zoom, the 2nd card opens the viewer titled "<title> · <filename>", Escape retur
|
||||
to it (production only: the concept re-renders its grid and drops focus), Info on the
|
||||
3rd card moves `.selected`, searches for "alpine" and for one image, and Load more with
|
||||
its toast. `mapImagesConcept` applies D3, D8 (the selection bar's real actions only),
|
||||
D9, D10, D14, D15, D17, D29, D32, D35 (singular counts) and D36 (categories
|
||||
D10, D14, D15, D17, D29, D32, D35 (singular counts) and D36 (categories
|
||||
sorted alphabetically). Removed toolbar and filter-row items flow again from the
|
||||
concept's own boxes (`reflow`), checked against the concept page with those items
|
||||
removed at runtime. Masonry tops and heights are compared unrounded, within one layout
|
||||
@@ -885,7 +889,9 @@ Recorded deviations on this page (spec §8):
|
||||
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.
|
||||
- **D9**: no Tag button until the image-annotations slice.
|
||||
- **D9 (retired, #249 C2)**: every card shows the concept's Tag (`tagImage`, "Tag or
|
||||
auto-tag <filename>") after Info; it opens the Tag image dialog without selecting
|
||||
the card (see "Tag image dialog" below).
|
||||
- **D10**: the card title is the linked positive's display title, else the filename;
|
||||
the `h3` keeps the relative path in `title`.
|
||||
- **D11**: the subtitle is "W × H · EXT · Seed N" with unknown parts omitted.
|
||||
@@ -1049,6 +1055,67 @@ npm run --prefix tools/vendor test:workflows -- images-concept.spec.mjs \
|
||||
--project chromium --project touch --project firefox --project webkit
|
||||
```
|
||||
|
||||
### Tag image dialog and recorded deviations (#249 PR C)
|
||||
|
||||
Every Images card and every Library-embedded gallery card shows Info then the
|
||||
concept's Tag (`app.js` mediaCard). Tag opens the concept's **Tag image** dialog
|
||||
(`web/js/workspace/image-tagging.mjs` over the C1 controller
|
||||
`image-tagging-state.mjs`) in the shared modal shell with the concept's
|
||||
`tagging-modal` class, so its `.dialog-actions` stay sticky while the content
|
||||
scrolls. Focus starts on × and returns to the Tag button. The preview is the
|
||||
capture's own `preview_url` (through `ApiClient.resolveUrl`): an `img`, or a
|
||||
`video preload="metadata" muted`. The capture is released on ×, Escape, Cancel,
|
||||
Save, a route change (the shell closes its dialog) and `pagehide`. Generate is
|
||||
explicit and uses the server's real defaults (`GET /prompt_manager/autotag/models`);
|
||||
an invalid field reports native validity and sends nothing. Save writes the manual
|
||||
names, then the remaining chips, deduplicated case-insensitively (first spelling
|
||||
wins), and toasts the mock's copy verbatim, "<n> tags saved for <filename>." even
|
||||
for one tag, plus " Prompt association still awaits review." from Needs review.
|
||||
Tagging one file never changes another file (an equal-byte copy included), a
|
||||
prompt, a pair or a link. States the mock lacks (generating, busy, conflict,
|
||||
changed storage or file, expired capture, unconfirmed save, unavailable model,
|
||||
video) are text in `#tag-generation-status` only; **Retry** (ruling T5) leads the
|
||||
actions while the controller allows it (failed observation, failed Generate,
|
||||
unconfirmed save), where the other workspace dialogs place their Retry. The Tags
|
||||
page re-reads its lists after every confirmed save, so the Images counts follow.
|
||||
|
||||
The image-tagging plan's deviation numbers were renumbered by +23 because the
|
||||
Inspect plan keeps D60–D66 (plan D60 → D83, D61 → D84, D68 → D91).
|
||||
|
||||
- **D83** (plan D60, ruling T4): the suggestions are removable chips instead of
|
||||
checkbox rows: `span.chip.tag-suggestion` in a `.tags` row, in the concept's
|
||||
checked-row tokens (accent border, `--soft-blue`), each with the name, the
|
||||
confidence (`98%` for WD14, "Suggested" for language models) and a × "Remove
|
||||
<tag>". At most the requested count, all preselected. The help copy reads
|
||||
"Remove anything you do not want to add." and removing the last chip shows "All
|
||||
suggestions removed. Generate again or enter tags manually."
|
||||
- **D84** (plan D61, rulings T1/T2): the Tagging model options are legacy's four
|
||||
engines in the mock's order with legacy labels and sizes (`WD14 SwinV2 · ~400
|
||||
MB`, `WD14 ViT · ~400 MB`, `GGUF JoyCaption (Recommended) · ~4.5 GB` in the
|
||||
mock's Ollama slot, `HuggingFace JoyCaption · ~16 GB`); the instructions start
|
||||
from the server's `default_prompt`.
|
||||
- **D91** (plan D68, ruling T3-bulk): a **Suggestions** number field (1–100) for
|
||||
every model, prefilled from `autotag.defaults.suggestion_limit` (10); a model
|
||||
change keeps it.
|
||||
- **D115** (beyond the plan's list): the concept always has a selected image, so
|
||||
its "Auto-tag images" always opens the dialog. Production opens it for the image
|
||||
selected in Images when it was last left; with none it is `aria-disabled` and
|
||||
pressing it toasts "Select an image in Images first." (no new chrome).
|
||||
|
||||
Test files: `tests/browser/workspace-tagging.spec.mjs` (the fixture is
|
||||
`tests/browser/workspace_tagging_fixture.py`: two files sharing one prompt and
|
||||
pair, an equal-byte copy, a video, an unlinked file, the fake model with `hf`, and
|
||||
no model files; every test asserts no download request),
|
||||
`tests/js/workspace-image-tagging.test.js`,
|
||||
`tests/js/workspace-image-tagging-state.test.js` and
|
||||
`tests/js/workspace-images-lifecycle.test.js`.
|
||||
|
||||
```sh
|
||||
node --test tests/js/workspace-image-tagging*.test.js tests/js/workspace-images-lifecycle.test.js
|
||||
npm run --prefix tools/vendor test:workflows -- workspace-tagging.spec.mjs \
|
||||
workspace-vocabulary.spec.mjs images-concept.spec.mjs --project chromium --project touch
|
||||
```
|
||||
|
||||
### Prompt library cards and recorded deviations (#249 PR E)
|
||||
|
||||
The shelf uses the shared concept toolbar and caption (E1) and the concept's
|
||||
@@ -1747,8 +1814,8 @@ page lifecycle recovery retain their separate behavioral tests.
|
||||
Initial production acceptance requires maintainer inspection of paired PNGs and
|
||||
geometry; never update snapshots automatically or mask content to bless a gap.
|
||||
Data-dependent text/images/counts and the clearly labeled saved/frozen/current
|
||||
distinctions are documented in the comparison helper. The Images card tag
|
||||
button (#249 D9) remains outside #209; every prompt block now renders the concept's
|
||||
distinctions are documented in the comparison helper. The gallery cards carry the
|
||||
concept's Tag button beside Info (#249 C2 retired D9); every prompt block now renders the concept's
|
||||
`section.prompt-section` (D22), so the shared role-block spacing, padding and glyph
|
||||
gap is closed, and only the production-only surfaces above keep their own rules
|
||||
(D48). The
|
||||
@@ -1912,7 +1979,7 @@ legacy admin page. The screen consumes only the `/prompt_manager/vocabulary/*` r
|
||||
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 shown as "—" with hidden "Not available yet", and Actions with Browse and
|
||||
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
|
||||
and keep `Load more tags` / `Load more categories` while more remain. Prompts counts
|
||||
@@ -1953,8 +2020,7 @@ npm run --prefix tools/vendor test:workflows -- \
|
||||
--project chromium --project touch --project firefox --project webkit
|
||||
```
|
||||
|
||||
Recorded deviations from the concept (maintainer acceptance): no Auto-tag images
|
||||
button and an Images "—" cell until the image-annotation slice; bounded lists with
|
||||
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
|
||||
|
||||
@@ -92,7 +92,7 @@ test('inspection wire preserves exact rooted envelope', async ({ request }) => {
|
||||
const inspector=page=>page.locator('.detail-panel');
|
||||
/** D19: the selected card's image opens the viewer (the side panel has no full-preview cover). */
|
||||
const openSelected=page=>page.locator('.media-card.selected .image-open').click();
|
||||
const selectFile=async(page,name,root=0)=>page.locator(`article[data-image-key="${root}:inspector/${name}"] .image-card-actions button`).click();
|
||||
const selectFile=async(page,name,root=0)=>page.locator(`article[data-image-key="${root}:inspector/${name}"] .image-card-actions button:first-child`).click();
|
||||
async function fullFixture(page,request) {
|
||||
await expect(await request.post('/__test__/reset',{data:{full_inspector:true}})).toBeOK();
|
||||
await page.goto('/prompt_manager/workspace#/images');await selectFile(page,'complete.png');
|
||||
@@ -460,7 +460,7 @@ test('full inspector labels container representations and releases action URLs',
|
||||
test('full inspector distinguishes missing, limited and legacy unknown generation facts',async({page,request})=>{
|
||||
await fullFixture(page,request);const panel=inspector(page);
|
||||
await selectFile(page,'empty.png');await expect(panel.locator('.meta-grid dd')).toHaveText(Array(6).fill('Not recorded'));
|
||||
await page.locator('article[data-image-key="0:architecture/study-01.png"] .image-card-actions button').click();await expect(panel.locator('.meta-grid dd')).toHaveText(Array(6).fill('Not recorded'));
|
||||
await page.locator('article[data-image-key="0:architecture/study-01.png"] .image-card-actions button:first-child').click();await expect(panel.locator('.meta-grid dd')).toHaveText(Array(6).fill('Not recorded'));
|
||||
await selectFile(page,'control-heavy.png');
|
||||
const explanation='Configuration exceeds inspection limits; download the original file to inspect it locally.';
|
||||
await expect(panel.getByText(explanation,{exact:true})).toHaveCount(1);await expect(panel.getByText(explanation,{exact:true})).toBeVisible();
|
||||
@@ -637,7 +637,7 @@ test('collection viewer follows filtered loaded order beyond mounted cards',asyn
|
||||
// #249 D26: the whole positive's images are in the All images dialog's own embedded gallery.
|
||||
await page.getByRole('button',{name:'All images for this positive',exact:true}).click();
|
||||
const listing=page.getByRole('dialog',{name:'All images for this positive',exact:true});
|
||||
await listing.locator('article[data-image-key="0:inspector/complete.png"] .image-card-actions button').click();
|
||||
await listing.locator('article[data-image-key="0:inspector/complete.png"] .image-card-actions button:first-child').click();
|
||||
await listing.locator('.media-card.selected .image-open').click();await expect(mediaDialog(page).locator('.viewer-canvas img')).toBeVisible();
|
||||
await expect(position(page)).toContainText('of 2 loaded:');
|
||||
});
|
||||
@@ -930,7 +930,7 @@ test('collection viewer matches approved responsive frame',async({page,request},
|
||||
await page.screenshot({path:testInfo.outputPath('viewer-mobile-wider-font-2x.png')});
|
||||
await modal.getByRole('button',{name:'Close image viewer',exact:true}).click();await page.evaluate(()=>{document.documentElement.style.zoom='1';});await settleViewport(390);
|
||||
const longFiles=await(await request.get('/prompt_manager/images/output?mode=workspace&q=a-very-long&limit=40')).json();const long=longFiles.images[0];
|
||||
await page.locator(`article[data-image-key="${long.root_index}:${long.path}"] .image-card-actions button`).click();await openCollection(page);
|
||||
await page.locator(`article[data-image-key="${long.root_index}:${long.path}"] .image-card-actions button:first-child`).click();await openCollection(page);
|
||||
await page.evaluate(()=>{document.documentElement.style.zoom='2';});await settleViewport(390);await modal.evaluate(node=>{node.scrollTop=0;});
|
||||
await expect(modal.getByRole('button',{name:'Close image viewer',exact:true})).toBeInViewport();
|
||||
});
|
||||
|
||||
@@ -17,7 +17,6 @@ export const canonicalDifferences = {
|
||||
// Closed by #249: the gallery's checkbox is Images-only (#259) and the role blocks are the concept's (PR D, F); the
|
||||
// exact history comparison is tests/browser/history-concept.spec.mjs.
|
||||
remainingApplicationPolish: [
|
||||
'Gallery card tag button (#249 D9, image-annotations slice) remains outside #209',
|
||||
'Production keeps mobile toolbar inside media panel and readable scrolling details; the concept toolbar collision is not reproduced',
|
||||
],
|
||||
};
|
||||
|
||||
@@ -59,12 +59,12 @@ function mapBulkbar(bulkbar,rise) {
|
||||
}
|
||||
|
||||
/**
|
||||
* A list row's info once D9 narrows its actions to `actionsWidth`, from the concept's boxes:
|
||||
* A list row's info with its actions `actionsWidth` wide, from the concept's boxes:
|
||||
* - D3 (the measurable list row, `.list-grid .media-info` in the overlay): the info fills the row beside the art and may
|
||||
* shrink below a long nowrap title (`min-width: 0`), so it is the card's inner width less the art, and the actions
|
||||
* end at its right padding; it keeps its own height (text or actions plus padding), centred in the row the concept
|
||||
* stretches it across, so the text, centred in either, moves only by half the lines it lost;
|
||||
* - D9: the wider text column fits a subtitle the concept wrapped when its one-line width (`maxContent`) now fits.
|
||||
* - the text column fits a subtitle the concept wrapped when its one-line width (`maxContent`) now fits.
|
||||
*/
|
||||
function listInfo(card,actionsWidth) {
|
||||
const {info}=card,{copy,p}=info;
|
||||
@@ -109,21 +109,16 @@ export function mapImagesConcept(reference,{manifest,now=new Date()}) {
|
||||
const cards=reference.cards.map(card=>{
|
||||
const filename=card.open.label.replace(/^Open /,'');
|
||||
const actions=card.info.actions;
|
||||
const tag=actions.buttons.find(button=>button.label?.startsWith('Tag or auto-tag '));
|
||||
// D9: no Tag button until the image-annotations slice; the actions group narrows by its box and gap.
|
||||
const freed=tag?tag.width+parseFloat(actions.columnGap):0;
|
||||
// D3: a list row's info keeps its own height (text or actions plus its padding) so bounded rendering can measure
|
||||
// it, centred in the row the concept stretches it across; the text and the actions keep their place.
|
||||
const actionsWidth=actions.width-freed;
|
||||
const listRow=list?listInfo(card,actionsWidth):{};
|
||||
const listRow=list?listInfo(card,actions.width):{};
|
||||
return {...card,
|
||||
// D29: the concept's alt adds its demo-only " · sample illustration N" to the card title.
|
||||
img:{...card.img,alt:card.img.alt.replace(/ · sample illustration \d+$/,'')},
|
||||
info:{...card.info,...listRow.info,copy:{...card.info.copy,...listRow.copy},p:{...card.info.p,...listRow.p},
|
||||
// D10: the title keeps the file's relative path (the fixture manifest's) in `title`.
|
||||
h3:{...card.info.h3,title:paths.get(filename)??null},
|
||||
actions:{...actions,width:actionsWidth,left:listRow.actionsLeft??actions.left+freed,
|
||||
buttons:actions.buttons.filter(button=>button!==tag)}}};
|
||||
actions:{...actions,left:listRow.actionsLeft??actions.left}}};
|
||||
});
|
||||
// D17: the right-hand date is the newest result's day under Recently added, here the fixture's newest mtime.
|
||||
// D35: the count line uses the singular for one.
|
||||
@@ -558,6 +553,7 @@ test('one footer height for the collection (D3)',async({page,request})=>{
|
||||
const seedless=actual.cards.slice(0,2);
|
||||
expect(seedless.map(card=>card.open.label)).toEqual(manifest.seedless.map(image=>`Open ${image.filename}`));
|
||||
expect(seedless.map(card=>card.info.p.text)).toEqual(['1536 × 1152 · PNG','1536 × 1152 · PNG']);
|
||||
// Their own footer is one line shorter than the row it reserves.
|
||||
for(const card of seedless)expect(card.info.height).toBeLessThan(reference.cards[0].info.height);
|
||||
// Their own footer fits the row it reserves. With Info and Tag (#249 C2, the concept's own pair) their subtitle
|
||||
// wraps at 390px as the concept's do, so it is no longer one line shorter; the boxes above pin the reservation.
|
||||
for(const card of seedless)expect(card.info.height).toBeLessThanOrEqual(reference.cards[0].info.height);
|
||||
});
|
||||
|
||||
@@ -35,6 +35,7 @@ from tests.browser.workspace_library_editor_fixture import (
|
||||
register_library_editor_commands,
|
||||
)
|
||||
from tests.browser.workspace_vocabulary_fixture import register_vocabulary_commands
|
||||
from tests.browser.workspace_tagging_fixture import register_tagging_commands
|
||||
|
||||
CANONICAL_ROOT = Path(__file__).resolve().parents[2]
|
||||
CANONICAL_ASSETS = {
|
||||
@@ -57,11 +58,16 @@ class TestModel:
|
||||
wd14_character_threshold = 0.85
|
||||
custom_prompt = ""
|
||||
loaded = None
|
||||
# Models the Tag image dialog workflows mark as not downloaded.
|
||||
unavailable: set = set()
|
||||
|
||||
def get_models_status(self) -> dict:
|
||||
return {
|
||||
name: {"downloaded": True, "loaded": self.loaded == name}
|
||||
for name in ("gguf", "wd14-vit", "wd14-convnext", "wd14-swinv2")
|
||||
name: {
|
||||
"downloaded": name not in self.unavailable,
|
||||
"loaded": self.loaded == name,
|
||||
}
|
||||
for name in ("gguf", "hf", "wd14-vit", "wd14-convnext", "wd14-swinv2")
|
||||
}
|
||||
|
||||
def is_model_loaded(self) -> bool:
|
||||
@@ -178,6 +184,7 @@ def create_app(root: Path) -> web.Application:
|
||||
history_commands = register_workspace_history_commands(routes, api, root)
|
||||
library_editor_commands = register_library_editor_commands(routes, api, root)
|
||||
register_vocabulary_commands(routes, api, root)
|
||||
register_tagging_commands(routes, api, root, model)
|
||||
reveal_stub = register_reveal_stub(routes, api, root)
|
||||
register_activity_commands(app, routes, api)
|
||||
|
||||
|
||||
@@ -119,10 +119,7 @@ function mapShellConcept(reference) {
|
||||
const spans=chrome.footer.spans.map(span=>({...span,text:'ComfyAssets · Local tools. Your library.'}));
|
||||
return {...chrome.footer,spans,buttons:[],height:remainingFooter(chrome.footer,spans)};
|
||||
})(),
|
||||
},
|
||||
heading:reference.heading&&{...reference.heading,actions:reference.heading.actions&&{...reference.heading.actions,
|
||||
// #245 vocabulary ruling D2 (mapVocabularyConcept): Auto-tag images is omitted until the image-annotation slice.
|
||||
buttons:reference.heading.actions.buttons.filter(item=>item.text!=='Auto-tag images')}}};
|
||||
}};
|
||||
}
|
||||
|
||||
/** The heading without its page-owned description copy and actions; the h1 text is the shell's and stays compared. */
|
||||
|
||||
@@ -64,12 +64,10 @@ export function pageInventoryOf(main) {
|
||||
// Recorded deviations only (plan "Mock inventory" + decisions D1-D15 + ledger rulings). Everything else must be equal.
|
||||
export function mapVocabularyConcept(reference) {
|
||||
return {...reference,
|
||||
heading:{...reference.heading,actions:{...reference.heading.actions,
|
||||
// D2: Auto-tag images is omitted until the image-annotation slice owns image tags.
|
||||
buttons:reference.heading.actions.buttons.filter(item=>item.text!=='Auto-tag images')}},
|
||||
table:{...reference.table,rows:reference.table.rows.map(row=>({...row,
|
||||
// D3: no image-tag owner yet, so the Images cell shows "—" with a visually hidden "Not available yet".
|
||||
images:'—',imagesHidden:'Not available yet'}))},
|
||||
// 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')}})),
|
||||
|
||||
@@ -1019,7 +1019,7 @@ test('shared gallery uses concept cards, image opens the viewer, empty copy',asy
|
||||
await expect(star).toHaveAccessibleName(`Favorite ${filename}`);await expect(star).toHaveAttribute('aria-pressed',String(image.favorite));
|
||||
await expect(star).toHaveText(image.favorite?'★':'☆');
|
||||
await expect(first.locator('.media-info h3')).toHaveCount(1);
|
||||
await expect(first.locator('.image-card-actions > button.icon-btn')).toHaveAccessibleName(`Inspect ${filename}`);
|
||||
await expect(first.locator('.image-card-actions > button.icon-btn:first-child')).toHaveAccessibleName(`Inspect ${filename}`);
|
||||
// C3 activation: the image opens the viewer and Escape returns focus to it.
|
||||
const openButton=first.locator('.image-open');await openButton.click();
|
||||
const viewer=page.locator('dialog#viewer-dialog');await expect(viewer).toBeVisible();
|
||||
|
||||
@@ -545,7 +545,7 @@ async function staleSourceRecovery(page, request, roots) {
|
||||
await expect(await request.post('/prompt_manager/settings', { data: { gallery_root_paths: roots } })).toBeOK();
|
||||
const selected = initial.images.at(-1), key = `${selected.root_index}:${selected.path}`;
|
||||
const stale = page.waitForResponse(response => response.url().includes('/images/inspect/') && response.status() === 409);
|
||||
await page.locator(`article[data-image-key="${key}"] .image-card-actions button`).click();expect((await stale).status()).toBe(409);
|
||||
await page.locator(`article[data-image-key="${key}"] .image-card-actions button:first-child`).click();expect((await stale).status()).toBe(409);
|
||||
await expect(details(page)).toContainText('The source changed. Refresh the image collection before inspecting this file.');
|
||||
await page.locator(`article[data-image-key="${key}"] .image-open`).click();
|
||||
const viewer = page.locator('dialog#viewer-dialog');await expect(viewer).toContainText('The source changed.');
|
||||
@@ -1028,8 +1028,7 @@ async function cardInventory(page, concept) {
|
||||
const walk = new Function(`return (${walkSource})()`)();
|
||||
return nodes.map(card => {
|
||||
const copy = card.cloneNode(true);
|
||||
// D9: no tag button until image annotations. The D8 checkbox and the D7 star are compared.
|
||||
if (concept) for (const node of copy.querySelectorAll('[aria-label^="Tag or auto-tag "]')) node.remove();
|
||||
// Info then Tag (#249 C2 retired D9). The D8 checkbox and the D7 star are compared.
|
||||
const open = card.querySelector('.image-open'), img = open.querySelector('img');
|
||||
const star = card.querySelector('.image-overlays > button.star-button');
|
||||
const box = card.querySelector('.image-overlays > label > input');
|
||||
@@ -1039,7 +1038,7 @@ async function cardInventory(page, concept) {
|
||||
size: [img.getAttribute('width'), img.getAttribute('height')],
|
||||
title: card.querySelector('.media-info h3').textContent, subtitle: card.querySelector('.media-info p').textContent,
|
||||
ratio: card.style.getPropertyValue('--media-ratio').replace(/\s/g, ''),
|
||||
inspect: card.querySelector('.image-card-actions > button').getAttribute('aria-label'),
|
||||
actions: [...card.querySelectorAll('.image-card-actions > button')].map(button => button.getAttribute('aria-label')),
|
||||
star: star && { text: star.textContent, pressed: star.getAttribute('aria-pressed'), label: star.getAttribute('aria-label') },
|
||||
select: box && { type: box.type, checked: box.checked, label: box.getAttribute('aria-label') },
|
||||
paths: walk(copy, `article.${[...card.classList].filter(value => value !== 'selected').sort().join('.')}`, []) };
|
||||
@@ -1209,7 +1208,7 @@ async function zoomedGallery(page, request, { cssZoom }) {
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(page.locator(`article[aria-posinset="${columns + 1}"] .image-overlays .star-button`)).toBeFocused();
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(page.locator(`article[aria-posinset="${columns + 1}"] .image-card-actions button`)).toBeFocused();
|
||||
await expect(page.locator(`article[aria-posinset="${columns + 1}"] .image-card-actions button:first-child`)).toBeFocused();
|
||||
await page.evaluate(() => {
|
||||
const card = document.querySelector('article[aria-posinset="1"]');
|
||||
const grid = document.querySelector('.image-grid');
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
// The Tag image dialog, the card Tag button and the Tags page hooks (#249 PR C, plan Task C2, Mock inventory M1, M2, M5)
|
||||
// against the real routes and the injected fake model (tests/browser/workspace_tagging_fixture.py). No model is ever
|
||||
// downloaded or loaded: the fake answers, and every test checks that no download request was sent.
|
||||
import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs';
|
||||
import { test, expect } from './fixtures.mjs';
|
||||
import { seedConcept, openConcept, openProduction } from './concept-inventory.mjs';
|
||||
|
||||
const images = '/prompt_manager/workspace#/images';
|
||||
const MODULE = '/prompt_manager/js/workspace/image-tagging.mjs';
|
||||
const MODEL_LABELS = ['WD14 SwinV2 · ~400 MB', 'WD14 ViT · ~400 MB', 'GGUF JoyCaption (Recommended) · ~4.5 GB', 'HuggingFace JoyCaption · ~16 GB'];
|
||||
const INITIAL_COPY = 'Choose a model, then generate tags. Remove anything you do not want to add.';
|
||||
const MODEL_CHANGED = 'Generate suggestions with the selected model before approving.';
|
||||
const ALL_REMOVED = 'All suggestions removed. Generate again or enter tags manually.';
|
||||
const card = (page, path) => page.locator(`article[data-image-key="0:${path}"]`);
|
||||
const tagButton = (page, path) => card(page, path).getByRole('button', { name: `Tag or auto-tag ${path.split('/').pop()}`, exact: true });
|
||||
const dialog = page => page.getByRole('dialog', { name: 'Tag image', exact: true });
|
||||
const manual = page => dialog(page).getByRole('textbox', { name: 'Image tags · edit manually', exact: true });
|
||||
const model = page => dialog(page).getByRole('combobox', { name: 'Tagging model', exact: true });
|
||||
const generate = page => dialog(page).getByRole('button', { name: 'Generate suggestions', exact: true });
|
||||
const chips = page => dialog(page).locator('#tag-suggestions .chip.tag-suggestion');
|
||||
const status = page => dialog(page).locator('#tag-generation-status');
|
||||
const toast = page => page.locator('#workspace-toast');
|
||||
const nav = (page, label) => page.locator('aside.sidebar nav').getByRole('link', { name: new RegExp(`^${label}(?:\\s*[\\d,]+)?$`) });
|
||||
const FIRST = 'tagging/dunes_00001.png', SECOND = 'tagging/dunes_00002.png', COPY = 'copies/dunes_00001.png';
|
||||
const VIDEO = 'tagging/clip.webm';
|
||||
|
||||
let manifest;
|
||||
test.beforeEach(async ({ page, request, browserName }) => {
|
||||
await startCoverage(page, browserName);
|
||||
const response = await request.post('/__test__/tagging/reset', { data: {} });
|
||||
await expect(response).toBeOK();
|
||||
manifest = await response.json();
|
||||
page.taggingErrors = [];
|
||||
page.on('pageerror', error => page.taggingErrors.push(error.message));
|
||||
page.downloads = [];
|
||||
page.on('request', item => { if (/\/autotag\/download\//.test(item.url())) page.downloads.push(item.url()); });
|
||||
});
|
||||
test.afterEach(async ({ page, browserName }) => {
|
||||
await saveCoverage(page, browserName);
|
||||
expect(page.taggingErrors).toEqual([]);
|
||||
expect(page.downloads).toEqual([]);
|
||||
});
|
||||
|
||||
/** Images, then the Tag dialog of `path`, settled on its observation (the manual field holds the saved tags). */
|
||||
async function openTag(page, path = FIRST, { goto = true } = {}) {
|
||||
if (goto) await page.goto(images);
|
||||
const observed = page.waitForResponse(item => item.url().endsWith('/image-annotations/observe'));
|
||||
await tagButton(page, path).click();
|
||||
await expect(dialog(page)).toBeVisible();
|
||||
expect((await observed).status()).toBe(200);
|
||||
await expect(dialog(page).locator('img.tag-image-preview, video.tag-image-preview')).toHaveAttribute('src', /\/prompt_manager\/image-annotations\/capture\//);
|
||||
}
|
||||
async function generated(page) {
|
||||
const answer = page.waitForResponse(item => item.url().endsWith('/image-annotations/suggest'));
|
||||
await generate(page).click();
|
||||
return answer;
|
||||
}
|
||||
/** Read the saved tags of `path` through the real observe route, then release that capture. */
|
||||
async function savedTags(request, path) {
|
||||
const target = { root_index: 0, root_scope: await scopeOf(request), path };
|
||||
const observed = await (await request.post('/prompt_manager/image-annotations/observe', { data: { target } })).json();
|
||||
expect(observed.success).toBe(true);
|
||||
await request.delete(`/prompt_manager/image-annotations/capture/${observed.capture.token}`);
|
||||
return observed.tags;
|
||||
}
|
||||
async function scopeOf(request) {
|
||||
const listing = await (await request.get('/prompt_manager/images/output?mode=workspace&limit=40')).json();
|
||||
return listing.images[0].root_scope;
|
||||
}
|
||||
|
||||
test('tag dialog control inventory and order match M2', async ({ page }) => {
|
||||
await openTag(page);
|
||||
await expect(page.locator('dialog#modal')).toHaveClass(/(^| )tagging-modal( |$)/);
|
||||
await expect(dialog(page).locator('#modal-title')).toHaveText('Tag image');
|
||||
const body = dialog(page).locator('#modal-body');
|
||||
expect(await body.evaluate(node => [...node.children].map(child => child.className))).toEqual(
|
||||
['tag-image-heading', 'field', 'tag-model-panel', 'tag-suggestion-panel', 'dialog-actions']);
|
||||
const heading = body.locator('.tag-image-heading');
|
||||
await expect(heading.locator('h3')).toHaveText(manifest.title);
|
||||
await expect(heading.locator('p.small')).toHaveText('dunes_00001.png');
|
||||
await expect(heading.locator('span.chip')).toHaveText('96 × 64');
|
||||
await expect(heading.locator('img.tag-image-preview')).toHaveAttribute('alt', manifest.title);
|
||||
await expect(body.locator('.tag-scope-note')).toHaveCount(0);
|
||||
await expect(manual(page)).toHaveAttribute('id', 'manual-image-tags');
|
||||
await expect(manual(page)).toHaveValue('');
|
||||
await expect(manual(page)).toHaveAccessibleDescription('Comma-separated. Generated suggestions are added only after you approve them.');
|
||||
const panel = body.locator('.tag-model-panel');
|
||||
await expect(panel.locator('h3')).toHaveText('Auto-tag this image');
|
||||
await expect(model(page).locator('option')).toHaveText(MODEL_LABELS);
|
||||
expect(await model(page).locator('option').evaluateAll(nodes => nodes.map(node => node.value))).toEqual(['wd14-swinv2', 'wd14-vit', 'gguf', 'hf']);
|
||||
await expect(model(page)).toHaveValue('wd14-swinv2');
|
||||
const options = panel.locator('#image-tag-model-options');
|
||||
const general = options.getByRole('spinbutton', { name: 'General confidence', exact: true });
|
||||
const character = options.getByRole('spinbutton', { name: 'Character confidence', exact: true });
|
||||
const limit = options.getByRole('spinbutton', { name: 'Suggestions', exact: true });
|
||||
await expect(options.locator('.two-col .field')).toHaveCount(2);
|
||||
await expect(general).toHaveValue('0.35');
|
||||
await expect(character).toHaveValue('0.85');
|
||||
await expect(limit).toHaveValue('10');
|
||||
for (const [input, attributes] of [[general, ['0', '1', '0.05']], [character, ['0', '1', '0.05']], [limit, ['1', '100', '1']]]) {
|
||||
expect(await input.evaluate(node => [node.min, node.max, node.step])).toEqual(attributes);
|
||||
}
|
||||
await expect(generate(page)).toHaveClass(/ca-primary/);
|
||||
await expect(generate(page).locator('svg.icon')).toHaveCount(1);
|
||||
const suggestions = body.locator('section.tag-suggestion-panel');
|
||||
await expect(suggestions).toHaveAttribute('aria-labelledby', 'tag-suggestions-title');
|
||||
await expect(suggestions.locator('h3#tag-suggestions-title')).toHaveText('Review suggestions');
|
||||
await expect(suggestions.locator('#suggestion-count')).toHaveText('Not generated');
|
||||
await expect(suggestions.locator('#tag-suggestions > p.small')).toHaveText(INITIAL_COPY);
|
||||
await expect(status(page)).toHaveAttribute('role', 'status');
|
||||
await expect(status(page)).toHaveText('');
|
||||
await expect(body.locator('.dialog-actions button')).toHaveText(['Cancel', 'Save manual tags']);
|
||||
await expect(body.locator('.dialog-actions button').last()).toHaveClass(/ca-primary/);
|
||||
await model(page).selectOption('gguf');
|
||||
await expect(options.getByRole('textbox', { name: 'Tagging instructions', exact: true })).toHaveValue('Describe the image');
|
||||
await expect(options.getByRole('spinbutton', { name: 'Suggestions', exact: true })).toHaveValue('10');
|
||||
await expect(options.locator('.two-col')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('generate shows real fixture percentages for WD14 and Suggested for GGUF, at most ten chips', async ({ page }) => {
|
||||
await openTag(page);
|
||||
const answer = await generated(page);
|
||||
expect(answer.request().postDataJSON()).toMatchObject({ model_type: 'wd14-swinv2', options: { general_threshold: 0.35, character_threshold: 0.85, suggestion_limit: 10 } });
|
||||
await expect(chips(page)).toHaveCount(10);
|
||||
await expect(chips(page).first().locator(':scope > span').first()).toHaveText('sandstone');
|
||||
await expect(chips(page).locator('.tag-confidence').first()).toHaveText('98%');
|
||||
await expect(chips(page).nth(1).locator('.tag-confidence')).toHaveText('97%');
|
||||
await expect(chips(page).first().getByRole('button', { name: 'Remove sandstone', exact: true })).toBeVisible();
|
||||
await expect(dialog(page).locator('#suggestion-count')).toHaveText('10 suggestions');
|
||||
await expect(status(page)).toHaveText('Suggestions from WD14 SwinV2 · ~400 MB. Approve tags to save.');
|
||||
await expect(dialog(page).locator('.dialog-actions .ca-primary')).toHaveText('Approve selected tags');
|
||||
for (const id of ['gguf', 'hf']) {
|
||||
await model(page).selectOption(id);
|
||||
await expect(chips(page)).toHaveCount(0);
|
||||
await generated(page);
|
||||
await expect(chips(page)).toHaveCount(10);
|
||||
await expect(chips(page).locator('.tag-confidence')).toHaveText(Array(10).fill('Suggested'));
|
||||
await expect(status(page)).toHaveText(`Suggestions from ${MODEL_LABELS[id === 'gguf' ? 2 : 3]}. Approve tags to save.`);
|
||||
}
|
||||
await dialog(page).getByRole('spinbutton', { name: 'Suggestions', exact: true }).fill('3');
|
||||
const fewer = await generated(page);
|
||||
expect(fewer.request().postDataJSON().options).toEqual({ instructions: 'Describe the image', suggestion_limit: 3 });
|
||||
await expect(chips(page)).toHaveCount(3);
|
||||
await expect(dialog(page).locator('#suggestion-count')).toHaveText('3 suggestions');
|
||||
});
|
||||
|
||||
test('removing chips and editing manually then approving persists across reload', async ({ page, request }) => {
|
||||
await openTag(page);
|
||||
await manual(page).fill('Minimal, sky_blue');
|
||||
await dialog(page).getByRole('spinbutton', { name: 'Suggestions', exact: true }).fill('3');
|
||||
await generated(page);
|
||||
await expect(chips(page)).toHaveCount(3);
|
||||
const remove = dialog(page).getByRole('button', { name: 'Remove desert', exact: true });
|
||||
await remove.click();
|
||||
await expect(chips(page)).toHaveCount(2);
|
||||
// Focus moves to the next chip's ×.
|
||||
await expect(dialog(page).getByRole('button', { name: 'Remove arch', exact: true })).toBeFocused();
|
||||
const saved = page.waitForResponse(item => item.url().endsWith('/image-annotations/tags') && item.request().method() === 'PUT');
|
||||
await dialog(page).getByRole('button', { name: 'Approve selected tags', exact: true }).click();
|
||||
expect((await saved).request().postDataJSON().tags).toEqual(['Minimal', 'sky_blue', 'sandstone', 'arch']);
|
||||
await expect(dialog(page)).toHaveCount(0);
|
||||
await expect(toast(page)).toHaveText('4 tags saved for dunes_00001.png.');
|
||||
await expect(tagButton(page, FIRST)).toBeFocused();
|
||||
expect(await savedTags(request, FIRST)).toEqual(['Minimal', 'sky_blue', 'sandstone', 'arch']);
|
||||
await page.reload();
|
||||
await openTag(page, FIRST, { goto: false });
|
||||
await expect(manual(page)).toHaveValue('Minimal, sky_blue, sandstone, arch');
|
||||
// Removing every chip shows the all-removed copy; the manual set still saves.
|
||||
await dialog(page).getByRole('spinbutton', { name: 'Suggestions', exact: true }).fill('2');
|
||||
await generated(page);
|
||||
for (const name of ['sandstone', 'desert']) await dialog(page).getByRole('button', { name: `Remove ${name}`, exact: true }).click();
|
||||
await expect(dialog(page).locator('#tag-suggestions > p.small')).toHaveText(ALL_REMOVED);
|
||||
await expect(generate(page)).toBeFocused();
|
||||
await dialog(page).getByRole('button', { name: 'Approve selected tags', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('4 tags saved for dunes_00001.png.');
|
||||
});
|
||||
|
||||
test('cancel escape and close write nothing', async ({ page, request }) => {
|
||||
const writes = [];
|
||||
page.on('request', item => { if (item.url().endsWith('/image-annotations/tags')) writes.push(item.method()); });
|
||||
await page.goto(images);
|
||||
for (const dismiss of [
|
||||
() => dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click(),
|
||||
() => page.keyboard.press('Escape'),
|
||||
() => dialog(page).getByRole('button', { name: 'Close dialog', exact: true }).click(),
|
||||
]) {
|
||||
const released = page.waitForResponse(item => item.url().includes('/image-annotations/capture/') && item.request().method() === 'DELETE');
|
||||
await openTag(page, FIRST, { goto: false });
|
||||
await manual(page).fill('never_saved');
|
||||
await dismiss();
|
||||
await expect(dialog(page)).toHaveCount(0);
|
||||
expect((await released).ok()).toBe(true);
|
||||
await expect(tagButton(page, FIRST)).toBeFocused();
|
||||
await expect(page.locator('dialog#modal.tagging-modal')).toHaveCount(0);
|
||||
}
|
||||
expect(writes).toEqual([]);
|
||||
expect(await savedTags(request, FIRST)).toEqual([]);
|
||||
});
|
||||
|
||||
test('model change discards suggestions and relabels primary', async ({ page }) => {
|
||||
await openTag(page);
|
||||
await manual(page).fill('kept');
|
||||
await generated(page);
|
||||
await expect(dialog(page).locator('.dialog-actions .ca-primary')).toHaveText('Approve selected tags');
|
||||
await dialog(page).getByRole('spinbutton', { name: 'General confidence', exact: true }).fill('0.5');
|
||||
await model(page).selectOption('wd14-vit');
|
||||
await expect(chips(page)).toHaveCount(0);
|
||||
await expect(dialog(page).locator('#tag-suggestions > p.small')).toHaveText(MODEL_CHANGED);
|
||||
await expect(dialog(page).locator('#suggestion-count')).toHaveText('Not generated');
|
||||
await expect(status(page)).toHaveText('');
|
||||
await expect(dialog(page).locator('.dialog-actions .ca-primary')).toHaveText('Save manual tags');
|
||||
await expect(manual(page)).toHaveValue('kept');
|
||||
// Like the concept, the new model's fields start from the defaults.
|
||||
await expect(dialog(page).getByRole('spinbutton', { name: 'General confidence', exact: true })).toHaveValue('0.35');
|
||||
});
|
||||
|
||||
test('invalid threshold reports validity without request', async ({ page }) => {
|
||||
const sent = [];
|
||||
page.on('request', item => { if (item.url().endsWith('/image-annotations/suggest')) sent.push(item.url()); });
|
||||
await openTag(page);
|
||||
const general = dialog(page).getByRole('spinbutton', { name: 'General confidence', exact: true });
|
||||
await general.fill('1.5');
|
||||
await generate(page).click();
|
||||
await expect(general).toBeFocused();
|
||||
expect(await general.evaluate(node => node.validity.rangeOverflow)).toBe(true);
|
||||
await general.fill('');
|
||||
await generate(page).click();
|
||||
await expect.poll(() => general.evaluate(node => node.validationMessage)).toBe('Enter a confidence from 0 to 1.');
|
||||
await general.fill('0.35');
|
||||
expect(await general.evaluate(node => node.validity.valid)).toBe(true);
|
||||
await model(page).selectOption('gguf');
|
||||
const instructions = dialog(page).getByRole('textbox', { name: 'Tagging instructions', exact: true });
|
||||
await instructions.fill(' ');
|
||||
await generate(page).click();
|
||||
await expect.poll(() => instructions.evaluate(node => node.validationMessage)).toBe('Enter tagging instructions of at most 4000 characters.');
|
||||
await expect(status(page)).toHaveText('');
|
||||
expect(sent).toEqual([]);
|
||||
});
|
||||
|
||||
test('unavailable model shows inline status and never downloads', async ({ page, request }) => {
|
||||
await expect(await request.post('/__test__/tagging/models', { data: { unavailable: ['wd14-swinv2'] } })).toBeOK();
|
||||
await openTag(page);
|
||||
expect((await generated(page)).status()).toBe(409);
|
||||
await expect(status(page)).toHaveText('WD14 SwinV2 is not downloaded. Download it from the legacy Auto-tag page.');
|
||||
await expect(chips(page)).toHaveCount(0);
|
||||
await expect(dialog(page).locator('#suggestion-count')).toHaveText('Not generated');
|
||||
// Retry (ruling T5) leads the actions while the controller allows it, and generates again.
|
||||
await expect(dialog(page).locator('.dialog-actions button')).toHaveText(['Retry', 'Cancel', 'Save manual tags']);
|
||||
await expect(await request.post('/__test__/tagging/models', { data: { unavailable: [] } })).toBeOK();
|
||||
const again = page.waitForResponse(item => item.url().endsWith('/image-annotations/suggest'));
|
||||
await dialog(page).getByRole('button', { name: 'Retry', exact: true }).click();
|
||||
expect((await again).status()).toBe(200);
|
||||
await expect(chips(page)).toHaveCount(10);
|
||||
await expect(dialog(page).locator('.dialog-actions button')).toHaveText(['Cancel', 'Approve selected tags']);
|
||||
await expect(dialog(page).locator('.dialog-actions .ca-primary')).toBeFocused();
|
||||
});
|
||||
|
||||
test('video tag is manual only', async ({ page, request }) => {
|
||||
await openTag(page, VIDEO);
|
||||
const preview = dialog(page).locator('video.tag-image-preview');
|
||||
await expect(preview).toHaveAttribute('preload', 'metadata');
|
||||
expect(await preview.evaluate(node => node.muted)).toBe(true);
|
||||
await expect(dialog(page).locator('.tag-image-heading span.chip')).toHaveCount(0);
|
||||
await expect(generate(page)).toHaveAttribute('aria-disabled', 'true');
|
||||
await expect(status(page)).toHaveText('Auto-tagging is available for supported still images only.');
|
||||
let suggested = false;
|
||||
page.on('request', item => { if (item.url().endsWith('/image-annotations/suggest')) suggested = true; });
|
||||
// Pressing the aria-disabled Generate sends nothing.
|
||||
await generate(page).focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await manual(page).fill('clip_tag');
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('1 tags saved for clip.webm.');
|
||||
expect(suggested).toBe(false);
|
||||
expect(await savedTags(request, VIDEO)).toEqual(['clip_tag']);
|
||||
});
|
||||
|
||||
test('sticky footer reachable at 390x844 while content scrolls', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openTag(page);
|
||||
await model(page).selectOption('gguf');
|
||||
await generated(page);
|
||||
await expect(chips(page)).toHaveCount(10);
|
||||
const shell = page.locator('dialog#modal');
|
||||
const actions = dialog(page).locator('.dialog-actions');
|
||||
expect(await actions.evaluate(node => getComputedStyle(node).position)).toBe('sticky');
|
||||
expect(await shell.evaluate(node => node.scrollHeight > node.clientHeight)).toBe(true);
|
||||
for (const top of [0, 10_000]) {
|
||||
await shell.evaluate((node, value) => { node.scrollTop = value; }, top);
|
||||
const box = await actions.boundingBox(), frame = await shell.boundingBox();
|
||||
expect(box.y + box.height).toBeLessThanOrEqual(frame.y + frame.height + 1);
|
||||
expect(box.y).toBeGreaterThanOrEqual(frame.y);
|
||||
await expect(dialog(page).getByRole('button', { name: 'Approve selected tags', exact: true })).toBeInViewport();
|
||||
}
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
|
||||
});
|
||||
|
||||
test('focus starts on close and returns to the Tag trigger', async ({ page }) => {
|
||||
await openTag(page, SECOND);
|
||||
await expect(dialog(page).getByRole('button', { name: 'Close dialog', exact: true })).toBeFocused();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(dialog(page)).toHaveCount(0);
|
||||
await expect(tagButton(page, SECOND)).toBeFocused();
|
||||
});
|
||||
|
||||
test('every Images and Library gallery card has Info then Tag with mock labels', async ({ page }) => {
|
||||
const expectCards = async locator => {
|
||||
const labels = await locator.evaluateAll(nodes => nodes.map(node => {
|
||||
const buttons = [...node.querySelectorAll('.media-info > .image-card-actions > button')];
|
||||
return { file: node.querySelector('.image-open').getAttribute('aria-label').replace(/^Open /, ''),
|
||||
buttons: buttons.map(button => [button.className, button.getAttribute('aria-label'), button.querySelector('svg.icon') !== null]) };
|
||||
}));
|
||||
expect(labels.length).toBeGreaterThan(0);
|
||||
for (const { file, buttons } of labels) {
|
||||
expect(buttons).toEqual([['icon-btn', `Inspect ${file}`, true], ['icon-btn', `Tag or auto-tag ${file}`, true]]);
|
||||
}
|
||||
return labels.map(({ file }) => file);
|
||||
};
|
||||
await page.goto(images);
|
||||
await expect(page.locator('article[data-image-key]')).toHaveCount(manifest.files.length);
|
||||
expect(await expectCards(page.locator('article[data-image-key]'))).toEqual(manifest.files.map(file => file.filename));
|
||||
await page.goto(`/prompt_manager/workspace#/library/${manifest.positive_id}`);
|
||||
const gallery = page.locator('.library-history .history-gallery article[data-image-key]');
|
||||
await expect(gallery.first()).toBeVisible();
|
||||
await expectCards(gallery);
|
||||
const observed = page.waitForResponse(item => item.url().endsWith('/image-annotations/observe'));
|
||||
await gallery.first().getByRole('button', { name: /^Tag or auto-tag / }).click();
|
||||
await expect(dialog(page)).toBeVisible();
|
||||
expect((await observed).status()).toBe(200);
|
||||
await dialog(page).getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(gallery.first().getByRole('button', { name: /^Tag or auto-tag / })).toBeFocused();
|
||||
});
|
||||
|
||||
test('Tag opens the dialog without selecting the card', async ({ page }) => {
|
||||
const panel = page.getByRole('complementary', { name: 'Selected image details', exact: true });
|
||||
await page.goto(images);
|
||||
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
|
||||
const before = await panel.innerText();
|
||||
await openTag(page, SECOND, { goto: false });
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(card(page, SECOND)).not.toHaveClass(/\bselected\b/);
|
||||
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
|
||||
expect(await panel.innerText()).toBe(before);
|
||||
});
|
||||
|
||||
test('tagging one of two cards sharing a prompt leaves the other card, the prompt and the pair unchanged', async ({ page, request }) => {
|
||||
const before = await (await request.get('/__test__/tagging/snapshot')).json();
|
||||
expect(await savedTags(request, SECOND)).toEqual([]);
|
||||
expect(await savedTags(request, COPY)).toEqual([]);
|
||||
const pairBefore = await (await request.get(`/prompt_manager/library/${manifest.positive_id}`)).json();
|
||||
await openTag(page, FIRST);
|
||||
await manual(page).fill('only_this_file');
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
await expect(toast(page)).toHaveText('1 tags saved for dunes_00001.png.');
|
||||
expect(await savedTags(request, FIRST)).toEqual(['only_this_file']);
|
||||
expect(await savedTags(request, SECOND)).toEqual([]);
|
||||
expect(await savedTags(request, COPY)).toEqual([]);
|
||||
expect(await (await request.get('/__test__/tagging/snapshot')).json()).toEqual(before);
|
||||
const pairAfter = await (await request.get(`/prompt_manager/library/${manifest.positive_id}`)).json();
|
||||
expect(pairAfter.success).toBe(true);
|
||||
expect({ ...pairAfter, library_epoch: null }).toEqual({ ...pairBefore, library_epoch: null });
|
||||
});
|
||||
|
||||
test('Tags page Auto-tag images opens the dialog for the selected image and is disabled without one', async ({ page }) => {
|
||||
await page.goto('/prompt_manager/workspace#/tags');
|
||||
const autoTag = page.locator('#workspace-main .heading-actions').getByRole('button', { name: 'Auto-tag images', exact: true });
|
||||
await expect(page.locator('#workspace-main .heading-actions button')).toHaveText(['+ Create tag', 'Auto-tag images']);
|
||||
await expect(autoTag).toHaveClass(/ca-primary/);
|
||||
await expect(autoTag).toHaveAttribute('aria-disabled', 'true');
|
||||
// An aria-disabled control still takes a press; it only explains.
|
||||
await autoTag.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(toast(page)).toHaveText('Select an image in Images first.');
|
||||
await expect(dialog(page)).toHaveCount(0);
|
||||
await nav(page, 'Images').click();
|
||||
await card(page, SECOND).getByRole('button', { name: 'Inspect dunes_00002.png', exact: true }).click();
|
||||
await expect(card(page, SECOND)).toHaveClass(/selected/);
|
||||
await nav(page, 'Tags & categories').click();
|
||||
await expect(autoTag).not.toHaveAttribute('aria-disabled', 'true');
|
||||
const observed = page.waitForResponse(item => item.url().endsWith('/image-annotations/observe'));
|
||||
await autoTag.click();
|
||||
expect((await observed).request().postDataJSON().target.path).toBe(SECOND);
|
||||
await expect(dialog(page).locator('.tag-image-heading p.small')).toHaveText('dunes_00002.png');
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(autoTag).toBeFocused();
|
||||
});
|
||||
|
||||
test('Images column shows image_count and updates after save', async ({ page }) => {
|
||||
await page.goto(images);
|
||||
await card(page, FIRST).getByRole('button', { name: 'Inspect dunes_00001.png', exact: true }).click();
|
||||
await nav(page, 'Tags & categories').click();
|
||||
const row = name => page.locator('#workspace-main table tbody tr').filter({ has: page.locator('td:first-child').getByText(name, { exact: true }) });
|
||||
await expect(row('minimal').locator('td:nth-child(3)')).toHaveText('0');
|
||||
await expect(row('editorial').locator('td:nth-child(3)')).toHaveText('0');
|
||||
await page.locator('#workspace-main .heading-actions').getByRole('button', { name: 'Auto-tag images', exact: true }).click();
|
||||
await expect(manual(page)).toBeVisible();
|
||||
await expect(dialog(page).locator('img.tag-image-preview')).toHaveAttribute('src', /capture/);
|
||||
await manual(page).fill('minimal, editorial');
|
||||
const reread = page.waitForResponse(item => item.url().includes('/prompt_manager/vocabulary/tags'));
|
||||
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
|
||||
await reread;
|
||||
await expect(row('minimal').locator('td:nth-child(3)')).toHaveText('1');
|
||||
await expect(row('editorial').locator('td:nth-child(3)')).toHaveText('1');
|
||||
await expect(row('warm_light').locator('td:nth-child(3)')).toHaveText('0');
|
||||
});
|
||||
|
||||
test('virtualized cards release listeners on unmount', async ({ page, request }) => {
|
||||
const extra = 140;
|
||||
await expect(await request.post('/__test__/tagging/reset', { data: { extra_images: extra } })).toBeOK();
|
||||
const count = () => page.evaluate(async url => (await import(url)).imageTagsListenerCount(), MODULE);
|
||||
await page.goto(images);
|
||||
await expect(card(page, FIRST)).toBeVisible();
|
||||
const mounted = await count();
|
||||
// Leaving and returning to Images several times keeps one subscription per mounted page.
|
||||
for (let round = 0; round < 3; round += 1) {
|
||||
await nav(page, 'Tags & categories').click();
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Tags & categories');
|
||||
await nav(page, 'Images').click();
|
||||
await expect(card(page, FIRST)).toBeVisible();
|
||||
}
|
||||
expect(await count()).toBe(mounted);
|
||||
// Cards scrolled out are unmounted; the delegated Tag still works on a card rendered again.
|
||||
const continuation = page.locator('#gallery-continuation');
|
||||
await expect(async () => {
|
||||
await continuation.scrollIntoViewIfNeeded();
|
||||
await expect(continuation).toContainText('You’re all caught up', { timeout: 1000 });
|
||||
}).toPass();
|
||||
await continuation.scrollIntoViewIfNeeded();
|
||||
await expect(card(page, FIRST)).toHaveCount(0);
|
||||
expect(await count()).toBe(mounted);
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await expect(card(page, FIRST)).toBeVisible();
|
||||
await openTag(page, FIRST, { goto: false });
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(tagButton(page, FIRST)).toBeFocused();
|
||||
expect(await count()).toBe(mounted);
|
||||
});
|
||||
|
||||
// Paired captures for review (the exact concept comparison is plan Task C4): the concept's dialog and production's,
|
||||
// before and after Generate, on the concept-shaped fixture. Only that both open and generate is asserted here.
|
||||
for (const [width, height] of [[1600, 1100], [390, 844]]) test(`paired Tag dialog captures ${width}x${height}`, async ({ page, request }, testInfo) => {
|
||||
await seedConcept(request);
|
||||
await page.setViewportSize({ width, height });
|
||||
const shot = async name => {
|
||||
await page.evaluate(async () => { await document.fonts.ready; await Promise.all([...document.images].map(image => image.decode().catch(() => {}))); });
|
||||
const path = testInfo.outputPath(`${name}.png`);
|
||||
await page.screenshot({ path });
|
||||
await testInfo.attach(name, { path, contentType: 'image/png' });
|
||||
};
|
||||
for (const side of ['concept', 'production']) {
|
||||
if (side === 'concept') await openConcept(page, 'images', 'dark');
|
||||
else await openProduction(page, 'images', 'dark');
|
||||
const first = page.locator(side === 'concept' ? '#image-grid > article.media-card' : 'article[data-image-key]').first();
|
||||
await first.getByRole('button', { name: /^Tag or auto-tag / }).click();
|
||||
const shell = page.getByRole('dialog', { name: 'Tag image', exact: true });
|
||||
await expect(shell).toBeVisible();
|
||||
if (side === 'production') await expect(shell.locator('img.tag-image-preview')).toHaveAttribute('src', /capture/);
|
||||
await shot(`${side}-tag-dialog-${width}`);
|
||||
await shell.getByRole('button', { name: 'Generate suggestions', exact: true }).click();
|
||||
await expect(shell.locator('#suggestion-count')).not.toHaveText('Not generated');
|
||||
await shot(`${side}-tag-dialog-generated-${width}`);
|
||||
}
|
||||
});
|
||||
@@ -107,7 +107,7 @@ for (const [width, height] of [[1600, 1100], [390, 844]]) for (const theme of ['
|
||||
await expect(roles.getByRole('button', { name: 'Send prompt pair', exact: true })).toHaveCount(0);
|
||||
await expect(page.locator('#workspace-main > .heading button.send')).toBeEnabled();
|
||||
await expect(page.locator('.library-history .history-gallery article[data-image-key]')).toHaveCount(2);
|
||||
await page.locator('.library-history .history-gallery .image-card-actions button').first().click();
|
||||
await page.locator('.library-history .history-gallery .image-card-actions button:first-child').first().click();
|
||||
await expect(page.getByRole('complementary', { name: 'Selected image details', exact: true })).toContainText('canonical-alt.png');
|
||||
await page.locator('.library-history .history-gallery .media-card.selected .image-open').click();
|
||||
await expect(page.locator('dialog#viewer-dialog')).toBeVisible();await page.keyboard.press('Escape');
|
||||
@@ -296,7 +296,7 @@ async function containedImageContent(page) {
|
||||
const failures = [];
|
||||
for (const card of nodes) {
|
||||
const outer = card.getBoundingClientRect();
|
||||
for (const selector of ['.media-info', '.media-info h3', '.media-info p', '.image-card-actions button']) {
|
||||
for (const selector of ['.media-info', '.media-info h3', '.media-info p', '.image-card-actions']) {
|
||||
const node = card.querySelector(selector), box = node.getBoundingClientRect();
|
||||
if (box.width <= 0 || box.height <= 0 || box.left < outer.left - 1 || box.right > outer.right + 1 ||
|
||||
box.top < outer.top - 1 || box.bottom > outer.bottom + 1 ||
|
||||
@@ -306,7 +306,7 @@ async function containedImageContent(page) {
|
||||
content: { left: box.left, right: box.right, top: box.top, bottom: box.bottom } });
|
||||
}
|
||||
}
|
||||
const action = card.querySelector('.image-card-actions button').getBoundingClientRect();
|
||||
const action = card.querySelector('.image-card-actions').getBoundingClientRect();
|
||||
const range = document.createRange(); range.selectNodeContents(card.querySelector('.media-info p'));
|
||||
for (const text of range.getClientRects()) {
|
||||
if (Math.min(text.right, action.right) - Math.max(text.left, action.left) > .5 &&
|
||||
@@ -351,7 +351,7 @@ for (const [width, height] of [[390, 844], [1600, 1100]]) for (const layout of [
|
||||
const filename = (await longCard.locator('.image-open').getAttribute('aria-label')).replace(/^Open /, '');
|
||||
expect(filename).toMatch(/layered-context-.*\.png/);
|
||||
// D19: Info selects into the panel; the image itself opens the viewer.
|
||||
await longCard.locator('.image-card-actions button').focus(); await page.keyboard.press('Enter');
|
||||
await longCard.locator('.image-card-actions button:first-child').focus(); await page.keyboard.press('Enter');
|
||||
await expect(page.locator('.detail-panel .panel-heading h2')).toHaveText(filename);
|
||||
// D19: the card image, not the panel, opens the viewer.
|
||||
const fullPreview = longCard.locator('.image-open');
|
||||
@@ -420,7 +420,7 @@ test('supplemental extreme zoom grows real footers preserves the visible anchor
|
||||
await page.evaluate(() => { document.documentElement.style.zoom = '2'; });
|
||||
await expect.poll(() => cards.nth(1).evaluate(node => node.offsetLeft)).toBe(0);
|
||||
const selected = cards.nth(16), key = await selected.getAttribute('data-image-key');
|
||||
await selected.locator('.image-card-actions button').click(); await selected.locator('.image-open').focus();
|
||||
await selected.locator('.image-card-actions button:first-child').click(); await selected.locator('.image-open').focus();
|
||||
const filename = (await selected.locator('.image-open').getAttribute('aria-label')).replace(/^Open /, '');
|
||||
await selected.evaluate(node => scrollBy(0, node.getBoundingClientRect().top - 120));
|
||||
await page.evaluate(() => {
|
||||
@@ -497,7 +497,7 @@ test('supplemental wrapped real footer reservation survives append without movin
|
||||
await page.setViewportSize({ width: 390, height: 844 }); await page.goto(`${workspace}#/images`);
|
||||
const cards = page.locator('article[data-image-key]'), grid = page.locator('.image-grid');
|
||||
await expect(cards).toHaveCount(40);
|
||||
await cards.first().locator('.image-card-actions button').click();
|
||||
await cards.first().locator('.image-card-actions button:first-child').click();
|
||||
const selectedKey = await cards.first().getAttribute('data-image-key');
|
||||
await page.evaluate(() => { document.documentElement.style.zoom = '4'; });
|
||||
await expect.poll(() => grid.evaluate(node => Number.parseFloat(node.style.getPropertyValue('--image-footer-height')))).toBeGreaterThan(54);
|
||||
@@ -609,7 +609,7 @@ test('Images visual layouts retain real delayed media long filenames and loaded
|
||||
await page.setViewportSize({width,height});
|
||||
for (const theme of ['dark','light']) {
|
||||
await page.getByRole('combobox',{name:'Theme',exact:true}).selectOption(theme);
|
||||
await page.locator('.media-card .image-card-actions button').first().click();
|
||||
await page.locator('.media-card .image-card-actions button:first-child').first().click();
|
||||
await expect(page.locator('.detail-panel .panel-heading h2')).toHaveText('study-01.png');
|
||||
for (const layout of ['Grid','List','Masonry']) {
|
||||
await page.getByRole('button',{name:layout,exact:true}).click();await page.evaluate(()=>scrollTo(0,0));
|
||||
@@ -621,7 +621,7 @@ test('Images visual layouts retain real delayed media long filenames and loaded
|
||||
const longCard=page.locator('article[data-image-key]').filter({has:page.locator('[aria-label^="Open a-very-long-geometric-composition-"]')});
|
||||
const longName=(await longCard.locator('.image-open').getAttribute('aria-label')).replace(/^Open /,'');
|
||||
expect(longName).toMatch(/layered-context-.*\.png/);
|
||||
await longCard.locator('.image-card-actions button').click();
|
||||
await longCard.locator('.image-card-actions button:first-child').click();
|
||||
await expect(page.locator('.detail-panel .panel-heading h2')).toHaveText(longName);
|
||||
await orderedMedia(page);
|
||||
await attachEvidence(page,testInfo,`after-images-loaded-scroll-${width}-${theme}`,false);
|
||||
|
||||
@@ -50,9 +50,12 @@ test('tags route renders the concept composition without a legacy handoff',async
|
||||
await expect(main(page).getByRole('heading',{level:1})).toHaveText('Tags & categories');
|
||||
await expect(main(page).locator(':scope > .heading p.muted')).toHaveText('Organize once. Reuse everywhere.');
|
||||
const actions=main(page).locator(':scope > .heading > .heading-actions button');
|
||||
await expect(actions).toHaveText(['+ Create tag']);
|
||||
// The concept's heading actions (#249 C2 retired #245 D2): Auto-tag images is primary; with no image selected in
|
||||
// Images it is aria-disabled (D115).
|
||||
await expect(actions).toHaveText(['+ Create tag','Auto-tag images']);
|
||||
await expect(actions.first()).not.toHaveClass(/ca-primary/);
|
||||
await expect(main(page).getByRole('button',{name:'Auto-tag images'})).toHaveCount(0);
|
||||
await expect(actions.last()).toHaveClass(/ca-primary/);
|
||||
await expect(actions.last()).toHaveAttribute('aria-disabled','true');
|
||||
await expect(surfaces(page)).toHaveCount(2);
|
||||
await expect(surfaces(page).locator('h2')).toHaveText(['Tag vocabulary','Categories']);
|
||||
await expect(surfaces(page).locator(':scope > p:visible')).toHaveText(['Category/tag autocomplete uses this same vocabulary across nodes and dialogs.',
|
||||
@@ -65,9 +68,9 @@ test('tags route renders the concept composition without a legacy handoff',async
|
||||
expect(manifest.tags).toEqual(expected.map(([name])=>name));
|
||||
await expect(rows(page).locator('td:nth-child(1)')).toHaveText(expected.map(([name])=>name));
|
||||
await expect(rows(page).locator('td:nth-child(2)')).toHaveText(expected.map(([,count])=>String(count)));
|
||||
const images=await rows(page).locator('td:nth-child(3)').evaluateAll(cells=>cells.map(cell=>[cell.querySelector('[aria-hidden="true"]')?.textContent,cell.querySelector('.ca-visually-hidden')?.textContent]));
|
||||
expect(images).toEqual(expected.map(()=>['—','Not available yet']));
|
||||
await expect(rows(page).first().locator('td:nth-child(3)')).toHaveAccessibleName('Not available yet');
|
||||
// Images counts the files carrying each tag (#249 C2 retired #245 D3); this fixture tags no image.
|
||||
await expect(rows(page).locator('td:nth-child(3)')).toHaveText(expected.map(()=>'0'));
|
||||
await expect(rows(page).locator('td:nth-child(3) .ca-visually-hidden')).toHaveCount(0);
|
||||
const labels=await rows(page).evaluateAll(nodes=>nodes.map(row=>[...row.querySelectorAll('td:nth-child(4) .row-actions button')].map(button=>button.textContent)));
|
||||
expect(labels).toEqual(expected.map(()=>['Browse','Rename / merge']));
|
||||
const operations=surfaces(page).nth(1).locator('.operation');
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Owned, deterministic files for the Tag image dialog workflows (#249 PR C).
|
||||
|
||||
Two files share one positive prompt and one saved pair, an equal-byte copy of
|
||||
the first sits at another path, and a video and an unlinked file follow. The
|
||||
injected fake model (``tests/browser/server.py`` ``TestModel``) answers
|
||||
Generate; no model file exists and nothing is ever downloaded.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from aiohttp import web
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from py.api import PromptManagerAPI
|
||||
|
||||
POSITIVE = (
|
||||
"Monolithic sandstone pavilion in a vast desert, sweeping dunes, warm "
|
||||
"terracotta, late afternoon sun, architectural photography, quiet geometry"
|
||||
)
|
||||
NEGATIVE = "plastic, harsh shadows, noise, watermark"
|
||||
TITLE = "Quiet geometry"
|
||||
PROMPT_TAGS = ["minimal", "warm_light"]
|
||||
PAIR_TAGS = ["editorial"]
|
||||
# Newest first, so the Images order (Recently added) is the manifest's.
|
||||
FILES = (
|
||||
("tagging/dunes_00001.png", "linked"),
|
||||
("tagging/dunes_00002.png", "linked"),
|
||||
("copies/dunes_00001.png", "copy"),
|
||||
("tagging/clip.webm", "video"),
|
||||
("loose/unlinked.png", "unlinked"),
|
||||
)
|
||||
MODELS = ("wd14-swinv2", "wd14-vit", "gguf", "hf")
|
||||
# Unlinked copies enough to virtualize the Images grid (it renders all up to 120).
|
||||
MAX_EXTRA_IMAGES = 160
|
||||
_TABLES = (
|
||||
"saved_views",
|
||||
"image_favorites",
|
||||
"media_image_tags",
|
||||
"media_annotation_bytes",
|
||||
"media_annotation_files",
|
||||
"image_prompt_pairs",
|
||||
"prompt_pair_tags",
|
||||
"prompt_pairs",
|
||||
"generated_images",
|
||||
"prompt_tags",
|
||||
"tags",
|
||||
"prompt_library_state",
|
||||
"prompts",
|
||||
)
|
||||
# Every prompt-side owner row a Tag save could touch, for before/after
|
||||
# comparisons: tagging one image never changes a prompt, a pair or a link.
|
||||
_SNAPSHOT = {
|
||||
"prompts": "SELECT id, text, category, tags, updated_at FROM prompts ORDER BY id",
|
||||
"prompt_tags": "SELECT prompt_id, tag_id FROM prompt_tags ORDER BY 1, 2",
|
||||
"prompt_pairs": "SELECT id, positive_prompt_id, negative_prompt_id, category,"
|
||||
" archived, updated_at FROM prompt_pairs ORDER BY id",
|
||||
"prompt_pair_tags": "SELECT pair_id, tag_id FROM prompt_pair_tags ORDER BY 1, 2",
|
||||
"generated_images": "SELECT id, prompt_id, image_path FROM generated_images"
|
||||
" ORDER BY id",
|
||||
"image_prompt_pairs": "SELECT id, image_id, pair_id FROM image_prompt_pairs"
|
||||
" ORDER BY id",
|
||||
}
|
||||
|
||||
|
||||
def _png(path: Path, colour: tuple[int, int, int], size=(96, 64)) -> None:
|
||||
graph = {
|
||||
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": POSITIVE}},
|
||||
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": NEGATIVE}},
|
||||
"3": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {"positive": ["1", 0], "negative": ["2", 0], "seed": 7},
|
||||
},
|
||||
"4": {"class_type": "SaveImage", "inputs": {"images": ["3", 0]}},
|
||||
}
|
||||
info = PngInfo()
|
||||
info.add_text("prompt", json.dumps(graph))
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with Image.new("RGB", size, colour) as image:
|
||||
image.save(path, pnginfo=info)
|
||||
|
||||
|
||||
def seed_tagging(api: PromptManagerAPI, root: Path, extra_images: int = 0) -> dict:
|
||||
"""Replace this server's disposable rows and files with the tagging set."""
|
||||
db = api.db
|
||||
with db.model.get_connection() as conn:
|
||||
for table in _TABLES:
|
||||
conn.execute(f"DELETE FROM {table}")
|
||||
for name in ("output", "input"):
|
||||
shutil.rmtree(root / name)
|
||||
(root / name).mkdir()
|
||||
output = root / "output"
|
||||
_png(output / FILES[0][0], (196, 140, 92))
|
||||
_png(output / FILES[1][0], (150, 110, 80))
|
||||
(output / FILES[2][0]).parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(output / FILES[0][0], output / FILES[2][0])
|
||||
shutil.copyfile(
|
||||
Path(__file__).resolve().parents[1] / "fixtures" / "tiny.webm",
|
||||
output / FILES[3][0],
|
||||
)
|
||||
_png(output / FILES[4][0], (60, 70, 90), size=(64, 64))
|
||||
now = time.time()
|
||||
for index, (relative, _) in enumerate(FILES):
|
||||
stamp = now - 60 - index * 10
|
||||
os.utime(output / relative, (stamp, stamp))
|
||||
for number in range(extra_images):
|
||||
extra = output / "bulk" / f"bulk_{number:04}.png"
|
||||
extra.parent.mkdir(exist_ok=True)
|
||||
shutil.copyfile(output / FILES[4][0], extra)
|
||||
stamp = now - 600 - number
|
||||
os.utime(extra, (stamp, stamp))
|
||||
|
||||
receipt = db.create_library_recipe(
|
||||
POSITIVE,
|
||||
NEGATIVE,
|
||||
expected_epoch=db.get_execution_epoch(),
|
||||
parent_metadata={
|
||||
"title": TITLE,
|
||||
"category": "Architecture",
|
||||
"tags": list(PROMPT_TAGS),
|
||||
},
|
||||
)
|
||||
positive_id = receipt["positive"]["id"]
|
||||
pair_id = receipt["pair"]["id"]
|
||||
with db.model.get_connection() as conn:
|
||||
tag_ids = []
|
||||
for name in PAIR_TAGS:
|
||||
conn.execute("INSERT OR IGNORE INTO tags (name) VALUES (?)", (name,))
|
||||
tag_ids.append(
|
||||
conn.execute("SELECT id FROM tags WHERE name=?", (name,)).fetchone()[0]
|
||||
)
|
||||
conn.executemany(
|
||||
"INSERT INTO prompt_pair_tags (pair_id, tag_id) VALUES (?, ?)",
|
||||
[(pair_id, tag_id) for tag_id in tag_ids],
|
||||
)
|
||||
for relative, kind in FILES:
|
||||
if kind in {"linked", "video"}:
|
||||
image_id = db.link_image_to_prompt(positive_id, str(output / relative))
|
||||
if kind == "linked":
|
||||
db.link_image_to_pair(pair_id, image_id, {"source": "fixture"})
|
||||
api._generate_gallery_thumbnails_sync(api._get_all_output_dirs(), (150, 150))
|
||||
api.invalidate_gallery_cache()
|
||||
return {
|
||||
"positive_id": positive_id,
|
||||
"pair_id": pair_id,
|
||||
"title": TITLE,
|
||||
"prompt_tags": list(PROMPT_TAGS),
|
||||
"pair_tags": list(PAIR_TAGS),
|
||||
"files": [
|
||||
{"path": relative, "filename": Path(relative).name, "kind": kind}
|
||||
for relative, kind in FILES
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def tagging_snapshot(api: PromptManagerAPI) -> dict:
|
||||
"""Plain rows of every prompt, pair and link table."""
|
||||
with api.db.model.get_connection() as conn:
|
||||
return {
|
||||
name: [list(row) for row in conn.execute(query)]
|
||||
for name, query in _SNAPSHOT.items()
|
||||
}
|
||||
|
||||
|
||||
def register_tagging_commands(
|
||||
routes: web.RouteTableDef, api: PromptManagerAPI, root: Path, model
|
||||
) -> None:
|
||||
"""Register fixed seed, snapshot and fake-model commands; no paths or SQL input."""
|
||||
|
||||
@routes.post("/__test__/tagging/reset")
|
||||
async def reset(request: web.Request) -> web.Response:
|
||||
data = await request.json() if request.can_read_body else {}
|
||||
if request.query or not isinstance(data, dict) or set(data) - {"extra_images"}:
|
||||
raise web.HTTPBadRequest()
|
||||
extra = data.get("extra_images", 0)
|
||||
if isinstance(extra, bool) or not isinstance(extra, int):
|
||||
raise web.HTTPBadRequest()
|
||||
if not 0 <= extra <= MAX_EXTRA_IMAGES:
|
||||
raise web.HTTPBadRequest()
|
||||
model.unavailable = set()
|
||||
model.loaded = None
|
||||
manifest = await api._run_in_executor(seed_tagging, api, root, extra)
|
||||
return web.json_response({"success": True, **manifest})
|
||||
|
||||
@routes.get("/__test__/tagging/snapshot")
|
||||
async def snapshot(request: web.Request) -> web.Response:
|
||||
if request.query:
|
||||
raise web.HTTPBadRequest()
|
||||
return web.json_response(await api._run_in_executor(tagging_snapshot, api))
|
||||
|
||||
@routes.post("/__test__/tagging/models")
|
||||
async def models(request: web.Request) -> web.Response:
|
||||
"""Mark fake models as not downloaded (``{"unavailable": [...]}``)."""
|
||||
data = await request.json() if request.can_read_body else None
|
||||
if (
|
||||
request.query
|
||||
or not isinstance(data, dict)
|
||||
or set(data) != {"unavailable"}
|
||||
or not isinstance(data["unavailable"], list)
|
||||
or any(name not in MODELS for name in data["unavailable"])
|
||||
):
|
||||
raise web.HTTPBadRequest()
|
||||
model.unavailable = set(data["unavailable"])
|
||||
return web.json_response({"success": True})
|
||||
@@ -149,3 +149,15 @@ test('a narrow gallery caps the concept columns at the smallest readable card',
|
||||
}
|
||||
assert.equal(layoutImages([{}], 990, 'list', {columns:3}).columns, 1);
|
||||
});
|
||||
|
||||
test('the card info stacks below the narrowest bounded card, a list row beside its art too (D31)', async () => {
|
||||
const {stacksCardInfo} = await source;
|
||||
assert.equal(typeof stacksCardInfo, 'function', 'stacksCardInfo is not implemented');
|
||||
assert.equal(stacksCardInfo(119, 'grid'), true);
|
||||
assert.equal(stacksCardInfo(120, 'grid'), false);
|
||||
assert.equal(stacksCardInfo(119, 'masonry'), true);
|
||||
// A list row's info is what its art (160px, at most 45%) leaves: 195px at 200% zoom on a phone leaves 107px.
|
||||
assert.equal(stacksCardInfo(195, 'list'), true);
|
||||
assert.equal(stacksCardInfo(280, 'list'), false);
|
||||
assert.equal(stacksCardInfo(358, 'list'), false);
|
||||
});
|
||||
|
||||
@@ -106,7 +106,7 @@ async function opened(routes, options = {}) {
|
||||
return { c, h };
|
||||
}
|
||||
|
||||
test('MODEL_OPTIONS lists legacy engines in mock order with D61 labels', async () => {
|
||||
test('MODEL_OPTIONS lists legacy engines in mock order with D84 labels', async () => {
|
||||
const { MODEL_OPTIONS } = await load();
|
||||
assert.deepEqual(MODEL_OPTIONS.map(option => [option.id, option.label]), [
|
||||
['wd14-swinv2', 'WD14 SwinV2 · ~400 MB'],
|
||||
@@ -425,7 +425,7 @@ test('toast matches the mock with the review suffix', async () => {
|
||||
const { c } = await opened({ [`PUT ${TAGS}`]: () => response(200, receipt(['one'])) });
|
||||
c.setManual('one');
|
||||
await c.save();
|
||||
assert.equal(c.state.toast, '1 tag saved for dunes_00001.png.');
|
||||
assert.equal(c.state.toast, '1 tags saved for dunes_00001.png.', 'the mock always says "tags"');
|
||||
const many = await opened({}, { source: 'library' });
|
||||
await many.c.save();
|
||||
assert.equal(many.c.state.toast, '2 tags saved for dunes_00001.png.');
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
// Guarded: before the dialog module exists every test fails on an assertion, not on the import.
|
||||
const loaded = await import('../../web/js/workspace/image-tagging.mjs').catch(() => null);
|
||||
const module = () => { assert.ok(loaded, 'image-tagging.mjs is not implemented'); return loaded; };
|
||||
const SCOPE = 'a'.repeat(64);
|
||||
|
||||
test('a listed file gives its tagging target, anything else none', () => {
|
||||
const { imageTarget } = module();
|
||||
assert.deepEqual(imageTarget({ root_index: 1, root_scope: SCOPE, path: 'a/b.png', filename: 'b.png' }),
|
||||
{ root_index: 1, root_scope: SCOPE, path: 'a/b.png' });
|
||||
for (const image of [null, {}, { root_index: -1, root_scope: SCOPE, path: 'a.png' }, { root_index: 0, path: 'a.png' },
|
||||
{ root_index: 0, root_scope: 'A'.repeat(64), path: 'a.png' }, { root_index: 0, root_scope: SCOPE, path: '' },
|
||||
{ root_index: 1.5, root_scope: SCOPE, path: 'a.png' }]) {
|
||||
assert.equal(imageTarget(image), null);
|
||||
}
|
||||
});
|
||||
|
||||
test('tag save listeners subscribe and unsubscribe once', () => {
|
||||
const { onImageTagsSaved, imageTagsListenerCount } = module();
|
||||
const before = imageTagsListenerCount();
|
||||
const off = onImageTagsSaved(() => {});
|
||||
assert.equal(imageTagsListenerCount(), before + 1);
|
||||
off(); off();
|
||||
assert.equal(imageTagsListenerCount(), before);
|
||||
assert.throws(() => onImageTagsSaved('nope'), TypeError);
|
||||
});
|
||||
@@ -21,3 +21,12 @@ test('Images mounted after its parent is aborted creates no DOM, requests or obs
|
||||
for(const [key,descriptor] of originals) {if(descriptor) Object.defineProperty(globalThis,key,descriptor);else delete globalThis[key];}
|
||||
}
|
||||
});
|
||||
|
||||
test('Images mounted after its parent is aborted follows no tag saves', async () => {
|
||||
const tagging = await import('../../web/js/workspace/image-tagging.mjs').catch(() => null);
|
||||
assert.ok(tagging, 'image-tagging.mjs is not implemented');
|
||||
const before = tagging.imageTagsListenerCount();
|
||||
const parent = createScope(); parent.dispose();
|
||||
mountImages({}, {get() { assert.fail('Request started after parent disposal'); }}, parent, {embedded: true, fetchPage() {}}).dispose();
|
||||
assert.equal(tagging.imageTagsListenerCount(), before);
|
||||
});
|
||||
|
||||
+10
-4
@@ -162,10 +162,10 @@
|
||||
"statements": 97
|
||||
},
|
||||
"workspace/activity-scan.mjs": {
|
||||
"lines": 95,
|
||||
"branches": 64,
|
||||
"lines": 96,
|
||||
"branches": 73,
|
||||
"functions": 98,
|
||||
"statements": 95
|
||||
"statements": 96
|
||||
},
|
||||
"workspace/activity-store.mjs": {
|
||||
"lines": 98,
|
||||
@@ -223,10 +223,16 @@
|
||||
},
|
||||
"workspace/image-tagging-state.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 92,
|
||||
"branches": 93,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/image-tagging.mjs": {
|
||||
"lines": 97,
|
||||
"branches": 88,
|
||||
"functions": 86,
|
||||
"statements": 97
|
||||
},
|
||||
"workspace/images-category.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
|
||||
@@ -4,6 +4,17 @@ const MEDIA_RATIO = 1.33, BORDER = 2, FOOTER_HEIGHT = 54, LIST_HEIGHT = 97;
|
||||
// Bounded rendering keeps a card readable when zoom shrinks the grid below the concept's columns (D3, D31).
|
||||
export const MINIMUM_CARD_WIDTH = 120;
|
||||
const AUTOMATIC_CARD_WIDTH = 240;
|
||||
// A list row's art: the concept's 160px, yielding to 45% of the row at extreme zoom (the overlay's D31).
|
||||
const LIST_ART_WIDTH = 160, LIST_ART_SHARE = 0.45;
|
||||
|
||||
/**
|
||||
* Whether a card's info row stacks its text above Info and Tag (D31): the row would be narrower than the narrowest
|
||||
* bounded card, which only extreme zoom reaches. A list row's info is what its art leaves of the row.
|
||||
*/
|
||||
export function stacksCardInfo(width, mode) {
|
||||
const info = mode === 'list' ? width - Math.min(LIST_ART_WIDTH, width * LIST_ART_SHARE) : width;
|
||||
return info < MINIMUM_CARD_WIDTH;
|
||||
}
|
||||
|
||||
/** The concept grid's own columns and gap, read from its computed style as the concept's mountMasonry does (D3). */
|
||||
export function gridMetrics(grid) {
|
||||
|
||||
@@ -39,9 +39,9 @@ const GENERIC_CODES = new Set(['observe-failed', 'suggest-failed', 'save-failed'
|
||||
const UNCONFIRMED_STATUS = new Set([502, 503, 504]);
|
||||
|
||||
const COPY = Object.freeze({
|
||||
initial: 'Choose a model, then generate tags. Remove anything you do not want to add.', // D60
|
||||
initial: 'Choose a model, then generate tags. Remove anything you do not want to add.', // D83
|
||||
modelChanged: 'Generate suggestions with the selected model before approving.',
|
||||
allRemoved: 'All suggestions removed. Generate again or enter tags manually.', // D60
|
||||
allRemoved: 'All suggestions removed. Generate again or enter tags manually.', // D83
|
||||
empty: 'No suggestions met this threshold. Adjust it or enter tags manually.',
|
||||
generating: 'Generating suggestions…',
|
||||
observeFailed: 'The image could not be opened for tagging.',
|
||||
@@ -298,7 +298,7 @@ export function createImageTaggingController({api, target, source = 'images'} =
|
||||
const phase = state.phase === 'generating' || state.phase === 'generate-error' ? 'ready' : state.phase;
|
||||
set({
|
||||
phase, model: id, generated: false, suggestions: [], suggestionsText: COPY.modelChanged, statusText: reasonText(),
|
||||
// Like the mock, a model change re-renders its fields with the defaults; the count is kept (D68).
|
||||
// Like the mock, a model change re-renders its fields with the defaults; the count is kept (D91).
|
||||
options: {...state.options, general_threshold: defaults.general_threshold,
|
||||
character_threshold: defaults.character_threshold, instructions: defaults.instructions},
|
||||
});
|
||||
@@ -363,7 +363,8 @@ export function createImageTaggingController({api, target, source = 'images'} =
|
||||
const n = value.tags.length;
|
||||
set({
|
||||
phase: 'saved', savedTags: value.tags.slice(), tagRevision: value.tag_revision, currentTags: null, statusText: '',
|
||||
toast: `${n} ${noun(n, 'tag')} saved for ${state.image.filename}.${source === 'review' ? COPY.reviewSuffix : ''}`,
|
||||
// The mock's toast verbatim (app.js:1545, without the demo's "in this tab"): "tags" even for one.
|
||||
toast: `${n} tags saved for ${state.image.filename}.${source === 'review' ? COPY.reviewSuffix : ''}`,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
/**
|
||||
* The concept's "Tag image" dialog (docs/concepts/workspace/app.js `tagReview` a1037–1061, `renderTagModelOptions`
|
||||
* a1062–1076, `generateTags` a1077–1135, `apply-tags` a1517–1547; style.css s1598–1694) over the tagging controller
|
||||
* (image-tagging-state.mjs), in the shared modal shell (workspace-dialogs.mjs) with the concept's `tagging-modal`
|
||||
* class, whose sticky `.dialog-actions` stay reachable while the content scrolls.
|
||||
*
|
||||
* Recorded deviations: D83 (#249 plan D60) the suggestions are removable chips, every one preselected, at most the
|
||||
* requested count, with "Remove anything you do not want to add." and the all-removed copy; D84 (plan D61) the
|
||||
* legacy engines' labels and sizes, GGUF in the concept's Ollama slot, instructions prefilled with the server's
|
||||
* default; D91 (plan D68) a Suggestions count field for every model. States the concept lacks (busy, conflict,
|
||||
* changed storage or file, lost answers, unavailable model, video) are only text in `#tag-generation-status`; Retry
|
||||
* (ruling T5) leads the actions wherever the controller allows it, as the other workspace dialogs place theirs.
|
||||
*
|
||||
* The capture is released when the dialog closes (×, Escape, Cancel, Save), when the route changes (the shell
|
||||
* closes its dialog) and on `pagehide`.
|
||||
*/
|
||||
import { createButton, createScope } from '../shared/toolkit/core.js';
|
||||
import { createImageTaggingController, MODEL_OPTIONS, optionError } from './image-tagging-state.mjs';
|
||||
import { closeWorkspaceDialog, field, openContentDialog } from './workspace-dialogs.mjs';
|
||||
import { createWorkspaceIcon } from './icons.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
import { cardTitle } from './images-state.mjs';
|
||||
|
||||
const MANUAL_HELP = 'Comma-separated. Generated suggestions are added only after you approve them.';
|
||||
const SCOPE_NOTE = 'Tags belong to this image. Resolve its prompt association separately in Needs review.';
|
||||
// The option inputs per controller key; the classifier keys only exist for WD14 models.
|
||||
const OPTION_IDS = Object.freeze({
|
||||
general_threshold: 'image-general-threshold',
|
||||
character_threshold: 'image-character-threshold',
|
||||
instructions: 'image-tag-instructions',
|
||||
suggestion_limit: 'image-suggestion-limit',
|
||||
});
|
||||
|
||||
function el(tag, text, className) {
|
||||
const node = document.createElement(tag);
|
||||
if (text !== undefined) node.textContent = text;
|
||||
if (className) node.className = className;
|
||||
return node;
|
||||
}
|
||||
function setText(node, text) {
|
||||
if (node.textContent !== text) node.textContent = text;
|
||||
}
|
||||
function setDisabled(button, disabled) {
|
||||
if (disabled) button.setAttribute('aria-disabled', 'true');
|
||||
else button.removeAttribute('aria-disabled');
|
||||
}
|
||||
const disabled = button => button.getAttribute('aria-disabled') === 'true';
|
||||
|
||||
const savedListeners = new Set();
|
||||
/** Run `listener({target, filename, tags})` after every confirmed tag save; returns the unsubscribe. */
|
||||
export function onImageTagsSaved(listener) {
|
||||
if (typeof listener !== 'function') throw new TypeError('A listener function is required');
|
||||
savedListeners.add(listener);
|
||||
return () => savedListeners.delete(listener);
|
||||
}
|
||||
/** How many pages follow tag saves (a mounted page's subscription ends with its scope). */
|
||||
export function imageTagsListenerCount() {
|
||||
return savedListeners.size;
|
||||
}
|
||||
function announceSaved(event) {
|
||||
for (const listener of [...savedListeners]) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch (error) {
|
||||
// One page's refresh failure never stops the others.
|
||||
console.error('Image tag listener failed', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const tagButtons = new WeakSet();
|
||||
/** The concept card's Tag beside Info (app.js mediaCard a398), for Images and every Library-embedded gallery. */
|
||||
export function createTagButton(image) {
|
||||
const button = createButton('', 'icon-btn');
|
||||
button.append(createWorkspaceIcon('tagImage'));
|
||||
button.setAttribute('aria-label', `Tag or auto-tag ${image.filename}`);
|
||||
tagButtons.add(button);
|
||||
return button;
|
||||
}
|
||||
/** Whether `node` is a card's Tag button. */
|
||||
export function isTagButton(node) {
|
||||
return tagButtons.has(node);
|
||||
}
|
||||
|
||||
/** A listed file's tagging identity (`root_index`, `root_scope`, `path`), else null. */
|
||||
export function imageTarget(image) {
|
||||
const {root_index: index, root_scope: scope, path} = image ?? {};
|
||||
if (!Number.isSafeInteger(index) || index < 0 || typeof scope !== 'string' || !/^[0-9a-f]{64}$/.test(scope) ||
|
||||
typeof path !== 'string' || !path) return null;
|
||||
return {root_index: index, root_scope: scope, path};
|
||||
}
|
||||
|
||||
function numberInput(value, {min, max, step}) {
|
||||
const input = el('input');
|
||||
Object.assign(input, {type: 'number', min, max, step});
|
||||
input.value = String(value);
|
||||
return input;
|
||||
}
|
||||
|
||||
/**
|
||||
* The concept's `.tag-model-panel` (a1049): `heading`, the Tagging model select, the model's options and Generate.
|
||||
* It follows `controller` until `scope` is disposed (reused by the bulk Auto-tag dialog).
|
||||
*/
|
||||
export function renderModelPanel({scope, controller, heading = 'Auto-tag this image'}) {
|
||||
const panel = el('div', undefined, 'tag-model-panel');
|
||||
const select = el('select');
|
||||
for (const option of MODEL_OPTIONS) {
|
||||
const node = el('option', option.label); // D84
|
||||
node.value = option.id;
|
||||
select.append(node);
|
||||
}
|
||||
const options = el('div');
|
||||
options.id = 'image-tag-model-options';
|
||||
const generate = createButton('', 'ca-primary');
|
||||
generate.append(createWorkspaceIcon('tagImage'), ' Generate suggestions');
|
||||
panel.append(el('h3', heading), field('Tagging model', select, 'image-tag-model'), options, generate);
|
||||
const inputs = new Map();
|
||||
let shownModel = null;
|
||||
|
||||
function renderOptions(state) {
|
||||
inputs.clear();
|
||||
const classifier = MODEL_OPTIONS.find(option => option.id === state.model).classifier;
|
||||
const rows = [];
|
||||
if (classifier) {
|
||||
const pair = el('div', undefined, 'two-col');
|
||||
for (const [key, label] of [['general_threshold', 'General confidence'], ['character_threshold', 'Character confidence']]) {
|
||||
const input = numberInput(state.options[key], {min: '0', max: '1', step: '0.05'});
|
||||
inputs.set(key, input);
|
||||
pair.append(field(label, input, OPTION_IDS[key]));
|
||||
}
|
||||
rows.push(pair);
|
||||
} else {
|
||||
const area = el('textarea');
|
||||
area.value = state.options.instructions;
|
||||
inputs.set('instructions', area);
|
||||
rows.push(field('Tagging instructions', area, OPTION_IDS.instructions));
|
||||
}
|
||||
// D91: the number of suggestions, for every model.
|
||||
const count = numberInput(state.options.suggestion_limit, {min: '1', max: '100', step: '1'});
|
||||
inputs.set('suggestion_limit', count);
|
||||
rows.push(field('Suggestions', count, OPTION_IDS.suggestion_limit));
|
||||
options.replaceChildren(...rows);
|
||||
for (const [key, input] of inputs) {
|
||||
input.addEventListener('input', () => {
|
||||
input.setCustomValidity('');
|
||||
controller.setOption(key, input.value);
|
||||
});
|
||||
}
|
||||
}
|
||||
function update(state) {
|
||||
if (state.model !== shownModel) {
|
||||
shownModel = state.model;
|
||||
select.value = state.model;
|
||||
renderOptions(state);
|
||||
}
|
||||
setDisabled(generate, !state.canGenerate);
|
||||
}
|
||||
select.addEventListener('change', () => controller.setModel(select.value));
|
||||
generate.addEventListener('click', () => {
|
||||
const {state} = controller;
|
||||
if (disabled(generate) || !state.canGenerate) return;
|
||||
// The concept's native validity first (a1104–1114), then what the controller would refuse (a blank field).
|
||||
for (const input of inputs.values()) input.setCustomValidity('');
|
||||
const invalid = [...inputs.values()].find(input => !input.checkValidity());
|
||||
if (invalid) { invalid.reportValidity(); return; }
|
||||
const error = optionError(state.model, state.options);
|
||||
if (error) {
|
||||
const input = inputs.get(error.field);
|
||||
input.setCustomValidity(error.message);
|
||||
input.reportValidity();
|
||||
return;
|
||||
}
|
||||
controller.generate();
|
||||
});
|
||||
scope.cleanup(controller.subscribe(update));
|
||||
update(controller.state);
|
||||
return panel;
|
||||
}
|
||||
|
||||
function previewOf(image, state) {
|
||||
const video = state?.image ? state.image.mediaType === 'video' : image?.is_video === true;
|
||||
const node = el(video ? 'video' : 'img', undefined, 'tag-image-preview');
|
||||
if (video) { node.muted = true; node.preload = 'metadata'; node.playsInline = true; }
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open "Tag image" for one listed `image` (its tagging target). `source` is the page (`images`, `library`, `review`,
|
||||
* `tags`); `onSaved(event)` runs after a confirmed save, as every `onImageTagsSaved` listener does.
|
||||
*/
|
||||
export function openImageTagging({api, scope, image, source = 'images', trigger = null, onSaved = null}) {
|
||||
const target = imageTarget(image);
|
||||
if (!target) throw new TypeError('The image has no tagging target');
|
||||
const controller = createImageTaggingController({api, target, source});
|
||||
const local = createScope();
|
||||
let open = true;
|
||||
let completed = false;
|
||||
const {resolveUrl} = window.ApiClient;
|
||||
|
||||
// 2. The heading: preview, title, filename and size; the listing's values until the capture answers.
|
||||
const heading = el('div', undefined, 'tag-image-heading');
|
||||
let preview = previewOf(image, null);
|
||||
const copy = el('div');
|
||||
const title = el('h3', cardTitle(image));
|
||||
const filename = el('p', image.filename, 'small');
|
||||
const size = el('span', undefined, 'chip');
|
||||
copy.append(title, filename, size);
|
||||
heading.append(preview, copy);
|
||||
const hasSize = (width, height) => [width, height].every(value => Number.isFinite(value) && value > 0);
|
||||
if (hasSize(image.width, image.height)) size.textContent = `${image.width} × ${image.height}`;
|
||||
else size.remove();
|
||||
|
||||
// 4. The manual field.
|
||||
const manual = el('input');
|
||||
manual.setAttribute('aria-describedby', 'manual-tags-help');
|
||||
const help = el('p', MANUAL_HELP, 'small');
|
||||
help.id = 'manual-tags-help';
|
||||
const manualField = field('Image tags · edit manually', manual, 'manual-image-tags');
|
||||
manualField.append(help);
|
||||
manual.addEventListener('input', () => controller.setManual(manual.value));
|
||||
|
||||
// 5. The model panel; 6. the suggestions.
|
||||
const panel = renderModelPanel({scope: local, controller});
|
||||
const suggestions = el('section', undefined, 'tag-suggestion-panel');
|
||||
suggestions.setAttribute('aria-labelledby', 'tag-suggestions-title');
|
||||
const panelHeading = el('div', undefined, 'panel-heading');
|
||||
const suggestionsTitle = el('h3', 'Review suggestions');
|
||||
suggestionsTitle.id = 'tag-suggestions-title';
|
||||
const count = el('span', 'Not generated', 'small');
|
||||
count.id = 'suggestion-count';
|
||||
panelHeading.append(suggestionsTitle, count);
|
||||
const list = el('div');
|
||||
list.id = 'tag-suggestions';
|
||||
const status = el('div', undefined, 'small');
|
||||
status.id = 'tag-generation-status';
|
||||
status.setAttribute('role', 'status');
|
||||
suggestions.append(panelHeading, list, status);
|
||||
|
||||
// 7. The actions; Retry leads them only while the controller allows it.
|
||||
const actions = el('div', undefined, 'dialog-actions');
|
||||
const retry = createButton('Retry');
|
||||
const cancel = createButton('Cancel');
|
||||
const primary = createButton('Save manual tags', 'ca-primary');
|
||||
actions.append(cancel, primary);
|
||||
|
||||
const nodes = [heading];
|
||||
if (controller.state.showScopeNote) nodes.push(el('p', SCOPE_NOTE, 'tag-scope-note small'));
|
||||
nodes.push(manualField, panel, suggestions, actions);
|
||||
|
||||
let shownImage = null, shownRows = null, shownText = null, filled = false;
|
||||
function renderImage(info) {
|
||||
shownImage = info;
|
||||
const next = previewOf(null, {image: info});
|
||||
if (next.tagName !== preview.tagName) { preview.replaceWith(next); preview = next; }
|
||||
title.textContent = info.title || info.filename;
|
||||
filename.textContent = info.filename;
|
||||
preview.alt = title.textContent;
|
||||
if (info.width && info.height) { preview.width = info.width; preview.height = info.height; }
|
||||
preview.src = resolveUrl(info.previewUrl);
|
||||
if (info.dimensions) { size.textContent = info.dimensions; if (!size.isConnected) copy.append(size); }
|
||||
else size.remove();
|
||||
}
|
||||
function renderSuggestions(state) {
|
||||
// A removed chip's focus moves to its neighbour's ×, else to Generate.
|
||||
const focused = list.contains(document.activeElement) ? [...list.querySelectorAll('button')].indexOf(document.activeElement) : -1;
|
||||
shownRows = state.suggestions;
|
||||
shownText = state.suggestionsText;
|
||||
if (!state.suggestions.length) {
|
||||
list.replaceChildren(el('p', state.suggestionsText, 'small'));
|
||||
} else {
|
||||
// D83: removable chips instead of the concept's checkbox rows.
|
||||
const chips = el('div', undefined, 'tags');
|
||||
for (const row of state.suggestions) {
|
||||
const chip = el('span', undefined, 'chip tag-suggestion');
|
||||
const remove = createButton('×');
|
||||
remove.setAttribute('aria-label', `Remove ${row.name}`);
|
||||
remove.addEventListener('click', () => controller.remove(row.name));
|
||||
chip.append(el('span', row.name), el('span', row.label, 'tag-confidence'), remove);
|
||||
chips.append(chip);
|
||||
}
|
||||
list.replaceChildren(chips);
|
||||
}
|
||||
if (focused >= 0) {
|
||||
const buttons = [...list.querySelectorAll('button')];
|
||||
(buttons[Math.min(focused, buttons.length - 1)] ?? panel.querySelector('.ca-primary')).focus();
|
||||
}
|
||||
}
|
||||
function complete(state) {
|
||||
completed = true;
|
||||
const event = {target, filename: state.image.filename, tags: state.savedTags.slice(), source};
|
||||
closeWorkspaceDialog();
|
||||
announceSaved(event);
|
||||
onSaved?.(event);
|
||||
showWorkspaceToast(state.toast);
|
||||
}
|
||||
function render(state) {
|
||||
if (!open || completed) return;
|
||||
if (state.image && state.image !== shownImage) renderImage(state.image);
|
||||
if (!filled && state.phase !== 'observing' && state.image) {
|
||||
filled = true;
|
||||
manual.value = state.manual;
|
||||
}
|
||||
if (state.suggestions !== shownRows || state.suggestionsText !== shownText) renderSuggestions(state);
|
||||
setText(count, state.countText);
|
||||
setText(status, state.statusText);
|
||||
setText(primary, state.primaryLabel);
|
||||
setDisabled(primary, !state.canSave);
|
||||
if (state.canRetry !== retry.isConnected) {
|
||||
if (state.canRetry) actions.prepend(retry);
|
||||
else {
|
||||
if (document.activeElement === retry) primary.focus();
|
||||
retry.remove();
|
||||
}
|
||||
}
|
||||
if (state.phase === 'saved') complete(state);
|
||||
}
|
||||
|
||||
cancel.addEventListener('click', () => closeWorkspaceDialog());
|
||||
retry.addEventListener('click', () => controller.retry());
|
||||
primary.addEventListener('click', () => {
|
||||
if (disabled(primary) || !controller.state.canSave) return;
|
||||
controller.save();
|
||||
});
|
||||
const pagehide = () => controller.dispose();
|
||||
window.addEventListener('pagehide', pagehide);
|
||||
|
||||
function release() {
|
||||
if (!open) return;
|
||||
open = false;
|
||||
window.removeEventListener('pagehide', pagehide);
|
||||
document.getElementById('modal')?.classList.remove('tagging-modal');
|
||||
local.dispose();
|
||||
controller.dispose();
|
||||
}
|
||||
openContentDialog({scope, title: 'Tag image', nodes, trigger, onClose: release});
|
||||
document.getElementById('modal')?.classList.add('tagging-modal');
|
||||
local.cleanup(controller.subscribe(render));
|
||||
render(controller.state);
|
||||
controller.open();
|
||||
return {
|
||||
close() { if (open) closeWorkspaceDialog(); },
|
||||
get controller() { return controller; },
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createScope, createButton } from '../shared/toolkit/core.js';
|
||||
import { createImageStream, withItem, imageKey, countLine, captionDate, scopeLabel, activeRefinements, cardTitle, cardSubtitle, mediaRatio } from './images-state.mjs';
|
||||
import { layoutImages, visibleImages, gridMetrics, MINIMUM_CARD_WIDTH } from './image-layout.mjs';
|
||||
import { layoutImages, visibleImages, gridMetrics, stacksCardInfo } from './image-layout.mjs';
|
||||
import { mountFileInspector } from './file-inspector.mjs';
|
||||
import { createInspectionController } from './inspection-state.mjs';
|
||||
import { mountSelectionBar } from './images-selection.mjs';
|
||||
@@ -15,6 +15,7 @@ import { openImportDialog, openRefineDialog } from './workspace-dialogs.mjs';
|
||||
import { openMetadataScan } from './activity-scan.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
import { imagesFilters, imagesViewState } from './saved-views.mjs';
|
||||
import { createTagButton, isTagButton, openImageTagging, onImageTagsSaved } from './image-tagging.mjs';
|
||||
|
||||
/** The concept's panel without an image (app.js updateImages a456–458). */
|
||||
function noSelection() {
|
||||
@@ -105,6 +106,12 @@ export function onFavoriteChange(listener) {
|
||||
return () => favoriteListeners.delete(listener);
|
||||
}
|
||||
|
||||
/** The image selected in Images when it was last left (the concept's `state.selectedImage`, for Tags › Auto-tag images). */
|
||||
export function selectedImage() {
|
||||
const key = lastVisit?.selection;
|
||||
return key ? lastVisit.state?.items.find(item => imageKey(item) === key) ?? null : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* An embedded collection (the history page's shared gallery and its All images and Repeat run dialogs, D47) has no
|
||||
* caption: its page heading or dialog title already names it, and `focusTarget` takes a stranded focus.
|
||||
@@ -341,11 +348,10 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
const title = el('h3', cardTitle(image)); title.title = image.path;
|
||||
text.append(title, el('p', cardSubtitle(image)));
|
||||
const actions = el('div', undefined, 'image-card-actions');
|
||||
// D9: the tag button waits for the image-annotations slice.
|
||||
const inspectButton = createButton('', 'icon-btn');
|
||||
inspectButton.append(createWorkspaceIcon('info'));
|
||||
inspectButton.setAttribute('aria-label', `Inspect ${image.filename}`);
|
||||
actions.append(inspectButton);
|
||||
actions.append(inspectButton, createTagButton(image));
|
||||
info.append(text, actions);
|
||||
card.append(art, info);
|
||||
markSelected(card, key === selection);
|
||||
@@ -397,10 +403,17 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
if (!card) return;
|
||||
const image = state.items.find(item => imageKey(item) === card.dataset.imageKey);
|
||||
if (!image) return;
|
||||
const action = event.target.closest('.image-card-actions button');
|
||||
if (event.target.closest('.star-button')) toggleFavorite(image);
|
||||
else if (event.target.closest('.image-open')) previewImage(image);
|
||||
else if (event.target.closest('.image-card-actions button')) inspect(image);
|
||||
// Tag opens the dialog without selecting the card, as the concept's `image-tag` does.
|
||||
else if (isTagButton(action)) openImageTagging({api, scope, image, source: embedded ? 'library' : 'images', trigger: action});
|
||||
else if (action) inspect(image);
|
||||
});
|
||||
// A saved tag set re-reads the selected file's details (its inspector envelope carries the image tags).
|
||||
scope.cleanup(onImageTagsSaved(({target}) => {
|
||||
if (selection === `${target.root_index}:${target.path}`) inspection.retry();
|
||||
}));
|
||||
|
||||
function renderVisible() {
|
||||
if (!state || scope.signal.aborted) return;
|
||||
@@ -487,7 +500,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
|
||||
function updateGeometry() {
|
||||
geometry = layoutImages(state.items, galleryWidth, layout, metrics);
|
||||
// Below the narrowest bounded card (extreme zoom) the info row stacks (the production overlay, #249 D31).
|
||||
grid.classList.toggle('narrow-cards', galleryWidth < MINIMUM_CARD_WIDTH);
|
||||
grid.classList.toggle('narrow-cards', stacksCardInfo(galleryWidth, layout));
|
||||
grid.style.height = `${geometry.height}px`;
|
||||
grid.style.setProperty('--image-footer-height', `${metrics.footerHeight}px`);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/**
|
||||
* Native Tags & categories screen (concept renderTags, app.js:719-729).
|
||||
*
|
||||
* Composition, labels and order follow the approved concept; the recorded
|
||||
* deviations are Auto-tag images omitted (no image-tag owner yet), Images cells
|
||||
* "—" / "Not available yet", and bounded lists with Load more. Rows are real
|
||||
* vocabulary pages from vocabulary-state.mjs; Browse hands the exact constraint
|
||||
* to the existing Library filters.
|
||||
* Composition, labels and order follow the approved concept: Auto-tag images
|
||||
* opens the Tag image dialog for the image selected in Images (aria-disabled
|
||||
* without one, D115) and the Images cells count the files carrying each tag
|
||||
* (#249 C2). The recorded deviation left is bounded lists with Load more. Rows
|
||||
* are real vocabulary pages from vocabulary-state.mjs; Browse hands the exact
|
||||
* constraint to the existing Library filters.
|
||||
*/
|
||||
import { createButton } from '../shared/toolkit/core.js';
|
||||
import { browseFilters, createVocabularyCommand, createVocabularyStream } from './vocabulary-state.mjs';
|
||||
@@ -13,6 +14,11 @@ import { countOf } from './plural.mjs';
|
||||
import { browseLibrary, invalidateLibraryAfterWrite } from './library.mjs';
|
||||
import { createVocabularyDialog } from './vocabulary-dialog.mjs';
|
||||
import { pageHeading } from './heading.mjs';
|
||||
import { selectedImage } from './images.mjs';
|
||||
import { onImageTagsSaved, openImageTagging } from './image-tagging.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
|
||||
const NO_SELECTION = 'Select an image in Images first.';
|
||||
|
||||
const COPY = {
|
||||
tags: {empty: 'No tags yet.', error: 'Tags could not be loaded. Nothing was changed.', more: 'Load more tags'},
|
||||
@@ -42,10 +48,8 @@ function operation(title, description, label) {
|
||||
|
||||
function tagRow(tag) {
|
||||
const row = el('tr');
|
||||
const images = el('td');
|
||||
const dash = el('span', '—');
|
||||
dash.setAttribute('aria-hidden', 'true');
|
||||
images.append(dash, el('span', 'Not available yet', 'ca-visually-hidden'));
|
||||
// The files carrying the tag (absent from an older server, which counts none).
|
||||
const images = el('td', String(tag.image_count ?? 0));
|
||||
const cell = el('td');
|
||||
const actions = el('div', undefined, 'row-actions');
|
||||
const browse = createButton('Browse');
|
||||
@@ -159,7 +163,15 @@ export function mountVocabulary(main, api, scope) {
|
||||
const { description, actions } = pageHeading(main);
|
||||
description.textContent = 'Organize once. Reuse everywhere.';
|
||||
const create = createButton('+ Create tag');
|
||||
actions.append(create);
|
||||
const autoTag = createButton('Auto-tag images', 'ca-primary');
|
||||
actions.append(create, autoTag);
|
||||
// The concept's `tag-review` for `state.selectedImage` (app.js a1511); D115: none selected, so it only explains.
|
||||
const selected = selectedImage();
|
||||
if (!selected) autoTag.setAttribute('aria-disabled', 'true');
|
||||
scope.listen(autoTag, 'click', () => {
|
||||
if (!selected) { showWorkspaceToast(NO_SELECTION); return; }
|
||||
openImageTagging({api, scope, image: selected, source: 'tags', trigger: autoTag});
|
||||
});
|
||||
|
||||
const grid = el('div', undefined, 'two-col');
|
||||
const tagSurface = el('section', undefined, 'surface');
|
||||
@@ -232,6 +244,8 @@ export function mountVocabulary(main, api, scope) {
|
||||
const tag = button && tags.itemOf(button.closest('tr'));
|
||||
if (tag) dialog.renameTag(tag, button);
|
||||
});
|
||||
// A saved image tag set changes the Images counts.
|
||||
scope.cleanup(onImageTagsSaved(() => { if (!scope.signal.aborted) reload(); }));
|
||||
reload();
|
||||
return {create, manage: manage.button, tags: tags.stream, categories: categories.stream, reload, createCommand, dialog};
|
||||
return {autoTag, create, manage: manage.button, tags: tags.stream, categories: categories.stream, reload, createCommand, dialog};
|
||||
}
|
||||
|
||||
@@ -137,6 +137,8 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
|
||||
row stacks its text above Inspect, so neither the title nor the subtitle collapses to nothing beside the button. */
|
||||
.narrow-cards .media-info { flex-wrap: wrap; }
|
||||
.narrow-cards .media-info > div { flex: 1 0 100%; }
|
||||
/* #249 D31: Info and Tag (#249 C2) wrap onto two lines when even the stacked row cannot hold them side by side. */
|
||||
.narrow-cards .media-info > .image-card-actions { flex: 0 1 auto; min-width: 0; flex-wrap: wrap; /* D31 */ }
|
||||
/* A list row beside its art keeps room for Inspect. */
|
||||
.narrow-cards.list-grid .media-art { width: 40%; }
|
||||
.narrow-cards.list-grid .media-info { padding-inline: 4px; }
|
||||
@@ -362,3 +364,14 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc
|
||||
.history-context-branch, .history-context-branch::before, .history-run-marker, .history-run-marker::before { border-color: CanvasText; }
|
||||
.history-run-marker[aria-pressed="true"] { outline-color: Highlight; }
|
||||
}
|
||||
|
||||
/* Tag image dialog (#249 C2): the concept's `tagging-modal` (s1598–1694) as it stands, but D83 (ruling T4): the
|
||||
suggestions are removable chips, each the concept's `.chip` geometry in its checked suggestion row's tokens
|
||||
(`.tag-suggestion:has(input:checked)`, s1655): a name, the confidence and a × button. */
|
||||
#tag-suggestions .chip.tag-suggestion { display: inline-flex; gap: 6px; padding: 2px 2px 2px 8px; margin-bottom: 0; cursor: default; color: inherit; border-color: var(--ca-accent); background: var(--soft-blue); /* D83 */ }
|
||||
#tag-suggestions .chip.tag-suggestion .tag-confidence { margin-inline-start: 0; /* D83 */ }
|
||||
.ca-tools #tag-suggestions .chip.tag-suggestion > button { min-block-size: 24px; min-inline-size: 24px; padding: 0 6px; /* D83 */ }
|
||||
@media (pointer: coarse) {
|
||||
/* D83: the chip's × keeps the concept's 40px coarse target. */
|
||||
.ca-tools #tag-suggestions .chip.tag-suggestion > button { min-block-size: 40px; min-inline-size: 40px; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user