feat(annotations): bulk Add tags and Auto-tag selected as Activity jobs (#283)

* feat(activity): image tagging jobs for bulk Add tags and Auto-tag

POST /prompt_manager/jobs/image-tags starts one record-changing
"image-tags" job over 1..500 listed images (202, or the same 409 busy
envelope as /jobs/scan). Add tags hashes each current file and unions a
fixed set into that byte record; Auto-tag captures each still image, asks
one legacy engine for its top N suggestions on the capture (count from
the options or autotag.defaults.suggestion_limit), re-hashes the file and
unions only onto the same bytes. Outcomes are grouped as tagged,
unchanged, skipped (video, too large, file changed, file not found, not a
still image) and errors; cancel stops at a file boundary.

Auto-tag holds the shared model gate for the whole job (409 model-busy at
start when it is held), loads the model once and never downloads one: an
unavailable model fails the job before the first file. The registry gains
blocker() so a start answers busy before taking the gate.

* feat(annotations): Add tags and Auto-tag selected in the selection bar

The Images selection bar gains the concept's Add tags and, beside it,
Auto-tag selected (D85). Add tags starts the image-tags Activity job with
the dialog's comma-separated names; Auto-tag selected reuses the Tag image
model panel (server defaults, Suggestions count) without Generate, refuses
a model that is not downloaded, and either starts the job (Apply
automatically) or walks the selection one Tag image dialog at a time
(Review each image, D90). Busy, model-busy and refused starts toast why;
the selection stays; a finished job's files are read again.

* feat(activity): show image tagging jobs with their own outcomes

Image tagging jobs get their own Activity view (D89): the pill reads
"Tagging", the item and heading line name the images and end with
"N tagged · M errors", and the job view shows Tagged, Unchanged, Skipped
and Errors tiles and outcomes, opens Tagged and Unchanged rows in the
viewer, and reads "Tagging log" and "Cancel tagging". The per-kind table
lives in activity-store.mjs; the scan's view is unchanged.

The selection bar's copy without a concept line is recorded as D117.

* fix(annotations): final review round for bulk image tagging

- Rename the D117 copy deviation to D118 (D117 belongs to another PR).
- The tagging job's progress line gives the scan's "about X min left"
  estimate, with each finished file as one batch.
- The start body cap fits 500 targets of the longest legal path, even
  escaped; one byte over answers 413.
- A capture store at capacity is retried with a short backoff, then the
  file is skipped with a rerun sentence instead of a Tag-dialog error.
- Output roots and their labels are resolved once per job in the
  executor, not twice per file on the event loop.
- Review each image holds Stop reviewing, Escape and x while a save is in
  flight, and its primary reads "Save and next" when only the manual
  field would be saved.
- An Add tags field that parses to no names keeps the dialog and its
  text and reports the message on the field.
- Image tagging finish toasts carry the counts ("... finished · N tagged
  · M errors").
- Specs: the bulk spec waits for the answered Start before checking that
  the invalid press sent nothing; the Activity spec uses a real error row.
This commit is contained in:
Vito
2026-10-22 18:55:24 -07:00
committed by GitHub
parent 9dcad31441
commit a8aff3a39d
32 changed files with 3019 additions and 155 deletions
+140 -3
View File
@@ -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: <title>." with a link to it (D57's toast), a held model
gives the server's `model-busy` sentence, any other coded refusal its own sentence,
and anything else "Tagging could not start. Try again." / "Auto-tagging could not
start. Try again.".
- When a job this page started ends having tagged files, those files are read again
once: the selected file's inspection (its chips, D88) and, under a Tags filter or a
search, the loaded range, as after a Tag save.
- **D85** (plan D62, ruling L1, beyond the concept): **Auto-tag selected** opens
"Auto-tag selected images" in the Tag image dialog's shell (`tagging-modal`) with
its model panel, headed "Auto-tag <N> image(s)" (the panel's "Auto-tag this image"
for N files), without the preview, the manual field, the suggestions or Generate.
Its fields show the server's defaults (`GET /prompt_manager/autotag/models`, the
D91 Suggestions count included) once that read lands; Start waits for it. A model
the status lists as not downloaded keeps Start `aria-disabled` and
`#tag-generation-status` says "<model> is not downloaded. Download it from the
legacy Auto-tag page." (the server's own sentence), so no job starts only to fail.
Start validates the fields as Generate does (native validity; nothing is sent).
The concept comparison (`images-concept.spec.mjs`) uses the concept page itself as
the oracle, with Add to collection and Remove links… removed and a copy of its Add
tags button reading "Auto-tag selected" inserted after it.
- **D90** (plan D67, ruling T3-bulk): the dialog's **How to apply** radio group
(`.field[role=radiogroup]`, radios beside their labels): **Apply automatically**
(checked; primary "Start auto-tagging") starts the `image-tags` job
(`{mode: "auto", targets, model_type, options}`) and toasts "Auto-tagging started.";
**Review each image** (primary "Start review") walks the selection in the browser
(`images-tagging-review.mjs`). Each image opens the Tag image dialog titled "Tag
image · Image <i> of <M>" on the chosen model and options with Generate already
running; its actions are Stop reviewing, Skip and Approve and next (Approve and
finish on the last). Approve saves the manual field plus the remaining chips as Save
does, without its per-image toast, and opens the next image; Skip writes nothing and
advances; Stop reviewing, Escape and × end the session. While Approve's save is in
flight, Skip and Stop reviewing are `aria-disabled` and Escape and × do nothing, so the
closing count includes that save. When no suggestions are on screen (a model change
cleared them, or Generate was refused) the primary saves the manual field only and
reads "Save and next" ("Save and finish" on the last image). Each image is one gated
`suggest` call, so the session holds no model between images; a `model-busy` answer
stays on that image with its sentence in `#tag-generation-status` and D116's Retry
until the user retries or skips. Ending toasts "<n> of <M> images tagged." and
returns focus to Auto-tag selected.
- **D91** extends to the bulk dialog: its Suggestions field is the job's and the
review's `suggestion_limit`.
- **D118** (copy the concept has no line for; the concept's `bulk-apply` only toasts a
demo sentence): an Add tags field that parses to no names reports "Enter at least one
tag." on the field; a start refused for no coded reason toasts "Tagging could not start. Try
again." or "Auto-tagging could not start. Try again." (as D55's "The scan could not
start. Try again."); and the bulk dialog's model panel is headed "Auto-tag <N>
image(s)" where the Tag image dialog's reads "Auto-tag this image". Everything else
the selection bar's tagging shows is the concept's or the plan's (D85, D90, D91).
- **D89** (plan D66, Activity for the `image-tags` job): the job's Activity item and its
view are the scan's (D54, D56, D57, D59) with the job kind's own counters, from one
per-kind table (`JOB_VIEWS` in `activity-store.mjs`; a kind without one keeps the
scan's tiles under "Job log" and "Cancel job"). The titles are the server's: "Add
tags to images" and "Auto-tag images".
- The pill's verb is "Tagging" ("Tagging · 41% · 8,240 / 19,622").
- The item line and the view's heading line read "Started HH:MM · N images · closing
this view does not cancel it" (the job's image count, grouped "en-US"), "Finished
HH:MM · N tagged · M errors", "Cancelled HH:MM at a file boundary · N tagged ·
completed work is kept" or "Stopped on an error at HH:MM · N tagged · M errors".
- The view's actions are Download report and, while running, "Cancel tagging"
(`.danger`); the progress bar is "<title> progress".
- Four `dl.stat-row` tiles in the mockup's tones: Tagged (`.added`, green),
Unchanged, Skipped (`.warn`, warning), Errors (`.bad`, red). They keep the
mockup's five-column row (two columns on a phone), so a tile is the scan's width
and the fifth cell stays empty.
- The outcome list reads Tagged, Errors, Skipped, Unchanged (what got tagged and what
went wrong first, as the scan's), and the view opens on the first with files.
- Rows under Tagged and Unchanged are D57's `button.file-row`s that open the file in
the viewer by its listing identity; Skipped and Errors rows are plain text.
- The log is "● Tagging log — raw detail, click to show".
- The row and log wording is the job's (D1), since neither the concept nor the
mockup has a line for it: "Added N tag(s)", "No new tags", and the skip reasons
"Video: manual tags only", "Too large to verify", "File changed", "File not found",
"Not a supported still image" and "The file could not be read now. Run the job again
for it." (the capture store stayed at capacity, for example with Tag dialogs open in
other tabs, after a short backoff of retries). Log prefixes are `+` tagged, `=`
unchanged, `-` skipped and `!` error.
- The progress line is the scan's: "Processing file N of M · about X min left", the
estimate (D56's `_time_left`) shown once two files are done, each file one batch.
- Download report saves `prompt-manager-image-tags-<YYYYMMDD-HHMM>.json`.
- The start toasts are "Tagging started." / "Auto-tagging started." (D85); the end
toasts follow the design's pattern with the job's counts, as the scan's: "<title>
finished · N tagged · M errors", "<title> cancelled · N tagged" or "<title> failed ·
N tagged · M errors".
- Tests: `tests/js/workspace-activity-store.test.js` ("image-tags jobs get their own
tiles outcomes and pill verb", "unknown kinds keep the generic view");
`tests/browser/workspace-activity.spec.mjs` ("an image tagging job shows Tagged
Unchanged Skipped Errors and opens tagged rows", "cancel tagging stops at a file
boundary", "the report download names the kind", on fake `image-tags` jobs from
`workspace_activity_fixture.py`); `tests/browser/activity-concept.spec.mjs` ("image
tagging job view follows the approved mockup", dark and light, with a mutation
check). The scan's view is unchanged.
Browser tests (`tests/browser/workspace-tagging-bulk.spec.mjs`, on the tagging
fixture): "selection bar shows Add tags then Auto-tag selected before Set category",
"Add tags runs a job and the cards show the tags after it finishes", "Auto-tag
selected with Apply automatically runs a job with the chosen model, thresholds and
suggestion count", "a model that is not downloaded keeps Start disabled and says
why", "Suggestions field prefills from the setting and caps chips at the entered
count", "Review each image walks the selection one dialog at a time and only approved
images gain tags", "Review holds Stop reviewing, Escape and × while a save is in
flight, and names a manual-only save" and "a running scan makes the start report
busy". Units:
`tests/js/workspace-images-tagging-bulk.test.js`,
`tests/js/workspace-images-tagging-review.test.js`.
| Difference | Record |
| --- | --- |
| Add tags ships (Add to collection and Remove links… still omitted) | D8 (amended) |
| Auto-tag selected and its dialog | D85 (ruling L1) |
| How to apply and the Review each image session | D90 (ruling T3-bulk) |
| Suggestions count in the bulk dialog | D91 |
| Copy without a concept line: no tags, start fallbacks, the panel heading | D118 |
| Activity item and job view for image tagging jobs | D89 (plan D66) |
Native image tagging (#249 PR B routes, PR C UI): `POST
/prompt_manager/image-annotations/observe` (capture one listed file, its saved set and
`tag_revision`), `GET`/`DELETE /prompt_manager/image-annotations/capture/<token>`
+3
View File
@@ -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,
+13 -4
View File
@@ -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):
+509
View File
@@ -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}",
)
+23 -13
View File
@@ -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):
+22 -9
View File
@@ -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)
+31 -5
View File
@@ -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 });
+12 -4
View File
@@ -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');
+13 -6
View File
@@ -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
+111
View File
@@ -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.');
});
+2 -1
View File
@@ -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();
@@ -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' } });
}
});
+31 -9
View File
@@ -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()
+42
View File
@@ -283,6 +283,48 @@ test('summaries are validated and the copy follows the spec', async () => {
assert.deepEqual([-1, 0, 1, 4, 5, 6, 1e9, 0.5].map(backoffDelay), [1000, 1000, 2000, 16000, 30000, 30000, 30000, 1000]);
});
// D89 (plan D66): an image tagging job's view has its own tiles, outcomes, openable rows, log and cancel labels.
test('image-tags jobs get their own tiles outcomes and pill verb', async () => {
const { parseJob, jobView, pillParts, outcomeLine, startToast } = await source;
assert.equal(typeof jobView, 'function');
const job = parseJob(summary({ kind: 'image-tags', title: 'Auto-tag images', progress: 40, processed: 2, total: 5,
counters: { tagged: 1420, unchanged: 3, skipped: 2, errors: 1 }, scope: { mode: 'auto', images: 5, model_type: 'wd14-vit' } }));
const view = jobView(job.kind);
assert.deepEqual(view.tiles, [['tagged', 'Tagged', 'added'], ['unchanged', 'Unchanged', ''], ['skipped', 'Skipped', 'warn'],
['errors', 'Errors', 'bad']]);
assert.deepEqual(view.outcomes, [['tagged', 'Tagged'], ['errors', 'Errors'], ['skipped', 'Skipped'], ['unchanged', 'Unchanged']]);
assert.deepEqual([...view.openable].sort(), ['tagged', 'unchanged']);
assert.equal(view.log, 'Tagging log');
assert.equal(view.cancel, 'Cancel tagging');
assert.deepEqual(pillParts(job), { label: 'Tagging · 40%', files: ' · 2 / 5', text: 'Tagging · 40% · 2 / 5' });
assert.equal(outcomeLine(job), '1,420 tagged · 1 error');
assert.equal(outcomeLine({ ...job, counters: {} }), '0 tagged · 0 errors');
assert.equal(startToast(job), 'Auto-tagging started.');
// The design's toast pattern, as the scan's: "<Job> finished · N tagged · M errors".
const { finishToast } = await source;
assert.equal(finishToast({ ...job, state: 'finished' }), 'Auto-tag images finished · 1,420 tagged · 1 error');
assert.equal(finishToast({ ...job, state: 'cancelled' }), 'Auto-tag images cancelled · 1,420 tagged');
assert.equal(finishToast({ ...job, state: 'failed' }), 'Auto-tag images failed · 1,420 tagged · 1 error');
assert.equal(finishToast({ ...job, title: 'Add tags to images', counters: {}, state: 'finished' }),
'Add tags to images finished · 0 tagged · 0 errors');
});
test('unknown kinds keep the generic view', async () => {
const { jobView } = await source;
const scan = jobView('scan');
assert.deepEqual(scan.tiles, [['added', 'Added to Library', 'added'], ['known', 'Already known', ''],
['review', 'Needs review', 'warn'], ['no_metadata', 'No usable metadata', ''], ['errors', 'Errors', 'bad']]);
assert.deepEqual(scan.outcomes, [['added', 'Added to Library'], ['review', 'Needs review'], ['errors', 'Errors'],
['no_metadata', 'No usable metadata'], ['known', 'Already known']]);
assert.deepEqual([...scan.openable].sort(), ['added', 'known']);
assert.deepEqual([scan.log, scan.cancel], ['Scan log', 'Cancel scan']);
for (const kind of ['maintenance', '', 'constructor', '__proto__']) {
const view = jobView(kind);
assert.deepEqual([view.tiles, view.outcomes, [...view.openable]], [scan.tiles, scan.outcomes, [...scan.openable]]);
assert.deepEqual([view.log, view.cancel], ['Job log', 'Cancel job']);
}
});
test('pagehide stops polling, closes streams and aborts the in-flight read; pageshow resumes', async () => {
const { createActivityStore } = await source;
const { world, api, ...deps } = harness();
@@ -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);
});
@@ -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.');
});
+9
View File
@@ -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):
+787
View File
@@ -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()
+9
View File
@@ -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()
+16 -4
View File
@@ -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,
+35 -36
View File
@@ -11,6 +11,9 @@
* filter, and the selected outcome's listed rows (at most 200 per outcome, so "Showing N of M"). Rows under Added and
* Already known open the file in the existing viewer through its listing identity (D57); the others are plain text.
* - Log: `details.term` "Scan log", collapsed, holding the raw timestamped lines in `pre[tabindex=0]`.
* - D89: the tiles, the outcome list, the openable rows and the log and cancel labels are the job kind's (`jobView`):
* an image tagging job shows Tagged, Unchanged, Skipped and Errors, opens Tagged and Unchanged rows, and reads
* "Tagging log" and "Cancel tagging"; another kind keeps the scan's tiles as "Job log" and "Cancel job".
*
* The summary follows the Activity store (one SSE stream per running job); the listed rows and the log are read from
* `GET /prompt_manager/jobs/<id>` at most once a second while the job runs, and once more when it ends.
@@ -19,7 +22,7 @@ import { createButton } from '../shared/toolkit/core.js';
import { pageHeading } from './heading.mjs';
import { showWorkspaceToast } from './toast.mjs';
import { noun } from './plural.mjs';
import { parseJob, JOBS_URL, LOST_DETAIL } from './activity-store.mjs';
import { parseJob, jobView, JOB_VIEWS, JOBS_URL, LOST_DETAIL } from './activity-store.mjs';
import { jobDetail, CANCEL_FAILED_TOAST } from './activity.mjs';
import { downloadJobReport } from './activity-scan.mjs';
import { createMediaViewer } from './media-viewer.mjs';
@@ -36,18 +39,6 @@ export const FILE_GONE = 'This file is no longer in Images. Rescan from Import a
export const OPEN_FAILED = 'The file could not be opened. Try again.';
const LISTING_LIMIT = 5;
/** The counter tiles, in the spec's order and the mockup's tones. */
export const TILES = Object.freeze([
['added', 'Added to Library', 'added'], ['known', 'Already known', ''], ['review', 'Needs review', 'warn'],
['no_metadata', 'No usable metadata', ''], ['errors', 'Errors', 'bad'],
]);
/** The outcome list, in the mockup's order: what got added and what went wrong first. */
export const OUTCOMES = Object.freeze([
['added', 'Added to Library'], ['review', 'Needs review'], ['errors', 'Errors'],
['no_metadata', 'No usable metadata'], ['known', 'Already known'],
]);
const OPENABLE = new Set(['added', 'known']);
function element(tag, text, className) {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
@@ -103,9 +94,9 @@ export function filterRows(rows, filter) {
return rows.filter(row => `${row.root}/${row.path}`.toLocaleLowerCase().includes(wanted));
}
/** The first outcome with files, so a view opens on what happened; Added when nothing has yet. */
export function defaultOutcome(counters) {
return OUTCOMES.find(([group]) => (counters[group] ?? 0) > 0)?.[0] ?? 'added';
/** The first outcome with files, so a view opens on what happened; the first outcome when nothing has yet. */
export function defaultOutcome(counters, outcomes = JOB_VIEWS.scan.outcomes) {
return (outcomes.find(([group]) => (counters[group] ?? 0) > 0) ?? outcomes[0])[0];
}
function section(title, id) {
@@ -132,13 +123,6 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) {
bar.max = 100;
const stats = element('dl', undefined, 'stat-row');
const tileValues = new Map();
for (const [group, label, tone] of TILES) {
const tile = element('div', undefined, tone ? `stat ${tone}` : 'stat');
const value = element('dd', '0');
tile.append(element('dt', label), value);
tileValues.set(group, value);
stats.append(tile);
}
progressPart.node.append(bar, stats);
const resultsPart = section('Results', 'job-results-title');
@@ -155,15 +139,6 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) {
list.setAttribute('aria-label', 'Outcomes');
const files = element('div', undefined, 'outcome-files');
const buttons = new Map();
for (const [group, label] of OUTCOMES) {
const button = createButton('');
const count = element('span', '0');
button.append(element('span', label), count);
button.setAttribute('aria-pressed', 'false');
button.count = count;
buttons.set(group, button);
list.append(button);
}
outcomes.append(list, files);
resultsPart.node.append(outcomes);
@@ -181,6 +156,8 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) {
missing.append(element('p', NOT_FOUND, 'empty-state'), back);
let detail = null;
// The job's kind decides its tiles, outcomes and labels (D89); it is known once the job is.
let view = null;
let selected = null;
let mounted = false;
let lastRead = 0;
@@ -200,13 +177,35 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) {
const viewer = createMediaViewer({ api, container: main, parentScope: scope, inspectionController: inspection,
sender: createPromptSender({ scope }), onSelect: image => inspection.select(image) });
/** The tiles and the outcome list of the job's kind, built once. */
function layout(kind) {
view = jobView(kind); // D89
for (const [group, label, tone] of view.tiles) {
const tile = element('div', undefined, tone ? `stat ${tone}` : 'stat');
const value = element('dd', '0');
tile.append(element('dt', label), value);
tileValues.set(group, value);
stats.append(tile);
}
for (const [group, label] of view.outcomes) {
const button = createButton('');
const count = element('span', '0');
button.append(element('span', label), count);
button.setAttribute('aria-pressed', 'false');
button.count = count;
buttons.set(group, button);
list.append(button);
}
}
function showBody(job) {
if (mounted) return;
mounted = true;
if (!view) layout(job.kind);
missing.remove();
parts.actions.append(download, cancel);
main.append(progressPart.node, resultsPart.node, log);
selected ??= defaultOutcome(job.counters);
selected ??= defaultOutcome(job.counters, view.outcomes);
}
/** The job is not (or no longer) in this session: say so instead of an empty view. */
@@ -242,7 +241,7 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) {
function renderRows(job) {
const rows = detail?.outcomes[selected] ?? [];
const openable = OPENABLE.has(selected);
const openable = view.openable.has(selected);
const wanted = filterRows(rows.map((row, index) => ({ ...row, index })), input.value);
const kept = new Map(wanted.map(row => rowNode(row, row.index, openable)));
rowNodes = kept;
@@ -270,7 +269,7 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) {
parts.h1.textContent = job.title;
document.title = `${job.title} · Prompt Manager`;
parts.description.textContent = jobDetail(job);
cancel.textContent = scan ? 'Cancel scan' : 'Cancel job';
cancel.textContent = view.cancel;
// A job lost with the server has no report there (review M9).
const lost = job.state === 'lost';
if (lost && document.activeElement === download) parts.h1.focus();
@@ -291,7 +290,7 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) {
button.count.textContent = number(job.counters[group] ?? 0);
button.setAttribute('aria-pressed', String(group === selected));
}
logLabel.textContent = `● ${scan ? 'Scan' : 'Job'} log`;
logLabel.textContent = `● ${view.log}`;
const text = detail?.log.length ? detail.log.join('\n') : 'No log lines yet.';
if (pre.textContent !== text) {
// Replacing the text clamps the scroll position, so keep it: pinned readers follow the end, others stay.
+63 -5
View File
@@ -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) {
+15 -6
View File
@@ -11,13 +11,14 @@
* - D54: each `.activity-item` is a real job. Its chip names the state (green "Completed", or blue "Running",
* "Cancelled", "Failed", "Ended"), its line says when and what, and a running job shows its `<progress>` and
* "Cancel job" (no "Simulate completion").
* - D89: an image tagging job's line names its images and, once ended, "N tagged · M errors".
*/
import { createButton } from '../shared/toolkit/core.js';
import { navigateTo } from './state.mjs';
import { pageHeading } from './heading.mjs';
import { showWorkspaceToast } from './toast.mjs';
import { countOf } from './plural.mjs';
import { pillParts, outcomeLine, addedOf, LOST_DETAIL } from './activity-store.mjs';
import { countOf, noun } from './plural.mjs';
import { pillParts, outcomeLine, addedOf, taggedOf, LOST_DETAIL } from './activity-store.mjs';
export const ACTIVITY_DESCRIPTION = 'Scans, tagging, downloads and maintenance share one history.';
export const EMPTY_ACTIVITY = 'No jobs have run since ComfyUI started. Scans and other long operations appear here.';
@@ -44,12 +45,21 @@ export function clockTime(seconds) {
const join = parts => parts.filter(Boolean).join(' · ');
/** What a job covers: "2 output folders" for a scan. */
const whole = value => Number.isSafeInteger(value) && value >= 0;
/** What a job covers: "2 output folders" for a scan, "4 images" for image tagging (D89). */
function scopeLine(job) {
// D89: grouped as the server writes its lines ("19,622 images").
const images = job.scope?.images;
if (job.kind === 'image-tags') return whole(images) ? `${images.toLocaleString('en-US')} ${noun(images, 'image')}` : '';
const folders = job.scope?.output_folders;
return Number.isSafeInteger(folders) && folders >= 0 ? countOf(folders, 'output folder') : '';
return whole(folders) ? countOf(folders, 'output folder') : '';
}
/** What a cancelled job kept: "N added" for a scan, "N tagged" for image tagging (D89). */
const KEPT = Object.freeze({ __proto__: null, scan: addedOf, 'image-tags': taggedOf }); // D89
const keptOf = job => KEPT[job.kind]?.(job) ?? '';
/** The item's line under its title, by state. */
export function jobDetail(job) {
const at = clockTime(job.finished_at);
@@ -58,8 +68,7 @@ export function jobDetail(job) {
}
if (job.state === 'lost') return LOST_DETAIL;
if (job.state === 'cancelled') {
return join([`Cancelled ${at} at a file boundary`, job.kind === 'scan' ? addedOf(job) : '',
'completed work is kept']);
return join([`Cancelled ${at} at a file boundary`, keptOf(job), 'completed work is kept']);
}
if (job.state === 'failed') return join([`Stopped on an error at ${at}`, outcomeLine(job)]);
return join([`Finished ${at}`, outcomeLine(job)]);
+116 -14
View File
@@ -128,7 +128,8 @@ export function optionError(model, options) {
return null;
}
function requestOptions(model, options) {
/** The options a request sends for `model`: numbers parsed, only the model's own keys. */
export function requestOptions(model, options) {
const body = {suggestion_limit: numberOf(options.suggestion_limit)};
if (modelOf(model).classifier) {
return {general_threshold: numberOf(options.general_threshold), character_threshold: numberOf(options.character_threshold), ...body};
@@ -177,28 +178,48 @@ function validateReceipt(value) {
return value;
}
/** Thresholds, instructions and suggestion count from the server, else the documented defaults. */
/**
* Thresholds, instructions and suggestion count from the server, else the documented defaults, and the models'
* download status (`models`, null when unknown).
*/
async function readDefaults(api, signal) {
try {
const value = await api.get(MODELS_URL, {signal, cache: 'no-store'});
if (!object(value)) return DEFAULTS;
if (!object(value)) return {defaults: DEFAULTS, models: null};
return {
general_threshold: unit(value.wd14_general_threshold) ? value.wd14_general_threshold : DEFAULTS.general_threshold,
character_threshold: unit(value.wd14_character_threshold) ? value.wd14_character_threshold : DEFAULTS.character_threshold,
instructions: typeof value.default_prompt === 'string' ? value.default_prompt : DEFAULTS.instructions,
suggestion_limit: limit(value.suggestion_limit) ? value.suggestion_limit : DEFAULTS.suggestion_limit,
defaults: {
general_threshold: unit(value.wd14_general_threshold) ? value.wd14_general_threshold : DEFAULTS.general_threshold,
character_threshold: unit(value.wd14_character_threshold) ? value.wd14_character_threshold : DEFAULTS.character_threshold,
instructions: typeof value.default_prompt === 'string' ? value.default_prompt : DEFAULTS.instructions,
suggestion_limit: limit(value.suggestion_limit) ? value.suggestion_limit : DEFAULTS.suggestion_limit,
},
models: object(value.models) ? value.models : null,
};
} catch {
// The defaults keep the dialog usable; Generate reports a real failure itself.
return DEFAULTS;
return {defaults: DEFAULTS, models: null};
}
}
function initialState(source) {
const UNAVAILABLE_HINT = 'Download it from the legacy Auto-tag page.';
/**
* Why `model` cannot run, from the `/autotag/models` status (the server's own `model-unavailable` sentence), else ''
* (downloaded, or the status is unknown: the job then reports a missing model itself).
*/
export function modelUnavailable(models, model) {
const option = modelOf(model);
requireValue(option, 'Unknown tagging model');
const status = object(models) && object(models[model]) ? models[model] : null;
if (!status || status.downloaded !== false) return '';
const name = typeof status.name === 'string' && status.name.trim() ? status.name : option.label.split(' · ')[0];
return `${name} is not downloaded. ${UNAVAILABLE_HINT}`;
}
function initialState(source, preset) {
return {
phase: 'observing', closed: false, source, showScopeNote: source === 'review',
image: null, savedTags: [], tagRevision: 0, currentTags: null, manual: '',
model: MODEL_OPTIONS[0].id, options: {...DEFAULTS}, generated: false, suggestions: [],
model: preset?.model ?? MODEL_OPTIONS[0].id, options: {...DEFAULTS, ...preset?.options}, generated: false, suggestions: [],
suggestionsText: COPY.modelChanged, statusText: '', generateSupported: false, generateReason: null, toast: null,
// Bumped whenever the controller itself rewrites the option fields or the manual draft, never while typing.
optionsRevision: 0, draftRevision: 0,
@@ -219,13 +240,31 @@ function derive(state) {
};
}
export function createImageTaggingController({api, target, source = 'images', onLateSave = () => {}} = {}) {
/** A `{model, options}` preset (the bulk review's choice) when valid, else a TypeError. */
function validatePreset(preset) {
if (preset === null || preset === undefined) return null;
requireValue(object(preset) && modelOf(preset.model) && object(preset.options), 'Invalid tagging preset');
const options = {};
for (const [key, value] of Object.entries(preset.options)) {
requireValue(OPTION_KEYS.has(key), 'Unknown tagging option');
options[key] = value;
}
requireValue(!optionError(preset.model, {...DEFAULTS, ...options}), 'Invalid tagging preset');
return Object.freeze({model: preset.model, options: Object.freeze(options)});
}
/**
* `preset` ({model, options}, D90's review session) starts the dialog on that model, and every observation applies
* those options over the server's defaults.
*/
export function createImageTaggingController({api, target, source = 'images', onLateSave = () => {}, preset = null} = {}) {
requireValue(object(api) && ['get', 'post', 'put', 'del'].every(name => typeof api[name] === 'function'), 'An api client is required');
const imageTarget = validateTarget(target);
requireValue(SOURCES.has(source), 'Unknown tagging source');
requireValue(typeof onLateSave === 'function', 'onLateSave must be a function');
const chosen = validatePreset(preset);
let state = freeze(derive(initialState(source)));
let state = freeze(derive(initialState(source, chosen)));
const listeners = new Set();
let closed = false;
let capture = null;
@@ -297,7 +336,7 @@ export function createImageTaggingController({api, target, source = 'images', on
}
capture = {token: value.capture.token, sha256: value.capture.sha256};
epoch = value.library_epoch;
defaults = await defaultsRead;
({defaults} = await defaultsRead);
if (closed || serial !== openSerial) return;
observedOnce = true;
const {width, height, media_type: mediaType, preview_url: previewUrl, generate} = value.capture;
@@ -311,7 +350,7 @@ export function createImageTaggingController({api, target, source = 'images', on
savedTags: value.tags.slice(), tagRevision: value.tag_revision,
manual: draft === null ? value.tags.join(', ') : changed ? mergeCurrent(value.tags, draft) : draft,
draftRevision: state.draftRevision + 1,
options: {...defaults}, optionsRevision: state.optionsRevision + 1,
options: {...defaults, ...chosen?.options}, optionsRevision: state.optionsRevision + 1,
generateSupported: generate.supported, generateReason: generate.reason,
statusText: changed ? `${COPY.changedElsewhere} ${savedNow(value.tags)}${reason ? ` ${reason}` : ''}` : reason,
});
@@ -482,3 +521,66 @@ export function createImageTaggingController({api, target, source = 'images', on
subscribe, open, setManual, setModel, setOption, generate, remove, save, retry, cancel, dispose,
};
}
/**
* The model and options of the bulk "Auto-tag selected images" dialog (D85): the same fields as the Tag image dialog's
* model panel, without an image. open() reads the server's defaults and the models' download status once; until it
* lands Start waits (`canStart` false). `unavailable` is why the chosen model cannot run ('' when it can), so the
* dialog refuses to start a job that would fail at once.
*/
export function createTaggingOptionsController({api} = {}) {
requireValue(object(api) && typeof api.get === 'function', 'An api client is required');
const listeners = new Set();
let closed = false;
let defaults = DEFAULTS;
let models = null;
let abort = null;
const deriveOptions = value => ({...value, canGenerate: false, unavailable: modelUnavailable(models, value.model),
canStart: value.phase === 'ready' && !modelUnavailable(models, value.model)});
let state = freeze(deriveOptions({phase: 'loading', model: MODEL_OPTIONS[0].id, options: {...DEFAULTS}, optionsRevision: 0}));
function set(patch) {
if (closed) return;
state = freeze(deriveOptions({...state, ...patch}));
for (const listener of [...listeners]) listener(state);
}
async function open() {
if (closed || abort) return;
abort = new AbortController();
const read = await readDefaults(api, abort.signal);
if (closed) return;
({defaults, models} = read);
set({phase: 'ready', options: {...defaults}, optionsRevision: state.optionsRevision + 1});
}
function setModel(id) {
requireValue(modelOf(id), 'Unknown tagging model');
if (closed || id === state.model) return;
// As in the Tag image dialog: the model's fields start from the defaults again; the count is kept (D91).
set({model: id, optionsRevision: state.optionsRevision + 1,
options: {...state.options, general_threshold: defaults.general_threshold,
character_threshold: defaults.character_threshold, instructions: defaults.instructions}});
}
function setOption(key, value) {
requireValue(OPTION_KEYS.has(key), 'Unknown tagging option');
set({options: {...state.options, [key]: value}});
}
/** `{model_type, options}` for a job or a review session, or null while the options are invalid. */
function request() {
if (optionError(state.model, state.options)) return null;
return {model_type: state.model, options: requestOptions(state.model, state.options)};
}
function dispose() {
closed = true;
listeners.clear();
abort?.abort();
}
function subscribe(listener) {
requireValue(typeof listener === 'function', 'A listener function is required');
listeners.add(listener);
return () => listeners.delete(listener);
}
return {
get state() {return state;},
subscribe, open, setModel, setOption, request, dispose,
};
}
+62 -20
View File
@@ -101,10 +101,12 @@ function numberInput(value, {min, max, step}) {
/**
* The concept's `.tag-model-panel` (a1049): `heading`, the Tagging model select, the model's options and Generate.
* It follows `controller` until `scope` is disposed (reused by the bulk Auto-tag dialog). Returns the `element` and
* `requestGenerate()`, Generate's own path (field validity first), for a Retry after a failed Generate.
* It follows `controller` until `scope` is disposed (reused by the bulk Auto-tag dialog, D85, without Generate:
* `generate: false`). Returns the `element`, `validate()` (the fields' native validity, then what the controller would
* refuse; true when valid, else the first invalid field reports) and `requestGenerate()`, Generate's own path (field
* validity first), for a Retry after a failed Generate.
*/
export function renderModelPanel({scope, controller, heading = 'Auto-tag this image'}) {
export function renderModelPanel({scope, controller, heading = 'Auto-tag this image', generate: withGenerate = true}) {
const panel = el('div', undefined, 'tag-model-panel');
const select = el('select');
for (const option of MODEL_OPTIONS) {
@@ -116,7 +118,8 @@ export function renderModelPanel({scope, controller, heading = 'Auto-tag this im
options.id = 'image-tag-model-options';
const generate = createButton('', 'ca-primary');
generate.append(createWorkspaceIcon('tagImage'), ' Generate suggestions');
panel.append(el('h3', heading), field('Tagging model', select, 'image-tag-model'), options, generate);
panel.append(el('h3', heading), field('Tagging model', select, 'image-tag-model'), options);
if (withGenerate) panel.append(generate);
const inputs = new Map();
let shownModel = null, shownOptions = null;
@@ -161,26 +164,28 @@ export function renderModelPanel({scope, controller, heading = 'Auto-tag this im
setDisabled(generate, !state.canGenerate);
}
select.addEventListener('change', () => controller.setModel(select.value));
function requestGenerate() {
function validate() {
const {state} = controller;
if (!state.canGenerate) return;
// The concept's native validity first (a1104–1114), then what the controller would refuse (a blank field).
for (const input of inputs.values()) input.setCustomValidity('');
const invalid = [...inputs.values()].find(input => !input.checkValidity());
if (invalid) { invalid.reportValidity(); return; }
if (invalid) { invalid.reportValidity(); return false; }
const error = optionError(state.model, state.options);
if (error) {
const input = inputs.get(error.field);
input.setCustomValidity(error.message);
input.reportValidity();
return;
return false;
}
controller.generate();
return true;
}
function requestGenerate() {
if (controller.state.canGenerate && validate()) controller.generate();
}
generate.addEventListener('click', () => { if (!disabled(generate)) requestGenerate(); });
scope.cleanup(controller.subscribe(update));
update(controller.state);
return {element: panel, requestGenerate};
return {element: panel, requestGenerate, validate};
}
function previewOf(image, state) {
@@ -192,20 +197,33 @@ function previewOf(image, state) {
/**
* Open "Tag image" for one listed `image` (its tagging target). `source` is the page (`images`, `library`, `review`,
* `tags`); `onSaved(event)` runs after a confirmed save, as every `onImageTagsSaved` listener does.
* `tags`, `bulk`); `onSaved(event)` runs after a confirmed save, as every `onImageTagsSaved` listener does.
*
* D90 (the bulk review session, `images-tagging-review.mjs`): `actions: 'review'` with `position: {index, total}`
* titles the dialog "Tag image · Image <index> of <total>" and makes its actions Stop reviewing, Skip and Approve and
* next (Approve and finish on the last image). `preset` ({model, options}) applies the session's model and options,
* and `autoGenerate` runs Generate as soon as the file is observed. Approve saves as Save does, without the per-image
* toast (the session toasts its count), and calls `onSaved`; Skip writes nothing and calls `onSkip`; Stop reviewing,
* Escape and × write nothing and call `onStop` once. Swapping the next image's dialog in is not a stop.
*/
export function openImageTagging({api, scope, image, source = 'images', trigger = null, onSaved = null}) {
export function openImageTagging({api, scope, image, source = 'images', trigger = null, onSaved = null, preset = null,
autoGenerate = false, actions: mode = 'tag', position = null, onSkip = null, onStop = null}) {
const target = imageTarget(image);
if (!target) throw new TypeError('The image has no tagging target');
const review = mode === 'review';
if (review && !(Number.isSafeInteger(position?.index) && Number.isSafeInteger(position?.total) &&
position.index >= 1 && position.index <= position.total)) throw new TypeError('A review needs its position');
// Set once this dialog hands over to the session (Approve, Skip): its close is then not a Stop.
let advancing = false;
const saved = ({filename: name, tags, toast}) => {
const event = {target, filename: name, tags, source};
announceSaved(event);
if (toast) showWorkspaceToast(toast);
onSaved?.(event);
showWorkspaceToast(toast);
};
// A save that settles after the dialog closed is still reported: its toast, and the pages' refresh (I5).
const onLateSave = outcome => (outcome.saved ? saved(outcome) : showWorkspaceToast(outcome.message));
const controller = createImageTaggingController({api, target, source, onLateSave});
const controller = createImageTaggingController({api, target, source, onLateSave, preset});
const local = createScope();
let open = true;
let completed = false;
@@ -253,9 +271,15 @@ export function openImageTagging({api, scope, image, source = 'images', trigger
// 7. The actions; Retry leads them only while the controller allows it.
const actions = el('div', undefined, 'dialog-actions');
const retry = createButton('Retry');
const cancel = createButton('Cancel');
const cancel = createButton(review ? 'Stop reviewing' : 'Cancel'); // D90
const skip = createButton('Skip');
const primary = createButton('Save manual tags', 'ca-primary');
actions.append(cancel, primary);
const last = review && position.index === position.total;
// D90: Approve with suggestions on screen; after a model change cleared them (M2) Save keeps only the manual field.
const reviewLabel = state => (state.generated ? (last ? 'Approve and finish' : 'Approve and next')
: (last ? 'Save and finish' : 'Save and next'));
if (review) actions.append(cancel, skip, primary);
else actions.append(cancel, primary);
const nodes = [heading];
if (controller.state.showScopeNote) nodes.push(el('p', SCOPE_NOTE, 'tag-scope-note small'));
@@ -301,6 +325,12 @@ export function openImageTagging({api, scope, image, source = 'images', trigger
}
function complete(state) {
completed = true;
if (review) {
// D90: the session swaps the next image in (or ends), so this dialog is not closed here.
advancing = true;
saved({filename: state.image.filename, tags: state.savedTags.slice(), toast: null});
return;
}
closeWorkspaceDialog();
saved({filename: state.image.filename, tags: state.savedTags.slice(), toast: state.toast});
}
@@ -315,8 +345,10 @@ export function openImageTagging({api, scope, image, source = 'images', trigger
if (state.suggestions !== shownRows || state.suggestionsText !== shownText) renderSuggestions(state);
setText(count, state.countText);
setText(status, state.statusText);
setText(primary, state.primaryLabel);
setText(primary, review ? reviewLabel(state) : state.primaryLabel);
setDisabled(primary, !state.canSave);
// D90: while Approve's save is in flight the session cannot be left, so its count includes that save.
if (review) for (const button of [skip, cancel]) setDisabled(button, state.phase === 'saving');
if (state.canRetry !== retry.isConnected) {
if (state.canRetry) actions.prepend(retry); // D116
else {
@@ -327,7 +359,12 @@ export function openImageTagging({api, scope, image, source = 'images', trigger
if (state.phase === 'saved') complete(state);
}
cancel.addEventListener('click', () => closeWorkspaceDialog());
cancel.addEventListener('click', () => { if (!disabled(cancel)) closeWorkspaceDialog(); });
skip.addEventListener('click', () => {
if (!open || advancing || disabled(skip)) return;
advancing = true;
onSkip?.();
});
// Retry after a failed Generate takes Generate's own path, so an invalid field reports its validity (M4).
retry.addEventListener('click', () => (controller.state.phase === 'generate-error' ? requestGenerate() : controller.retry()));
primary.addEventListener('click', () => {
@@ -345,12 +382,17 @@ export function openImageTagging({api, scope, image, source = 'images', trigger
document.getElementById('modal')?.classList.remove('tagging-modal');
local.dispose();
controller.dispose();
if (review && !advancing) onStop?.();
}
openContentDialog({scope, title: 'Tag image', nodes, trigger, onClose: release});
const dialogTitle = review ? `Tag image · Image ${position.index} of ${position.total}` : 'Tag image'; // D90
openContentDialog({scope, title: dialogTitle, nodes, trigger, onClose: release,
canClose: review ? () => controller.state.phase !== 'saving' : null});
document.getElementById('modal')?.classList.add('tagging-modal');
local.cleanup(controller.subscribe(render));
render(controller.state);
controller.open();
controller.open().then(() => {
if (open && autoGenerate && controller.state.canGenerate) controller.generate();
});
return {
close() { if (open) closeWorkspaceDialog(); },
get controller() { return controller; },
+14 -2
View File
@@ -4,6 +4,7 @@ import { inspectPath, toInspectionRecord } from './inspection-state.mjs';
import { BUNDLE_FILENAME, metadataBundle, collectImagesMetadata, exportOutcome } from './images-export.mjs';
import { CATEGORY_DIALOG, CATEGORY_FAILED, CATEGORY_MAX_LENGTH, CATEGORY_NONE_LINKED, categoryOutcome, categoryTargets, updatedCount } from './images-category.mjs';
import { openInputDialog } from './workspace-dialogs.mjs';
import { openAddTags, openAutoTag } from './images-tagging-bulk.mjs';
import { showWorkspaceToast } from './toast.mjs';
const CHECKBOX = '.image-overlays input[type="checkbox"]';
@@ -26,8 +27,11 @@ function el(tag, text, className) {
* - `onCategorySet()` reloads the categories and the listing after a successful Set category (the reload clears the
* selection through `clear`); focus then moves to `focusFallback` if it was on the hidden action.
* - `retain(kept)` drops the checked files a refreshed listing no longer holds (a tag save under a filter, I7).
* - With the Activity store `activity`, Add tags and Auto-tag selected (D85) follow the count, as the concept's Add tags
* leads its actions; `onTagged(keys)` re-reads the files a finished tagging job tagged. The selection stays.
*/
export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, onCategorySet, checked: initial = Object.freeze(new Set()) }) {
export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, onCategorySet, activity = null, onTagged,
checked: initial = Object.freeze(new Set()) }) {
let checked = initial;
let exporting = false, categorizing = false;
const element = el('div');
@@ -40,7 +44,15 @@ export function mountSelectionBar({ scope, api, grid, getItems, focusFallback, o
const clearButton = createButton('Clear');
// Clear hides the bar it sits in, so focus moves to the page heading instead of falling to the page.
scope.listen(clearButton, 'click', () => { clear(); focusFallback.focus({ preventScroll: true }); });
bulk.append(count, setCategory, exportMetadata, clearButton);
bulk.append(count);
if (activity) {
const addTags = createButton('Add tags');
scope.listen(addTags, 'click', () => openAddTags({ scope, store: activity, images: checkedImages(), trigger: addTags, onTagged }));
const autoTag = createButton('Auto-tag selected'); // D85
scope.listen(autoTag, 'click', () => openAutoTag({ api, scope, store: activity, images: checkedImages(), trigger: autoTag, onTagged }));
bulk.append(addTags, autoTag);
}
bulk.append(setCategory, exportMetadata, clearButton);
/** The count updates in place: the same bar and `strong`, its text written only when it changes. */
function render() {
+192
View File
@@ -0,0 +1,192 @@
/**
* The Images selection bar's tagging actions (#249 PR D, Mock inventory M3):
*
* - **Add tags** (the concept's `bulk-tags` simpleInput, app.js a1378–1383): "Tag selected images", "Tags, separated
* by commas", Apply. The names are parsed as the Tag image dialog's manual field parses them, then the `image-tags`
* Activity job adds them to every selected image.
* - **Auto-tag selected** (D85, ruling L1, beyond the concept): "Auto-tag selected images" with the Tag image dialog's
* model panel (its fields show the server's defaults, D91's Suggestions included) and no Generate, then **How to
* apply** (D90, ruling T3-bulk): **Apply automatically** starts the job, **Review each image** starts the browser
* review session (images-tagging-review.mjs). A model that is not downloaded keeps Start disabled and says why.
*
* The toast says the job started (the Activity store's "Tagging started." / "Auto-tagging started."), or why it did
* not: a running record-changing job (named, with its link), a held model, a refused body. Activity shows progress.
* The selection stays either way. When a started job ends having tagged files, `onTagged(keys)` re-reads them.
*/
import { createButton, createScope } from '../shared/toolkit/core.js';
import { createTaggingOptionsController, parseManualTags } from './image-tagging-state.mjs';
import { imageTarget, renderModelPanel } from './image-tagging.mjs';
import { startReviewSession } from './images-tagging-review.mjs';
import { closeWorkspaceDialog, openContentDialog, openInputDialog } from './workspace-dialogs.mjs';
import { busyToast } from './activity-scan.mjs';
import { imageKey } from './images-state.mjs';
import { countOf } from './plural.mjs';
import { showWorkspaceToast } from './toast.mjs';
export const IMAGE_TAG_JOB = 'image-tags';
export const MAX_BULK_IMAGES = 500;
export const TOO_MANY = 'Select at most 500 images.';
export const NO_TAGS = 'Enter at least one tag.'; // D118
export const ADD_DIALOG = Object.freeze({title: 'Tag selected images', label: 'Tags, separated by commas', action: 'Apply'});
export const AUTO_TITLE = 'Auto-tag selected images';
export const APPLY_CHOICES = Object.freeze([
Object.freeze({value: 'auto', label: 'Apply automatically', action: 'Start auto-tagging'}),
Object.freeze({value: 'review', label: 'Review each image', action: 'Start review'}),
]);
// D118: the concept has no copy for these; they follow D55's "The scan could not start. Try again.".
const START_FAILED = Object.freeze({add: 'Tagging could not start. Try again.', auto: 'Auto-tagging could not start. Try again.'});
const FINAL = new Set(['finished', 'cancelled', 'failed']);
function el(tag, text, className) {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
if (className) node.className = className;
return node;
}
/**
* The job targets of the checked `images` in listing order (their reveal identity), or null when more than 500 are
* checked. A file without a tagging identity is left out.
*/
export function bulkTargets(images) {
if (images.length > MAX_BULK_IMAGES) return null;
return images.map(imageTarget).filter(Boolean);
}
/** The Add tags field's names, deduplicated as the Tag image dialog's manual field is. */
export const parseBulkTags = parseManualTags;
// Duck-typed: this ES module cannot import the UMD api-client.js.
const apiMessage = error => (error?.name === 'ApiError' && error.code && error.message ? error.message : null);
/**
* Start one `image-tags` job with `body` through `store.start`. Returns the job, or null after toasting why not: the
* busy answer naming the running job, a coded refusal's own sentence (`model-busy`, a bad body), else a fixed one.
*/
export async function startTagJob({store, scope, body, notify = showWorkspaceToast}) {
try {
const result = await store.start(IMAGE_TAG_JOB, body);
if (scope.signal.aborted) return null;
if (result.busy !== undefined) {
notify(busyToast(result.busy));
return null;
}
return result.job;
} catch (error) {
if (!scope.signal.aborted) notify(apiMessage(error) ?? START_FAILED[body.mode]);
return null;
}
}
/** Call `onEnd(job)` once the job `id` has ended in `store`; returns the unsubscribe. */
export function followJobEnd({store, scope, id, onEnd}) {
let done = false;
const check = () => {
const job = store.find(id);
if (done || !job || !FINAL.has(job.state)) return;
done = true;
stop();
onEnd(job);
};
const stop = store.subscribe(check);
scope.cleanup(stop);
check();
return stop;
}
/**
* Wire one started job: when it ends having tagged files, `onTagged(keys)` gets the image keys it was started for.
*/
function followTagged({store, scope, job, images, onTagged}) {
const keys = new Set(images.map(imageKey));
followJobEnd({store, scope, id: job.id, onEnd: ended => { if ((ended.counters.tagged ?? 0) > 0) onTagged(keys); }});
}
/**
* Add tags: the concept's simpleInput, then the job over `images` (the checked files, taken now). A field that parses
* to no names keeps the dialog and its text, and reports NO_TAGS on the field (D118). `openInput` is the dialog seam.
*/
export function openAddTags({scope, store, images, trigger, onTagged = () => {}, notify = showWorkspaceToast,
openInput = openInputDialog}) {
const targets = bulkTargets(images);
if (!targets) { notify(TOO_MANY); return; }
openInput({scope, trigger, ...ADD_DIALOG, validate: text => (parseBulkTags(text).length ? null : NO_TAGS),
onApply: async text => {
const tags = parseBulkTags(text);
const job = await startTagJob({store, scope, body: {mode: 'add', targets, tags}, notify});
if (job) followTagged({store, scope, job, images, onTagged});
}});
}
/** The D90 How to apply radio group: Apply automatically (checked) or Review each image. */
function applyChoice(onChange) {
const group = el('div', undefined, 'field');
group.setAttribute('role', 'radiogroup');
const caption = el('span', 'How to apply');
caption.id = 'auto-tag-apply-label';
group.setAttribute('aria-labelledby', caption.id);
group.append(caption);
for (const choice of APPLY_CHOICES) {
const label = el('label');
const input = el('input');
Object.assign(input, {type: 'radio', name: 'auto-tag-apply', value: choice.value, checked: choice.value === 'auto'});
input.addEventListener('change', () => { if (input.checked) onChange(choice); });
label.append(input, ` ${choice.label}`);
group.append(label);
}
return group;
}
/**
* Auto-tag selected (D85): the model panel, How to apply (D90), Cancel and Start. Start validates the fields as
* Generate does, then starts the job or the review session over `images` (the checked files, taken now).
*/
export function openAutoTag({api, scope, store, images, trigger, onTagged = () => {}, notify = showWorkspaceToast}) {
const targets = bulkTargets(images);
if (!targets) { notify(TOO_MANY); return; }
const listed = images.filter(imageTarget);
const local = createScope();
const options = createTaggingOptionsController({api});
// D85: the Tag image dialog's model panel without Generate; its Suggestions field is the job's count (D91).
const {element: panel, validate} = renderModelPanel({scope: local, controller: options,
heading: `Auto-tag ${countOf(listed.length, 'image')}`, generate: false}); // D118
let choice = APPLY_CHOICES[0];
const actions = el('div', undefined, 'dialog-actions');
const cancel = createButton('Cancel');
const start = createButton(choice.action, 'ca-primary');
actions.append(cancel, start);
// A model that is not downloaded: the reason, as the Tag image dialog's status line words it.
const status = el('div', undefined, 'small');
status.id = 'tag-generation-status';
status.setAttribute('role', 'status');
const choices = applyChoice(next => { choice = next; start.textContent = next.action; }); // D90
const render = state => {
if (status.textContent !== state.unavailable) status.textContent = state.unavailable;
if (state.canStart) start.removeAttribute('aria-disabled');
else start.setAttribute('aria-disabled', 'true');
};
local.cleanup(options.subscribe(render));
render(options.state);
cancel.addEventListener('click', () => closeWorkspaceDialog());
start.addEventListener('click', () => {
if (start.getAttribute('aria-disabled') === 'true' || !options.state.canStart || !validate()) return;
const request = options.request();
if (!request) return;
if (choice.value === 'review') {
// The first image's dialog replaces this one in the shell (D90).
startReviewSession({api, scope, images: listed, ...request, trigger});
return;
}
closeWorkspaceDialog();
startTagJob({store, scope, body: {mode: 'auto', targets, ...request}, notify}).then(job => {
if (job) followTagged({store, scope, job, images: listed, onTagged});
});
});
openContentDialog({scope, title: AUTO_TITLE, nodes: [panel, choices, status, actions], trigger, onClose: () => {
document.getElementById('modal')?.classList.remove('tagging-modal');
local.dispose();
options.dispose();
}});
document.getElementById('modal')?.classList.add('tagging-modal');
options.open();
}
@@ -0,0 +1,75 @@
/**
* D90 (ruling T3-bulk): "Review each image", the bulk Auto-tag session in the browser. The selected images are walked
* one Tag image dialog at a time (image-tagging.mjs `actions: 'review'`), each opened with the session's model and
* options and Generate already running. Approve saves that image as the dialog's Save does and advances; Skip
* advances without a write; Stop reviewing, Escape and × end the session. Each image is one gated `suggest` call, so
* the session holds no model between images; a refused one (`model-busy`) stays on its image with the dialog's own
* status and Retry until the user retries or skips. Ending toasts "<n> of <M> images tagged."; the selection stays.
*/
import { closeWorkspaceDialog } from './workspace-dialogs.mjs';
import { openImageTagging } from './image-tagging.mjs';
import { showWorkspaceToast } from './toast.mjs';
/** The closing toast: approved saves of the session's images (the plan's copy verbatim). */
export function reviewOutcome(tagged, total) {
return `${tagged} of ${total} images tagged.`;
}
/**
* Walk `images` (listing items with a tagging target, in selection order) with `model_type` and `options`.
* `onSaved(event)` follows every approved save. Returns `{stop()}`, which ends the session (its open dialog closes and
* writes nothing more). `open`, `close` and `notify` are the dialog seams (the defaults in the page).
*/
export function startReviewSession({api, scope, images, model_type: model, options, trigger = null, onSaved = null,
open = openImageTagging, close = closeWorkspaceDialog, notify = showWorkspaceToast}) {
if (!Array.isArray(images) || !images.length) throw new TypeError('A review needs its images');
const total = images.length;
let index = 0, tagged = 0, ended = false, handle = null;
function end() {
if (ended) return;
ended = true;
handle = null;
notify(reviewOutcome(tagged, total));
}
function advance() {
if (ended) return;
index += 1;
if (index >= total) {
// The last dialog handed over (advancing), so closing it is not a Stop.
end();
close();
return;
}
show();
}
function show() {
let current = true;
const settled = () => {
const was = current;
current = false;
return was;
};
handle = open({api, scope, image: images[index], source: 'bulk', trigger, preset: {model, options},
autoGenerate: true, actions: 'review', position: {index: index + 1, total},
onSaved: event => {
// A save that settles after its dialog was left (Skip or Stop while it ran) still counts.
tagged += 1;
onSaved?.(event);
if (settled()) advance();
},
onSkip: () => { if (settled()) advance(); },
onStop: () => { if (settled()) end(); },
});
}
show();
return {
stop() {
if (ended) return;
const open = handle;
end();
open?.close();
},
};
}
+7 -4
View File
@@ -23,10 +23,13 @@ export function openCardTagging({api, scope, image, action, embedded}) {
/**
* Follow confirmed tag saves until `scope` ends. `filtered()` tells whether a Tags filter or a search is active,
* `selection()` is the selected image key, and `onRefreshing(busy)` marks Load more busy while the re-read runs.
* Returns `refresh(keys)`, the same re-read for the files a bulk tagging job tagged (once per job, D85).
*/
export function followImageTagSaves({scope, stream, inspection, filtered, selection, onRefreshing}) {
scope.cleanup(onImageTagsSaved(async ({target}) => {
const key = `${target.root_index}:${target.path}`;
scope.cleanup(onImageTagsSaved(({target}) => refresh(new Set([`${target.root_index}:${target.path}`]))));
return refresh;
async function refresh(keys) {
if (filtered()) {
onRefreshing(true);
try {
@@ -37,6 +40,6 @@ export function followImageTagSaves({scope, stream, inspection, filtered, select
if (!scope.signal.aborted) onRefreshing(false);
}
}
if (!scope.signal.aborted && selection() === key) inspection.refresh();
}));
if (!scope.signal.aborted && keys.has(selection())) inspection.refresh();
}
}
+2 -2
View File
@@ -254,7 +254,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
// The checked cards and the concept's selection bar (#249 D8), between the filter row and the gallery layout. The
// selection is kept across appended batches and cleared with the collection; the Library-embedded gallery has none.
const bar = embedded ? null : mountSelectionBar({scope, api, grid, getItems:() => state.items, focusFallback,
checked:visit?.checked, onCategorySet:() => { loadCategories(); reset(); }});
checked:visit?.checked, onCategorySet:() => { loadCategories(); reset(); }, activity, onTagged:keys => refreshTagged(keys)});
if (bar) main.append(bar.element);
main.append(body);
const mediaViewer = createMediaViewer({api,container:main,parentScope:scope,inspectionController:inspection,sender,
@@ -529,7 +529,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
scheduleLoad();
}
const stream = createImageStream({ api, fetchPage, onChange: renderState, restore: visit ? {filters, state:visit.state} : undefined });
followImageTagSaves({scope, stream, inspection, selection:() => selection, filtered:() => !embedded && !recovering && Boolean(filters.tags.length || filters.q),
const refreshTagged = followImageTagSaves({scope, stream, inspection, selection:() => selection, filtered:() => !embedded && !recovering && Boolean(filters.tags.length || filters.q),
onRefreshing:busy => more.setAttribute('aria-disabled', String(busy || Boolean(state?.loading) || recovering))});
scope.cleanup(() => {
stream.dispose();
+28 -8
View File
@@ -59,10 +59,10 @@ function createShell() {
body.id = 'modal-body';
header.append(title, close);
dialog.append(header, body);
close.addEventListener('click', () => finish());
close.addEventListener('click', () => dismiss());
dialog.addEventListener('cancel', event => {
event.preventDefault();
finish();
dismiss();
});
// A browser may still close a modal on its own; that is a dismissal too. `close` arrives as a later task, so it
// is ignored once this shell was reopened or replaced.
@@ -82,6 +82,12 @@ function finish() {
if (trigger?.isConnected) trigger.focus();
}
/** × or Escape: close, unless the open dialog's `canClose()` holds it (a save in flight). */
function dismiss() {
if (session?.canClose && !session.canClose()) return;
finish();
}
const boundScopes = new WeakSet();
function requireScope(scope) {
@@ -104,13 +110,13 @@ function bindScope(scope) {
});
}
function show(heading, nodes, { scope, trigger, focusTarget, onClose = null }) {
function show(heading, nodes, { scope, trigger, focusTarget, onClose = null, canClose = null }) {
shell ??= createShell();
if (!shell.dialog.isConnected) document.body.append(shell.dialog);
bindScope(scope);
// A swapped-out body is gone from the shell, so its owner releases it.
const replaced = session?.onClose;
session = { scope, trigger: trigger ?? session?.trigger ?? null, onClose };
session = { scope, trigger: trigger ?? session?.trigger ?? null, onClose, canClose };
replaced?.();
shell.title.textContent = heading;
shell.body.replaceChildren(...nodes);
@@ -298,10 +304,14 @@ export function openCopyFallback(text, { scope, trigger = null } = {}) {
* The concept's `simpleInput`: one labelled `input#action-input` (starting at `value`), then Cancel and the primary
* `action`. The action reports the trimmed value and closes; an empty value keeps the dialog open on its field, or
* reports `fallback` when one is given (Save view's "Saved view", a1339). A positive `maxLength` limits the field.
* `validate(value)` may refuse a value with a message: the dialog then stays open with the typed text and the field
* reports the message (its native validity) until it is edited.
*/
export function openInputDialog({ scope, title, label, action, value = '', maxLength, fallback, onApply, trigger = null }) {
export function openInputDialog({ scope, title, label, action, value = '', maxLength, fallback, onApply, trigger = null,
validate = null }) {
requireScope(scope);
requireCallbacks({ onApply });
if (validate !== null) requireCallbacks({ validate });
for (const [name, text] of Object.entries({ title, label, action })) {
if (typeof text !== 'string' || !text) throw new TypeError(`The input dialog needs a ${name}`);
}
@@ -314,9 +324,17 @@ export function openInputDialog({ scope, title, label, action, value = '', maxLe
const cancel = createButton('Cancel');
const apply = createButton(action, 'ca-primary');
cancel.addEventListener('click', () => finish());
input.addEventListener('input', () => input.setCustomValidity(''));
apply.addEventListener('click', () => {
const entered = input.value.trim() || fallback;
if (!entered) { input.focus(); return; }
const problem = validate?.(entered);
if (problem) {
input.setCustomValidity(problem);
input.reportValidity();
input.focus();
return;
}
finish();
onApply(entered);
});
@@ -327,14 +345,16 @@ export function openInputDialog({ scope, title, label, action, value = '', maxLe
/**
* The concept's plain `dialog(title, body)` with page-built `nodes`. `onClose` runs once when the dialog closes or
* another dialog replaces its body, so the page can dispose what it mounted there.
* another dialog replaces its body, so the page can dispose what it mounted there. While `canClose()` answers false,
* × and Escape leave the dialog open (the page, a route change or a replacing dialog still close it).
*/
export function openContentDialog({ scope, title, nodes, onClose = null, trigger = null }) {
export function openContentDialog({ scope, title, nodes, onClose = null, trigger = null, canClose = null }) {
requireScope(scope);
if (typeof title !== 'string' || !title) throw new TypeError('The dialog needs a title');
if (!Array.isArray(nodes) || nodes.some(node => !node || typeof node !== 'object')) throw new TypeError('The dialog needs its nodes');
if (onClose !== null && typeof onClose !== 'function') throw new TypeError('The dialog onClose must be a function');
show(title, nodes, { scope, trigger, onClose });
if (canClose !== null && typeof canClose !== 'function') throw new TypeError('The dialog canClose must be a function');
show(title, nodes, { scope, trigger, onClose, canClose });
}
export const SCAN_REVIEW_SENTENCE = 'Enrich positive / negative associations, reuse canonical text and report unchanged files. ' +
+6
View File
@@ -376,3 +376,9 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc
/* D83: the chip's × keeps the concept's 40px coarse target. */
.ca-tools #tag-suggestions .chip.tag-suggestion > button { min-block-size: 40px; min-inline-size: 40px; }
}
/* Auto-tag selected images (#249 PR D, D85/D90): the Tag image dialog's model panel, then How to apply, a radio group
in the concept's `.field` row whose radios keep their own size (the concept's `.field input` is a full-width text
field) beside their label, as the concept's own radio rows (s1160 `.compare-item label`). */
.field[role="radiogroup"] > label { display: flex; align-items: center; gap: 8px; /* D90 */ }
.field[role="radiogroup"] input[type="radio"] { width: auto; margin: 0; /* D90 */ }