From a8aff3a39d837f8f1a35a1df481c635dfd7f9e24 Mon Sep 17 00:00:00 2001 From: Vito Date: Thu, 22 Oct 2026 18:55:24 -0700 Subject: [PATCH] feat(annotations): bulk Add tags and Auto-tag selected as Activity jobs (#283) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(activity): image tagging jobs for bulk Add tags and Auto-tag POST /prompt_manager/jobs/image-tags starts one record-changing "image-tags" job over 1..500 listed images (202, or the same 409 busy envelope as /jobs/scan). Add tags hashes each current file and unions a fixed set into that byte record; Auto-tag captures each still image, asks one legacy engine for its top N suggestions on the capture (count from the options or autotag.defaults.suggestion_limit), re-hashes the file and unions only onto the same bytes. Outcomes are grouped as tagged, unchanged, skipped (video, too large, file changed, file not found, not a still image) and errors; cancel stops at a file boundary. Auto-tag holds the shared model gate for the whole job (409 model-busy at start when it is held), loads the model once and never downloads one: an unavailable model fails the job before the first file. The registry gains blocker() so a start answers busy before taking the gate. * feat(annotations): Add tags and Auto-tag selected in the selection bar The Images selection bar gains the concept's Add tags and, beside it, Auto-tag selected (D85). Add tags starts the image-tags Activity job with the dialog's comma-separated names; Auto-tag selected reuses the Tag image model panel (server defaults, Suggestions count) without Generate, refuses a model that is not downloaded, and either starts the job (Apply automatically) or walks the selection one Tag image dialog at a time (Review each image, D90). Busy, model-busy and refused starts toast why; the selection stays; a finished job's files are read again. * feat(activity): show image tagging jobs with their own outcomes Image tagging jobs get their own Activity view (D89): the pill reads "Tagging", the item and heading line name the images and end with "N tagged · M errors", and the job view shows Tagged, Unchanged, Skipped and Errors tiles and outcomes, opens Tagged and Unchanged rows in the viewer, and reads "Tagging log" and "Cancel tagging". The per-kind table lives in activity-store.mjs; the scan's view is unchanged. The selection bar's copy without a concept line is recorded as D117. * fix(annotations): final review round for bulk image tagging - Rename the D117 copy deviation to D118 (D117 belongs to another PR). - The tagging job's progress line gives the scan's "about X min left" estimate, with each finished file as one batch. - The start body cap fits 500 targets of the longest legal path, even escaped; one byte over answers 413. - A capture store at capacity is retried with a short backoff, then the file is skipped with a rerun sentence instead of a Tag-dialog error. - Output roots and their labels are resolved once per job in the executor, not twice per file on the event loop. - Review each image holds Stop reviewing, Escape and x while a save is in flight, and its primary reads "Save and next" when only the manual field would be saved. - An Add tags field that parses to no names keeps the dialog and its text and reports the message on the field. - Image tagging finish toasts carry the counts ("... finished · N tagged · M errors"). - Specs: the bulk spec waits for the answered Start before checking that the invalid press sent nothing; the Activity spec uses a real error row. --- TESTING.md | 143 +++- py/api/__init__.py | 3 + py/api/admin.py | 17 +- py/api/image_tag_jobs.py | 509 +++++++++++ py/api/job_routes.py | 36 +- py/api/jobs.py | 31 +- tests/browser/activity-concept.spec.mjs | 36 +- tests/browser/concept-inventory.mjs | 16 +- tests/browser/images-concept.spec.mjs | 19 +- tests/browser/workspace-activity.spec.mjs | 111 +++ tests/browser/workspace-images.spec.mjs | 3 +- tests/browser/workspace-tagging-bulk.spec.mjs | 323 +++++++ tests/browser/workspace_activity_fixture.py | 40 +- tests/js/workspace-activity-store.test.js | 42 + .../js/workspace-images-tagging-bulk.test.js | 198 +++++ .../workspace-images-tagging-review.test.js | 110 +++ tests/test_api_core.py | 9 + tests/test_image_tag_jobs.py | 787 ++++++++++++++++++ tests/test_jobs.py | 9 + tools/vendor/frontend-coverage-baseline.json | 20 +- web/js/workspace/activity-job-view.mjs | 71 +- web/js/workspace/activity-store.mjs | 68 +- web/js/workspace/activity.mjs | 21 +- web/js/workspace/image-tagging-state.mjs | 130 ++- web/js/workspace/image-tagging.mjs | 82 +- web/js/workspace/images-selection.mjs | 16 +- web/js/workspace/images-tagging-bulk.mjs | 192 +++++ web/js/workspace/images-tagging-review.mjs | 75 ++ web/js/workspace/images-tagging.mjs | 11 +- web/js/workspace/images.mjs | 4 +- web/js/workspace/workspace-dialogs.mjs | 36 +- web/js/workspace/workspace.css | 6 + 32 files changed, 3019 insertions(+), 155 deletions(-) create mode 100644 py/api/image_tag_jobs.py create mode 100644 tests/browser/workspace-tagging-bulk.spec.mjs create mode 100644 tests/js/workspace-images-tagging-bulk.test.js create mode 100644 tests/js/workspace-images-tagging-review.test.js create mode 100644 tests/test_image_tag_jobs.py create mode 100644 web/js/workspace/images-tagging-bulk.mjs create mode 100644 web/js/workspace/images-tagging-review.mjs diff --git a/TESTING.md b/TESTING.md index c2a82ca..603f53e 100644 --- a/TESTING.md +++ b/TESTING.md @@ -861,9 +861,10 @@ Recorded deviations on this page (spec §8): - **D7**: the star is a real persisted favorite with "Added to favorites." / "Removed from favorites." and a rollback toast on failure. - **D8**: every Images card has the concept's select checkbox before the star; the - `#bulkbar` shows "N selected" and only the real actions in the concept's order (Set - category, Export metadata, Clear). Add tags and Add to collection wait for image tags - and collections; "Remove links…" is dropped. The selection survives Load more and + `#bulkbar` shows "N selected" and only the real actions in the concept's order (Add + tags, then D85's Auto-tag selected, Set category, Export metadata, Clear). Add to + collection waits for collections; "Remove links…" is dropped. (Amended by #249 PR D: + Add tags ships, see "Bulk tagging from the selection bar".) The selection survives Load more and bounded re-mounting and is cleared whenever the collection is replaced (search, category, sort, Filters, source change, rescan, reload). A return to Images restores the saved collection unreplaced and keeps its selection; a full page reload starts @@ -1201,6 +1202,142 @@ production-versus-concept difference of PR C and its record: | Retry leads the actions when the controller allows it | D116 (ruling T5) | | States the concept lacks (busy, conflict, changed storage or file, unconfirmed save, unavailable model, video) | Global Constraints: `#tag-generation-status` text only | +### Bulk tagging from the selection bar (#249 PR D) + +The Images selection bar shows the concept's **Add tags** after the count, then +**Auto-tag selected** (D85), before Set category. Both take the checked files when +pressed, in listing order, as their reveal identity (`{root_index, root_scope, path}`); +more than 500 toasts "Select at most 500 images." and opens nothing. The selection +stays after every start, refusal and review. The job's start body is bounded so 500 +targets of the longest legal path (1024 code points, even escaped as `\uXXXX`) fit: +`500 × (1024 × 6 + 256) + 64 KiB`; a larger body answers 413 +(`tests/test_image_tag_jobs.py` "test_start_body_bounds"). + +- **Add tags** opens the concept's `simpleInput` (app.js a1378–1383) as Set category + does: "Tag selected images", "Tags, separated by commas" (empty: the concept's + "cinematic, concept" is sample text), Cancel / Apply. The names are parsed as the Tag + image dialog's manual field parses them (trimmed, case-insensitive repeats dropped, + first spelling wins); a field that parses to no names keeps the dialog open with the + typed text and reports "Enter at least one tag." on the field (its native validity, + cleared on the next edit), and nothing is sent. Apply starts the + `image-tags` job (`POST /prompt_manager/jobs/image-tags`, `{mode: "add", targets, + tags}`) and toasts "Tagging started."; Activity shows its progress. +- A refused start toasts why and changes nothing: another record-changing job (a scan) + gives "Another job is running: ." with a link to it (D57's toast), a held model + gives the server's `model-busy` sentence, any other coded refusal its own sentence, + and anything else "Tagging could not start. Try again." / "Auto-tagging could not + start. Try again.". +- When a job this page started ends having tagged files, those files are read again + once: the selected file's inspection (its chips, D88) and, under a Tags filter or a + search, the loaded range, as after a Tag save. + +- **D85** (plan D62, ruling L1, beyond the concept): **Auto-tag selected** opens + "Auto-tag selected images" in the Tag image dialog's shell (`tagging-modal`) with + its model panel, headed "Auto-tag <N> image(s)" (the panel's "Auto-tag this image" + for N files), without the preview, the manual field, the suggestions or Generate. + Its fields show the server's defaults (`GET /prompt_manager/autotag/models`, the + D91 Suggestions count included) once that read lands; Start waits for it. A model + the status lists as not downloaded keeps Start `aria-disabled` and + `#tag-generation-status` says "<model> is not downloaded. Download it from the + legacy Auto-tag page." (the server's own sentence), so no job starts only to fail. + Start validates the fields as Generate does (native validity; nothing is sent). + The concept comparison (`images-concept.spec.mjs`) uses the concept page itself as + the oracle, with Add to collection and Remove links… removed and a copy of its Add + tags button reading "Auto-tag selected" inserted after it. +- **D90** (plan D67, ruling T3-bulk): the dialog's **How to apply** radio group + (`.field[role=radiogroup]`, radios beside their labels): **Apply automatically** + (checked; primary "Start auto-tagging") starts the `image-tags` job + (`{mode: "auto", targets, model_type, options}`) and toasts "Auto-tagging started."; + **Review each image** (primary "Start review") walks the selection in the browser + (`images-tagging-review.mjs`). Each image opens the Tag image dialog titled "Tag + image · Image <i> of <M>" on the chosen model and options with Generate already + running; its actions are Stop reviewing, Skip and Approve and next (Approve and + finish on the last). Approve saves the manual field plus the remaining chips as Save + does, without its per-image toast, and opens the next image; Skip writes nothing and + advances; Stop reviewing, Escape and × end the session. While Approve's save is in + flight, Skip and Stop reviewing are `aria-disabled` and Escape and × do nothing, so the + closing count includes that save. When no suggestions are on screen (a model change + cleared them, or Generate was refused) the primary saves the manual field only and + reads "Save and next" ("Save and finish" on the last image). Each image is one gated + `suggest` call, so the session holds no model between images; a `model-busy` answer + stays on that image with its sentence in `#tag-generation-status` and D116's Retry + until the user retries or skips. Ending toasts "<n> of <M> images tagged." and + returns focus to Auto-tag selected. +- **D91** extends to the bulk dialog: its Suggestions field is the job's and the + review's `suggestion_limit`. +- **D118** (copy the concept has no line for; the concept's `bulk-apply` only toasts a + demo sentence): an Add tags field that parses to no names reports "Enter at least one + tag." on the field; a start refused for no coded reason toasts "Tagging could not start. Try + again." or "Auto-tagging could not start. Try again." (as D55's "The scan could not + start. Try again."); and the bulk dialog's model panel is headed "Auto-tag <N> + image(s)" where the Tag image dialog's reads "Auto-tag this image". Everything else + the selection bar's tagging shows is the concept's or the plan's (D85, D90, D91). +- **D89** (plan D66, Activity for the `image-tags` job): the job's Activity item and its + view are the scan's (D54, D56, D57, D59) with the job kind's own counters, from one + per-kind table (`JOB_VIEWS` in `activity-store.mjs`; a kind without one keeps the + scan's tiles under "Job log" and "Cancel job"). The titles are the server's: "Add + tags to images" and "Auto-tag images". + - The pill's verb is "Tagging" ("Tagging · 41% · 8,240 / 19,622"). + - The item line and the view's heading line read "Started HH:MM · N images · closing + this view does not cancel it" (the job's image count, grouped "en-US"), "Finished + HH:MM · N tagged · M errors", "Cancelled HH:MM at a file boundary · N tagged · + completed work is kept" or "Stopped on an error at HH:MM · N tagged · M errors". + - The view's actions are Download report and, while running, "Cancel tagging" + (`.danger`); the progress bar is "<title> progress". + - Four `dl.stat-row` tiles in the mockup's tones: Tagged (`.added`, green), + Unchanged, Skipped (`.warn`, warning), Errors (`.bad`, red). They keep the + mockup's five-column row (two columns on a phone), so a tile is the scan's width + and the fifth cell stays empty. + - The outcome list reads Tagged, Errors, Skipped, Unchanged (what got tagged and what + went wrong first, as the scan's), and the view opens on the first with files. + - Rows under Tagged and Unchanged are D57's `button.file-row`s that open the file in + the viewer by its listing identity; Skipped and Errors rows are plain text. + - The log is "● Tagging log — raw detail, click to show". + - The row and log wording is the job's (D1), since neither the concept nor the + mockup has a line for it: "Added N tag(s)", "No new tags", and the skip reasons + "Video: manual tags only", "Too large to verify", "File changed", "File not found", + "Not a supported still image" and "The file could not be read now. Run the job again + for it." (the capture store stayed at capacity, for example with Tag dialogs open in + other tabs, after a short backoff of retries). Log prefixes are `+` tagged, `=` + unchanged, `-` skipped and `!` error. + - The progress line is the scan's: "Processing file N of M · about X min left", the + estimate (D56's `_time_left`) shown once two files are done, each file one batch. + - Download report saves `prompt-manager-image-tags-<YYYYMMDD-HHMM>.json`. + - The start toasts are "Tagging started." / "Auto-tagging started." (D85); the end + toasts follow the design's pattern with the job's counts, as the scan's: "<title> + finished · N tagged · M errors", "<title> cancelled · N tagged" or "<title> failed · + N tagged · M errors". + - Tests: `tests/js/workspace-activity-store.test.js` ("image-tags jobs get their own + tiles outcomes and pill verb", "unknown kinds keep the generic view"); + `tests/browser/workspace-activity.spec.mjs` ("an image tagging job shows Tagged + Unchanged Skipped Errors and opens tagged rows", "cancel tagging stops at a file + boundary", "the report download names the kind", on fake `image-tags` jobs from + `workspace_activity_fixture.py`); `tests/browser/activity-concept.spec.mjs` ("image + tagging job view follows the approved mockup", dark and light, with a mutation + check). The scan's view is unchanged. + +Browser tests (`tests/browser/workspace-tagging-bulk.spec.mjs`, on the tagging +fixture): "selection bar shows Add tags then Auto-tag selected before Set category", +"Add tags runs a job and the cards show the tags after it finishes", "Auto-tag +selected with Apply automatically runs a job with the chosen model, thresholds and +suggestion count", "a model that is not downloaded keeps Start disabled and says +why", "Suggestions field prefills from the setting and caps chips at the entered +count", "Review each image walks the selection one dialog at a time and only approved +images gain tags", "Review holds Stop reviewing, Escape and × while a save is in +flight, and names a manual-only save" and "a running scan makes the start report +busy". Units: +`tests/js/workspace-images-tagging-bulk.test.js`, +`tests/js/workspace-images-tagging-review.test.js`. + +| Difference | Record | +| --- | --- | +| Add tags ships (Add to collection and Remove links… still omitted) | D8 (amended) | +| Auto-tag selected and its dialog | D85 (ruling L1) | +| How to apply and the Review each image session | D90 (ruling T3-bulk) | +| Suggestions count in the bulk dialog | D91 | +| Copy without a concept line: no tags, start fallbacks, the panel heading | D118 | +| Activity item and job view for image tagging jobs | D89 (plan D66) | + Native image tagging (#249 PR B routes, PR C UI): `POST /prompt_manager/image-annotations/observe` (capture one listed file, its saved set and `tag_revision`), `GET`/`DELETE /prompt_manager/image-annotations/capture/<token>` diff --git a/py/api/__init__.py b/py/api/__init__.py index b45b3d5..fbebf5c 100644 --- a/py/api/__init__.py +++ b/py/api/__init__.py @@ -7,6 +7,7 @@ Split into domain-specific mixins for maintainability: - LoggingRoutesMixin: log management endpoints - AutotagRoutesMixin: auto-tagging model management and tagging - JobRoutesMixin: the session job registry behind Activity (py/api/jobs.py) +- ImageTagJobsMixin: bulk Add tags / Auto-tag as image-tags jobs """ import asyncio @@ -32,6 +33,7 @@ from .history import HistoryRoutesMixin from .images import ImageRoutesMixin from .admin import AdminRoutesMixin from .job_routes import JobRoutesMixin +from .image_tag_jobs import ImageTagJobsMixin from .jobs import JobRegistry from .logging_routes import LoggingRoutesMixin from .autotag_routes import AutotagRoutesMixin @@ -260,6 +262,7 @@ class PromptManagerAPI( ImageRoutesMixin, AdminRoutesMixin, JobRoutesMixin, + ImageTagJobsMixin, LoggingRoutesMixin, AutotagRoutesMixin, LoraIntegrationMixin, diff --git a/py/api/admin.py b/py/api/admin.py index bc900ee..f4a6af9 100644 --- a/py/api/admin.py +++ b/py/api/admin.py @@ -198,13 +198,22 @@ def _plural(count, word): return f"{count} {word}" if count == 1 else f"{count} {word}s" +def _time_left(done, total, batches, elapsed): + """ " · about X min left" once enough batches ran, else "" (D56). + + Shared by every Activity job that reports "Processing file N of M": the + scan counts its batches, the image tagging job each file. + """ + if batches < MIN_BATCHES_FOR_ESTIMATE or done <= 0 or done >= total: + return "" + minutes = max(1, math.ceil(elapsed / done * (total - done) / 60)) + return f" · about {minutes} min left" + + def _scan_status_line(done, total, batches, elapsed): """ "Processing file N of M", with an estimate once enough batches ran.""" line = f"Processing file {done:,} of {total:,}" - if batches < MIN_BATCHES_FOR_ESTIMATE or done <= 0 or done >= total: - return line - minutes = max(1, math.ceil(elapsed / done * (total - done) / 60)) - return f"{line} · about {minutes} min left" + return line + _time_left(done, total, batches, elapsed) def _file_size(path): diff --git a/py/api/image_tag_jobs.py b/py/api/image_tag_jobs.py new file mode 100644 index 0000000..38fa9b6 --- /dev/null +++ b/py/api/image_tag_jobs.py @@ -0,0 +1,509 @@ +"""Bulk image tagging as an Activity job: Add tags and Auto-tag (#249 D1). + +``POST /prompt_manager/jobs/image-tags`` starts one ``image-tags`` job over +1..500 listed images, named by the listing identity ``{root_index, +root_scope, path}``. The job is record-changing (it writes image tag sets and +new vocabulary names), so it never runs beside the metadata scan. + +- **Add tags** (``mode: "add"``) hashes each current file and unions a fixed + tag set into the record of exactly those bytes. +- **Auto-tag** (``mode: "auto"``) copies each still image into a private + capture, asks one legacy engine for its top N tags on the capture, re-hashes + the current file and unions the suggestions only when the bytes are still + the captured ones. It holds the shared model gate for the whole job, loads + the model once and never downloads one. + +Every step reuses the Tag dialog's pieces: the capture store, the model gate, +the suggestion worker and the storage call ``add_image_tags``. Files are +handled one at a time and the job stops at a file boundary when cancelled, +keeping what it already wrote. Rows and log lines carry root-relative paths +and public root labels only. +""" + +import asyncio +import functools +import os +import time +from dataclasses import dataclass, field + +from aiohttp import web + +from .. import model_gate +from .admin import _plural, _time_left +from .annotation_suggest import ( + OptionError, + unavailable_message, + validate_options, +) +from .annotations import ( + _MESSAGES, + MAX_TARGET_PATH, + _coded, + _error, + _pinned_suggestions, + _settled, + _submit, + _target, +) +from .autotag_routes import model_busy_response +from .images import _public_path +from .job_routes import busy_response +from .jobs import IMAGE_TAG_GROUP_NAMES, IMAGE_TAG_JOB_KIND, JobBusy +from .prompts import bad_request + +try: + from ...database.annotations import ImageTagLimitError, normalize_image_tags + from ...database.library_commands import LibraryCommandError + from ...utils.media_capture import ( + CaptureCapacity, + CaptureChanged, + CaptureDenied, + CaptureError, + CaptureTooLarge, + ) + from ...utils.video_metadata import is_video_path +except ImportError: + from database.annotations import ImageTagLimitError, normalize_image_tags + from database.library_commands import LibraryCommandError + from utils.media_capture import ( + CaptureCapacity, + CaptureChanged, + CaptureDenied, + CaptureError, + CaptureTooLarge, + ) + from utils.video_metadata import is_video_path + +MAX_JOB_TARGETS = 500 +# 500 maximal targets plus the options: a path of MAX_TARGET_PATH code points +# takes at most 6 bytes each on the wire (a \uXXXX escape; raw UTF-8 is at most +# 4), and the root index and scope fit in 256. The annotation routes' 64 KiB +# bound is for one target, too small for a full selection. +MAX_JOB_BODY_BYTES = MAX_JOB_TARGETS * (MAX_TARGET_PATH * 6 + 256) + 64 * 1024 +# A busy capture worker (a Tag dialog in another tab) is waited for this long, +# try by try, before the file is left for a rerun. +CAPACITY_BACKOFF = (0.1, 0.2, 0.4, 0.8) + +ADD_TITLE = "Add tags to images" +AUTO_TITLE = "Auto-tag images" +GATE_OWNER_KIND = "image-tag-job" + +_FIELDS = { + "add": frozenset({"mode", "targets", "tags"}), + "auto": frozenset({"mode", "targets", "model_type", "options"}), +} +_VERBS = {"add": "Add tags", "auto": "Auto-tag"} +_LOG_PREFIXES = {"tagged": "+", "unchanged": "=", "skipped": "-", "errors": "!"} + +NO_NEW_TAGS = "No new tags" +VIDEO_SKIPPED = "Video: manual tags only" +TOO_LARGE = "Too large to verify" +FILE_CHANGED = "File changed" +FILE_NOT_FOUND = "File not found" +NOT_A_STILL = "Not a supported still image" +INFERENCE_FAILED = "Auto-tagging failed. Check server logs for details." +SAVE_FAILED = "The tags could not be saved. Check server logs for details." +READ_FAILED = "The file could not be read." +SOURCE_CHANGED = "The source changed. Refresh the image collection." +STORAGE_RESTORED = "Prompt storage was restored; start the job again." +BUSY_RETRY = "The file could not be read now. Run the job again for it." + +_monotonic = time.monotonic # the job's clock (a test can fake it) + + +class _JobStopped(Exception): + """No later file can be written (prompt storage was restored).""" + + +@dataclass +class _Run: + """One running job: its request, the gate owner and the resolved roots. + + ``roots`` maps each target's ``(root_index, root_scope)`` to ``(root, + label)``, read once per job off the event loop; ``root`` is None when the + scope no longer names a current root. + """ + + job: object + spec: object + epoch: str + owner: object + service: object = None + roots: dict = field(default_factory=dict) + + +@dataclass(frozen=True) +class _Spec: + """A validated start request.""" + + mode: str + targets: tuple + tags: tuple = () + model_type: str | None = None + options: dict | None = None + + @property + def title(self) -> str: + return ADD_TITLE if self.mode == "add" else AUTO_TITLE + + def scope(self) -> dict: + scope = {"mode": self.mode, "images": len(self.targets)} + if self.mode == "auto": + scope["model_type"] = self.model_type + return scope + + +def _parse_targets(raw): + """Unique validated targets in request order, or ``(None, error)``.""" + if not isinstance(raw, list) or not 1 <= len(raw) <= MAX_JOB_TARGETS: + return None, bad_request(f"targets must list 1 to {MAX_JOB_TARGETS} images") + seen, targets = set(), [] + for item in raw: + target, error = _target(item) + if error is not None: + return None, error + if target not in seen: + seen.add(target) + targets.append(target) + return tuple(targets), None + + +def _parse_add(targets, data): + try: + tags = normalize_image_tags(data["tags"]) + except ImageTagLimitError as exc: + return None, _error(400, "tag-limit", str(exc)) + except ValueError as exc: + return None, bad_request(str(exc)) + if not tags: + return None, bad_request("tags must name at least one tag") + return _Spec("add", targets, tags=tuple(tags)), None + + +def _parse_auto(targets, data): + from ..config import MAX_SUGGESTION_LIMIT, AutotagConfig + + try: + model_type, options = validate_options( + data["model_type"], + data["options"], + default_limit=AutotagConfig.suggestion_limit(), + max_limit=MAX_SUGGESTION_LIMIT, + ) + except OptionError as exc: + return None, bad_request(str(exc)) + return _Spec("auto", targets, model_type=model_type, options=options), None + + +def parse_image_tag_request(data: dict): + """A :class:`_Spec` for a start body, or ``(None, error response)``. + + ``{mode:"add", targets, tags}`` or ``{mode:"auto", targets, model_type, + options}`` exactly; options follow the Tag dialog's Generate rules. + """ + fields = _FIELDS.get(data.get("mode")) + if fields is None: + return None, bad_request('mode must be "add" or "auto"') + if set(data) != fields: + return None, bad_request( + "Request must contain exactly " + ", ".join(sorted(fields)) + ) + targets, error = _parse_targets(data["targets"]) + if error is not None: + return None, error + if data["mode"] == "add": + return _parse_add(targets, data) + return _parse_auto(targets, data) + + +def _capture_outcome(exc: CaptureError, root, relative: str): + """The (group, detail) of a file the capture store refused to read.""" + if isinstance(exc, CaptureTooLarge): + return "skipped", TOO_LARGE + if isinstance(exc, CaptureDenied): + return "skipped", FILE_NOT_FOUND + if isinstance(exc, CaptureChanged): + path = os.path.join(root, *relative.split("/")) + return "skipped", FILE_CHANGED if os.path.lexists(path) else FILE_NOT_FOUND + if isinstance(exc, CaptureCapacity): + return "skipped", BUSY_RETRY + return "errors", READ_FAILED + + +async def _capacity_retried(job, attempt): + """``await attempt()``, tried again while the capture store is at capacity. + + Raises the last ``CaptureCapacity`` once ``CAPACITY_BACKOFF`` is spent or the + job was cancelled meanwhile. + """ + for delay in CAPACITY_BACKOFF: + try: + return await attempt() + except CaptureCapacity: + if job.cancelled: + raise + await asyncio.sleep(delay) + return await attempt() + + +def _load_model(service, model_type: str, use_gpu: bool) -> None: + """Worker thread: load (or swap to) *model_type*; never downloads.""" + if service.is_model_loaded() and service.get_loaded_model_type() == model_type: + return + if service.load_model(model_type, use_gpu) is False: + raise RuntimeError(f"{model_type} could not be loaded") + + +class ImageTagJobsMixin: + """The ``image-tags`` job kind: start route and runner.""" + + async def start_image_tag_job(self, request: web.Request) -> web.Response: + """Start bulk tagging (202), or 409 ``busy`` / ``model-busy``. + + Every target's root scope is checked before the job starts + (``source-changed``). Auto-tag takes the model gate here, so a start + while anything else holds it is refused at once. + """ + data, error = await self._read_json_body(request, max_bytes=MAX_JOB_BODY_BYTES) + if error is not None: + return error + spec, error = parse_image_tag_request(data) + if error is not None: + return error + roots = await self._run_in_executor(self._job_roots, spec.targets) + if any(root is None for root, _label in roots.values()): + return _coded("source-changed") + epoch = await self._run_in_executor(self.db.get_execution_epoch) + # A running job in the way answers first: it says which job to wait for. + blocker = self._jobs.blocker(IMAGE_TAG_JOB_KIND, record_changing=True) + if blocker is not None: + return busy_response(JobBusy(blocker)) + owner = None + if spec.mode == "auto": + owner = model_gate.new_owner(GATE_OWNER_KIND) + if not model_gate.try_acquire(owner): + return model_busy_response() + try: + job = self._jobs.start( + IMAGE_TAG_JOB_KIND, + spec.title, + record_changing=True, + scope=spec.scope(), + runner=functools.partial(self._run_image_tag_job, spec, epoch, owner), + groups=IMAGE_TAG_GROUP_NAMES, + ) + except JobBusy as busy: + if owner is not None: + model_gate.release(owner) + return busy_response(busy) + if owner is not None: + # Also when the task is cancelled before its runner ever starts. + job.task.add_done_callback(lambda _task: model_gate.release(owner)) + return web.json_response({"success": True, "job": job.summary()}, status=202) + + # -- runner ---------------------------------------------------------------- + + def _job_roots(self, targets) -> dict: + """Worker thread: ``{(index, scope): (root or None, label)}`` (see _Run).""" + roots = list(self._get_all_output_dirs()) + resolved = {} + for index, scope in dict.fromkeys((t[0], t[1]) for t in targets): + label = _public_path(roots[index]) if index < len(roots) else "" + error = self._gallery_scope_error( + {"root_scope": scope, "root": str(index)}, roots + ) + resolved[(index, scope)] = (roots[index] if error is None else None, label) + return resolved + + async def _run_image_tag_job(self, spec: _Spec, epoch: str, owner, job) -> None: + verb = _VERBS[spec.mode] + total = len(spec.targets) + job.update(total=total, status_line=f"Processing file 0 of {total:,}") + started = f"{verb} started · {_plural(total, 'image')}" + if spec.mode == "auto": + started += f" · {spec.model_type}" + job.log_line(started) + run = _Run(job, spec, epoch, owner) + try: + if spec.mode == "auto": + run.service = await self._prepare_tagging_model(job, spec, owner) + if run.service is None: + job.finish("failed") + return + if not await self._tag_each_file(run): + job.log_line(f"{verb} stopped · {STORAGE_RESTORED}") + job.finish("failed") + return + state = "cancelled" if job.cancelled else "finished" + job.log_line(f"{verb} {state} · {_plural(job.processed, 'file')} processed") + job.publish({"type": state, "progress": job.progress}) + job.finish(state) + finally: + if owner is not None: + model_gate.release(owner) + + async def _tag_each_file(self, run: _Run) -> bool: + """Tag the targets in order; False when the job had to stop.""" + job, total = run.job, len(run.spec.targets) + run.roots = await self._run_in_executor(self._job_roots, run.spec.targets) + started = _monotonic() + for number, target in enumerate(run.spec.targets, 1): + if job.cancelled: + break + # The scan's estimate (D56), with each finished file as one batch. + left = _time_left(number - 1, total, number - 1, _monotonic() - started) + job.update(status_line=f"Processing file {number:,} of {total:,}{left}") + job.publish({"type": "progress", "progress": job.progress}) + stopped = False + try: + group, detail = await self._tag_one_file(run, target) + except _JobStopped: + stopped, group, detail = True, "errors", STORAGE_RESTORED + except Exception: + self.logger.exception("Image tagging failed for one file") + group, detail = "errors", SAVE_FAILED + self._record_tag_outcome(run, target, group, detail) + job.update(processed=number, progress=number * 100 // total) + if stopped: + return False + return True + + async def _prepare_tagging_model(self, job, spec: _Spec, owner): + """The loaded service, or None (logged) when the job cannot run.""" + from ..autotag import MODELS, get_autotag_service + + service = get_autotag_service() + status = await self._run_in_executor(service.get_models_status) + if not status.get(spec.model_type, {}).get("downloaded"): + name = MODELS.get(spec.model_type, {}).get("name", spec.model_type) + message = unavailable_message(name) + job.update(status_line=message) + job.log_line(f"! {message}") + return None + try: + await model_gate.run( + owner, + _load_model, + service, + spec.model_type, + spec.options["use_gpu"], + ) + except Exception: + self.logger.exception("The auto-tag job could not load its model") + job.update(status_line=INFERENCE_FAILED) + job.log_line(f"! {INFERENCE_FAILED}") + return None + return service + + async def _verified(self, run: _Run, store, root, relative): + """The current file's facts, waiting out a busy capture store.""" + return await _capacity_retried( + run.job, lambda: self._run_in_executor(store.verify, root, relative) + ) + + async def _tag_one_file(self, run: _Run, target): + """The (group, detail) of one target; raises _JobStopped on a restore.""" + index, scope, relative = target + root, _label = run.roots[(index, scope)] + if root is None: + return "errors", SOURCE_CHANGED + store = self._capture_store() + if run.spec.mode == "add": + try: + facts = await self._verified(run, store, root, relative) + except CaptureError as exc: + return _capture_outcome(exc, root, relative) + return await self._union_image_tags(facts, list(run.spec.tags), run.epoch) + if is_video_path(relative): + return "skipped", VIDEO_SKIPPED + try: + capture = await _capacity_retried( + run.job, + lambda: _settled( + _submit( + store.capture, + root, + relative, + epoch=run.epoch, + scope=scope, + root_index=index, + ), + lambda made: made is not None and store.release(made.token), + ), + ) + except CaptureError as exc: + return _capture_outcome(exc, root, relative) + try: + return await self._auto_tag_capture(run, store, capture, root, relative) + finally: + store.release(capture.token) + + async def _auto_tag_capture(self, run: _Run, store, capture, root, relative): + """Suggest on the capture, then union only onto the same current bytes.""" + if not capture.inferable: + return "skipped", NOT_A_STILL + if store.acquire(capture.token) is None: + raise _JobStopped # every capture was invalidated (restore) + spec = run.spec + try: + pairs = await model_gate.run( + run.owner, + _pinned_suggestions, + store, + capture.token, + run.service, + capture, + spec.model_type, + spec.options, + on_skipped=functools.partial(store.release_use, capture.token), + ) + except Exception: + self.logger.exception("Auto-tagging failed for one file") + return "errors", INFERENCE_FAILED + try: + facts = await self._verified(run, store, root, relative) + except CaptureError as exc: + return _capture_outcome(exc, root, relative) + if facts.locator != capture.locator or facts.sha256 != capture.sha256: + return "skipped", FILE_CHANGED + names = [str(name) for name, _score in pairs] + if not names: + return "unchanged", NO_NEW_TAGS + return await self._union_image_tags(facts, names, run.epoch) + + async def _union_image_tags(self, facts, tags: list, epoch: str): + """``add_image_tags`` on the verified bytes, as a (group, detail).""" + try: + receipt = await self._run_in_executor( + self.db.add_image_tags, + locator=facts.locator, + sha256=facts.sha256, + size=facts.size, + mtime_ns=facts.mtime_ns, + expected_epoch=epoch, + tags=tags, + ) + except LibraryCommandError as exc: + if exc.code == "storage-changed": + raise _JobStopped from None + if exc.code == "tag-limit": + return "errors", _MESSAGES["tag-limit"] + self.logger.warning(f"Image tags not saved ({exc.code})") + return "errors", SAVE_FAILED + except ValueError: + self.logger.exception("Image tags were refused") + return "errors", SAVE_FAILED + added = receipt.get("added", 0) + if added: + return "tagged", f"Added {_plural(added, 'tag')}" + return "unchanged", NO_NEW_TAGS + + def _record_tag_outcome(self, run: _Run, target, group: str, detail: str) -> None: + index, scope, relative = target + _root, label = run.roots[(index, scope)] + where = f"{label}/{relative}" if label else relative + run.job.record( + group, + {"path": relative, "root": label, "root_index": index, "detail": detail}, + f"{_LOG_PREFIXES[group]} {where} · {detail}", + ) diff --git a/py/api/job_routes.py b/py/api/job_routes.py index 71b35cb..f855660 100644 --- a/py/api/job_routes.py +++ b/py/api/job_routes.py @@ -1,8 +1,9 @@ """Session job routes (Activity): list, inspect, follow and cancel jobs. -The registry itself is ``py/api/jobs.py``; the scan job's runner is in -``py/api/admin.py``. Every response leaves out absolute server paths: job -rows already carry root-relative paths and public root labels. +The registry itself is ``py/api/jobs.py``. The scan job's runner is in +``py/api/admin.py``, the image tagging job's in ``py/api/image_tag_jobs.py``. +Every response leaves out absolute server paths: job rows already carry +root-relative paths and public root labels. """ import contextlib @@ -42,6 +43,20 @@ async def _scan_scope(request): return scope if scope in SCAN_SCOPES else None +def busy_response(busy: JobBusy): + """``409 busy``, naming the running job that holds the slot.""" + running = busy.running + return web.json_response( + { + "success": False, + "code": "busy", + "error": str(busy), + "running": {"id": running.id, "title": running.title}, + }, + status=409, + ) + + def _summary_event(job): return f"data: {json.dumps(job.summary())}\n\n".encode("utf-8") @@ -58,6 +73,10 @@ class JobRoutesMixin: async def start_scan_job_route(request): return await self.start_scan_job(request) + @routes.post("/prompt_manager/jobs/image-tags") + async def start_image_tag_job_route(request): + return await self.start_image_tag_job(request) + @routes.get("/prompt_manager/jobs/{job_id}") async def job_detail_route(request): return await self.job_detail(request) @@ -121,16 +140,7 @@ class JobRoutesMixin: try: job = self._start_scan_job(files) except JobBusy as busy: - running = busy.running - return web.json_response( - { - "success": False, - "code": "busy", - "error": str(busy), - "running": {"id": running.id, "title": running.title}, - }, - status=409, - ) + return busy_response(busy) return web.json_response({"success": True, "job": job.summary()}, status=202) async def job_events(self, request): diff --git a/py/api/jobs.py b/py/api/jobs.py index a5a4b0e..27e4ecb 100644 --- a/py/api/jobs.py +++ b/py/api/jobs.py @@ -9,8 +9,9 @@ as a snapshot and then every event published after it, until the job ends. Jobs live only in server memory and are gone after a restart. The registry keeps the running jobs and this session's last ``MAX_FINISHED_JOBS`` finished -ones. Record-changing jobs (the metadata scan, and later maintenance, cleanup -and restore) never run together, and one job of a kind runs at a time. +ones. Record-changing jobs (the metadata scan, bulk image tagging, and later +maintenance, cleanup and restore) never run together, and one job of a kind +runs at a time. """ import asyncio @@ -54,6 +55,11 @@ SCAN_GROUPS = { "failed": "errors", } +# Bulk image tagging (#249 D1): Add tags and Auto-tag over selected images. +# Record-changing: it writes image tag sets and new vocabulary names. +IMAGE_TAG_JOB_KIND = "image-tags" +IMAGE_TAG_GROUP_NAMES = ("tagged", "unchanged", "skipped", "errors") + def _relative_path(path) -> str: """``path`` as a root-relative POSIX path; an absolute one keeps its name.""" @@ -292,13 +298,9 @@ class JobRegistry: running or when ``record_changing`` and another record-changing job runs. Must be called from the event loop. """ - same_kind = self.active(kind) - if same_kind is not None: - raise JobBusy(same_kind) - if record_changing: - for job in self._jobs.values(): - if job.running and job.record_changing: - raise JobBusy(job) + blocker = self.blocker(kind, record_changing=record_changing) + if blocker is not None: + raise JobBusy(blocker) self._prune() job = Job( kind, title, record_changing=record_changing, scope=scope, groups=groups @@ -307,6 +309,17 @@ class JobRegistry: job.task = asyncio.ensure_future(self._run(job, runner)) return job + def blocker(self, kind, *, record_changing): + """The running job a new ``kind`` job would be refused for, or None.""" + same_kind = self.active(kind) + if same_kind is not None: + return same_kind + if record_changing: + for job in self._jobs.values(): + if job.running and job.record_changing: + return job + return None + async def _run(self, job, runner): try: await runner(job) diff --git a/tests/browser/activity-concept.spec.mjs b/tests/browser/activity-concept.spec.mjs index 2f4ef4d..9dd4e64 100644 --- a/tests/browser/activity-concept.spec.mjs +++ b/tests/browser/activity-concept.spec.mjs @@ -249,14 +249,16 @@ const EXPECTED_JOB_VIEW = { pressed: { border: true, background: true, added: true, bad: true, warn: true }, }; -async function openRunningJob(page, request, theme, viewport = { width: 1600, height: 1100 }) { +const SCAN_START = { start: { total: 19622 }, step: { processed: 8240, + counters: { known: 6511, review: 37, no_metadata: 254, errors: 18 }, + records: [{ group: 'added', path: 'CCC/mabes9sans-dataset-v1/IMG_GEN_00369_.png', detail: 'Added to the Library as “Mabes portrait”' }] } }; + +async function openRunningJob(page, request, theme, viewport = { width: 1600, height: 1100 }, data = SCAN_START) { await page.setViewportSize(viewport); - const started = await request.post('/__test__/jobs/start', { data: { total: 19622 } }); + const started = await request.post('/__test__/jobs/start', { data: data.start }); await expect(started).toBeOK(); const { job } = await started.json(); - await expect(await request.post(`/__test__/jobs/${job.id}/step`, { data: { processed: 8240, - counters: { known: 6511, review: 37, no_metadata: 254, errors: 18 }, - records: [{ group: 'added', path: 'CCC/mabes9sans-dataset-v1/IMG_GEN_00369_.png', detail: 'Added to the Library as “Mabes portrait”' }] } })).toBeOK(); + await expect(await request.post(`/__test__/jobs/${job.id}/step`, { data: data.step })).toBeOK(); await openProduction(page, `activity/${job.id}`, theme); await expect(page.locator('#workspace-main .file-row')).toHaveCount(1); return job; @@ -278,6 +280,30 @@ for (const theme of THEMES) test(`job view follows the approved mockup ${theme}` expect(await page.evaluate(measureJobView)).toEqual(EXPECTED_JOB_VIEW); }); +// D89 (plan D66): an image tagging job's view is the same mockup with its own four tiles (Tagged green, Unchanged, +// Skipped warning, Errors red) in the mockup's five-column row, its own outcome list and "Tagging log". +const TAG_START = { start: { kind: 'image-tags', title: 'Auto-tag images', mode: 'auto', images: 4, total: 4 }, step: { processed: 2, + counters: { unchanged: 1, errors: 1 }, records: [{ group: 'tagged', path: 'CCC/mabes9sans-dataset-v1/IMG_GEN_00369_.png', detail: 'Added 3 tags' }] } }; +const EXPECTED_TAG_VIEW = { + ...EXPECTED_JOB_VIEW, + progress: [MOCKUP_PROGRESS[0], MOCKUP_PROGRESS[1], 'dl.stat-row(div.added.stat,div.stat,div.stat.warn,div.bad.stat)'], + summary: '● Tagging log — raw detail, click to show', +}; + +for (const theme of THEMES) test(`image tagging job view follows the approved mockup ${theme}`, async ({ page, request }) => { + await seedConcept(request); + await openRunningJob(page, request, theme, undefined, TAG_START); + expect(await page.evaluate(measureJobView)).toEqual(EXPECTED_TAG_VIEW); + await expect(page.locator('#workspace-main .outcome-list > button')).toHaveText(['Tagged1', 'Errors1', 'Skipped0', 'Unchanged1']); + // Mutation check: the scan's five tiles and the scan's log label are both noticed. + expect(diffInventory(EXPECTED_JOB_VIEW, await page.evaluate(measureJobView)).map(item => item.path).sort()) + .toEqual(['progress.2', 'summary']); + const undoCss = await mutate(page, { css: '#workspace-main .stat-row .stat.warn dd { color: red; }' }); + expect(diffInventory(EXPECTED_TAG_VIEW, await page.evaluate(measureJobView)).map(item => item.path)).toEqual(['pressed.warn']); + await undoCss(); + expect(await page.evaluate(measureJobView)).toEqual(EXPECTED_TAG_VIEW); +}); + test('job view fits a phone without page scroll', async ({ page, request }) => { await seedConcept(request); await openRunningJob(page, request, 'dark', { width: 390, height: 844 }); diff --git a/tests/browser/concept-inventory.mjs b/tests/browser/concept-inventory.mjs index 862ad30..3912286 100644 --- a/tests/browser/concept-inventory.mjs +++ b/tests/browser/concept-inventory.mjs @@ -138,10 +138,12 @@ export async function hoverStateOf(page,locator) { /** * Mutate the open page so a comparison can be shown to notice a change (the A7 mutation check): `css` is appended as * the last stylesheet, so it wins over equally specific concept rules, and every element matching a `remove` selector - * is detached. Returns an async undo that restores the stylesheet and the removed elements in place. + * is detached; each `clone` ({selector, text}) inserts a copy of the first match after it, holding `text` and no + * `data-action` (a control production adds beside a concept one, D85). Returns an async undo that restores the + * stylesheet and the removed elements in place and drops the copies. */ -export async function mutate(page,{css=null,remove=[]}={}) { - const token=await page.evaluate(({css,remove})=>{ +export async function mutate(page,{css=null,remove=[],clone=[]}={}) { + const token=await page.evaluate(({css,remove,clone})=>{ const token=`mutation-${Math.random().toString(36).slice(2)}`; const undo=[]; if(css) { @@ -156,9 +158,15 @@ export async function mutate(page,{css=null,remove=[]}={}) { node.remove();undo.push(()=>parent.insertBefore(node,next)); } } + for(const {selector,text} of clone) { + const node=document.querySelector(selector); + if(!node)throw new Error(`mutate: nothing matches ${selector}`); + const copy=node.cloneNode(true);copy.textContent=text;copy.removeAttribute('data-action'); + node.after(copy);undo.push(()=>copy.remove()); + } (window.__conceptMutations??=new Map()).set(token,undo); return token; - },{css,remove}); + },{css,remove,clone}); return ()=>page.evaluate(token=>{ const undo=window.__conceptMutations?.get(token); if(!undo)throw new Error('mutate: this mutation was already undone or the page reloaded'); diff --git a/tests/browser/images-concept.spec.mjs b/tests/browser/images-concept.spec.mjs index e7e219c..52e5d0b 100644 --- a/tests/browser/images-concept.spec.mjs +++ b/tests/browser/images-concept.spec.mjs @@ -30,9 +30,11 @@ function captionDay(seconds,now=new Date()) { return `${name} · ${date.toLocaleDateString('en-US',options)}`; } -/** D8: the selection bar's real actions only; Add tags, Add to collection and Remove links… are left out. */ -const OMITTED_BULK=Object.freeze(['Add tags','Add to collection','Remove links…']); -const OMITTED_BULK_SELECTORS=Object.freeze(['bulk-tags','bulk-collection','bulk-remove'].map(action=>`#bulkbar [data-action="${action}"]`)); +/** D8 (amended by #249 PR D): the selection bar's real actions only; Add to collection and Remove links… are left out. */ +const OMITTED_BULK=Object.freeze(['Add to collection','Remove links…']); +const OMITTED_BULK_SELECTORS=Object.freeze(['bulk-collection','bulk-remove'].map(action=>`#bulkbar [data-action="${action}"]`)); +/** D85: Auto-tag selected follows the concept's Add tags, a concept button of its own text. */ +const AUTO_TAG_CLONE=Object.freeze([{selector:'#bulkbar [data-action="bulk-tags"]',text:'Auto-tag selected'}]); /** * `withoutItems` for a row with padding and a border (the `.bulkbar`): its items flow again inside the content box, @@ -443,6 +445,10 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`images selecti const restore=await mutate(page,{remove:[...OMITTED_BULK_SELECTORS]}); const removed=(await inventoryOf(page,'#main')).bulkbar; await restore(); + // The oracle for production's bar: the concept page itself without the omitted actions and with D85's button. + const restoreProduction=await mutate(page,{remove:[...OMITTED_BULK_SELECTORS],clone:AUTO_TAG_CLONE}); + const oracle=await inventoryOf(page,'#main'); + await restoreProduction(); await captureCanonicalState(page,testInfo,`concept-images-selected-${width}-${theme}`); await openProductionLoaded(page,theme,'no-preference'); const emptyActual=await inventoryOf(page,'#workspace-main'); @@ -454,12 +460,13 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`images selecti expect.soft(reference.bulkbar.bar.items.map(item=>item.text),'concept bar').toEqual(['1 selected','Add tags','Set category', 'Add to collection','Export metadata','Remove links…','Clear']); expect.soft(diffInventory(stripExact(mapBulkbar(reference.bulkbar,0).bulkbar.bar),stripExact(removed.bar)),'bar without the omitted actions').toEqual([]); - expect.soft(actual.bulkbar.bar.items.map(item=>item.text),'production bar').toEqual(['1 selected','Set category','Export metadata','Clear']); + expect.soft(actual.bulkbar.bar.items.map(item=>item.text),'production bar').toEqual(['1 selected','Add tags','Auto-tag selected', + 'Set category','Export metadata','Clear']); expect.soft(compare(empty,emptyActual,manifest,['bulkbar','cards','panel']),'nothing checked').toEqual([]); - expect.soft(compare(reference,actual,manifest,['bulkbar','cards','panel']),'one card checked').toEqual([]); + expect.soft(compare(oracle,actual,manifest,['bulkbar','cards','panel']),'one card checked').toEqual([]); // The bar comparison notices a change to the production bar. await mutate(page,{css:'.bulkbar { padding: 4px }'}); - expect(compare(reference,await inventoryOf(page,'#workspace-main'),manifest,['bulkbar']).map(item=>item.path)).toContain('bulkbar.bar.paddingTop'); + expect(compare(oracle,await inventoryOf(page,'#workspace-main'),manifest,['bulkbar']).map(item=>item.path)).toContain('bulkbar.bar.paddingTop'); }); // The comparison is only worth something if it notices a change (spec §7 mutation check): one CSS mutation (the diff --git a/tests/browser/workspace-activity.spec.mjs b/tests/browser/workspace-activity.spec.mjs index 05cb2d7..80f9fcd 100644 --- a/tests/browser/workspace-activity.spec.mjs +++ b/tests/browser/workspace-activity.spec.mjs @@ -799,3 +799,114 @@ test('a failed report download says so and leaves the button usable', async ({ p await expect(toast(page)).toHaveText('The report could not be downloaded. Try again.'); await expect(button).not.toHaveAttribute('aria-disabled', 'true'); }); + +// ── #249 PR D Task D3 (D89, plan D66): image tagging jobs in Activity ───────────────────────── + +const TAG_JOB = { kind: 'image-tags', title: 'Auto-tag images', mode: 'auto', images: 4, total: 4 }; + +test('an image tagging job shows Tagged Unchanged Skipped Errors and opens tagged rows', async ({ page, request }) => { + await page.setViewportSize({ width: 1600, height: 1100 }); + const job = await startJob(request, TAG_JOB); + await step(request, job.id, { processed: 3, records: [ + { group: 'tagged', path: 'shared/same.png', root: 'output', detail: 'Added 2 tags' }, + { group: 'unchanged', path: 'shared/same.png', root: 'input', detail: 'No new tags' }, + { group: 'skipped', path: 'video/clip_0004.webm', root: 'output', detail: 'Video: manual tags only' }, + ] }); + await page.goto(`${workspace}#/images`); + // The pill names the kind's verb. + await expect(pill(page)).toHaveText('Tagging · 75% · 3 / 4'); + await openJob(page, job.id); + await expect(heading(page).locator('h1')).toHaveText('Auto-tag images'); + await expect(page).toHaveTitle('Auto-tag images · Prompt Manager'); + // The header's scope is the number of images, and the job cancels as "Cancel tagging" (danger). + await expect(heading(page).locator('p.muted')).toHaveText(/^Started \d\d:\d\d · 4 images · closing this view does not cancel it$/); + await expect(heading(page).locator('.heading-actions > button')).toHaveText(['Download report', 'Cancel tagging']); + await expect(heading(page).getByRole('button', { name: 'Cancel tagging', exact: true })).toHaveClass(/danger/); + await expect(progressSection(page).locator('.panel-heading > span.small')).toHaveText('Processing file 3 of 4'); + await expect(progressSection(page).locator('progress')).toHaveAttribute('aria-label', 'Auto-tag images progress'); + // Four tiles in the scan's tones: Tagged green, Unchanged plain, Skipped warning, Errors red. + expect(await tileValues(page)).toEqual([ + ['stat added', 'Tagged', '1'], ['stat', 'Unchanged', '1'], ['stat warn', 'Skipped', '1'], ['stat bad', 'Errors', '0'], + ]); + // What got tagged and what went wrong first, as the scan's list does. + await expect(outcomeButtons(page)).toHaveText(['Tagged1', 'Errors0', 'Skipped1', 'Unchanged1']); + await expect(outcomeButtons(page).first()).toHaveAttribute('aria-pressed', 'true'); + + // Tagged rows open the viewer on that file (D57's identity). + const tagged = fileRows(page).first(); + expect(await tagged.evaluate(node => node.localName)).toBe('button'); + await expect(tagged.locator('.path')).toHaveText('shared/same.png'); + await expect(tagged.locator('.why')).toHaveText('Added 2 tags'); + await expect(resultsNote(page)).toHaveText('Each row opens the image in the viewer.'); + await tagged.click(); + const viewer = page.locator('dialog#viewer-dialog[open]'); + await expect(viewer.locator('#viewer-stage img').first()).toHaveAttribute('src', /[?&]root=0(&|$)/); + await page.keyboard.press('Escape'); + await expect(viewer).toHaveCount(0); + await expect(tagged).toBeFocused(); + // Unchanged rows open too, here the input root's file. + await outcomeButtons(page).filter({ hasText: 'Unchanged' }).click(); + await fileRows(page).first().click(); + await expect(page.locator('dialog#viewer-dialog[open] #viewer-stage img').first()).toHaveAttribute('src', /[?&]root=1(&|$)/); + await page.keyboard.press('Escape'); + // Skipped rows are plain text with their reason. + await outcomeButtons(page).filter({ hasText: 'Skipped' }).click(); + expect(await fileRows(page).first().evaluate(node => node.localName)).toBe('div'); + await expect(fileRows(page).first().locator('.why')).toHaveText('Video: manual tags only'); + await expect(resultsNote(page)).toHaveCount(0); + // The log is the job's own: "Tagging log", with the runner's prefixes. + await expect(scanLog(page).locator('summary')).toHaveText('● Tagging log — raw detail, click to show'); + await scanLog(page).locator('summary').click(); + await expect(scanLog(page).locator('pre')).toContainText('+ shared/same.png · Added 2 tags'); + + // An error, then the end: the header and the Activity item read "N tagged · M errors". + await step(request, job.id, { processed: 4, records: [ + { group: 'errors', path: 'broken/truncated.png', root: 'output', detail: 'Auto-tagging failed. Check server logs for details.' }, + ] }); + await expect(tiles(page).last().locator('dd')).toHaveText('1'); + await finish(request, job.id); + await expect(toast(page)).toHaveText('Auto-tag images finished · 1 tagged · 1 error'); + await expect(heading(page).locator('p.muted')).toHaveText(/^Finished \d\d:\d\d · 1 tagged · 1 error$/); + await expect(heading(page).locator('.heading-actions > button')).toHaveText(['Download report']); + await expect(progressSection(page).locator('.panel-heading > span.small')).toHaveText('4 of 4 files processed'); + await page.goto(`${workspace}#/activity`); + await expect(items(page).first().locator('h3')).toHaveText('✓ Auto-tag images'); + await expect(items(page).first().locator('p')).toHaveText(/^Finished \d\d:\d\d · 1 tagged · 1 error$/); +}); + +test('cancel tagging stops at a file boundary', async ({ page, request }) => { + await page.setViewportSize({ width: 1600, height: 1100 }); + const job = await startJob(request, { ...TAG_JOB, title: 'Add tags to images', mode: 'add', images: 1250, total: 1250 }); + await step(request, job.id, { processed: 2, counters: { tagged: 2 } }); + await page.goto(`${workspace}#/activity`); + await expect(items(page).first().locator('p')).toHaveText(/^Started \d\d:\d\d · 1,250 images · closing this view does not cancel it$/); + await openJob(page, job.id); + await heading(page).getByRole('button', { name: 'Cancel tagging', exact: true }).focus(); + await page.keyboard.press('Enter'); + await expect(toast(page)).toHaveText('Add tags to images cancelled · 2 tagged'); + await expect(heading(page).locator('h1')).toBeFocused(); + await expect(heading(page).locator('p.muted')).toHaveText(/^Cancelled \d\d:\d\d at a file boundary · 2 tagged · completed work is kept$/); + await expect(heading(page).getByRole('button', { name: 'Cancel tagging', exact: true })).toHaveCount(0); + await expect(progressSection(page).locator('.panel-heading > span.small')).toHaveText('Cancelled after 2 of 1,250 files'); + expect((await tileValues(page)).map(([, , value]) => value)).toEqual(['2', '0', '0', '0']); + expect((await listed(request))[0].state).toBe('cancelled'); +}); + +test('the report download names the kind', async ({ page, request }) => { + await page.setViewportSize({ width: 1600, height: 1100 }); + const job = await startJob(request, TAG_JOB); + await step(request, job.id, { processed: 1, records: [ + { group: 'tagged', path: 'sub/a.png', root: 'output', detail: 'Added 1 tag' }, + ] }); + await finish(request, job.id); + await openJob(page, job.id); + const button = heading(page).getByRole('button', { name: 'Download report', exact: true }); + const [download] = await Promise.all([page.waitForEvent('download'), button.click()]); + expect(download.suggestedFilename()).toMatch(/^prompt-manager-image-tags-\d{8}-\d{4}\.json$/); + const report = JSON.parse(await (await import('node:fs/promises')).readFile(await download.path(), 'utf8')); + expect(report.job.kind).toBe('image-tags'); + expect(report.job.scope).toEqual({ mode: 'auto', images: 4 }); + expect(Object.keys(report.outcomes)).toEqual(['tagged', 'unchanged', 'skipped', 'errors']); + expect(report.outcomes.tagged.rows.map(row => [row.path, row.detail])).toEqual([['sub/a.png', 'Added 1 tag']]); + await expect(toast(page)).toHaveText('Job report downloaded.'); +}); diff --git a/tests/browser/workspace-images.spec.mjs b/tests/browser/workspace-images.spec.mjs index bd07349..27380a9 100644 --- a/tests/browser/workspace-images.spec.mjs +++ b/tests/browser/workspace-images.spec.mjs @@ -1702,7 +1702,8 @@ test('card checkboxes select images into the concept bar and Clear empties it', const selectedBefore = await cards(page).evaluateAll(nodes => nodes.map(node => node.classList.contains('selected'))); await checkbox(first, 'same.png').check(); await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('1 selected'); - await expect(barButtons(page)).toHaveText(['Set category', 'Export metadata', 'Clear']); + // D85: Add tags (the concept's) and Auto-tag selected lead the real actions. + await expect(barButtons(page)).toHaveText(['Add tags', 'Auto-tag selected', 'Set category', 'Export metadata', 'Clear']); // Toggling neither opens the viewer, nor inspects, nor moves focus. await expect(page.locator('dialog[open]')).toHaveCount(0); await expect(checkbox(first, 'same.png')).toBeFocused(); diff --git a/tests/browser/workspace-tagging-bulk.spec.mjs b/tests/browser/workspace-tagging-bulk.spec.mjs new file mode 100644 index 0000000..d356105 --- /dev/null +++ b/tests/browser/workspace-tagging-bulk.spec.mjs @@ -0,0 +1,323 @@ +// The Images selection bar's Add tags and Auto-tag selected (#249 PR D, plan Task D2, Mock inventory M3; D85, D90, +// D91) against the real `image-tags` job route and the injected fake model (tests/browser/workspace_tagging_fixture.py). +// No model is ever downloaded or loaded for real: the fake answers, and every test checks no download request was sent. +import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs'; +import { test, expect } from './fixtures.mjs'; + +const images = '/prompt_manager/workspace#/images'; +const FIRST = 'tagging/dunes_00001.png', SECOND = 'tagging/dunes_00002.png', UNLINKED = 'loose/unlinked.png'; +const card = (page, path) => page.locator(`article[data-image-key="0:${path}"]`); +const bar = page => page.locator('#workspace-main > #bulkbar > .bulkbar'); +const barButton = (page, name) => bar(page).getByRole('button', { name, exact: true }); +const modal = page => page.locator('dialog#modal[open]'); +const title = page => modal(page).locator('#modal-title'); +const toast = page => page.locator('#workspace-toast'); +const toasts = page => page.evaluate(() => window.__toasts); +const chips = page => modal(page).locator('#tag-suggestions .chip.tag-suggestion'); +const actionButtons = page => modal(page).locator('.dialog-actions button'); +const panel = page => page.getByRole('complementary', { name: 'Selected image details', exact: true }); +const tagRow = page => panel(page).locator('.detail-inner > .tags').last(); +const NOT_DOWNLOADED = 'WD14 SwinV2 is not downloaded. Download it from the legacy Auto-tag page.'; + +test.beforeEach(async ({ page, request, browserName }) => { + await startCoverage(page, browserName); + // Every toast text in order: a fast job's finish toast may replace its start toast before an assertion reads it. + await page.addInitScript(() => { + window.__toasts = []; + new MutationObserver(() => { + const text = document.getElementById('workspace-toast')?.textContent ?? ''; + if (text && window.__toasts.at(-1) !== text) window.__toasts.push(text); + }).observe(document, { subtree: true, childList: true, characterData: true }); + }); + await expect(await request.post('/__test__/tagging/reset', { data: {} })).toBeOK(); + 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 with `paths` checked, in that order. */ +async function select(page, ...paths) { + await page.goto(images); + for (const path of paths) await card(page, path).getByRole('checkbox').check(); + await expect(bar(page).locator('strong')).toHaveText(`${paths.length} selected`); +} +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; +} +/** The saved tags of `path` through the real observe route, then that capture's release. */ +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; +} +/** Wait until the session's job `id` has ended; returns its summary. */ +async function ended(request, id) { + let job; + await expect.poll(async () => { + const { jobs } = await (await request.get('/prompt_manager/jobs')).json(); + job = jobs.find(item => item.id === id); + return job?.state; + }).toBe('finished'); + return job; +} +const jobStart = page => page.waitForRequest(item => item.method() === 'POST' && new URL(item.url()).pathname === '/prompt_manager/jobs/image-tags'); + +test('selection bar shows Add tags then Auto-tag selected before Set category', async ({ page }) => { + await select(page, FIRST); + await expect(bar(page).locator(':scope > *')).toHaveText(['1 selected', 'Add tags', 'Auto-tag selected', 'Set category', 'Export metadata', 'Clear']); +}); + +test('Add tags runs a job and the cards show the tags after it finishes', async ({ page, request }) => { + await select(page, SECOND, FIRST); + await expect(card(page, FIRST)).toHaveClass(/\bselected\b/); + await barButton(page, 'Add tags').click(); + await expect(title(page)).toHaveText('Tag selected images'); + const field = modal(page).getByRole('textbox', { name: 'Tags, separated by commas', exact: true }); + await expect(field).toHaveValue(''); + await expect(actionButtons(page)).toHaveText(['Cancel', 'Apply']); + let posted = 0; + page.on('request', item => { if (new URL(item.url()).pathname === '/prompt_manager/jobs/image-tags') posted += 1; }); + // A field that parses to no names keeps the dialog and its text, and reports why on the field (D118). + await field.fill(' , ,'); + await actionButtons(page).last().click(); + await expect.poll(() => field.evaluate(node => node.validationMessage)).toBe('Enter at least one tag.'); + await expect(title(page)).toHaveText('Tag selected images'); + await expect(field).toHaveValue(' , ,'); + await field.fill('sunset, Sunset , blue_hour,,'); + expect(await field.evaluate(node => node.validity.valid)).toBe(true); + const started = jobStart(page); + const answer = page.waitForResponse(item => new URL(item.url()).pathname === '/prompt_manager/jobs/image-tags'); + await actionButtons(page).last().click(); + const scope = await scopeOf(request); + // Listing order (the newer FIRST before SECOND), the reveal identity, the dialog's dedupe. + expect((await started).postDataJSON()).toEqual({ mode: 'add', tags: ['sunset', 'blue_hour'], targets: [ + { root_index: 0, root_scope: scope, path: FIRST }, { root_index: 0, root_scope: scope, path: SECOND }] }); + const { job } = await (await answer).json(); + // The refused Apply sent nothing: only the valid one was posted. + expect(posted).toBe(1); + await expect.poll(() => toasts(page)).toContain('Tagging started.'); + expect(await toasts(page)).not.toContain('Enter at least one tag.'); + await expect(modal(page)).toHaveCount(0); + await expect(bar(page).locator('strong')).toHaveText('2 selected'); + expect((await ended(request, job.id)).counters).toEqual({ tagged: 2, unchanged: 0, skipped: 0, errors: 0 }); + // The finished job's files are read again: the selected card's panel shows its new image tags. + await expect(tagRow(page).locator('button.chip')).toHaveText(['sunset', 'blue_hour']); + expect(await savedTags(request, SECOND)).toEqual(['sunset', 'blue_hour']); + expect(await savedTags(request, UNLINKED)).toEqual([]); +}); + +test('Auto-tag selected with Apply automatically runs a job with the chosen model, thresholds and suggestion count', async ({ page, request }) => { + await select(page, FIRST, UNLINKED); + await barButton(page, 'Auto-tag selected').click(); + await expect(title(page)).toHaveText('Auto-tag selected images'); + await expect(modal(page)).toHaveClass(/(^| )tagging-modal( |$)/); + const body = modal(page).locator('#modal-body'); + expect(await body.evaluate(node => [...node.children].map(child => child.className))).toEqual(['tag-model-panel', 'field', 'small', 'dialog-actions']); + await expect(body.locator('.tag-model-panel h3')).toHaveText('Auto-tag 2 images'); + // No preview, manual field, suggestions or Generate: only the model panel's fields. + await expect(body.getByRole('button', { name: 'Generate suggestions', exact: true })).toHaveCount(0); + await expect(body.locator('#manual-image-tags, .tag-suggestion-panel, .tag-image-preview')).toHaveCount(0); + const group = body.getByRole('radiogroup', { name: 'How to apply', exact: true }); + await expect(group.getByRole('radio', { name: 'Apply automatically', exact: true })).toBeChecked(); + await expect(group.getByRole('radio', { name: 'Review each image', exact: true })).not.toBeChecked(); + await expect(actionButtons(page)).toHaveText(['Cancel', 'Start auto-tagging']); + await body.getByRole('combobox', { name: 'Tagging model', exact: true }).selectOption('wd14-vit'); + await body.getByRole('spinbutton', { name: 'General confidence', exact: true }).fill('0.5'); + await body.getByRole('spinbutton', { name: 'Character confidence', exact: true }).fill('0.9'); + const limit = body.getByRole('spinbutton', { name: 'Suggestions', exact: true }); + // An invalid count reports its validity and sends nothing. + await limit.fill('0'); + let posted = 0; + page.on('request', item => { if (new URL(item.url()).pathname === '/prompt_manager/jobs/image-tags') posted += 1; }); + await actionButtons(page).last().click(); + expect(await limit.evaluate(node => node.validity.valid)).toBe(false); + await expect(title(page)).toHaveText('Auto-tag selected images'); + await limit.fill('2'); + const started = jobStart(page); + const answer = page.waitForResponse(item => new URL(item.url()).pathname === '/prompt_manager/jobs/image-tags'); + await actionButtons(page).last().click(); + const scope = await scopeOf(request); + expect((await started).postDataJSON()).toEqual({ mode: 'auto', model_type: 'wd14-vit', + options: { general_threshold: 0.5, character_threshold: 0.9, suggestion_limit: 2 }, + targets: [{ root_index: 0, root_scope: scope, path: FIRST }, { root_index: 0, root_scope: scope, path: UNLINKED }] }); + const { job } = await (await answer).json(); + // The invalid press sent nothing: only this Start was posted. + expect(posted).toBe(1); + await expect.poll(() => toasts(page)).toContain('Auto-tagging started.'); + await expect(modal(page)).toHaveCount(0); + await expect(bar(page).locator('strong')).toHaveText('2 selected'); + expect((await ended(request, job.id)).counters).toEqual({ tagged: 2, unchanged: 0, skipped: 0, errors: 0 }); + expect(await savedTags(request, FIRST)).toEqual(['sandstone', 'desert']); + expect(await savedTags(request, UNLINKED)).toEqual(['sandstone', 'desert']); + expect(await savedTags(request, SECOND)).toEqual([]); +}); + +test('a model that is not downloaded keeps Start disabled and says why', async ({ page, request }) => { + await expect(await request.post('/__test__/tagging/models', { data: { unavailable: ['wd14-swinv2'] } })).toBeOK(); + await select(page, FIRST); + await barButton(page, 'Auto-tag selected').click(); + const status = modal(page).locator('#tag-generation-status'); + await expect(status).toHaveAttribute('role', 'status'); + await expect(status).toHaveText(NOT_DOWNLOADED); + const start = actionButtons(page).last(); + await expect(start).toHaveAttribute('aria-disabled', 'true'); + let posted = 0; + page.on('request', item => { if (new URL(item.url()).pathname === '/prompt_manager/jobs/image-tags') posted += 1; }); + // aria-disabled: a press does nothing. + await start.dispatchEvent('click'); + await expect(title(page)).toHaveText('Auto-tag selected images'); + await modal(page).getByRole('combobox', { name: 'Tagging model', exact: true }).selectOption('wd14-vit'); + await expect(status).toHaveText(''); + await expect(start).not.toHaveAttribute('aria-disabled', 'true'); + await modal(page).getByRole('combobox', { name: 'Tagging model', exact: true }).selectOption('wd14-swinv2'); + await expect(start).toHaveAttribute('aria-disabled', 'true'); + await actionButtons(page).first().click(); + await expect(modal(page)).toHaveCount(0); + await expect(barButton(page, 'Auto-tag selected')).toBeFocused(); + expect(posted).toBe(0); +}); + +test('Suggestions field prefills from the setting and caps chips at the entered count', async ({ page, request }) => { + await expect(await request.post('/__test__/tagging/defaults', { data: { general: 0.5, character: 0.9, limit: 7 } })).toBeOK(); + await select(page, FIRST); + await barButton(page, 'Auto-tag selected').click(); + const limit = modal(page).getByRole('spinbutton', { name: 'Suggestions', exact: true }); + await expect(limit).toHaveValue('7'); + await expect(modal(page).getByRole('spinbutton', { name: 'General confidence', exact: true })).toHaveValue('0.5'); + await expect(modal(page).getByRole('spinbutton', { name: 'Character confidence', exact: true })).toHaveValue('0.9'); + await modal(page).getByRole('radio', { name: 'Review each image', exact: true }).check(); + await expect(actionButtons(page)).toHaveText(['Cancel', 'Start review']); + await limit.fill('3'); + const suggest = page.waitForRequest(item => item.url().endsWith('/image-annotations/suggest')); + await actionButtons(page).last().click(); + await expect(title(page)).toHaveText('Tag image · Image 1 of 1'); + expect((await suggest).postDataJSON().options).toEqual({ general_threshold: 0.5, character_threshold: 0.9, suggestion_limit: 3 }); + await expect(chips(page)).toHaveCount(3); + await expect(modal(page).getByRole('spinbutton', { name: 'Suggestions', exact: true })).toHaveValue('3'); + await expect(actionButtons(page)).toHaveText(['Stop reviewing', 'Skip', 'Approve and finish']); + await page.keyboard.press('Escape'); + await expect(modal(page)).toHaveCount(0); + await expect(toast(page)).toHaveText('0 of 1 images tagged.'); + expect(await savedTags(request, FIRST)).toEqual([]); +}); + +test('Review each image walks the selection one dialog at a time and only approved images gain tags', async ({ page, request }) => { + await select(page, FIRST, SECOND, UNLINKED); + // The second image's first Generate finds the model busy (a legacy batch): the dialog waits for Retry or Skip. + let suggests = 0; + await page.route('**/prompt_manager/image-annotations/suggest', async route => { + suggests += 1; + if (suggests === 2) { + await route.fulfill({ status: 409, json: { success: false, code: 'model-busy', error: 'The tagging model is busy. Try again when it finishes.' } }); + return; + } + await route.continue(); + }); + const puts = []; + page.on('request', item => { if (item.method() === 'PUT' && item.url().endsWith('/image-annotations/tags')) puts.push(item.postDataJSON()); }); + await barButton(page, 'Auto-tag selected').click(); + await modal(page).getByRole('radio', { name: 'Review each image', exact: true }).check(); + await modal(page).getByRole('spinbutton', { name: 'Suggestions', exact: true }).fill('2'); + await actionButtons(page).last().click(); + await expect(title(page)).toHaveText('Tag image · Image 1 of 3'); + await expect(modal(page).getByRole('combobox', { name: 'Tagging model', exact: true })).toHaveValue('wd14-swinv2'); + await expect(chips(page)).toHaveCount(2); + await expect(actionButtons(page)).toHaveText(['Stop reviewing', 'Skip', 'Approve and next']); + await expect(actionButtons(page).last()).toHaveClass(/ca-primary/); + // Approve saves the dialog's manual field plus its remaining chips. + await modal(page).getByRole('textbox', { name: 'Image tags · edit manually', exact: true }).fill('mine'); + await modal(page).getByRole('button', { name: 'Remove desert', exact: true }).click(); + await actionButtons(page).last().click(); + await expect(title(page)).toHaveText('Tag image · Image 2 of 3'); + await expect(modal(page).locator('#tag-generation-status')).toHaveText('The tagging model is busy. Try again when it finishes.'); + // No suggestions on screen: the primary would save the manual field only (D90). + await expect(actionButtons(page)).toHaveText(['Retry', 'Stop reviewing', 'Skip', 'Save and next']); + await actionButtons(page).nth(2).click(); + await expect(title(page)).toHaveText('Tag image · Image 3 of 3'); + await expect(chips(page)).toHaveCount(2); + await expect(actionButtons(page)).toHaveText(['Stop reviewing', 'Skip', 'Approve and finish']); + await actionButtons(page).last().click(); + await expect(modal(page)).toHaveCount(0); + await expect(toast(page)).toHaveText('2 of 3 images tagged.'); + await expect(barButton(page, 'Auto-tag selected')).toBeFocused(); + await expect(bar(page).locator('strong')).toHaveText('3 selected'); + expect(puts.map(body => [body.target.path, body.tags])).toEqual([[FIRST, ['mine', 'sandstone']], [UNLINKED, ['sandstone', 'desert']]]); + expect(await savedTags(request, FIRST)).toEqual(['mine', 'sandstone']); + expect(await savedTags(request, SECOND)).toEqual([]); + expect(await savedTags(request, UNLINKED)).toEqual(['sandstone', 'desert']); + // The reviewed file's panel shows its saved tags without reselecting. + await expect(tagRow(page).locator('button.chip')).toHaveText(['mine', 'sandstone']); + await expect.poll(async () => (await (await request.get('/__test__/tagging/captures')).json()).captures).toBe(0); +}); + +test('Review holds Stop reviewing, Escape and × while a save is in flight, and names a manual-only save', async ({ page, request }) => { + await select(page, FIRST, SECOND); + let release; + const held = new Promise(resolve => { release = resolve; }); + await page.route('**/prompt_manager/image-annotations/tags', async route => { + if (route.request().method() === 'PUT') await held; + await route.continue(); + }); + await barButton(page, 'Auto-tag selected').click(); + await modal(page).getByRole('radio', { name: 'Review each image', exact: true }).check(); + await actionButtons(page).last().click(); + await expect(title(page)).toHaveText('Tag image · Image 1 of 2'); + await expect(chips(page)).not.toHaveCount(0); + await expect(actionButtons(page)).toHaveText(['Stop reviewing', 'Skip', 'Approve and next']); + // D90: a model change clears the suggestions, so the primary saves the manual field only and says so. + await modal(page).getByRole('combobox', { name: 'Tagging model', exact: true }).selectOption('wd14-vit'); + await expect(chips(page)).toHaveCount(0); + await expect(actionButtons(page)).toHaveText(['Stop reviewing', 'Skip', 'Save and next']); + await modal(page).getByRole('textbox', { name: 'Image tags · edit manually', exact: true }).fill('mine'); + const put = page.waitForRequest(item => item.method() === 'PUT' && item.url().endsWith('/image-annotations/tags')); + await actionButtons(page).last().click(); + await put; + const stop = actionButtons(page).first(); + await expect(stop).toHaveAttribute('aria-disabled', 'true'); + await expect(actionButtons(page).nth(1)).toHaveAttribute('aria-disabled', 'true'); + // None of these ends the session while the save is in flight; the count then includes it. + await page.keyboard.press('Escape'); + await modal(page).locator('#modal-close').click(); + await stop.dispatchEvent('click'); // aria-disabled: Playwright's click would wait for it + release(); + await expect(title(page)).toHaveText('Tag image · Image 2 of 2'); + await expect(chips(page)).not.toHaveCount(0); + await expect(actionButtons(page)).toHaveText(['Stop reviewing', 'Skip', 'Approve and finish']); + await expect(actionButtons(page).first()).not.toHaveAttribute('aria-disabled', 'true'); + await actionButtons(page).first().click(); + await expect(modal(page)).toHaveCount(0); + await expect(toast(page)).toHaveText('1 of 2 images tagged.'); + expect(await savedTags(request, FIRST)).toEqual(['mine']); + expect(await savedTags(request, SECOND)).toEqual([]); +}); + +test('a running scan makes the start report busy', async ({ page, request }) => { + const scan = await (await request.post('/__test__/jobs/start', { data: { kind: 'scan', title: 'Scan for metadata' } })).json(); + try { + await select(page, FIRST, SECOND); + await barButton(page, 'Add tags').click(); + await modal(page).getByRole('textbox', { name: 'Tags, separated by commas', exact: true }).fill('sunset'); + await actionButtons(page).last().click(); + await expect(toast(page)).toHaveText('Another job is running: Scan for metadata.'); + await expect(toast(page).getByRole('link', { name: 'Scan for metadata', exact: true })).toHaveAttribute('href', `#/activity/${scan.job.id}`); + await expect(bar(page).locator('strong')).toHaveText('2 selected'); + // Auto-tag is refused the same way. + await barButton(page, 'Auto-tag selected').click(); + await expect(actionButtons(page).last()).not.toHaveAttribute('aria-disabled', 'true'); + await actionButtons(page).last().click(); + await expect(toast(page)).toHaveText('Another job is running: Scan for metadata.'); + await expect(bar(page).locator('strong')).toHaveText('2 selected'); + expect(await savedTags(request, FIRST)).toEqual([]); + } finally { + await request.post(`/__test__/jobs/${scan.job.id}/finish`, { data: { state: 'finished' } }); + } +}); diff --git a/tests/browser/workspace_activity_fixture.py b/tests/browser/workspace_activity_fixture.py index 77f102a..c2a3d4d 100644 --- a/tests/browser/workspace_activity_fixture.py +++ b/tests/browser/workspace_activity_fixture.py @@ -4,7 +4,9 @@ A fake job is a real registry job whose runner waits for commands instead of reading files, so a test sets its progress and counters exactly: - ``POST /__test__/jobs/start`` starts one (``kind``, ``title``, ``total``, - ``output_folders``) and returns its summary, or 409 while one runs; + ``output_folders``; for ``kind: "image-tags"`` the image tagging job's + groups and its ``{mode, images}`` scope instead) and returns its summary, + or 409 while one runs; - ``POST /__test__/jobs/{id}/step`` records ``records`` (``{group, path, root, root_index, detail}`` file outcomes, counted, listed and logged as the scan records them) and ``bulk`` (``{group, count, root}``: ``count`` numbered @@ -28,6 +30,8 @@ POLL_SECONDS = 0.02 PREFIXES = {"added": "+", "known": "=", "review": "?", "errors": "!"} PREFIXES["no_metadata"] = "-" +# The image tagging job's groups (#249 PR D), logged with the runner's prefixes. +PREFIXES.update({"tagged": "+", "unchanged": "=", "skipped": "-"}) ROOT_INDEXES = {"output": 0, "input": 1} @@ -72,7 +76,13 @@ def outcome_records(data): def register_activity_commands(app, routes, api): - from py.api.jobs import SCAN_GROUP_NAMES, JobBusy, JobRegistry + from py.api.jobs import ( + IMAGE_TAG_GROUP_NAMES, + IMAGE_TAG_JOB_KIND, + SCAN_GROUP_NAMES, + JobBusy, + JobRegistry, + ) streams = set() endings = {} @@ -123,17 +133,29 @@ def register_activity_commands(app, routes, api): async def start_job(request): data = await body(request) ending = asyncio.get_running_loop().create_future() + kind = str(data.get("kind", "scan")) + if kind == IMAGE_TAG_JOB_KIND: + title = "Add tags to images" + scope = { + "mode": str(data.get("mode", "add")), + "images": count(data.get("images"), 0), + } + groups = IMAGE_TAG_GROUP_NAMES + else: + title = "Scan for metadata" + scope = { + "files": str(data.get("files", "new")), + "output_folders": count(data.get("output_folders"), 2), + } + groups = SCAN_GROUP_NAMES try: job = api._jobs.start( - str(data.get("kind", "scan")), - str(data.get("title", "Scan for metadata")), + kind, + str(data.get("title", title)), record_changing=True, - scope={ - "files": str(data.get("files", "new")), - "output_folders": count(data.get("output_folders"), 2), - }, + scope=scope, runner=fake_runner(ending), - groups=SCAN_GROUP_NAMES, + groups=groups, ) except JobBusy: raise web.HTTPConflict() diff --git a/tests/js/workspace-activity-store.test.js b/tests/js/workspace-activity-store.test.js index 89eb72d..a562fec 100644 --- a/tests/js/workspace-activity-store.test.js +++ b/tests/js/workspace-activity-store.test.js @@ -283,6 +283,48 @@ test('summaries are validated and the copy follows the spec', async () => { assert.deepEqual([-1, 0, 1, 4, 5, 6, 1e9, 0.5].map(backoffDelay), [1000, 1000, 2000, 16000, 30000, 30000, 30000, 1000]); }); +// D89 (plan D66): an image tagging job's view has its own tiles, outcomes, openable rows, log and cancel labels. +test('image-tags jobs get their own tiles outcomes and pill verb', async () => { + const { parseJob, jobView, pillParts, outcomeLine, startToast } = await source; + assert.equal(typeof jobView, 'function'); + const job = parseJob(summary({ kind: 'image-tags', title: 'Auto-tag images', progress: 40, processed: 2, total: 5, + counters: { tagged: 1420, unchanged: 3, skipped: 2, errors: 1 }, scope: { mode: 'auto', images: 5, model_type: 'wd14-vit' } })); + const view = jobView(job.kind); + assert.deepEqual(view.tiles, [['tagged', 'Tagged', 'added'], ['unchanged', 'Unchanged', ''], ['skipped', 'Skipped', 'warn'], + ['errors', 'Errors', 'bad']]); + assert.deepEqual(view.outcomes, [['tagged', 'Tagged'], ['errors', 'Errors'], ['skipped', 'Skipped'], ['unchanged', 'Unchanged']]); + assert.deepEqual([...view.openable].sort(), ['tagged', 'unchanged']); + assert.equal(view.log, 'Tagging log'); + assert.equal(view.cancel, 'Cancel tagging'); + assert.deepEqual(pillParts(job), { label: 'Tagging · 40%', files: ' · 2 / 5', text: 'Tagging · 40% · 2 / 5' }); + assert.equal(outcomeLine(job), '1,420 tagged · 1 error'); + assert.equal(outcomeLine({ ...job, counters: {} }), '0 tagged · 0 errors'); + assert.equal(startToast(job), 'Auto-tagging started.'); + // The design's toast pattern, as the scan's: "<Job> finished · N tagged · M errors". + const { finishToast } = await source; + assert.equal(finishToast({ ...job, state: 'finished' }), 'Auto-tag images finished · 1,420 tagged · 1 error'); + assert.equal(finishToast({ ...job, state: 'cancelled' }), 'Auto-tag images cancelled · 1,420 tagged'); + assert.equal(finishToast({ ...job, state: 'failed' }), 'Auto-tag images failed · 1,420 tagged · 1 error'); + assert.equal(finishToast({ ...job, title: 'Add tags to images', counters: {}, state: 'finished' }), + 'Add tags to images finished · 0 tagged · 0 errors'); +}); + +test('unknown kinds keep the generic view', async () => { + const { jobView } = await source; + const scan = jobView('scan'); + assert.deepEqual(scan.tiles, [['added', 'Added to Library', 'added'], ['known', 'Already known', ''], + ['review', 'Needs review', 'warn'], ['no_metadata', 'No usable metadata', ''], ['errors', 'Errors', 'bad']]); + assert.deepEqual(scan.outcomes, [['added', 'Added to Library'], ['review', 'Needs review'], ['errors', 'Errors'], + ['no_metadata', 'No usable metadata'], ['known', 'Already known']]); + assert.deepEqual([...scan.openable].sort(), ['added', 'known']); + assert.deepEqual([scan.log, scan.cancel], ['Scan log', 'Cancel scan']); + for (const kind of ['maintenance', '', 'constructor', '__proto__']) { + const view = jobView(kind); + assert.deepEqual([view.tiles, view.outcomes, [...view.openable]], [scan.tiles, scan.outcomes, [...scan.openable]]); + assert.deepEqual([view.log, view.cancel], ['Job log', 'Cancel job']); + } +}); + test('pagehide stops polling, closes streams and aborts the in-flight read; pageshow resumes', async () => { const { createActivityStore } = await source; const { world, api, ...deps } = harness(); diff --git a/tests/js/workspace-images-tagging-bulk.test.js b/tests/js/workspace-images-tagging-bulk.test.js new file mode 100644 index 0000000..e5647db --- /dev/null +++ b/tests/js/workspace-images-tagging-bulk.test.js @@ -0,0 +1,198 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +// Guarded: before the module exists every test fails on an assertion, not on the import. +const loaded = await import('../../web/js/workspace/images-tagging-bulk.mjs').catch(() => null); +const module = () => { assert.ok(loaded, 'images-tagging-bulk.mjs is not implemented'); return loaded; }; +const state = await import('../../web/js/workspace/image-tagging-state.mjs'); +const { startToast, parseJob } = await import('../../web/js/workspace/activity-store.mjs'); +const SCOPE = 'b'.repeat(64); +const JOB_ID = 'c'.repeat(32); +const image = (path, extra = {}) => ({ root_index: 0, root_scope: SCOPE, path, filename: path.split('/').pop(), ...extra }); +const pageScope = () => { + const controller = new AbortController(); + const cleanups = []; + return { signal: controller.signal, cleanup: fn => cleanups.push(fn), abort: () => controller.abort(), cleanups }; +}; + +test('targets keep listing order and identity', () => { + const { bulkTargets } = module(); + const listing = [image('b/2.png', { width: 9 }), image('a/1.png'), { ...image('x.png'), root_scope: 'nope' }, image('c/3.webm')]; + assert.deepEqual(bulkTargets(listing), [ + { root_index: 0, root_scope: SCOPE, path: 'b/2.png' }, + { root_index: 0, root_scope: SCOPE, path: 'a/1.png' }, + { root_index: 0, root_scope: SCOPE, path: 'c/3.webm' }, + ]); + assert.deepEqual(bulkTargets([]), []); +}); + +test('over 500 selected refuses with the toast', () => { + const { bulkTargets, openAddTags, openAutoTag, MAX_BULK_IMAGES, TOO_MANY } = module(); + assert.equal(MAX_BULK_IMAGES, 500); + assert.equal(TOO_MANY, 'Select at most 500 images.'); + const many = Array.from({ length: 501 }, (_, index) => image(`bulk/${index}.png`)); + assert.equal(bulkTargets(many), null); + assert.equal(bulkTargets(many.slice(0, 500)).length, 500); + const toasts = []; + const store = { start: () => assert.fail('no job starts') }; + // Refused before any dialog opens (no DOM here). + openAddTags({ scope: pageScope(), store, images: many, notify: text => toasts.push(text) }); + openAutoTag({ api: {}, scope: pageScope(), store, images: many, notify: text => toasts.push(text) }); + assert.deepEqual(toasts, [TOO_MANY, TOO_MANY]); +}); + +test('add parses comma tags with the same dedupe as the dialog', () => { + const { parseBulkTags, ADD_DIALOG } = module(); + assert.deepEqual(ADD_DIALOG, { title: 'Tag selected images', label: 'Tags, separated by commas', action: 'Apply' }); + assert.deepEqual(parseBulkTags(' Sunset, sunset ,, blue_hour,SUNSET, Blue_Hour '), ['Sunset', 'blue_hour']); + assert.deepEqual(parseBulkTags(' , ,'), []); + assert.equal(parseBulkTags, state.parseManualTags); +}); + +test('Add tags keeps its dialog and the typed text when the field parses to no names', async () => { + const { openAddTags, NO_TAGS } = module(); + const toasts = []; + const posted = []; + const store = { start: async (kind, body) => { posted.push(body); return { job: { id: JOB_ID, state: 'running' } }; }, + find: () => null, subscribe: () => () => {} }; + let dialog = null; + openAddTags({ scope: pageScope(), store, images: [image('a.png')], trigger: null, notify: text => toasts.push(text), + openInput: args => { dialog = args; } }); + assert.equal(typeof dialog?.validate, 'function'); + // The dialog reports the message on its field and stays open: nothing toasts, nothing is sent. + assert.equal(dialog.validate(' , ,'), NO_TAGS); + assert.equal(dialog.validate('sunset'), null); + assert.deepEqual(toasts, []); + await dialog.onApply('sunset, Sunset'); + assert.deepEqual(posted.map(body => body.tags), [['sunset']]); +}); + +test('busy start keeps the selection and names the running job', async () => { + const { startTagJob } = module(); + const toasts = []; + const posted = []; + const running = { id: JOB_ID, title: 'Scan for metadata' }; + const store = { start: async (kind, body) => { posted.push([kind, body]); return { busy: running, message: 'busy' }; } }; + const body = { mode: 'add', targets: [{ root_index: 0, root_scope: SCOPE, path: 'a.png' }], tags: ['x'] }; + assert.equal(await startTagJob({ store, scope: pageScope(), body, notify: text => toasts.push(text) }), null); + assert.deepEqual(posted, [['image-tags', body]]); + assert.deepEqual(toasts, [['Another job is running: ', { text: 'Scan for metadata', href: `#/activity/${JOB_ID}` }, '.']]); +}); + +test('a started job is returned without a toast of its own; refusals toast their sentence', async () => { + const { startTagJob } = module(); + const toasts = []; + const notify = text => toasts.push(text); + const job = { id: JOB_ID, kind: 'image-tags' }; + assert.equal(await startTagJob({ store: { start: async () => ({ job }) }, scope: pageScope(), body: { mode: 'add' }, notify }), job); + const refused = Object.assign(new Error('The tagging model is busy. Try again when it finishes.'), { name: 'ApiError', status: 409, code: 'model-busy' }); + assert.equal(await startTagJob({ store: { start: async () => { throw refused; } }, scope: pageScope(), body: { mode: 'auto' }, notify }), null); + assert.equal(await startTagJob({ store: { start: async () => { throw new TypeError('down'); } }, scope: pageScope(), body: { mode: 'auto' }, notify }), null); + assert.equal(await startTagJob({ store: { start: async () => { throw new TypeError('down'); } }, scope: pageScope(), body: { mode: 'add' }, notify }), null); + assert.deepEqual(toasts, ['The tagging model is busy. Try again when it finishes.', 'Auto-tagging could not start. Try again.', + 'Tagging could not start. Try again.']); + // A page left meanwhile says nothing. + const left = pageScope(); + left.abort(); + assert.equal(await startTagJob({ store: { start: async () => { throw refused; } }, scope: left, body: { mode: 'auto' }, notify }), null); + assert.equal(toasts.length, 3); +}); + +test('the start toast names the tagging mode', () => { + const job = mode => parseJob({ id: JOB_ID, state: 'running', kind: 'image-tags', title: 'Add tags to images', scope: { mode, images: 2 } }); + assert.equal(startToast(job('add')), 'Tagging started.'); + assert.equal(startToast(job('auto')), 'Auto-tagging started.'); + assert.equal(startToast(job('other')), 'Add tags to images started.'); + assert.equal(startToast(parseJob({ id: JOB_ID, state: 'running', kind: 'scan', title: 'Scan for metadata' })), 'Scan started.'); +}); + +test('a followed job reports its end once', () => { + const { followJobEnd } = module(); + let jobs = [{ id: JOB_ID, state: 'running', counters: {} }]; + const listeners = new Set(); + const store = { find: id => jobs.find(job => job.id === id), subscribe: fn => { listeners.add(fn); return () => listeners.delete(fn); } }; + const ended = []; + const scope = pageScope(); + followJobEnd({ store, scope, id: JOB_ID, onEnd: job => ended.push(job.state) }); + assert.equal(listeners.size, 1); + for (const fn of [...listeners]) fn(store); + assert.deepEqual(ended, []); + jobs = [{ id: JOB_ID, state: 'finished', counters: { tagged: 1 } }]; + for (const fn of [...listeners]) fn(store); + for (const fn of [...listeners]) fn(store); + assert.deepEqual(ended, ['finished']); + assert.equal(listeners.size, 0); + assert.equal(scope.cleanups.length, 1); +}); + +test('a model that is not downloaded is named with the server sentence', () => { + const { modelUnavailable } = state; + assert.equal(modelUnavailable(null, 'wd14-vit'), ''); + assert.equal(modelUnavailable({ 'wd14-vit': { downloaded: true } }, 'wd14-vit'), ''); + assert.equal(modelUnavailable({ 'wd14-vit': { downloaded: false } }, 'wd14-vit'), 'WD14 ViT is not downloaded. Download it from the legacy Auto-tag page.'); + assert.equal(modelUnavailable({ gguf: { downloaded: false, name: 'GGUF (Recommended)' } }, 'gguf'), + 'GGUF (Recommended) is not downloaded. Download it from the legacy Auto-tag page.'); + assert.throws(() => modelUnavailable({}, 'nope'), TypeError); +}); + +test('the bulk options start from the server defaults and wait for them before Start', async () => { + const { createTaggingOptionsController } = state; + let answer; + const api = { get: () => new Promise(resolve => { answer = resolve; }) }; + const options = createTaggingOptionsController({ api }); + const seen = []; + options.subscribe(value => seen.push(value.optionsRevision)); + assert.equal(options.state.canStart, false); + assert.equal(options.state.canGenerate, false); + const opening = options.open(); + answer({ wd14_general_threshold: 0.5, wd14_character_threshold: 0.9, default_prompt: 'Say it', suggestion_limit: 7, + models: { 'wd14-swinv2': { downloaded: true }, 'wd14-vit': { downloaded: false }, gguf: { downloaded: true } } }); + await opening; + assert.equal(options.state.canStart, true); + assert.deepEqual(options.request(), { model_type: 'wd14-swinv2', options: { general_threshold: 0.5, character_threshold: 0.9, suggestion_limit: 7 } }); + options.setOption('suggestion_limit', '3'); + options.setModel('wd14-vit'); + assert.equal(options.state.canStart, false); + assert.match(options.state.unavailable, /^WD14 ViT is not downloaded\./); + options.setModel('gguf'); + assert.deepEqual(options.request(), { model_type: 'gguf', options: { instructions: 'Say it', suggestion_limit: 3 } }); + options.setOption('instructions', ' '); + assert.equal(options.request(), null); + assert.deepEqual(seen, [1, 1, 2, 3, 3]); + assert.throws(() => options.setModel('nope'), TypeError); + assert.throws(() => options.setOption('nope', 1), TypeError); + options.dispose(); + options.setOption('suggestion_limit', '4'); + assert.equal(options.state.options.suggestion_limit, '3'); +}); + +test('the bulk options keep the documented defaults when the read fails', async () => { + const { createTaggingOptionsController } = state; + const options = createTaggingOptionsController({ api: { get: async () => { throw new Error('offline'); } } }); + await options.open(); + await options.open(); + assert.equal(options.state.canStart, true); + assert.equal(options.state.unavailable, ''); + assert.deepEqual(options.request(), { model_type: 'wd14-swinv2', options: { general_threshold: 0.35, character_threshold: 0.85, suggestion_limit: 10 } }); + assert.throws(() => createTaggingOptionsController({}), TypeError); +}); + +test('a preset starts the Tag image dialog on the chosen model and options', async () => { + const { createImageTaggingController } = state; + const target = { root_index: 0, root_scope: SCOPE, path: 'a.png' }; + const api = { + get: async () => ({ wd14_general_threshold: 0.35, wd14_character_threshold: 0.85, default_prompt: 'Describe', suggestion_limit: 10 }), + post: async () => ({ success: true, library_epoch: '12345678-1234-1234-1234-123456789abc', filename: 'a.png', title: null, tags: [], tag_revision: 0, + capture: { token: 'tok', sha256: 'd'.repeat(64), preview_url: '/prompt_manager/image-annotations/capture/tok', width: 4, height: 4, + media_type: 'image', generate: { supported: true, reason: null } } }), + put: async () => ({}), del: async () => ({}), + }; + const controller = createImageTaggingController({ api, target, source: 'bulk', preset: { model: 'gguf', options: { instructions: 'Name it', suggestion_limit: 3 } } }); + assert.equal(controller.state.model, 'gguf'); + await controller.open(); + assert.deepEqual([controller.state.model, controller.state.options.instructions, controller.state.options.suggestion_limit], ['gguf', 'Name it', 3]); + controller.dispose(); + assert.throws(() => createImageTaggingController({ api, target, preset: { model: 'nope', options: {} } }), TypeError); + assert.throws(() => createImageTaggingController({ api, target, preset: { model: 'gguf', options: { instructions: '' } } }), TypeError); + assert.throws(() => createImageTaggingController({ api, target, preset: { model: 'gguf', options: { nope: 1 } } }), TypeError); +}); diff --git a/tests/js/workspace-images-tagging-review.test.js b/tests/js/workspace-images-tagging-review.test.js new file mode 100644 index 0000000..123ba63 --- /dev/null +++ b/tests/js/workspace-images-tagging-review.test.js @@ -0,0 +1,110 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +// Guarded: before the module exists every test fails on an assertion, not on the import. +const loaded = await import('../../web/js/workspace/images-tagging-review.mjs').catch(() => null); +const module = () => { assert.ok(loaded, 'images-tagging-review.mjs is not implemented'); return loaded; }; +const SCOPE = 'e'.repeat(64); +const images = ['a.png', 'b.png', 'c.png'].map(path => ({ root_index: 0, root_scope: SCOPE, path, filename: path })); +const OPTIONS = { general_threshold: 0.4, character_threshold: 0.8, suggestion_limit: 3 }; + +/** A session over fake dialogs: `dialogs` holds every open call's arguments and whether it was closed. */ +function session(extra = {}) { + const dialogs = []; + const toasts = []; + let closes = 0; + const saved = []; + const handle = module().startReviewSession({ + api: {}, scope: {}, images, model_type: 'wd14-vit', options: OPTIONS, trigger: 'auto-tag', + onSaved: event => saved.push(event.filename), + open: args => { + const dialog = { args, closed: 0 }; + dialogs.push(dialog); + return { close: () => { dialog.closed += 1; args.onStop(); } }; + }, + close: () => { closes += 1; }, + notify: text => toasts.push(text), + ...extra, + }); + return { handle, dialogs, toasts, saved, closes: () => closes }; +} +const approve = (dialog, tags = ['x']) => dialog.args.onSaved({ filename: dialog.args.image.filename, tags, target: {} }); + +test('the first image opens at once with the session model, options and its position', () => { + const { dialogs } = session(); + assert.equal(dialogs.length, 1); + const { args } = dialogs[0]; + assert.equal(args.image, images[0]); + assert.deepEqual(args.position, { index: 1, total: 3 }); + assert.deepEqual(args.preset, { model: 'wd14-vit', options: OPTIONS }); + assert.equal(args.autoGenerate, true); + assert.equal(args.actions, 'review'); + assert.equal(args.source, 'bulk'); + assert.equal(args.trigger, 'auto-tag'); + assert.throws(() => module().startReviewSession({ images: [] }), TypeError); +}); + +test('advances only on approve or skip and writes nothing on skip or stop', () => { + const { dialogs, toasts, saved } = session(); + // Nothing from the dialog (a Generate in progress, a model-busy answer): the session waits on that image. + assert.equal(dialogs.length, 1); + dialogs[0].args.onSkip(); + assert.equal(dialogs.length, 2); + assert.deepEqual(dialogs[1].args.position, { index: 2, total: 3 }); + // A second report from a dialog already left changes nothing. + dialogs[0].args.onSkip(); + dialogs[0].args.onStop(); + assert.equal(dialogs.length, 2); + approve(dialogs[1]); + assert.equal(dialogs.length, 3); + dialogs[2].args.onStop(); + assert.deepEqual(saved, ['b.png']); + assert.deepEqual(toasts, ['1 of 3 images tagged.']); + assert.equal(dialogs.length, 3); +}); + +test('approve saves exactly the dialog\'s manual-plus-chips set', () => { + // The session never writes itself: each approve is the dialog's own Save (its event carries the saved set). + const forwarded = []; + const { dialogs } = session({ onSaved: event => forwarded.push(event.tags) }); + approve(dialogs[0], ['manual', 'chip']); + assert.deepEqual(forwarded, [['manual', 'chip']]); + assert.deepEqual(dialogs[1].args.preset, { model: 'wd14-vit', options: OPTIONS }); +}); + +test('last image offers Approve and finish', () => { + const { dialogs, toasts, closes } = session(); + approve(dialogs[0]); + dialogs[1].args.onSkip(); + assert.deepEqual(dialogs[2].args.position, { index: 3, total: 3 }); + approve(dialogs[2]); + // The last approve ends the session: the dialog closes and the count is toasted once. + assert.equal(closes(), 1); + assert.deepEqual(toasts, ['2 of 3 images tagged.']); + dialogs[2].args.onStop(); + assert.deepEqual(toasts, ['2 of 3 images tagged.']); +}); + +test('model-busy on one image waits for retry or skip', () => { + const { dialogs, toasts } = session(); + // The dialog shows the refusal with Retry; a Retry that succeeds then approves, all on the same image. + assert.equal(dialogs.length, 1); + approve(dialogs[0]); + assert.equal(dialogs.length, 2); + assert.deepEqual(toasts, []); +}); + +test('stop releases listeners and in-flight requests', () => { + const { handle, dialogs, toasts, saved } = session(); + handle.stop(); + assert.equal(dialogs[0].closed, 1); + assert.deepEqual(toasts, ['0 of 3 images tagged.']); + // A save that settles after the stop still counts as written, but nothing opens or toasts again. + approve(dialogs[0]); + dialogs[0].args.onSkip(); + handle.stop(); + assert.equal(dialogs.length, 1); + assert.deepEqual(saved, ['a.png']); + assert.deepEqual(toasts, ['0 of 3 images tagged.']); + assert.equal(module().reviewOutcome(1, 1), '1 of 1 images tagged.'); +}); diff --git a/tests/test_api_core.py b/tests/test_api_core.py index 14f7c46..8971810 100644 --- a/tests/test_api_core.py +++ b/tests/test_api_core.py @@ -462,6 +462,15 @@ class TestUiRoutes(CoreRoutesTestCase): ): self.assertIn(entry, table) + async def test_image_tag_job_route_is_registered_beside_the_scan_job(self): + table = { + (route.method, route.resource.canonical) + for route in self.app.router.routes() + if route.resource is not None + } + self.assertIn(("POST", "/prompt_manager/jobs/scan"), table) + self.assertIn(("POST", "/prompt_manager/jobs/image-tags"), table) + class TestStaticRoutes(CoreRoutesTestCase): diff --git a/tests/test_image_tag_jobs.py b/tests/test_image_tag_jobs.py new file mode 100644 index 0000000..3332422 --- /dev/null +++ b/tests/test_image_tag_jobs.py @@ -0,0 +1,787 @@ +"""Bulk image tagging as an Activity job: Add tags and Auto-tag (#249 D1). + +``POST /prompt_manager/jobs/image-tags`` starts one record-changing +``image-tags`` job over 1..500 listed images. Add tags unions a fixed set +into each file's byte record; Auto-tag runs one legacy engine on a private +capture of each still image (under the shared model gate, never +downloading) and unions its top N suggestions. No real model is loaded: the +engine is B2's fake service. +""" + +import asyncio +import hashlib +import json +import sqlite3 +import threading +import unittest +from unittest.mock import patch + +try: + from test_api_annotation_suggest import SCORED, WD14, FakeTagger, SuggestTestCase + from test_api_images import make_png +except ImportError: + from tests.test_api_annotation_suggest import ( + SCORED, + WD14, + FakeTagger, + SuggestTestCase, + ) + from tests.test_api_images import make_png + +from database.execution_history_schema import rotate_history_epoch +from py import model_gate +from py.api import jobs as jobs_module +from py.config import AutotagConfig +from utils.media_capture import CaptureCapacity + +START = "/prompt_manager/jobs/image-tags" +WAIT = 30.0 +KIND = getattr(jobs_module, "IMAGE_TAG_JOB_KIND", "image-tags") +GROUPS = ("tagged", "unchanged", "skipped", "errors") + + +class HookedTagger(FakeTagger): + """The B2 fake plus a hook that runs inside each inference.""" + + def __init__(self): + super().__init__() + self.on_generate = None + + def generate_tag_suggestions(self, image_path, **kwargs): + result = super().generate_tag_suggestions(image_path, **kwargs) + if self.on_generate is not None: + self.on_generate(image_path) + return result + + +class ImageTagJobTestCase(SuggestTestCase): + async def get_application(self): + app = await super().get_application() + self.tagger = HookedTagger() + patcher = patch("py.autotag.get_autotag_service", return_value=self.tagger) + patcher.start() + self.addCleanup(patcher.stop) + return app + + async def tearDownAsync(self): + # Every job of the test has ended before the app goes away. + for job in self.api._jobs.list(): + if job.task is not None and not job.task.done(): + job.task.cancel() + await asyncio.gather(job.task, return_exceptions=True) + await super().tearDownAsync() + + # -- helpers --------------------------------------------------------- + + def png(self, path, size=(8, 6), color=(200, 120, 40)): + make_png(self.output_dir / path, size=size, color=color) + return self.output_dir / path + + def digest(self, path): + return hashlib.sha256((self.output_dir / path).read_bytes()).hexdigest() + + def stored(self, path): + return self.api.db.read_image_tags(self.locator(path), self.digest(path)) + + def add_body(self, paths, tags, **changes): + return { + "mode": "add", + "targets": [self.target(p) for p in paths], + "tags": list(tags), + **changes, + } + + def auto_body(self, paths, model_type="wd14-vit", options=None, **changes): + return { + "mode": "auto", + "targets": [self.target(p) for p in paths], + "model_type": model_type, + "options": dict(WD14) if options is None else options, + **changes, + } + + async def start(self, body): + response = await self.client.post(START, json=body) + return response.status, await self.json_of(response) + + async def start_ok(self, body): + status, data = await self.start(body) + self.assertEqual(status, 202, data) + return self.api._jobs.get(data["job"]["id"]) + + async def run_job(self, body): + job = await self.start_ok(body) + await asyncio.wait_for(asyncio.shield(job.task), WAIT) + return job + + def rows(self, job, group): + return [(row["path"], row["detail"]) for row in job.outcomes[group]] + + async def wait_for(self, event): + ok = await asyncio.get_running_loop().run_in_executor(None, event.wait, WAIT) + self.assertTrue(ok, "the fake engine was never entered") + + +class TestAddTags(ImageTagJobTestCase): + async def test_add_job_unions_tags_on_each_target_and_records_outcomes(self): + for name in ("a.png", "b.png", "c.png"): + self.png(name, color=(len(name), ord(name[0]), 9)) + self.saved_set("a.png", (self.output_dir / "a.png").read_bytes(), ["keep_me"]) + self.saved_set( + "c.png", (self.output_dir / "c.png").read_bytes(), ["Sandstone", "desert"] + ) + + job = await self.run_job( + self.add_body( + ["a.png", "b.png", "c.png", "gone.png"], ["sandstone", "desert"] + ) + ) + + self.assertEqual(job.kind, KIND) + self.assertEqual(job.title, "Add tags to images") + self.assertEqual(job.state, "finished") + self.assertTrue(job.record_changing) + self.assertEqual(job.scope, {"mode": "add", "images": 4}) + self.assertEqual( + job.counters, {"tagged": 2, "unchanged": 1, "skipped": 1, "errors": 0} + ) + self.assertEqual( + self.stored("a.png")["tags"], ["keep_me", "sandstone", "desert"] + ) + self.assertEqual(self.stored("b.png")["tags"], ["sandstone", "desert"]) + # The stored spelling wins; nothing new means no write. + self.assertEqual(self.stored("c.png")["tags"], ["Sandstone", "desert"]) + self.assertEqual(self.stored("c.png")["tag_revision"], 1) + self.assertEqual( + self.rows(job, "tagged"), + [("a.png", "Added 2 tags"), ("b.png", "Added 2 tags")], + ) + self.assertEqual(self.rows(job, "unchanged"), [("c.png", "No new tags")]) + self.assertEqual(self.rows(job, "skipped"), [("gone.png", "File not found")]) + self.assertEqual(job.outcomes["tagged"][0]["root_index"], 0) + self.assertEqual((job.processed, job.total, job.progress), (4, 4, 100)) + self.assertTrue(any("+ " in line and "a.png" in line for line in job.log)) + self.assertTrue(job.log[-1].endswith("Add tags finished · 4 files processed")) + # Add tags never needs the model. + self.assertEqual(self.tagger.load_calls, []) + self.assertIsNone(model_gate.holder()) + + async def test_add_tags_vocabulary_names_become_global_tags(self): + self.png("a.png") + + await self.run_job(self.add_body(["a.png"], ["brand_new_tag"])) + + names = set(self.api.db.get_all_tags()) + self.assertIn("brand_new_tag", names) + + async def test_add_over_the_tag_cap_is_an_error_row_with_zero_writes(self): + self.png("a.png") + full = [f"t{i:03d}" for i in range(100)] + self.saved_set("a.png", (self.output_dir / "a.png").read_bytes(), full) + + job = await self.run_job(self.add_body(["a.png"], ["one_more"])) + + self.assertEqual(job.counters["errors"], 1) + self.assertEqual( + self.rows(job, "errors"), + [("a.png", "An image can carry at most 100 tags.")], + ) + self.assertEqual(self.stored("a.png")["tags"], full) + + async def test_video_skipped_in_auto_and_tagged_in_add(self): + (self.output_dir / "clip.mp4").write_bytes(b"\x00\x00\x00\x18ftypmp42" * 8) + + added = await self.run_job(self.add_body(["clip.mp4"], ["motion"])) + self.assertEqual(self.rows(added, "tagged"), [("clip.mp4", "Added 1 tag")]) + self.assertEqual(self.stored("clip.mp4")["tags"], ["motion"]) + + auto = await self.run_job(self.auto_body(["clip.mp4"])) + self.assertEqual( + self.rows(auto, "skipped"), [("clip.mp4", "Video: manual tags only")] + ) + self.assertEqual(self.tagger.calls, []) + self.assertEqual(self.stored("clip.mp4")["tags"], ["motion"]) + + async def test_not_a_still_image_and_too_large_are_skipped(self): + (self.output_dir / "broken.png").write_bytes(b"not an image") + self.png("big.png") + self.api._capture_store().max_file = 16 + + auto = await self.run_job(self.auto_body(["big.png"])) + self.assertEqual( + self.rows(auto, "skipped"), [("big.png", "Too large to verify")] + ) + + self.api._capture_store().max_file = 2**20 + auto = await self.run_job(self.auto_body(["broken.png"])) + self.assertEqual( + self.rows(auto, "skipped"), [("broken.png", "Not a supported still image")] + ) + + self.api._capture_store().max_file = 16 + added = await self.run_job(self.add_body(["big.png"], ["x"])) + self.assertEqual( + self.rows(added, "skipped"), [("big.png", "Too large to verify")] + ) + self.assertEqual(self.tagger.calls, []) + + +class TestAutoTag(ImageTagJobTestCase): + async def test_auto_job_applies_the_requested_count_per_file_without_removing_existing( # noqa: E501 + self, + ): + self.png("a.png") + self.png("b.png", color=(1, 2, 3)) + self.saved_set("a.png", (self.output_dir / "a.png").read_bytes(), ["keep_me"]) + options = {**WD14, "suggestion_limit": 2} + + job = await self.run_job(self.auto_body(["a.png", "b.png"], options=options)) + + self.assertEqual(job.title, "Auto-tag images") + self.assertEqual( + job.scope, {"mode": "auto", "images": 2, "model_type": "wd14-vit"} + ) + self.assertEqual(job.state, "finished") + self.assertEqual( + self.api.db.read_image_tags(self.locator("a.png"), self.digest("a.png"))[ + "tags" + ], + ["keep_me", "sandstone", "desert"], + ) + self.assertEqual(self.stored("b.png")["tags"], ["sandstone", "desert"]) + self.assertEqual( + job.counters, {"tagged": 2, "unchanged": 0, "skipped": 0, "errors": 0} + ) + # Inference read the private captures only, never the current files. + for path in self.tagger.read_paths: + self.assertNotIn(str(self.output_dir), path) + self.assertEqual([call["limit"] for call in self.tagger.calls], [2, 2]) + + # limit 3: at most three names are added to a file. + self.png("c.png", color=(9, 9, 9)) + options = {**WD14, "suggestion_limit": 3} + job = await self.run_job(self.auto_body(["c.png"], options=options)) + self.assertEqual(self.stored("c.png")["tags"], [name for name, _ in SCORED[:3]]) + self.assertEqual(self.rows(job, "tagged"), [("c.png", "Added 3 tags")]) + + # Omitted: the configured default. + AutotagConfig.SUGGESTION_LIMIT = 4 + self.png("d.png", color=(7, 7, 7)) + await self.run_job(self.auto_body(["d.png"])) + self.assertEqual(self.tagger.calls[-1]["limit"], 4) + self.assertEqual(len(self.stored("d.png")["tags"]), 4) + + # Suggestions the file already carries add nothing. + job = await self.run_job(self.auto_body(["d.png"])) + self.assertEqual(self.rows(job, "unchanged"), [("d.png", "No new tags")]) + + async def test_auto_job_holds_gate_and_never_downloads(self): + self.png("a.png") + self.png("b.png", color=(1, 2, 3)) + entered, proceed = threading.Event(), threading.Event() + self.tagger.block = (entered, proceed) + + job = await self.start_ok(self.auto_body(["a.png", "b.png"])) + await self.wait_for(entered) + holder = model_gate.holder() + self.assertIsNotNone(holder) + self.assertTrue(holder.startswith("image-tag-job:"), holder) + # A legacy route cannot swap the model under the running job. + response = await self.client.post("/prompt_manager/autotag/unload") + self.assertEqual(response.status, 409) + self.assertEqual((await response.json())["code"], "model-busy") + proceed.set() + await asyncio.wait_for(asyncio.shield(job.task), WAIT) + + self.assertEqual(job.counters["tagged"], 2) + self.assertEqual(self.tagger.load_calls, [("wd14-vit", True)]) + self.tagger.download_model.assert_not_called() + self.assertIsNone(model_gate.holder()) + + async def test_model_unavailable_fails_the_job_before_the_first_file(self): + self.png("a.png") + self.tagger.downloaded["wd14-vit"] = False + + job = await self.run_job(self.auto_body(["a.png"])) + + self.assertEqual(job.state, "failed") + self.assertEqual(job.processed, 0) + self.assertEqual(job.counters, dict.fromkeys(GROUPS, 0)) + self.assertTrue( + any( + line.endswith( + "is not downloaded. Download it from the legacy Auto-tag page." + ) + for line in job.log + ), + job.log, + ) + self.assertEqual(self.tagger.load_calls, []) + self.tagger.download_model.assert_not_called() + self.assertIsNone(model_gate.holder()) + self.assertEqual(self.api._capture_store().stats()["captures"], 0) + + async def test_model_load_failure_fails_the_job_and_frees_the_gate(self): + self.png("a.png") + self.tagger.load_error = RuntimeError("/secret/models/wd14 exploded") + + job = await self.run_job(self.auto_body(["a.png"])) + + self.assertEqual(job.state, "failed") + self.assertEqual(job.processed, 0) + self.assertNotIn("/secret", json.dumps(job.detail())) + self.assertIsNone(model_gate.holder()) + + async def test_inference_failure_is_an_error_row_and_the_job_goes_on(self): + self.png("a.png") + self.png("b.png", color=(1, 2, 3)) + failing = {"first": True} + + def explode(_path): + if failing.pop("first", False): + raise RuntimeError("/secret/path boom") + + self.tagger.on_generate = explode + + job = await self.run_job(self.auto_body(["a.png", "b.png"])) + + self.assertEqual(job.counters["errors"], 1) + self.assertEqual(job.counters["tagged"], 1) + self.assertEqual( + self.rows(job, "errors"), + [("a.png", "Auto-tagging failed. Check server logs for details.")], + ) + self.assertNotIn("/secret", json.dumps(job.report())) + self.assertIsNone(model_gate.holder()) + + async def test_changed_file_is_skipped_not_tagged(self): + self.png("a.png") + before = self.digest("a.png") + + def rewrite(_capture_path): + make_png(self.output_dir / "a.png", size=(8, 6), color=(1, 1, 1)) + + self.tagger.on_generate = rewrite + + job = await self.run_job(self.auto_body(["a.png"])) + + self.assertEqual(self.rows(job, "skipped"), [("a.png", "File changed")]) + self.assertEqual(job.counters["tagged"], 0) + locator = self.locator("a.png") + self.assertEqual(self.api.db.read_image_tags(locator, before)["tags"], []) + self.assertEqual(self.stored("a.png")["tags"], []) + + async def test_captures_released_and_handles_closed(self): + try: + from open_handles import assert_closed + except ImportError: + from tests.open_handles import assert_closed + for index in range(3): + self.png(f"f{index}.png", color=(index, 0, 0)) + + await self.run_job(self.auto_body([f"f{index}.png" for index in range(3)])) + + store = self.api._capture_store() + self.assertEqual(store.stats()["captures"], 0) + assert_closed(self, store.directory) + assert_closed(self, str(self.output_dir)) + + +class TestStartAndExclusion(ImageTagJobTestCase): + async def test_start_returns_model_busy_or_job_busy(self): + self.png("a.png") + other = model_gate.new_owner("legacy-batch") + self.assertTrue(model_gate.try_acquire(other)) + try: + status, body = await self.start(self.auto_body(["a.png"])) + self.assertEqual((status, body["code"]), (409, "model-busy")) + self.assertEqual(self.api._jobs.list(), []) + # Add tags needs no model: it starts while the gate is held. + job = await self.run_job(self.add_body(["a.png"], ["x"])) + self.assertEqual(job.state, "finished") + finally: + model_gate.release(other) + + entered, proceed = threading.Event(), threading.Event() + self.tagger.block = (entered, proceed) + job = await self.start_ok(self.auto_body(["a.png"])) + await self.wait_for(entered) + try: + for body in (self.add_body(["a.png"], ["y"]), self.auto_body(["a.png"])): + resp = await self.client.post(START, json=body) + body = await resp.json() + self.assertEqual((resp.status, body["code"]), (409, "busy")) + self.assertEqual( + body["running"], {"id": job.id, "title": "Auto-tag images"} + ) + finally: + proceed.set() + await asyncio.wait_for(asyncio.shield(job.task), WAIT) + # A refused start never leaves the gate taken. + self.assertIsNone(model_gate.holder()) + + async def test_record_changing_excludes_running_scan(self): + self.png("a.png") + gate = asyncio.Event() + + async def scan_runner(job): + await gate.wait() + + scan = self.api._jobs.start( + "scan", + "Scan for metadata", + record_changing=True, + scope={"files": "new"}, + runner=scan_runner, + ) + try: + status, body = await self.start(self.add_body(["a.png"], ["x"])) + self.assertEqual((status, body["code"]), (409, "busy")) + self.assertEqual(body["running"]["title"], "Scan for metadata") + status, body = await self.start(self.auto_body(["a.png"])) + self.assertEqual((status, body["code"]), (409, "busy")) + self.assertIsNone(model_gate.holder()) + finally: + gate.set() + await scan.task + + # The other way round: a running tagging job keeps the scan out. + entered, proceed = threading.Event(), threading.Event() + self.tagger.block = (entered, proceed) + job = await self.start_ok(self.auto_body(["a.png"])) + await self.wait_for(entered) + try: + resp = await self.client.post("/prompt_manager/jobs/scan", json={}) + self.assertEqual(resp.status, 409) + self.assertEqual((await resp.json())["running"]["id"], job.id) + finally: + proceed.set() + await asyncio.wait_for(asyncio.shield(job.task), WAIT) + + async def test_a_start_that_loses_the_slot_race_frees_the_gate(self): + self.png("a.png") + gate = asyncio.Event() + + async def scan_runner(job): + await gate.wait() + + scan = self.api._jobs.start( + "scan", + "Scan for metadata", + record_changing=True, + scope={}, + runner=scan_runner, + ) + try: + # The pre-check saw a free slot; registry.start still refuses. + with patch.object(self.api._jobs, "blocker", side_effect=[None, scan]): + status, body = await self.start(self.auto_body(["a.png"])) + self.assertEqual((status, body["code"]), (409, "busy")) + self.assertIsNone(model_gate.holder()) + finally: + gate.set() + await scan.task + + async def test_cancel_stops_at_file_boundary_and_keeps_written_files(self): + for index in range(3): + self.png(f"f{index}.png", color=(index, 5, 5)) + entered, proceed = threading.Event(), threading.Event() + self.tagger.block = (entered, proceed) + + job = await self.start_ok(self.auto_body([f"f{i}.png" for i in range(3)])) + await self.wait_for(entered) + response = await self.client.post(f"/prompt_manager/jobs/{job.id}/cancel") + self.assertTrue((await response.json())["cancellation_requested"]) + proceed.set() + await asyncio.wait_for(asyncio.shield(job.task), WAIT) + + self.assertEqual(job.state, "cancelled") + self.assertEqual(job.counters["tagged"], 1) + self.assertEqual(job.processed, 1) + self.assertEqual(len(self.stored("f0.png")["tags"]), 10) + self.assertEqual(self.stored("f1.png")["tags"], []) + self.assertEqual(len(self.tagger.calls), 1) + self.assertTrue(job.log[-1].endswith("Auto-tag cancelled · 1 file processed")) + self.assertIsNone(model_gate.holder()) + self.assertEqual(self.api._capture_store().stats()["captures"], 0) + + async def test_stale_root_scope_refuses_the_start(self): + self.png("a.png") + body = self.add_body(["a.png"], ["x"]) + body["targets"][0]["root_scope"] = "0" * 64 + + status, data = await self.start(body) + + self.assertEqual((status, data["code"]), (409, "source-changed")) + self.assertEqual(self.api._jobs.list(), []) + + async def test_restore_during_the_job_stops_it_without_writes(self): + self.png("a.png") + self.png("b.png", color=(1, 2, 3)) + + def rotate(_path): + conn = sqlite3.connect(self._temp_db.name) + try: + rotate_history_epoch(conn) + finally: + conn.close() + + self.tagger.on_generate = rotate + + job = await self.run_job(self.auto_body(["a.png", "b.png"])) + + self.assertEqual(job.state, "failed") + self.assertEqual(job.counters["tagged"], 0) + self.assertEqual( + self.rows(job, "errors"), + [("a.png", "Prompt storage was restored; start the job again.")], + ) + self.assertEqual(self.stored("a.png")["tags"], []) + self.assertEqual(len(self.tagger.calls), 1) + self.assertIsNone(model_gate.holder()) + + async def test_invalid_bodies_rejected(self): + self.png("a.png") + target = self.target("a.png") + bad_bodies = [ + {"mode": "add", "targets": [], "tags": ["x"]}, + {"mode": "add", "targets": [target] * 501, "tags": ["x"]}, + {"mode": "add", "targets": "a.png", "tags": ["x"]}, + {"mode": "add", "targets": [{**target, "path": "../a.png"}], "tags": ["x"]}, + {"mode": "add", "targets": [{**target, "root_index": True}], "tags": ["x"]}, + {"mode": "add", "targets": [{**target, "extra": 1}], "tags": ["x"]}, + {"mode": "add", "targets": [{**target, "path": "a.txt"}], "tags": ["x"]}, + {"mode": "add", "targets": [target], "tags": []}, + {"mode": "add", "targets": [target], "tags": "x"}, + {"mode": "add", "targets": [target], "tags": ["bad\nname"]}, + {"mode": "add", "targets": [target]}, + {"mode": "add", "targets": [target], "tags": ["x"], "extra": 1}, + {"mode": "add", "targets": [target], "tags": ["x"], "model_type": "hf"}, + {"mode": "remove", "targets": [target], "tags": ["x"]}, + {"targets": [target], "tags": ["x"]}, + {"mode": "auto", "targets": [target], "model_type": "wd14-vit"}, + {"mode": "auto", "targets": [target], "model_type": "x", "options": {}}, + { + "mode": "auto", + "targets": [target], + "model_type": "wd14-vit", + "options": {}, + }, + { + "mode": "auto", + "targets": [target], + "model_type": "wd14-vit", + "options": {**WD14, "general_threshold": 2}, + }, + { + "mode": "auto", + "targets": [target], + "model_type": "wd14-vit", + "options": {**WD14, "suggestion_limit": 0}, + }, + { + "mode": "auto", + "targets": [target], + "model_type": "gguf", + "options": dict(WD14), + }, + { + "mode": "auto", + "targets": [target], + "model_type": "wd14-vit", + "options": dict(WD14), + "tags": ["x"], + }, + ] + for body in bad_bodies: + with self.subTest(body=json.dumps(body)[:120]): + status, data = await self.start(body) + self.assertIn(status, (400, 403), data) + self.assertFalse(data["success"]) + too_many = { + "mode": "add", + "targets": [target], + "tags": [f"t{i}" for i in range(101)], + } + status, data = await self.start(too_many) + self.assertEqual((status, data["code"]), (400, "tag-limit")) + response = await self.client.post(START, data="not json") + self.assertEqual(response.status, 400) + self.assertEqual(self.api._jobs.list(), []) + self.assertIsNone(model_gate.holder()) + + async def test_duplicate_targets_are_tagged_once(self): + self.png("a.png") + + job = await self.run_job(self.add_body(["a.png", "a.png"], ["x"])) + + self.assertEqual(job.scope["images"], 1) + self.assertEqual(job.counters["tagged"], 1) + + async def test_rows_and_report_carry_no_absolute_paths(self): + self.png("sub/a.png") + self.png("sub/b.png", color=(3, 3, 3)) + job = await self.run_job( + self.auto_body(["sub/a.png", "sub/b.png", "sub/missing.png"]) + ) + + for url in ( + START.rsplit("/", 1)[0], + f"/prompt_manager/jobs/{job.id}", + f"/prompt_manager/jobs/{job.id}/report", + ): + response = await self.client.get(url) + self.assertEqual(response.status, 200) + body = json.loads(await response.text()) + self.assert_no_paths(body) + detail = job.detail() + self.assertEqual( + [row["path"] for row in detail["outcomes"]["tagged"]], + ["sub/a.png", "sub/b.png"], + ) + self.assertTrue( + all(row["root"] == "output" for row in detail["outcomes"]["tagged"]) + ) + self.assertTrue(any("output/sub/a.png" in line for line in detail["log"])) + + +class TestFixRound(ImageTagJobTestCase): + """The final review's fixes: estimate, body bound, busy captures, roots.""" + + async def test_status_line_estimates_minutes_left_after_the_minimum(self): + for index in range(3): + self.png(f"f{index}.png", color=(index, 7, 7)) + clock = iter([0.0, 0.0, 120.0, 240.0]) + lines = [] + self.tagger.on_generate = lambda _path: lines.append( + self.api._jobs.list()[0].status_line + ) + + with patch( + "py.api.image_tag_jobs._monotonic", side_effect=lambda: next(clock, 240.0) + ): + await self.run_job(self.auto_body([f"f{i}.png" for i in range(3)])) + + # Two files make the minimum (the scan's MIN_BATCHES_FOR_ESTIMATE); then + # 120 s per file leaves about 2 min for the third. + self.assertEqual( + lines, + [ + "Processing file 1 of 3", + "Processing file 2 of 3", + "Processing file 3 of 3 · about 2 min left", + ], + ) + + def maximal_target(self, number): + """A distinct target whose path is 1024 non-ASCII code points long.""" + head = f"{number:03d}/" + ("界" * 200 + "/") * 5 + path = head + "界" * (1024 - len(head) - 4) + ".png" + self.assertEqual(len(path), 1024) + return self.target(path) + + async def test_start_body_bounds(self): + from py.api import image_tag_jobs + + body = { + "mode": "add", + "targets": [self.maximal_target(n) for n in range(500)], + "tags": ["x"], + } + # Escaped as \uXXXX (6 bytes a code point), far over the old 1 MiB. + payload = json.dumps(body).encode("utf-8") + self.assertGreater(len(payload), 3 * 1000 * 1000) + response = await self.client.post( + START, data=payload, headers={"Content-Type": "application/json"} + ) + data = await self.json_of(response) + self.assertEqual(response.status, 202, data) + self.assertEqual(data["job"]["scope"]["images"], 500) + job = self.api._jobs.get(data["job"]["id"]) + await self.client.post(f"/prompt_manager/jobs/{job.id}/cancel") + await asyncio.wait_for(asyncio.shield(job.task), WAIT) + + cap = image_tag_jobs.MAX_JOB_BODY_BYTES + self.assertGreaterEqual(cap, len(payload)) + over = b'{"mode":"add","targets":[],"tags":["x"]' + over += b" " * (cap + 1 - len(over) - 1) + b"}" + self.assertEqual(len(over), cap + 1) + response = await self.client.post( + START, data=over, headers={"Content-Type": "application/json"} + ) + self.assertEqual(response.status, 413) + + def saturate(self, store): + with store._lock: + store._workers += store.max_workers + self.addCleanup(self.unsaturate, store) + + def unsaturate(self, store): + with store._lock: + if store._workers >= store.max_workers: + store._workers -= store.max_workers + + async def test_busy_capture_workers_are_retried_then_left_for_a_rerun(self): + self.png("a.png") + self.png("b.png", color=(1, 2, 3)) + store = self.api._capture_store() + self.saturate(store) + + with patch("py.api.image_tag_jobs.CAPACITY_BACKOFF", (0.001, 0.001)): + added = await self.run_job(self.add_body(["a.png"], ["x"])) + auto = await self.run_job(self.auto_body(["b.png"])) + + retry = "The file could not be read now. Run the job again for it." + for job, path in ((added, "a.png"), (auto, "b.png")): + self.assertEqual(job.state, "finished") + self.assertEqual(self.rows(job, "skipped"), [(path, retry)]) + self.assertEqual(job.counters["errors"], 0) + self.assertNotIn("Tag dialog", json.dumps(job.detail())) + self.assertEqual(self.tagger.calls, []) + self.assertEqual(self.stored("a.png")["tags"], []) + + async def test_a_busy_capture_worker_that_frees_up_is_tagged(self): + self.png("a.png") + store = self.api._capture_store() + real = store.verify + tries = [] + + def verify(root, relative): + tries.append(relative) + if len(tries) < 3: + raise CaptureCapacity("Too many captures are in progress") + return real(root, relative) + + with ( + patch.object(store, "verify", side_effect=verify), + patch("py.api.image_tag_jobs.CAPACITY_BACKOFF", (0.001, 0.001, 0.001)), + ): + job = await self.run_job(self.add_body(["a.png"], ["x"])) + + self.assertEqual(len(tries), 3) + self.assertEqual(self.rows(job, "tagged"), [("a.png", "Added 1 tag")]) + self.assertEqual(self.stored("a.png")["tags"], ["x"]) + + async def test_output_roots_are_read_once_per_job_off_the_event_loop(self): + for index in range(3): + self.png(f"f{index}.png", color=(index, 1, 1)) + body = self.add_body([f"f{i}.png" for i in range(3)], ["x"]) + original = self.api._get_all_output_dirs + threads = [] + + def lookup(): + threads.append(threading.current_thread()) + return original() + + with patch.object(self.api, "_get_all_output_dirs", side_effect=lookup): + job = await self.run_job(body) + + self.assertEqual(job.counters["tagged"], 3) + # One read when the start checks the scopes, one for the whole run. + self.assertEqual(len(threads), 2) + self.assertNotIn(threading.main_thread(), threads) + self.assertEqual(job.outcomes["tagged"][0]["root"], "output") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_jobs.py b/tests/test_jobs.py index 7f87f85..f561be0 100644 --- a/tests/test_jobs.py +++ b/tests/test_jobs.py @@ -12,6 +12,7 @@ import unittest sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from py.api import jobs as jobs_module # noqa: E402 from py.api.jobs import ( # noqa: E402 DEFAULT_SCAN_SCOPE, LOG_CAP_LINE, @@ -415,6 +416,14 @@ class TestJobOutcomesAndLog(unittest.TestCase): SCAN_GROUP_NAMES, ("added", "known", "review", "no_metadata", "errors") ) + def test_image_tag_job_kind_and_groups(self): + # #249 D1: bulk Add tags / Auto-tag, counter-tile order. + self.assertEqual(getattr(jobs_module, "IMAGE_TAG_JOB_KIND", None), "image-tags") + self.assertEqual( + getattr(jobs_module, "IMAGE_TAG_GROUP_NAMES", None), + ("tagged", "unchanged", "skipped", "errors"), + ) + if __name__ == "__main__": unittest.main() diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json index 9fc588f..31ab8b3 100644 --- a/tools/vendor/frontend-coverage-baseline.json +++ b/tools/vendor/frontend-coverage-baseline.json @@ -169,7 +169,7 @@ }, "workspace/activity-store.mjs": { "lines": 98, - "branches": 95, + "branches": 96, "functions": 98, "statements": 98 }, @@ -229,8 +229,8 @@ }, "workspace/image-tagging.mjs": { "lines": 97, - "branches": 88, - "functions": 87, + "branches": 90, + "functions": 88, "statements": 97 }, "workspace/images-category.mjs": { @@ -253,7 +253,7 @@ }, "workspace/images-selection.mjs": { "lines": 98, - "branches": 92, + "branches": 93, "functions": 98, "statements": 98 }, @@ -263,6 +263,18 @@ "functions": 98, "statements": 98 }, + "workspace/images-tagging-bulk.mjs": { + "lines": 98, + "branches": 93, + "functions": 86, + "statements": 98 + }, + "workspace/images-tagging-review.mjs": { + "lines": 98, + "branches": 93, + "functions": 98, + "statements": 98 + }, "workspace/images-tagging.mjs": { "lines": 98, "branches": 98, diff --git a/web/js/workspace/activity-job-view.mjs b/web/js/workspace/activity-job-view.mjs index 0732989..9808b7a 100644 --- a/web/js/workspace/activity-job-view.mjs +++ b/web/js/workspace/activity-job-view.mjs @@ -11,6 +11,9 @@ * filter, and the selected outcome's listed rows (at most 200 per outcome, so "Showing N of M"). Rows under Added and * Already known open the file in the existing viewer through its listing identity (D57); the others are plain text. * - Log: `details.term` "Scan log", collapsed, holding the raw timestamped lines in `pre[tabindex=0]`. + * - D89: the tiles, the outcome list, the openable rows and the log and cancel labels are the job kind's (`jobView`): + * an image tagging job shows Tagged, Unchanged, Skipped and Errors, opens Tagged and Unchanged rows, and reads + * "Tagging log" and "Cancel tagging"; another kind keeps the scan's tiles as "Job log" and "Cancel job". * * The summary follows the Activity store (one SSE stream per running job); the listed rows and the log are read from * `GET /prompt_manager/jobs/<id>` at most once a second while the job runs, and once more when it ends. @@ -19,7 +22,7 @@ import { createButton } from '../shared/toolkit/core.js'; import { pageHeading } from './heading.mjs'; import { showWorkspaceToast } from './toast.mjs'; import { noun } from './plural.mjs'; -import { parseJob, JOBS_URL, LOST_DETAIL } from './activity-store.mjs'; +import { parseJob, jobView, JOB_VIEWS, JOBS_URL, LOST_DETAIL } from './activity-store.mjs'; import { jobDetail, CANCEL_FAILED_TOAST } from './activity.mjs'; import { downloadJobReport } from './activity-scan.mjs'; import { createMediaViewer } from './media-viewer.mjs'; @@ -36,18 +39,6 @@ export const FILE_GONE = 'This file is no longer in Images. Rescan from Import a export const OPEN_FAILED = 'The file could not be opened. Try again.'; const LISTING_LIMIT = 5; -/** The counter tiles, in the spec's order and the mockup's tones. */ -export const TILES = Object.freeze([ - ['added', 'Added to Library', 'added'], ['known', 'Already known', ''], ['review', 'Needs review', 'warn'], - ['no_metadata', 'No usable metadata', ''], ['errors', 'Errors', 'bad'], -]); -/** The outcome list, in the mockup's order: what got added and what went wrong first. */ -export const OUTCOMES = Object.freeze([ - ['added', 'Added to Library'], ['review', 'Needs review'], ['errors', 'Errors'], - ['no_metadata', 'No usable metadata'], ['known', 'Already known'], -]); -const OPENABLE = new Set(['added', 'known']); - function element(tag, text, className) { const node = document.createElement(tag); if (text !== undefined) node.textContent = text; @@ -103,9 +94,9 @@ export function filterRows(rows, filter) { return rows.filter(row => `${row.root}/${row.path}`.toLocaleLowerCase().includes(wanted)); } -/** The first outcome with files, so a view opens on what happened; Added when nothing has yet. */ -export function defaultOutcome(counters) { - return OUTCOMES.find(([group]) => (counters[group] ?? 0) > 0)?.[0] ?? 'added'; +/** The first outcome with files, so a view opens on what happened; the first outcome when nothing has yet. */ +export function defaultOutcome(counters, outcomes = JOB_VIEWS.scan.outcomes) { + return (outcomes.find(([group]) => (counters[group] ?? 0) > 0) ?? outcomes[0])[0]; } function section(title, id) { @@ -132,13 +123,6 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) { bar.max = 100; const stats = element('dl', undefined, 'stat-row'); const tileValues = new Map(); - for (const [group, label, tone] of TILES) { - const tile = element('div', undefined, tone ? `stat ${tone}` : 'stat'); - const value = element('dd', '0'); - tile.append(element('dt', label), value); - tileValues.set(group, value); - stats.append(tile); - } progressPart.node.append(bar, stats); const resultsPart = section('Results', 'job-results-title'); @@ -155,15 +139,6 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) { list.setAttribute('aria-label', 'Outcomes'); const files = element('div', undefined, 'outcome-files'); const buttons = new Map(); - for (const [group, label] of OUTCOMES) { - const button = createButton(''); - const count = element('span', '0'); - button.append(element('span', label), count); - button.setAttribute('aria-pressed', 'false'); - button.count = count; - buttons.set(group, button); - list.append(button); - } outcomes.append(list, files); resultsPart.node.append(outcomes); @@ -181,6 +156,8 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) { missing.append(element('p', NOT_FOUND, 'empty-state'), back); let detail = null; + // The job's kind decides its tiles, outcomes and labels (D89); it is known once the job is. + let view = null; let selected = null; let mounted = false; let lastRead = 0; @@ -200,13 +177,35 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) { const viewer = createMediaViewer({ api, container: main, parentScope: scope, inspectionController: inspection, sender: createPromptSender({ scope }), onSelect: image => inspection.select(image) }); + /** The tiles and the outcome list of the job's kind, built once. */ + function layout(kind) { + view = jobView(kind); // D89 + for (const [group, label, tone] of view.tiles) { + const tile = element('div', undefined, tone ? `stat ${tone}` : 'stat'); + const value = element('dd', '0'); + tile.append(element('dt', label), value); + tileValues.set(group, value); + stats.append(tile); + } + for (const [group, label] of view.outcomes) { + const button = createButton(''); + const count = element('span', '0'); + button.append(element('span', label), count); + button.setAttribute('aria-pressed', 'false'); + button.count = count; + buttons.set(group, button); + list.append(button); + } + } + function showBody(job) { if (mounted) return; mounted = true; + if (!view) layout(job.kind); missing.remove(); parts.actions.append(download, cancel); main.append(progressPart.node, resultsPart.node, log); - selected ??= defaultOutcome(job.counters); + selected ??= defaultOutcome(job.counters, view.outcomes); } /** The job is not (or no longer) in this session: say so instead of an empty view. */ @@ -242,7 +241,7 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) { function renderRows(job) { const rows = detail?.outcomes[selected] ?? []; - const openable = OPENABLE.has(selected); + const openable = view.openable.has(selected); const wanted = filterRows(rows.map((row, index) => ({ ...row, index })), input.value); const kept = new Map(wanted.map(row => rowNode(row, row.index, openable))); rowNodes = kept; @@ -270,7 +269,7 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) { parts.h1.textContent = job.title; document.title = `${job.title} · Prompt Manager`; parts.description.textContent = jobDetail(job); - cancel.textContent = scan ? 'Cancel scan' : 'Cancel job'; + cancel.textContent = view.cancel; // A job lost with the server has no report there (review M9). const lost = job.state === 'lost'; if (lost && document.activeElement === download) parts.h1.focus(); @@ -291,7 +290,7 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) { button.count.textContent = number(job.counters[group] ?? 0); button.setAttribute('aria-pressed', String(group === selected)); } - logLabel.textContent = `● ${scan ? 'Scan' : 'Job'} log`; + logLabel.textContent = `● ${view.log}`; const text = detail?.log.length ? detail.log.join('\n') : 'No log lines yet.'; if (pre.textContent !== text) { // Replacing the text clamps the scroll position, so keep it: pinned readers follow the end, others stay. diff --git a/web/js/workspace/activity-store.mjs b/web/js/workspace/activity-store.mjs index 2e1f976..3df6405 100644 --- a/web/js/workspace/activity-store.mjs +++ b/web/js/workspace/activity-store.mjs @@ -23,10 +23,52 @@ export const LOST_DETAIL = 'This job ended when ComfyUI restarted.'; const SERVER_STATES = new Set(['running', 'finished', 'cancelled', 'failed']); const FINAL_STATES = new Set(['finished', 'cancelled', 'failed']); const JOB_ID = /^[0-9a-f]{32}$/; -// The pill's verb for each job kind; a kind without one shows its title. -const PILL_VERBS = Object.freeze({ scan: 'Scanning' }); +// The pill's verb for each job kind; a kind without one shows its title (D89: image tagging jobs read "Tagging"). +const PILL_VERBS = Object.freeze({ scan: 'Scanning', 'image-tags': 'Tagging' }); const START_TOASTS = Object.freeze({ scan: 'Scan started.' }); +/** The scan's job view (D56): its counter tiles `[group, label, tone]` and outcome list in the mockup's orders. */ +const SCAN_VIEW = Object.freeze({ + tiles: Object.freeze([ + ['added', 'Added to Library', 'added'], ['known', 'Already known', ''], ['review', 'Needs review', 'warn'], + ['no_metadata', 'No usable metadata', ''], ['errors', 'Errors', 'bad'], + ].map(Object.freeze)), + // What got added and what went wrong first. + outcomes: Object.freeze([ + ['added', 'Added to Library'], ['review', 'Needs review'], ['errors', 'Errors'], + ['no_metadata', 'No usable metadata'], ['known', 'Already known'], + ].map(Object.freeze)), + // Rows that open the file in the viewer (D57); the others are plain text. + openable: Object.freeze(new Set(['added', 'known'])), + log: 'Scan log', + cancel: 'Cancel scan', +}); + +/** + * Each job kind's view: its tiles, outcome list, openable rows, log label and cancel label. D89: an image tagging job + * (`image-tags`) counts Tagged (green), Unchanged, Skipped (warning) and Errors (red), lists what got tagged and what + * went wrong first, and opens Tagged and Unchanged rows in the viewer like the scan's (D57). + */ +export const JOB_VIEWS = Object.freeze({ + scan: SCAN_VIEW, + 'image-tags': Object.freeze({ + tiles: Object.freeze([['tagged', 'Tagged', 'added'], ['unchanged', 'Unchanged', ''], ['skipped', 'Skipped', 'warn'], + ['errors', 'Errors', 'bad']].map(Object.freeze)), + outcomes: Object.freeze([['tagged', 'Tagged'], ['errors', 'Errors'], ['skipped', 'Skipped'], + ['unchanged', 'Unchanged']].map(Object.freeze)), + openable: Object.freeze(new Set(['tagged', 'unchanged'])), + log: 'Tagging log', + cancel: 'Cancel tagging', + }), +}); +// Another kind keeps the scan's tiles and outcomes under the generic labels. +const GENERIC_VIEW = Object.freeze({ ...SCAN_VIEW, log: 'Job log', cancel: 'Cancel job' }); + +/** The view of a job `kind`; an unknown kind gets the generic one. */ +export function jobView(kind) { + return Object.hasOwn(JOB_VIEWS, kind) ? JOB_VIEWS[kind] : GENERIC_VIEW; +} + /** The wait before the `attempt`-th re-read (0-based): 1 s, 2 s, 4 s … never more than 30 s. */ export function backoffDelay(attempt) { const step = Number.isSafeInteger(attempt) && attempt > 0 ? Math.min(attempt, 16) : 0; @@ -70,20 +112,31 @@ const errorsOf = job => `${number(job.counters.errors ?? 0)} ${noun(job.counters /** "1,420 added". */ export const addedOf = job => `${number(job.counters.added ?? 0)} added`; -/** The toast when a job ends: "Scan finished · N added · M errors", "Scan cancelled · N added". */ +/** + * The toast when a job ends: "Scan finished · N added · M errors", "Scan cancelled · N added"; an image tagging job + * the same way with its own title and count (D89): "Auto-tag images finished · N tagged · M errors". + */ export function finishToast(job) { if (job.kind === 'scan') { if (job.state === 'cancelled') return `Scan cancelled · ${addedOf(job)}`; if (job.state === 'failed') return `Scan failed · ${addedOf(job)} · ${errorsOf(job)}`; return `Scan finished · ${addedOf(job)} · ${errorsOf(job)}`; } + if (job.kind === 'image-tags') { + if (job.state === 'cancelled') return `${job.title} cancelled · ${taggedOf(job)}`; + return `${job.title} ${job.state === 'failed' ? 'failed' : 'finished'} · ${outcomeLine(job)}`; + } if (job.state === 'cancelled') return `${job.title} was cancelled.`; if (job.state === 'failed') return `${job.title} failed.`; return `${job.title} finished.`; } +// D85: an image tagging job says which of its two modes started. +const IMAGE_TAG_START_TOASTS = Object.freeze({ add: 'Tagging started.', auto: 'Auto-tagging started.' }); + /** The toast after this tab starts a job. */ export function startToast(job) { + if (job.kind === 'image-tags' && IMAGE_TAG_START_TOASTS[job.scope.mode]) return IMAGE_TAG_START_TOASTS[job.scope.mode]; return START_TOASTS[job.kind] ?? `${job.title} started.`; } @@ -94,9 +147,14 @@ export function pillParts(job) { return { label, files, text: label + files }; } -/** The scan's counters as one line: "N added · M errors"; other kinds have none yet. */ +/** "12 tagged" (D89). */ +export const taggedOf = job => `${number(job.counters.tagged ?? 0)} tagged`; + +/** The counters as one line: "N added · M errors" for a scan, "N tagged · M errors" for image tagging (D89). */ export function outcomeLine(job) { - return job.kind === 'scan' ? `${addedOf(job)} · ${errorsOf(job)}` : ''; + if (job.kind === 'scan') return `${addedOf(job)} · ${errorsOf(job)}`; + if (job.kind === 'image-tags') return `${taggedOf(job)} · ${errorsOf(job)}`; + return ''; } function defaultConnect(url) { diff --git a/web/js/workspace/activity.mjs b/web/js/workspace/activity.mjs index 1a192aa..48b83c4 100644 --- a/web/js/workspace/activity.mjs +++ b/web/js/workspace/activity.mjs @@ -11,13 +11,14 @@ * - D54: each `.activity-item` is a real job. Its chip names the state (green "Completed", or blue "Running", * "Cancelled", "Failed", "Ended"), its line says when and what, and a running job shows its `<progress>` and * "Cancel job" (no "Simulate completion"). + * - D89: an image tagging job's line names its images and, once ended, "N tagged · M errors". */ import { createButton } from '../shared/toolkit/core.js'; import { navigateTo } from './state.mjs'; import { pageHeading } from './heading.mjs'; import { showWorkspaceToast } from './toast.mjs'; -import { countOf } from './plural.mjs'; -import { pillParts, outcomeLine, addedOf, LOST_DETAIL } from './activity-store.mjs'; +import { countOf, noun } from './plural.mjs'; +import { pillParts, outcomeLine, addedOf, taggedOf, LOST_DETAIL } from './activity-store.mjs'; export const ACTIVITY_DESCRIPTION = 'Scans, tagging, downloads and maintenance share one history.'; export const EMPTY_ACTIVITY = 'No jobs have run since ComfyUI started. Scans and other long operations appear here.'; @@ -44,12 +45,21 @@ export function clockTime(seconds) { const join = parts => parts.filter(Boolean).join(' · '); -/** What a job covers: "2 output folders" for a scan. */ +const whole = value => Number.isSafeInteger(value) && value >= 0; + +/** What a job covers: "2 output folders" for a scan, "4 images" for image tagging (D89). */ function scopeLine(job) { + // D89: grouped as the server writes its lines ("19,622 images"). + const images = job.scope?.images; + if (job.kind === 'image-tags') return whole(images) ? `${images.toLocaleString('en-US')} ${noun(images, 'image')}` : ''; const folders = job.scope?.output_folders; - return Number.isSafeInteger(folders) && folders >= 0 ? countOf(folders, 'output folder') : ''; + return whole(folders) ? countOf(folders, 'output folder') : ''; } +/** What a cancelled job kept: "N added" for a scan, "N tagged" for image tagging (D89). */ +const KEPT = Object.freeze({ __proto__: null, scan: addedOf, 'image-tags': taggedOf }); // D89 +const keptOf = job => KEPT[job.kind]?.(job) ?? ''; + /** The item's line under its title, by state. */ export function jobDetail(job) { const at = clockTime(job.finished_at); @@ -58,8 +68,7 @@ export function jobDetail(job) { } if (job.state === 'lost') return LOST_DETAIL; if (job.state === 'cancelled') { - return join([`Cancelled ${at} at a file boundary`, job.kind === 'scan' ? addedOf(job) : '', - 'completed work is kept']); + return join([`Cancelled ${at} at a file boundary`, keptOf(job), 'completed work is kept']); } if (job.state === 'failed') return join([`Stopped on an error at ${at}`, outcomeLine(job)]); return join([`Finished ${at}`, outcomeLine(job)]); diff --git a/web/js/workspace/image-tagging-state.mjs b/web/js/workspace/image-tagging-state.mjs index e37b6b6..e85b959 100644 --- a/web/js/workspace/image-tagging-state.mjs +++ b/web/js/workspace/image-tagging-state.mjs @@ -128,7 +128,8 @@ export function optionError(model, options) { return null; } -function requestOptions(model, options) { +/** The options a request sends for `model`: numbers parsed, only the model's own keys. */ +export function requestOptions(model, options) { const body = {suggestion_limit: numberOf(options.suggestion_limit)}; if (modelOf(model).classifier) { return {general_threshold: numberOf(options.general_threshold), character_threshold: numberOf(options.character_threshold), ...body}; @@ -177,28 +178,48 @@ function validateReceipt(value) { return value; } -/** Thresholds, instructions and suggestion count from the server, else the documented defaults. */ +/** + * Thresholds, instructions and suggestion count from the server, else the documented defaults, and the models' + * download status (`models`, null when unknown). + */ async function readDefaults(api, signal) { try { const value = await api.get(MODELS_URL, {signal, cache: 'no-store'}); - if (!object(value)) return DEFAULTS; + if (!object(value)) return {defaults: DEFAULTS, models: null}; return { - general_threshold: unit(value.wd14_general_threshold) ? value.wd14_general_threshold : DEFAULTS.general_threshold, - character_threshold: unit(value.wd14_character_threshold) ? value.wd14_character_threshold : DEFAULTS.character_threshold, - instructions: typeof value.default_prompt === 'string' ? value.default_prompt : DEFAULTS.instructions, - suggestion_limit: limit(value.suggestion_limit) ? value.suggestion_limit : DEFAULTS.suggestion_limit, + defaults: { + general_threshold: unit(value.wd14_general_threshold) ? value.wd14_general_threshold : DEFAULTS.general_threshold, + character_threshold: unit(value.wd14_character_threshold) ? value.wd14_character_threshold : DEFAULTS.character_threshold, + instructions: typeof value.default_prompt === 'string' ? value.default_prompt : DEFAULTS.instructions, + suggestion_limit: limit(value.suggestion_limit) ? value.suggestion_limit : DEFAULTS.suggestion_limit, + }, + models: object(value.models) ? value.models : null, }; } catch { // The defaults keep the dialog usable; Generate reports a real failure itself. - return DEFAULTS; + return {defaults: DEFAULTS, models: null}; } } -function initialState(source) { +const UNAVAILABLE_HINT = 'Download it from the legacy Auto-tag page.'; +/** + * Why `model` cannot run, from the `/autotag/models` status (the server's own `model-unavailable` sentence), else '' + * (downloaded, or the status is unknown: the job then reports a missing model itself). + */ +export function modelUnavailable(models, model) { + const option = modelOf(model); + requireValue(option, 'Unknown tagging model'); + const status = object(models) && object(models[model]) ? models[model] : null; + if (!status || status.downloaded !== false) return ''; + const name = typeof status.name === 'string' && status.name.trim() ? status.name : option.label.split(' · ')[0]; + return `${name} is not downloaded. ${UNAVAILABLE_HINT}`; +} + +function initialState(source, preset) { return { phase: 'observing', closed: false, source, showScopeNote: source === 'review', image: null, savedTags: [], tagRevision: 0, currentTags: null, manual: '', - model: MODEL_OPTIONS[0].id, options: {...DEFAULTS}, generated: false, suggestions: [], + model: preset?.model ?? MODEL_OPTIONS[0].id, options: {...DEFAULTS, ...preset?.options}, generated: false, suggestions: [], suggestionsText: COPY.modelChanged, statusText: '', generateSupported: false, generateReason: null, toast: null, // Bumped whenever the controller itself rewrites the option fields or the manual draft, never while typing. optionsRevision: 0, draftRevision: 0, @@ -219,13 +240,31 @@ function derive(state) { }; } -export function createImageTaggingController({api, target, source = 'images', onLateSave = () => {}} = {}) { +/** A `{model, options}` preset (the bulk review's choice) when valid, else a TypeError. */ +function validatePreset(preset) { + if (preset === null || preset === undefined) return null; + requireValue(object(preset) && modelOf(preset.model) && object(preset.options), 'Invalid tagging preset'); + const options = {}; + for (const [key, value] of Object.entries(preset.options)) { + requireValue(OPTION_KEYS.has(key), 'Unknown tagging option'); + options[key] = value; + } + requireValue(!optionError(preset.model, {...DEFAULTS, ...options}), 'Invalid tagging preset'); + return Object.freeze({model: preset.model, options: Object.freeze(options)}); +} + +/** + * `preset` ({model, options}, D90's review session) starts the dialog on that model, and every observation applies + * those options over the server's defaults. + */ +export function createImageTaggingController({api, target, source = 'images', onLateSave = () => {}, preset = null} = {}) { requireValue(object(api) && ['get', 'post', 'put', 'del'].every(name => typeof api[name] === 'function'), 'An api client is required'); const imageTarget = validateTarget(target); requireValue(SOURCES.has(source), 'Unknown tagging source'); requireValue(typeof onLateSave === 'function', 'onLateSave must be a function'); + const chosen = validatePreset(preset); - let state = freeze(derive(initialState(source))); + let state = freeze(derive(initialState(source, chosen))); const listeners = new Set(); let closed = false; let capture = null; @@ -297,7 +336,7 @@ export function createImageTaggingController({api, target, source = 'images', on } capture = {token: value.capture.token, sha256: value.capture.sha256}; epoch = value.library_epoch; - defaults = await defaultsRead; + ({defaults} = await defaultsRead); if (closed || serial !== openSerial) return; observedOnce = true; const {width, height, media_type: mediaType, preview_url: previewUrl, generate} = value.capture; @@ -311,7 +350,7 @@ export function createImageTaggingController({api, target, source = 'images', on savedTags: value.tags.slice(), tagRevision: value.tag_revision, manual: draft === null ? value.tags.join(', ') : changed ? mergeCurrent(value.tags, draft) : draft, draftRevision: state.draftRevision + 1, - options: {...defaults}, optionsRevision: state.optionsRevision + 1, + options: {...defaults, ...chosen?.options}, optionsRevision: state.optionsRevision + 1, generateSupported: generate.supported, generateReason: generate.reason, statusText: changed ? `${COPY.changedElsewhere} ${savedNow(value.tags)}${reason ? ` ${reason}` : ''}` : reason, }); @@ -482,3 +521,66 @@ export function createImageTaggingController({api, target, source = 'images', on subscribe, open, setManual, setModel, setOption, generate, remove, save, retry, cancel, dispose, }; } + +/** + * The model and options of the bulk "Auto-tag selected images" dialog (D85): the same fields as the Tag image dialog's + * model panel, without an image. open() reads the server's defaults and the models' download status once; until it + * lands Start waits (`canStart` false). `unavailable` is why the chosen model cannot run ('' when it can), so the + * dialog refuses to start a job that would fail at once. + */ +export function createTaggingOptionsController({api} = {}) { + requireValue(object(api) && typeof api.get === 'function', 'An api client is required'); + const listeners = new Set(); + let closed = false; + let defaults = DEFAULTS; + let models = null; + let abort = null; + const deriveOptions = value => ({...value, canGenerate: false, unavailable: modelUnavailable(models, value.model), + canStart: value.phase === 'ready' && !modelUnavailable(models, value.model)}); + let state = freeze(deriveOptions({phase: 'loading', model: MODEL_OPTIONS[0].id, options: {...DEFAULTS}, optionsRevision: 0})); + + function set(patch) { + if (closed) return; + state = freeze(deriveOptions({...state, ...patch})); + for (const listener of [...listeners]) listener(state); + } + async function open() { + if (closed || abort) return; + abort = new AbortController(); + const read = await readDefaults(api, abort.signal); + if (closed) return; + ({defaults, models} = read); + set({phase: 'ready', options: {...defaults}, optionsRevision: state.optionsRevision + 1}); + } + function setModel(id) { + requireValue(modelOf(id), 'Unknown tagging model'); + if (closed || id === state.model) return; + // As in the Tag image dialog: the model's fields start from the defaults again; the count is kept (D91). + set({model: id, optionsRevision: state.optionsRevision + 1, + options: {...state.options, general_threshold: defaults.general_threshold, + character_threshold: defaults.character_threshold, instructions: defaults.instructions}}); + } + function setOption(key, value) { + requireValue(OPTION_KEYS.has(key), 'Unknown tagging option'); + set({options: {...state.options, [key]: value}}); + } + /** `{model_type, options}` for a job or a review session, or null while the options are invalid. */ + function request() { + if (optionError(state.model, state.options)) return null; + return {model_type: state.model, options: requestOptions(state.model, state.options)}; + } + function dispose() { + closed = true; + listeners.clear(); + abort?.abort(); + } + function subscribe(listener) { + requireValue(typeof listener === 'function', 'A listener function is required'); + listeners.add(listener); + return () => listeners.delete(listener); + } + return { + get state() {return state;}, + subscribe, open, setModel, setOption, request, dispose, + }; +} diff --git a/web/js/workspace/image-tagging.mjs b/web/js/workspace/image-tagging.mjs index 8993c27..52d31c2 100644 --- a/web/js/workspace/image-tagging.mjs +++ b/web/js/workspace/image-tagging.mjs @@ -101,10 +101,12 @@ function numberInput(value, {min, max, step}) { /** * 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). Returns the `element` and - * `requestGenerate()`, Generate's own path (field validity first), for a Retry after a failed Generate. + * It follows `controller` until `scope` is disposed (reused by the bulk Auto-tag dialog, D85, without Generate: + * `generate: false`). Returns the `element`, `validate()` (the fields' native validity, then what the controller would + * refuse; true when valid, else the first invalid field reports) and `requestGenerate()`, Generate's own path (field + * validity first), for a Retry after a failed Generate. */ -export function renderModelPanel({scope, controller, heading = 'Auto-tag this image'}) { +export function renderModelPanel({scope, controller, heading = 'Auto-tag this image', generate: withGenerate = true}) { const panel = el('div', undefined, 'tag-model-panel'); const select = el('select'); for (const option of MODEL_OPTIONS) { @@ -116,7 +118,8 @@ export function renderModelPanel({scope, controller, heading = 'Auto-tag this im 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); + panel.append(el('h3', heading), field('Tagging model', select, 'image-tag-model'), options); + if (withGenerate) panel.append(generate); const inputs = new Map(); let shownModel = null, shownOptions = null; @@ -161,26 +164,28 @@ export function renderModelPanel({scope, controller, heading = 'Auto-tag this im setDisabled(generate, !state.canGenerate); } select.addEventListener('change', () => controller.setModel(select.value)); - function requestGenerate() { + function validate() { const {state} = controller; - if (!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; } + if (invalid) { invalid.reportValidity(); return false; } const error = optionError(state.model, state.options); if (error) { const input = inputs.get(error.field); input.setCustomValidity(error.message); input.reportValidity(); - return; + return false; } - controller.generate(); + return true; + } + function requestGenerate() { + if (controller.state.canGenerate && validate()) controller.generate(); } generate.addEventListener('click', () => { if (!disabled(generate)) requestGenerate(); }); scope.cleanup(controller.subscribe(update)); update(controller.state); - return {element: panel, requestGenerate}; + return {element: panel, requestGenerate, validate}; } function previewOf(image, state) { @@ -192,20 +197,33 @@ function previewOf(image, state) { /** * 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. + * `tags`, `bulk`); `onSaved(event)` runs after a confirmed save, as every `onImageTagsSaved` listener does. + * + * D90 (the bulk review session, `images-tagging-review.mjs`): `actions: 'review'` with `position: {index, total}` + * titles the dialog "Tag image · Image <index> of <total>" and makes its actions Stop reviewing, Skip and Approve and + * next (Approve and finish on the last image). `preset` ({model, options}) applies the session's model and options, + * and `autoGenerate` runs Generate as soon as the file is observed. Approve saves as Save does, without the per-image + * toast (the session toasts its count), and calls `onSaved`; Skip writes nothing and calls `onSkip`; Stop reviewing, + * Escape and × write nothing and call `onStop` once. Swapping the next image's dialog in is not a stop. */ -export function openImageTagging({api, scope, image, source = 'images', trigger = null, onSaved = null}) { +export function openImageTagging({api, scope, image, source = 'images', trigger = null, onSaved = null, preset = null, + autoGenerate = false, actions: mode = 'tag', position = null, onSkip = null, onStop = null}) { const target = imageTarget(image); if (!target) throw new TypeError('The image has no tagging target'); + const review = mode === 'review'; + if (review && !(Number.isSafeInteger(position?.index) && Number.isSafeInteger(position?.total) && + position.index >= 1 && position.index <= position.total)) throw new TypeError('A review needs its position'); + // Set once this dialog hands over to the session (Approve, Skip): its close is then not a Stop. + let advancing = false; const saved = ({filename: name, tags, toast}) => { const event = {target, filename: name, tags, source}; announceSaved(event); + if (toast) showWorkspaceToast(toast); onSaved?.(event); - showWorkspaceToast(toast); }; // A save that settles after the dialog closed is still reported: its toast, and the pages' refresh (I5). const onLateSave = outcome => (outcome.saved ? saved(outcome) : showWorkspaceToast(outcome.message)); - const controller = createImageTaggingController({api, target, source, onLateSave}); + const controller = createImageTaggingController({api, target, source, onLateSave, preset}); const local = createScope(); let open = true; let completed = false; @@ -253,9 +271,15 @@ export function openImageTagging({api, scope, image, source = 'images', trigger // 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 cancel = createButton(review ? 'Stop reviewing' : 'Cancel'); // D90 + const skip = createButton('Skip'); const primary = createButton('Save manual tags', 'ca-primary'); - actions.append(cancel, primary); + const last = review && position.index === position.total; + // D90: Approve with suggestions on screen; after a model change cleared them (M2) Save keeps only the manual field. + const reviewLabel = state => (state.generated ? (last ? 'Approve and finish' : 'Approve and next') + : (last ? 'Save and finish' : 'Save and next')); + if (review) actions.append(cancel, skip, primary); + else actions.append(cancel, primary); const nodes = [heading]; if (controller.state.showScopeNote) nodes.push(el('p', SCOPE_NOTE, 'tag-scope-note small')); @@ -301,6 +325,12 @@ export function openImageTagging({api, scope, image, source = 'images', trigger } function complete(state) { completed = true; + if (review) { + // D90: the session swaps the next image in (or ends), so this dialog is not closed here. + advancing = true; + saved({filename: state.image.filename, tags: state.savedTags.slice(), toast: null}); + return; + } closeWorkspaceDialog(); saved({filename: state.image.filename, tags: state.savedTags.slice(), toast: state.toast}); } @@ -315,8 +345,10 @@ export function openImageTagging({api, scope, image, source = 'images', trigger if (state.suggestions !== shownRows || state.suggestionsText !== shownText) renderSuggestions(state); setText(count, state.countText); setText(status, state.statusText); - setText(primary, state.primaryLabel); + setText(primary, review ? reviewLabel(state) : state.primaryLabel); setDisabled(primary, !state.canSave); + // D90: while Approve's save is in flight the session cannot be left, so its count includes that save. + if (review) for (const button of [skip, cancel]) setDisabled(button, state.phase === 'saving'); if (state.canRetry !== retry.isConnected) { if (state.canRetry) actions.prepend(retry); // D116 else { @@ -327,7 +359,12 @@ export function openImageTagging({api, scope, image, source = 'images', trigger if (state.phase === 'saved') complete(state); } - cancel.addEventListener('click', () => closeWorkspaceDialog()); + cancel.addEventListener('click', () => { if (!disabled(cancel)) closeWorkspaceDialog(); }); + skip.addEventListener('click', () => { + if (!open || advancing || disabled(skip)) return; + advancing = true; + onSkip?.(); + }); // Retry after a failed Generate takes Generate's own path, so an invalid field reports its validity (M4). retry.addEventListener('click', () => (controller.state.phase === 'generate-error' ? requestGenerate() : controller.retry())); primary.addEventListener('click', () => { @@ -345,12 +382,17 @@ export function openImageTagging({api, scope, image, source = 'images', trigger document.getElementById('modal')?.classList.remove('tagging-modal'); local.dispose(); controller.dispose(); + if (review && !advancing) onStop?.(); } - openContentDialog({scope, title: 'Tag image', nodes, trigger, onClose: release}); + const dialogTitle = review ? `Tag image · Image ${position.index} of ${position.total}` : 'Tag image'; // D90 + openContentDialog({scope, title: dialogTitle, nodes, trigger, onClose: release, + canClose: review ? () => controller.state.phase !== 'saving' : null}); document.getElementById('modal')?.classList.add('tagging-modal'); local.cleanup(controller.subscribe(render)); render(controller.state); - controller.open(); + controller.open().then(() => { + if (open && autoGenerate && controller.state.canGenerate) controller.generate(); + }); return { close() { if (open) closeWorkspaceDialog(); }, get controller() { return controller; }, diff --git a/web/js/workspace/images-selection.mjs b/web/js/workspace/images-selection.mjs index 6938e52..c30894a 100644 --- a/web/js/workspace/images-selection.mjs +++ b/web/js/workspace/images-selection.mjs @@ -4,6 +4,7 @@ import { inspectPath, toInspectionRecord } from './inspection-state.mjs'; import { BUNDLE_FILENAME, metadataBundle, collectImagesMetadata, exportOutcome } from './images-export.mjs'; import { CATEGORY_DIALOG, CATEGORY_FAILED, CATEGORY_MAX_LENGTH, CATEGORY_NONE_LINKED, categoryOutcome, categoryTargets, updatedCount } from './images-category.mjs'; import { openInputDialog } from './workspace-dialogs.mjs'; +import { openAddTags, openAutoTag } from './images-tagging-bulk.mjs'; import { showWorkspaceToast } from './toast.mjs'; const CHECKBOX = '.image-overlays input[type="checkbox"]'; @@ -26,8 +27,11 @@ function el(tag, text, className) { * - `onCategorySet()` reloads the categories and the listing after a successful Set category (the reload clears the * selection through `clear`); focus then moves to `focusFallback` if it was on the hidden action. * - `retain(kept)` drops the checked files a refreshed listing no longer holds (a tag save under a filter, I7). + * - With the Activity store `activity`, Add tags and Auto-tag selected (D85) follow the count, as the concept's Add tags + * leads its actions; `onTagged(keys)` re-reads the files a finished tagging job tagged. The selection stays. */ -export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, onCategorySet, checked: initial = Object.freeze(new Set()) }) { +export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, onCategorySet, activity = null, onTagged, + checked: initial = Object.freeze(new Set()) }) { let checked = initial; let exporting = false, categorizing = false; const element = el('div'); @@ -40,7 +44,15 @@ export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, o const clearButton = createButton('Clear'); // Clear hides the bar it sits in, so focus moves to the page heading instead of falling to the page. scope.listen(clearButton, 'click', () => { clear(); focusFallback.focus({ preventScroll: true }); }); - bulk.append(count, setCategory, exportMetadata, clearButton); + bulk.append(count); + if (activity) { + const addTags = createButton('Add tags'); + scope.listen(addTags, 'click', () => openAddTags({ scope, store: activity, images: checkedImages(), trigger: addTags, onTagged })); + const autoTag = createButton('Auto-tag selected'); // D85 + scope.listen(autoTag, 'click', () => openAutoTag({ api, scope, store: activity, images: checkedImages(), trigger: autoTag, onTagged })); + bulk.append(addTags, autoTag); + } + bulk.append(setCategory, exportMetadata, clearButton); /** The count updates in place: the same bar and `strong`, its text written only when it changes. */ function render() { diff --git a/web/js/workspace/images-tagging-bulk.mjs b/web/js/workspace/images-tagging-bulk.mjs new file mode 100644 index 0000000..b4788be --- /dev/null +++ b/web/js/workspace/images-tagging-bulk.mjs @@ -0,0 +1,192 @@ +/** + * The Images selection bar's tagging actions (#249 PR D, Mock inventory M3): + * + * - **Add tags** (the concept's `bulk-tags` simpleInput, app.js a1378–1383): "Tag selected images", "Tags, separated + * by commas", Apply. The names are parsed as the Tag image dialog's manual field parses them, then the `image-tags` + * Activity job adds them to every selected image. + * - **Auto-tag selected** (D85, ruling L1, beyond the concept): "Auto-tag selected images" with the Tag image dialog's + * model panel (its fields show the server's defaults, D91's Suggestions included) and no Generate, then **How to + * apply** (D90, ruling T3-bulk): **Apply automatically** starts the job, **Review each image** starts the browser + * review session (images-tagging-review.mjs). A model that is not downloaded keeps Start disabled and says why. + * + * The toast says the job started (the Activity store's "Tagging started." / "Auto-tagging started."), or why it did + * not: a running record-changing job (named, with its link), a held model, a refused body. Activity shows progress. + * The selection stays either way. When a started job ends having tagged files, `onTagged(keys)` re-reads them. + */ +import { createButton, createScope } from '../shared/toolkit/core.js'; +import { createTaggingOptionsController, parseManualTags } from './image-tagging-state.mjs'; +import { imageTarget, renderModelPanel } from './image-tagging.mjs'; +import { startReviewSession } from './images-tagging-review.mjs'; +import { closeWorkspaceDialog, openContentDialog, openInputDialog } from './workspace-dialogs.mjs'; +import { busyToast } from './activity-scan.mjs'; +import { imageKey } from './images-state.mjs'; +import { countOf } from './plural.mjs'; +import { showWorkspaceToast } from './toast.mjs'; + +export const IMAGE_TAG_JOB = 'image-tags'; +export const MAX_BULK_IMAGES = 500; +export const TOO_MANY = 'Select at most 500 images.'; +export const NO_TAGS = 'Enter at least one tag.'; // D118 +export const ADD_DIALOG = Object.freeze({title: 'Tag selected images', label: 'Tags, separated by commas', action: 'Apply'}); +export const AUTO_TITLE = 'Auto-tag selected images'; +export const APPLY_CHOICES = Object.freeze([ + Object.freeze({value: 'auto', label: 'Apply automatically', action: 'Start auto-tagging'}), + Object.freeze({value: 'review', label: 'Review each image', action: 'Start review'}), +]); +// D118: the concept has no copy for these; they follow D55's "The scan could not start. Try again.". +const START_FAILED = Object.freeze({add: 'Tagging could not start. Try again.', auto: 'Auto-tagging could not start. Try again.'}); +const FINAL = new Set(['finished', 'cancelled', 'failed']); + +function el(tag, text, className) { + const node = document.createElement(tag); + if (text !== undefined) node.textContent = text; + if (className) node.className = className; + return node; +} + +/** + * The job targets of the checked `images` in listing order (their reveal identity), or null when more than 500 are + * checked. A file without a tagging identity is left out. + */ +export function bulkTargets(images) { + if (images.length > MAX_BULK_IMAGES) return null; + return images.map(imageTarget).filter(Boolean); +} + +/** The Add tags field's names, deduplicated as the Tag image dialog's manual field is. */ +export const parseBulkTags = parseManualTags; + +// Duck-typed: this ES module cannot import the UMD api-client.js. +const apiMessage = error => (error?.name === 'ApiError' && error.code && error.message ? error.message : null); + +/** + * Start one `image-tags` job with `body` through `store.start`. Returns the job, or null after toasting why not: the + * busy answer naming the running job, a coded refusal's own sentence (`model-busy`, a bad body), else a fixed one. + */ +export async function startTagJob({store, scope, body, notify = showWorkspaceToast}) { + try { + const result = await store.start(IMAGE_TAG_JOB, body); + if (scope.signal.aborted) return null; + if (result.busy !== undefined) { + notify(busyToast(result.busy)); + return null; + } + return result.job; + } catch (error) { + if (!scope.signal.aborted) notify(apiMessage(error) ?? START_FAILED[body.mode]); + return null; + } +} + +/** Call `onEnd(job)` once the job `id` has ended in `store`; returns the unsubscribe. */ +export function followJobEnd({store, scope, id, onEnd}) { + let done = false; + const check = () => { + const job = store.find(id); + if (done || !job || !FINAL.has(job.state)) return; + done = true; + stop(); + onEnd(job); + }; + const stop = store.subscribe(check); + scope.cleanup(stop); + check(); + return stop; +} + +/** + * Wire one started job: when it ends having tagged files, `onTagged(keys)` gets the image keys it was started for. + */ +function followTagged({store, scope, job, images, onTagged}) { + const keys = new Set(images.map(imageKey)); + followJobEnd({store, scope, id: job.id, onEnd: ended => { if ((ended.counters.tagged ?? 0) > 0) onTagged(keys); }}); +} + +/** + * Add tags: the concept's simpleInput, then the job over `images` (the checked files, taken now). A field that parses + * to no names keeps the dialog and its text, and reports NO_TAGS on the field (D118). `openInput` is the dialog seam. + */ +export function openAddTags({scope, store, images, trigger, onTagged = () => {}, notify = showWorkspaceToast, + openInput = openInputDialog}) { + const targets = bulkTargets(images); + if (!targets) { notify(TOO_MANY); return; } + openInput({scope, trigger, ...ADD_DIALOG, validate: text => (parseBulkTags(text).length ? null : NO_TAGS), + onApply: async text => { + const tags = parseBulkTags(text); + const job = await startTagJob({store, scope, body: {mode: 'add', targets, tags}, notify}); + if (job) followTagged({store, scope, job, images, onTagged}); + }}); +} + +/** The D90 How to apply radio group: Apply automatically (checked) or Review each image. */ +function applyChoice(onChange) { + const group = el('div', undefined, 'field'); + group.setAttribute('role', 'radiogroup'); + const caption = el('span', 'How to apply'); + caption.id = 'auto-tag-apply-label'; + group.setAttribute('aria-labelledby', caption.id); + group.append(caption); + for (const choice of APPLY_CHOICES) { + const label = el('label'); + const input = el('input'); + Object.assign(input, {type: 'radio', name: 'auto-tag-apply', value: choice.value, checked: choice.value === 'auto'}); + input.addEventListener('change', () => { if (input.checked) onChange(choice); }); + label.append(input, ` ${choice.label}`); + group.append(label); + } + return group; +} + +/** + * Auto-tag selected (D85): the model panel, How to apply (D90), Cancel and Start. Start validates the fields as + * Generate does, then starts the job or the review session over `images` (the checked files, taken now). + */ +export function openAutoTag({api, scope, store, images, trigger, onTagged = () => {}, notify = showWorkspaceToast}) { + const targets = bulkTargets(images); + if (!targets) { notify(TOO_MANY); return; } + const listed = images.filter(imageTarget); + const local = createScope(); + const options = createTaggingOptionsController({api}); + // D85: the Tag image dialog's model panel without Generate; its Suggestions field is the job's count (D91). + const {element: panel, validate} = renderModelPanel({scope: local, controller: options, + heading: `Auto-tag ${countOf(listed.length, 'image')}`, generate: false}); // D118 + let choice = APPLY_CHOICES[0]; + const actions = el('div', undefined, 'dialog-actions'); + const cancel = createButton('Cancel'); + const start = createButton(choice.action, 'ca-primary'); + actions.append(cancel, start); + // A model that is not downloaded: the reason, as the Tag image dialog's status line words it. + const status = el('div', undefined, 'small'); + status.id = 'tag-generation-status'; + status.setAttribute('role', 'status'); + const choices = applyChoice(next => { choice = next; start.textContent = next.action; }); // D90 + const render = state => { + if (status.textContent !== state.unavailable) status.textContent = state.unavailable; + if (state.canStart) start.removeAttribute('aria-disabled'); + else start.setAttribute('aria-disabled', 'true'); + }; + local.cleanup(options.subscribe(render)); + render(options.state); + cancel.addEventListener('click', () => closeWorkspaceDialog()); + start.addEventListener('click', () => { + if (start.getAttribute('aria-disabled') === 'true' || !options.state.canStart || !validate()) return; + const request = options.request(); + if (!request) return; + if (choice.value === 'review') { + // The first image's dialog replaces this one in the shell (D90). + startReviewSession({api, scope, images: listed, ...request, trigger}); + return; + } + closeWorkspaceDialog(); + startTagJob({store, scope, body: {mode: 'auto', targets, ...request}, notify}).then(job => { + if (job) followTagged({store, scope, job, images: listed, onTagged}); + }); + }); + openContentDialog({scope, title: AUTO_TITLE, nodes: [panel, choices, status, actions], trigger, onClose: () => { + document.getElementById('modal')?.classList.remove('tagging-modal'); + local.dispose(); + options.dispose(); + }}); + document.getElementById('modal')?.classList.add('tagging-modal'); + options.open(); +} diff --git a/web/js/workspace/images-tagging-review.mjs b/web/js/workspace/images-tagging-review.mjs new file mode 100644 index 0000000..f00aabe --- /dev/null +++ b/web/js/workspace/images-tagging-review.mjs @@ -0,0 +1,75 @@ +/** + * D90 (ruling T3-bulk): "Review each image", the bulk Auto-tag session in the browser. The selected images are walked + * one Tag image dialog at a time (image-tagging.mjs `actions: 'review'`), each opened with the session's model and + * options and Generate already running. Approve saves that image as the dialog's Save does and advances; Skip + * advances without a write; Stop reviewing, Escape and × end the session. Each image is one gated `suggest` call, so + * the session holds no model between images; a refused one (`model-busy`) stays on its image with the dialog's own + * status and Retry until the user retries or skips. Ending toasts "<n> of <M> images tagged."; the selection stays. + */ +import { closeWorkspaceDialog } from './workspace-dialogs.mjs'; +import { openImageTagging } from './image-tagging.mjs'; +import { showWorkspaceToast } from './toast.mjs'; + +/** The closing toast: approved saves of the session's images (the plan's copy verbatim). */ +export function reviewOutcome(tagged, total) { + return `${tagged} of ${total} images tagged.`; +} + +/** + * Walk `images` (listing items with a tagging target, in selection order) with `model_type` and `options`. + * `onSaved(event)` follows every approved save. Returns `{stop()}`, which ends the session (its open dialog closes and + * writes nothing more). `open`, `close` and `notify` are the dialog seams (the defaults in the page). + */ +export function startReviewSession({api, scope, images, model_type: model, options, trigger = null, onSaved = null, + open = openImageTagging, close = closeWorkspaceDialog, notify = showWorkspaceToast}) { + if (!Array.isArray(images) || !images.length) throw new TypeError('A review needs its images'); + const total = images.length; + let index = 0, tagged = 0, ended = false, handle = null; + + function end() { + if (ended) return; + ended = true; + handle = null; + notify(reviewOutcome(tagged, total)); + } + function advance() { + if (ended) return; + index += 1; + if (index >= total) { + // The last dialog handed over (advancing), so closing it is not a Stop. + end(); + close(); + return; + } + show(); + } + function show() { + let current = true; + const settled = () => { + const was = current; + current = false; + return was; + }; + handle = open({api, scope, image: images[index], source: 'bulk', trigger, preset: {model, options}, + autoGenerate: true, actions: 'review', position: {index: index + 1, total}, + onSaved: event => { + // A save that settles after its dialog was left (Skip or Stop while it ran) still counts. + tagged += 1; + onSaved?.(event); + if (settled()) advance(); + }, + onSkip: () => { if (settled()) advance(); }, + onStop: () => { if (settled()) end(); }, + }); + } + + show(); + return { + stop() { + if (ended) return; + const open = handle; + end(); + open?.close(); + }, + }; +} diff --git a/web/js/workspace/images-tagging.mjs b/web/js/workspace/images-tagging.mjs index babfd78..dc3f0f9 100644 --- a/web/js/workspace/images-tagging.mjs +++ b/web/js/workspace/images-tagging.mjs @@ -23,10 +23,13 @@ export function openCardTagging({api, scope, image, action, embedded}) { /** * Follow confirmed tag saves until `scope` ends. `filtered()` tells whether a Tags filter or a search is active, * `selection()` is the selected image key, and `onRefreshing(busy)` marks Load more busy while the re-read runs. + * Returns `refresh(keys)`, the same re-read for the files a bulk tagging job tagged (once per job, D85). */ export function followImageTagSaves({scope, stream, inspection, filtered, selection, onRefreshing}) { - scope.cleanup(onImageTagsSaved(async ({target}) => { - const key = `${target.root_index}:${target.path}`; + scope.cleanup(onImageTagsSaved(({target}) => refresh(new Set([`${target.root_index}:${target.path}`])))); + return refresh; + + async function refresh(keys) { if (filtered()) { onRefreshing(true); try { @@ -37,6 +40,6 @@ export function followImageTagSaves({scope, stream, inspection, filtered, select if (!scope.signal.aborted) onRefreshing(false); } } - if (!scope.signal.aborted && selection() === key) inspection.refresh(); - })); + if (!scope.signal.aborted && keys.has(selection())) inspection.refresh(); + } } diff --git a/web/js/workspace/images.mjs b/web/js/workspace/images.mjs index 19045c0..1ac497a 100644 --- a/web/js/workspace/images.mjs +++ b/web/js/workspace/images.mjs @@ -254,7 +254,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage // The checked cards and the concept's selection bar (#249 D8), between the filter row and the gallery layout. The // selection is kept across appended batches and cleared with the collection; the Library-embedded gallery has none. const bar = embedded ? null : mountSelectionBar({scope, api, grid, getItems:() => state.items, focusFallback, - checked:visit?.checked, onCategorySet:() => { loadCategories(); reset(); }}); + checked:visit?.checked, onCategorySet:() => { loadCategories(); reset(); }, activity, onTagged:keys => refreshTagged(keys)}); if (bar) main.append(bar.element); main.append(body); const mediaViewer = createMediaViewer({api,container:main,parentScope:scope,inspectionController:inspection,sender, @@ -529,7 +529,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage scheduleLoad(); } const stream = createImageStream({ api, fetchPage, onChange: renderState, restore: visit ? {filters, state:visit.state} : undefined }); - followImageTagSaves({scope, stream, inspection, selection:() => selection, filtered:() => !embedded && !recovering && Boolean(filters.tags.length || filters.q), + const refreshTagged = followImageTagSaves({scope, stream, inspection, selection:() => selection, filtered:() => !embedded && !recovering && Boolean(filters.tags.length || filters.q), onRefreshing:busy => more.setAttribute('aria-disabled', String(busy || Boolean(state?.loading) || recovering))}); scope.cleanup(() => { stream.dispose(); diff --git a/web/js/workspace/workspace-dialogs.mjs b/web/js/workspace/workspace-dialogs.mjs index 72b3e86..bdfa84f 100644 --- a/web/js/workspace/workspace-dialogs.mjs +++ b/web/js/workspace/workspace-dialogs.mjs @@ -59,10 +59,10 @@ function createShell() { body.id = 'modal-body'; header.append(title, close); dialog.append(header, body); - close.addEventListener('click', () => finish()); + close.addEventListener('click', () => dismiss()); dialog.addEventListener('cancel', event => { event.preventDefault(); - finish(); + dismiss(); }); // A browser may still close a modal on its own; that is a dismissal too. `close` arrives as a later task, so it // is ignored once this shell was reopened or replaced. @@ -82,6 +82,12 @@ function finish() { if (trigger?.isConnected) trigger.focus(); } +/** × or Escape: close, unless the open dialog's `canClose()` holds it (a save in flight). */ +function dismiss() { + if (session?.canClose && !session.canClose()) return; + finish(); +} + const boundScopes = new WeakSet(); function requireScope(scope) { @@ -104,13 +110,13 @@ function bindScope(scope) { }); } -function show(heading, nodes, { scope, trigger, focusTarget, onClose = null }) { +function show(heading, nodes, { scope, trigger, focusTarget, onClose = null, canClose = null }) { shell ??= createShell(); if (!shell.dialog.isConnected) document.body.append(shell.dialog); bindScope(scope); // A swapped-out body is gone from the shell, so its owner releases it. const replaced = session?.onClose; - session = { scope, trigger: trigger ?? session?.trigger ?? null, onClose }; + session = { scope, trigger: trigger ?? session?.trigger ?? null, onClose, canClose }; replaced?.(); shell.title.textContent = heading; shell.body.replaceChildren(...nodes); @@ -298,10 +304,14 @@ export function openCopyFallback(text, { scope, trigger = null } = {}) { * The concept's `simpleInput`: one labelled `input#action-input` (starting at `value`), then Cancel and the primary * `action`. The action reports the trimmed value and closes; an empty value keeps the dialog open on its field, or * reports `fallback` when one is given (Save view's "Saved view", a1339). A positive `maxLength` limits the field. + * `validate(value)` may refuse a value with a message: the dialog then stays open with the typed text and the field + * reports the message (its native validity) until it is edited. */ -export function openInputDialog({ scope, title, label, action, value = '', maxLength, fallback, onApply, trigger = null }) { +export function openInputDialog({ scope, title, label, action, value = '', maxLength, fallback, onApply, trigger = null, + validate = null }) { requireScope(scope); requireCallbacks({ onApply }); + if (validate !== null) requireCallbacks({ validate }); for (const [name, text] of Object.entries({ title, label, action })) { if (typeof text !== 'string' || !text) throw new TypeError(`The input dialog needs a ${name}`); } @@ -314,9 +324,17 @@ export function openInputDialog({ scope, title, label, action, value = '', maxLe const cancel = createButton('Cancel'); const apply = createButton(action, 'ca-primary'); cancel.addEventListener('click', () => finish()); + input.addEventListener('input', () => input.setCustomValidity('')); apply.addEventListener('click', () => { const entered = input.value.trim() || fallback; if (!entered) { input.focus(); return; } + const problem = validate?.(entered); + if (problem) { + input.setCustomValidity(problem); + input.reportValidity(); + input.focus(); + return; + } finish(); onApply(entered); }); @@ -327,14 +345,16 @@ export function openInputDialog({ scope, title, label, action, value = '', maxLe /** * The concept's plain `dialog(title, body)` with page-built `nodes`. `onClose` runs once when the dialog closes or - * another dialog replaces its body, so the page can dispose what it mounted there. + * another dialog replaces its body, so the page can dispose what it mounted there. While `canClose()` answers false, + * × and Escape leave the dialog open (the page, a route change or a replacing dialog still close it). */ -export function openContentDialog({ scope, title, nodes, onClose = null, trigger = null }) { +export function openContentDialog({ scope, title, nodes, onClose = null, trigger = null, canClose = null }) { requireScope(scope); if (typeof title !== 'string' || !title) throw new TypeError('The dialog needs a title'); if (!Array.isArray(nodes) || nodes.some(node => !node || typeof node !== 'object')) throw new TypeError('The dialog needs its nodes'); if (onClose !== null && typeof onClose !== 'function') throw new TypeError('The dialog onClose must be a function'); - show(title, nodes, { scope, trigger, onClose }); + if (canClose !== null && typeof canClose !== 'function') throw new TypeError('The dialog canClose must be a function'); + show(title, nodes, { scope, trigger, onClose, canClose }); } export const SCAN_REVIEW_SENTENCE = 'Enrich positive / negative associations, reuse canonical text and report unchanged files. ' + diff --git a/web/js/workspace/workspace.css b/web/js/workspace/workspace.css index 7394f31..2e87e8b 100644 --- a/web/js/workspace/workspace.css +++ b/web/js/workspace/workspace.css @@ -376,3 +376,9 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc /* 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; } } + +/* Auto-tag selected images (#249 PR D, D85/D90): the Tag image dialog's model panel, then How to apply, a radio group + in the concept's `.field` row whose radios keep their own size (the concept's `.field input` is a full-width text + field) beside their label, as the concept's own radio rows (s1160 `.compare-item label`). */ +.field[role="radiogroup"] > label { display: flex; align-items: center; gap: 8px; /* D90 */ } +.field[role="radiogroup"] input[type="radio"] { width: auto; margin: 0; /* D90 */ }