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 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 " 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 of " 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 " of 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
+ 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 " 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-.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: "
+ finished · N tagged · M errors", " cancelled · N tagged" or " 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/`
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: " 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/` 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 `