From a20e59f6744f9b5ec96fe8cd0b2a84dfcee20fcd Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Sat, 31 Oct 2026 16:43:15 -0700 Subject: [PATCH] feat(tagging): skipped tags hide matching suggestions (#431) A Skipped tags list in System > Auto-tagging (D202) hides every suggested tag whose words contain an entry's words, on every suggestion path (Tag image, Auto-tag selected, the System batch, the legacy single and batch routes), before the limit. Jobs snapshot the list at their start. A read-only preview route counts what a list hides among the library's tags and the downloaded WD14 label lists. The draft store compares list and object drafts by value (sameDraft). --- TESTING.md | 48 +++ py/api/annotation_suggest.py | 8 +- py/api/annotations.py | 10 +- py/api/autotag_routes.py | 90 ++++++ py/api/image_tag_jobs.py | 44 ++- py/api/settings_routes.py | 15 + py/autotag.py | 20 +- py/config.py | 26 +- py/tagging_defaults.py | 136 ++++++++- tests/browser/server.py | 29 +- tests/browser/system-tagging-concept.spec.mjs | 36 ++- .../browser/workspace-system-tagging.spec.mjs | 82 +++++ tests/browser/workspace-tagging.spec.mjs | 12 + tests/browser/workspace_tagging_fixture.py | 55 +++- tests/fixtures/legacy_contract_snapshot.json | 2 + .../js/workspace-image-tagging-state.test.js | 14 + .../js/workspace-system-skipped-tags.test.js | 247 +++++++++++++++ tests/js/workspace-system-state.test.js | 41 ++- tests/js/workspace-system-tagging.test.js | 2 +- tests/test_api_annotation_suggest.py | 9 +- tests/test_api_autotag.py | 5 +- tests/test_autotag_batch.py | 2 + tests/test_config.py | 28 +- tests/test_settings_sources.py | 63 ++++ tests/test_skipped_tags.py | 271 +++++++++++++++++ tests/test_skipped_tags_routes.py | 280 ++++++++++++++++++ tools/vendor/frontend-coverage-baseline.json | 6 + web/js/workspace/image-tagging-state.mjs | 14 +- web/js/workspace/system-skipped-tags.mjs | 246 +++++++++++++++ web/js/workspace/system-state.mjs | 22 +- web/js/workspace/system-tagging.mjs | 6 + web/js/workspace/workspace.css | 26 +- 32 files changed, 1837 insertions(+), 58 deletions(-) create mode 100644 tests/js/workspace-system-skipped-tags.test.js create mode 100644 tests/test_skipped_tags.py create mode 100644 tests/test_skipped_tags_routes.py create mode 100644 web/js/workspace/system-skipped-tags.mjs diff --git a/TESTING.md b/TESTING.md index df7c6695..5826997e 100644 --- a/TESTING.md +++ b/TESTING.md @@ -4578,6 +4578,26 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati Cancel-only review matches the concept review shell` (system-tagging-concept.spec) and the batch tests of workspace-system-tagging.spec; the mutation check renames each grid label in turn. +- **D202** (Auto-tagging, #431, beyond the concept): a "Skipped tags" block + (`.setting-block`, `data-setting="tag-skipped"`, `data-key="autotag_skipped_tags"`) + follows "Suggestions per image": the label and description in the concept's + setting-row tokens ("Suggested tags that contain one of these words are hidden. + Words are separated by spaces, _ or -; other characters such as ( ) belong to the + word."), an "Add a word or words" input (`#tag-skipped-input`) with Add (Enter adds + too), the entries as removable chips in the Tag image dialog's D83 chip markup (each + with its count, title "Hides N tags", or muted "none" with "Hides no known tag"), and + a `role="status"` preview line: "**Hides N tags from suggestions**: [ and K + more]. Tags already on images are not changed." ("Checked against library tags only; + no WD14 label list is downloaded." before the last sentence without a label list; + "No tags are skipped." for an empty list; "Checking…" while waiting; "The preview + could not be read." on failure). The client refuses an entry with "Enter a word.", + "No commas or line breaks.", "At most 50 characters.", "Already in the list." or "The + list is full (200)." under the input. The Tag image dialog's chip hint adds "N skipped + tags were not suggested." when the server hid any (the static mock's copy). Pinned by + the comparison's mapping (system-tagging-concept.spec: the block's tokens are the + concept row's), `skipped tags add, preview, remove, save and reload (D202)`, `skipped + tags preview checks library tags only …` (workspace-system-tagging.spec) and + `suggestions omit skipped tags and say how many were hidden` (workspace-tagging.spec). - **D158** (Sources, #247 D-Q4): the concept has no switch for prompts from workflows without a PromptManager node; the maintainer chose a visible switch over a config key. A `.setting-row` in the concept's toggle markup follows "Watch for new outputs": @@ -5186,6 +5206,34 @@ saved values (`wd14_general_threshold`, `wd14_character_threshold`, `suggestion_ `default_prompt`, `model_type`, `existing`), so the Tag image and Auto-tag selected dialogs start from them; a preset (the bulk review, Preview tagging review) wins. +**Skipped tags** (#431, D202): `autotag.defaults.skipped_tags`, host draft +`autotag_skipped_tags`, a list of at most 200 entries, each trimmed, 1–50 code points, +no comma or control character, at least one word, no two with the same words (refused +with "Skipped tags: at most 200 entries.", "Skipped tags: entry N is empty.", "… must be +at most 50 characters.", "… must not contain a comma or a line break.", "… repeats entry +M."); a save carrying it without `expected_revision` is refused ("Reload the page and try +again."). A damaged saved list falls back to none with one warning. Words are the +NFC, case-folded text split on runs of space, `_` and `-` (`tag_words`); an entry hides +a suggested tag whose words contain the entry's words consecutively (`SkipMatcher`: +`blurry` hides `very_blurry` and `blurry_background`, not `unblurry`; `(fate)` hides +`saber_(fate)`, `fate` does not). `generate_tag_suggestions(skip=)` drops a match after +the name check and before the dedupe and the limit, so skipped tags use up no slot +(WD14 fills from lower scores; a language model may return fewer). Every path filters: +`/image-annotations/suggest` (compiled per request; the answer adds `skipped`, the +number of distinct names it hid), `/autotag/single`, and the `image-tags` (Auto-tag +selected) and `autotag` (System batch, legacy `/autotag/start`) jobs, which snapshot the +list at their start and log "Skipped tags: N entries" or "Skipped tags: none"; a save +during a run does not change it. Hand-typed and stored tags are never filtered; the +language models' hard-coded exclude list (`_parse_tags`) runs first. `POST +/prompt_manager/autotag/skipped-tags/preview {entries}` (validated as a save) answers +`count`, `sample` (12, library tags first, each part sorted case-folded), +`sample_limit`, `per_entry [{entry, count}]` and `sources {library, wd14}` over the +library's tags plus the category 0/4 names of each downloaded WD14 `selected_tags.csv` +(read as UTF-8, cached by mtime and size; a missing list is skipped). It loads no model, +opens no image, writes nothing and is `409 busy` while an exclusive job runs. Tests: +`tests/test_skipped_tags.py`, `tests/test_skipped_tags_routes.py`, the settings and +config tests, `tests/js/workspace-system-skipped-tags.test.js`. + `GET /prompt_manager/autotag/models` reads the disk in the executor, never loads or downloads a model and carries no absolute path: per engine `name`, `label`, `size_label`, `description`, `size`, `downloaded`, `loaded` (GGUF also `model_exists`, diff --git a/py/api/annotation_suggest.py b/py/api/annotation_suggest.py index bb676d92..d3065316 100644 --- a/py/api/annotation_suggest.py +++ b/py/api/annotation_suggest.py @@ -102,11 +102,14 @@ def model_refusal(status: dict, model_type: str, model_name: str): return None -def run_suggestions(service, capture_path: str, model_type: str, options: dict): +def run_suggestions( + service, capture_path: str, model_type: str, options: dict, skip=None +): """Blocking: load (or swap to) *model_type*, then suggest for the capture. The caller holds the model gate for the whole call; nothing is ever - downloaded (``load_model`` refuses a model that is not on disk). + downloaded (``load_model`` refuses a model that is not on disk). ``skip`` + is the request's or job's compiled Skipped tags (#431). """ if not service.is_model_loaded() or service.get_loaded_model_type() != model_type: service.load_model(model_type, options["use_gpu"]) @@ -116,6 +119,7 @@ def run_suggestions(service, capture_path: str, model_type: str, options: dict): prompt=options.get("instructions"), general_threshold=options.get("general_threshold"), character_threshold=options.get("character_threshold"), + skip=skip, ) diff --git a/py/api/annotations.py b/py/api/annotations.py index 7f3ab943..1ab14008 100644 --- a/py/api/annotations.py +++ b/py/api/annotations.py @@ -35,6 +35,7 @@ from .media_target import media_target as _target from .images import _unavailable_link, _verified_title from .prompts import bad_request from .. import model_gate +from ..tagging_defaults import SkipMatcher try: from ...database.annotations import ( @@ -640,6 +641,8 @@ class AnnotationRoutesMixin: owner = model_gate.new_owner("image-annotations") if not model_gate.try_acquire(owner): return model_busy_response() + # #431: the Skipped tags as they are when the request is accepted. + skip = SkipMatcher(AutotagConfig.SKIPPED_TAGS).counting() try: # From here the pin is the gate's to end: the worker ends it, # or ``on_skipped`` does when the worker never starts. @@ -653,6 +656,7 @@ class AnnotationRoutesMixin: capture, model_type, options, + skip, on_skipped=functools.partial(store.release_use, token), ) except MissingBackendPackages as exc: @@ -677,6 +681,8 @@ class AnnotationRoutesMixin: "model_type": model_type, "options": options, "suggestions": suggestion_rows(pairs), + # How many distinct suggestions the Skipped tags hid (#431). + "skipped": len(skip.hidden), }, headers={"Cache-Control": "no-store"}, ) @@ -732,10 +738,10 @@ def _byte_range(request: web.Request, size: int): return start, stop -def _pinned_suggestions(store, token, service, capture, model_type, options): +def _pinned_suggestions(store, token, service, capture, model_type, options, skip=None): """Worker thread: infer on the capture, then end its pin (even on error).""" try: - return run_suggestions(service, capture.path, model_type, options) + return run_suggestions(service, capture.path, model_type, options, skip) finally: store.release_use(token) diff --git a/py/api/autotag_routes.py b/py/api/autotag_routes.py index 9e82d30d..f02c683b 100644 --- a/py/api/autotag_routes.py +++ b/py/api/autotag_routes.py @@ -40,6 +40,10 @@ BATCH_PREVIEW_BODY_MAX = 1024 APPLY_BODY_MAX = 64 * 1024 # POST /autotag/apply: one image and its tags _HEX64 = re.compile(r"[0-9a-f]{64}") BATCH_JOB_BODY_MAX = 32 * 1024 +# Skipped tags preview (#431): 200 entries of 50 characters, with room for JSON. +SKIPPED_PREVIEW_BODY_MAX = 64 * 1024 +SKIPPED_PREVIEW_LEASE = "Skipped tags preview" +SKIPPED_PREVIEW_SAMPLE = 12 REVIEW_REQUIRED = ( "Replacing tags needs a review of each image. Use Preview tagging review, " "or choose Skip or Merge for a batch run." @@ -247,6 +251,10 @@ class AutotagRoutesMixin: async def preview_autotag_batch_route(request): return await self.preview_autotag_batch(request) + @routes.post("/prompt_manager/autotag/skipped-tags/preview") + async def preview_skipped_tags_route(request): + return await self.preview_skipped_tags(request) + @routes.post("/prompt_manager/autotag/start") async def start_autotag_route(request): return await self.start_autotag(request) @@ -699,6 +707,83 @@ class AutotagRoutesMixin: return None, "existing must be one of " + ", ".join(EXISTING_POLICIES) return MODELS[model_type], None + async def preview_skipped_tags(self, request): + """Which known tags a Skipped tags list would hide (#431); no write. + + ``{entries}`` is checked as a save checks it (the same 400 sentence). + The known tags are the library's plus the general and character names + of each downloaded WD14 label list; no model is loaded and no image is + opened. Refused (409 ``busy``) while an exclusive job runs. + """ + from ..tagging_defaults import validate_skipped_tags + + data, error = await self._batch_body( + request, ("entries",), SKIPPED_PREVIEW_BODY_MAX + ) + if error is not None: + return error + try: + entries = validate_skipped_tags(data["entries"]) + except ValueError as exc: + return web.json_response({"success": False, "error": str(exc)}, status=400) + try: + async with self._jobs.lease(SKIPPED_PREVIEW_LEASE): + result = await self._run_in_executor(self._skipped_preview, entries) + except JobBusy as busy: + return busy_response(busy) + except Exception: + self.logger.exception("The Skipped tags preview failed") + return web.json_response( + {"success": False, "error": _INTERNAL_ERROR}, status=500 + ) + return web.json_response({"success": True, **result}) + + def _skipped_preview(self, entries): + """Worker thread: the preview's counts over the known tags.""" + from ..autotag import MODELS, get_autotag_service + from ..tagging_defaults import ( + WD14_MODEL_TYPES, + SkipMatcher, + wd14_label_names, + ) + + library = self.db.get_all_tags() + known = {} # case-folded -> (0 library / 1 label list, spelling) + for name in library: + known.setdefault(name.casefold(), (0, name)) + lists = [] + models_dir = getattr(get_autotag_service(), "models_dir", None) + for model_type in sorted(WD14_MODEL_TYPES, key=lambda t: MODELS[t]["name"]): + if models_dir is None: + break + path = Path(models_dir) / MODELS[model_type]["subdir"] / "selected_tags.csv" + try: + names = wd14_label_names(path) + except (OSError, ValueError) as exc: + self.logger.debug(f"No WD14 label list for {model_type}: {exc}") + continue + lists.append(MODELS[model_type]["name"]) + for name in names: + known.setdefault(name.casefold(), (1, name)) + matchers = [(entry, SkipMatcher([entry])) for entry in entries] + whole = SkipMatcher(entries) + hidden = [item for item in known.values() if whole.matches(item[1])] + hidden.sort(key=lambda item: (item[0], item[1].casefold())) + per_entry = [ + { + "entry": entry, + "count": sum(1 for _, name in hidden if matcher.matches(name)), + } + for entry, matcher in matchers + ] + return { + "count": len(hidden), + "sample": [name for _, name in hidden[:SKIPPED_PREVIEW_SAMPLE]], + "sample_limit": SKIPPED_PREVIEW_SAMPLE, + "per_entry": per_entry, + "sources": {"library": len(library), "wd14": lists}, + } + async def preview_autotag_batch(self, request): """Review Tag output images: counts, model, backup and disk; no write. @@ -1096,6 +1181,8 @@ class AutotagRoutesMixin: async def _suggest_single(self, target, model_type, options, base): from ..autotag import MODELS, MissingBackendPackages, get_autotag_service + from ..config import AutotagConfig + from ..tagging_defaults import SkipMatcher from .annotation_suggest import model_refusal from .annotations import _pinned_suggestions, _settled, _submit @@ -1112,6 +1199,8 @@ class AutotagRoutesMixin: owner = model_gate.new_owner("legacy-single") if not model_gate.try_acquire(owner): return model_busy_response() + # #431: the legacy review hides the Skipped tags too (19A). + skip = SkipMatcher(AutotagConfig.SKIPPED_TAGS) store = self._capture_store() try: epoch = await self._run_in_executor(self.db.get_execution_epoch) @@ -1151,6 +1240,7 @@ class AutotagRoutesMixin: capture, model_type, options, + skip, on_skipped=functools.partial(store.release_use, capture.token), ) except MissingBackendPackages as exc: diff --git a/py/api/image_tag_jobs.py b/py/api/image_tag_jobs.py index 33b679c6..4886e1fc 100644 --- a/py/api/image_tag_jobs.py +++ b/py/api/image_tag_jobs.py @@ -39,6 +39,7 @@ from typing import NamedTuple from aiohttp import web from .. import model_gate +from ..tagging_defaults import SkipMatcher from .admin import _plural, _time_left from .annotation_suggest import ( OptionError, @@ -170,6 +171,8 @@ class _Run: owner: object service: object = None roots: dict = field(default_factory=dict) + # The job's Skipped tags, compiled once at its start (#431). + skip: object = None @dataclass(frozen=True) @@ -183,6 +186,9 @@ class _Spec: options: dict | None = None # A listing filter the start resolves into ``targets`` (#392). filter: dict | None = None + # The Skipped tags when the job was requested (#431); a later save does + # not change a running job. + skip: tuple = () @property def title(self) -> str: @@ -238,7 +244,11 @@ def _parse_auto(targets, data): ) except OptionError as exc: return None, bad_request(str(exc)) - return _Spec("auto", targets, model_type=model_type, options=options), None + skip = tuple(AutotagConfig.SKIPPED_TAGS) + return ( + _Spec("auto", targets, model_type=model_type, options=options, skip=skip), + None, + ) def parse_image_tag_request(data: dict): @@ -329,6 +339,8 @@ class AutotagRun: keep_in_memory: bool backup_on: bool owner: object + # The Skipped tags when the job was started (#431). + skip: tuple = () @dataclass(frozen=True) @@ -400,6 +412,14 @@ def _load_model(service, model_type: str, use_gpu: bool) -> None: raise RuntimeError(f"{model_type} could not be loaded") +def _skipped_tags_line(entries: tuple) -> str: + """The job log's one line about the Skipped tags it runs with (#431).""" + if not entries: + return "Skipped tags: none" + count = len(entries) + return f"Skipped tags: {count} {'entry' if count == 1 else 'entries'}" + + def _refusal(status: int, code: str, message: str) -> Refusal: response = web.json_response( {"success": False, "code": code, "error": message}, status=status @@ -507,6 +527,7 @@ class ImageTagJobsMixin: released when the task ends, also before its runner ever starts. """ from ..autotag import MODELS, get_autotag_service + from ..config import AutotagConfig backup_on = self._safety_backup_policy() == "on" status = await self._run_in_executor(get_autotag_service().get_models_status) @@ -529,7 +550,13 @@ class ImageTagJobsMixin: response = model_busy_response() return None, Refusal(409, "model-busy", MODEL_BUSY_ERROR, response) run = AutotagRun( - model_type, existing, options, keep_in_memory, backup_on, owner + model_type, + existing, + options, + keep_in_memory, + backup_on, + owner, + skip=tuple(AutotagConfig.SKIPPED_TAGS), ) admitted = False try: @@ -598,7 +625,9 @@ class ImageTagJobsMixin: return _batch_cancelled(job) job.publish({"type": "phase", "phase": "model"}) job.update(status_line="Loading the tagging model") - spec = _Spec("auto", (), model_type=run.model_type, options=run.options) + spec = _Spec( + "auto", (), model_type=run.model_type, options=run.options, skip=run.skip + ) service = await self._prepare_tagging_model(job, spec, run.owner) if service is None: return "failed" @@ -624,6 +653,7 @@ class ImageTagJobsMixin: f"Tagging started · {job.scope.get('label') or run.model_type} · " f"{_plural(total, 'image')} · {policy}" ) + job.log_line(_skipped_tags_line(spec.skip)) if job.cancelled: return _batch_cancelled(job) remaining = await self._autotag_prepass(job, targets, run.existing == "skip") @@ -633,7 +663,9 @@ class ImageTagJobsMixin: spec = dataclasses.replace( spec, targets=tuple(item.target for item in remaining) ) - loop_run = _Run(job, spec, epoch, run.owner, service) + loop_run = _Run( + job, spec, epoch, run.owner, service, skip=SkipMatcher(spec.skip) + ) if not await self._tag_each_file(loop_run, done_before=done): job.update(status_line=STORAGE_RESTORED) job.log_line(f"Tagging stopped · {STORAGE_RESTORED}") @@ -739,6 +771,9 @@ class ImageTagJobsMixin: started += f" · {spec.model_type}" job.log_line(started) run = _Run(job, spec, epoch, owner) + if spec.mode == "auto": + job.log_line(_skipped_tags_line(spec.skip)) + run.skip = SkipMatcher(spec.skip) try: if spec.mode == "auto": run.service = await self._prepare_tagging_model(job, spec, owner) @@ -894,6 +929,7 @@ class ImageTagJobsMixin: capture, spec.model_type, spec.options, + run.skip, on_skipped=functools.partial(store.release_use, capture.token), ) except Exception: diff --git a/py/api/settings_routes.py b/py/api/settings_routes.py index d617286f..d3d9fb93 100644 --- a/py/api/settings_routes.py +++ b/py/api/settings_routes.py @@ -56,7 +56,12 @@ _SETTINGS_FILE_KEYS = { "AUTOTAG_CHARACTER_THRESHOLD": ("autotag", "defaults", "character_threshold"), "AUTOTAG_TEMPLATE": ("autotag", "defaults", "template"), "AUTOTAG_SUGGESTION_LIMIT": ("autotag", "defaults", "suggestion_limit"), + "AUTOTAG_SKIPPED_TAGS": ("autotag", "defaults", "skipped_tags"), } +# Whole-list settings (#431): a save without ``expected_revision`` would merge +# a stale copy over a list changed elsewhere, so it is refused. +_REVISION_REQUIRED = frozenset({"autotag_skipped_tags"}) +REVISION_REQUIRED_ERROR = "Reload the page and try again." # The thumbnail settings a running Convert outdated job fixes (#317). _THUMBNAIL_SETTINGS = ("THUMBNAIL_FORMAT", "THUMBNAIL_QUALITY", "THUMBNAIL_EDGE") # Attributes that live on GalleryConfig; the others on PromptManagerConfig. @@ -176,6 +181,7 @@ def _validate_autotag_settings(data): MAX_SUGGESTION_LIMIT, MODEL_TYPES, validate_saved_threshold, + validate_skipped_tags, validate_template, ) @@ -219,6 +225,10 @@ def _validate_autotag_settings(data): f"{MAX_SUGGESTION_LIMIT}." ) updates["AUTOTAG_SUGGESTION_LIMIT"] = value + if "autotag_skipped_tags" in data: + updates["AUTOTAG_SKIPPED_TAGS"] = tuple( + validate_skipped_tags(data["autotag_skipped_tags"]) + ) return updates @@ -283,6 +293,7 @@ class SettingsRoutesMixin: ), "autotag_template": AutotagConfig.TEMPLATE, "autotag_suggestion_limit": AutotagConfig.SUGGESTION_LIMIT, + "autotag_skipped_tags": list(AutotagConfig.SKIPPED_TAGS), "cpu_count": PromptManagerConfig.max_worker_threads(), "gallery_root_paths": root_paths, "gallery_root_path": root_paths[0] if root_paths else "", @@ -383,6 +394,10 @@ class SettingsRoutesMixin: {"success": False, "error": "expected_revision must be a string"}, status=400, ) + if expected_revision is None and _REVISION_REQUIRED & set(data): + return web.json_response( + {"success": False, "error": REVISION_REQUIRED_ERROR}, status=400 + ) try: updates = self._validate_settings(data) except ValueError as ve: diff --git a/py/autotag.py b/py/autotag.py index 727af79d..1fed7368 100644 --- a/py/autotag.py +++ b/py/autotag.py @@ -30,9 +30,9 @@ except ImportError: # The tag template and thresholds live with the saved tagging defaults # (#328), so config.py never imports PIL. try: - from .tagging_defaults import DEFAULT_TAG_TEMPLATE + from .tagging_defaults import DEFAULT_TAG_TEMPLATE, read_wd14_label_rows except ImportError: - from py.tagging_defaults import DEFAULT_TAG_TEMPLATE + from py.tagging_defaults import DEFAULT_TAG_TEMPLATE, read_wd14_label_rows try: from ..utils import exit_signal @@ -559,7 +559,6 @@ class AutoTagService: # A missing onnxruntime is refused by load_model before this runs # (#424); a ModuleNotFoundError here becomes the same refusal. import onnxruntime as ort - import csv config = MODELS[model_type] model_dir = self.models_dir / config["subdir"] @@ -576,12 +575,8 @@ class AutoTagService: session = ort.InferenceSession(str(model_path), providers=providers) - # Read tag list from CSV - tags_list = [] - with open(tags_path, "r", encoding="utf-8") as f: - reader = csv.DictReader(f) - for row in reader: - tags_list.append(row) + # Read tag list from CSV (the reader the Skipped tags preview shares) + tags_list = read_wd14_label_rows(tags_path) self.logger.info( f"WD14 model loaded ({len(tags_list)} tags, " @@ -992,6 +987,7 @@ class AutoTagService: prompt: Optional[str] = None, general_threshold: Optional[float] = None, character_threshold: Optional[float] = None, + skip: Any = None, ) -> List[Tuple[str, Optional[float]]]: """The top ``limit`` suggestions as ``(name, confidence)`` pairs. @@ -1000,7 +996,9 @@ class AutoTagService: ``None``. Names are distinct case-insensitively (first one wins). A name Save would reject (over 50 characters, a control character such as a newline, a comma) is skipped before the dedupe and the - ``limit`` count. ``prompt`` is used for this call only; + ``limit`` count. A name ``skip`` matches (a ``SkipMatcher``, the + Skipped tags compiled for this request or job, #431) is dropped next, + so it uses up no slot. ``prompt`` is used for this call only; ``custom_prompt`` is never changed. """ if type(limit) is not int or not 1 <= limit <= MAX_SUGGESTION_LIMIT: @@ -1017,6 +1015,8 @@ class AutoTagService: name = validate_vocabulary_tag_name(raw) except ValueError: continue + if skip is not None and skip.matches(name): + continue key = name.casefold() if key in seen: continue diff --git a/py/config.py b/py/config.py index b5ad1e49..ab7c1071 100644 --- a/py/config.py +++ b/py/config.py @@ -587,6 +587,7 @@ try: MODEL_TYPES, WD14_MODEL_TYPES, validate_saved_threshold, + validate_skipped_tags, validate_template, validate_threshold, ) @@ -600,6 +601,7 @@ except ImportError: MODEL_TYPES, WD14_MODEL_TYPES, validate_saved_threshold, + validate_skipped_tags, validate_template, validate_threshold, ) @@ -617,6 +619,20 @@ def _valid_existing(value: Any) -> str: return value +def _valid_skipped_tags(value: Any) -> Tuple[str, ...]: + return tuple(validate_skipped_tags(value)) + + +def _fallback_warning(key: str, error: ValueError, default: Any) -> str: + """The one load warning of a saved tagging default that fails its check.""" + if key == "template": + return f"autotag.defaults.template {error}; using the built-in one" + if key == "skipped_tags": + reason = str(error).removeprefix("Skipped tags").lstrip(": ").rstrip(".") + return f"autotag.defaults.skipped_tags {reason}; using no skipped tags" + return f"autotag.defaults.{key} {error}; using {default!r}" + + def _valid_suggestion_limit(value: Any) -> int: if type(value) is not int or not 1 <= value <= MAX_SUGGESTION_LIMIT: raise ValueError(f"must be an integer from 1 to {MAX_SUGGESTION_LIMIT}") @@ -639,6 +655,7 @@ class AutotagConfig: CHARACTER_THRESHOLD = 0.85 TEMPLATE = DEFAULT_TAG_TEMPLATE SUGGESTION_LIMIT = DEFAULT_SUGGESTION_LIMIT + SKIPPED_TAGS: Tuple[str, ...] = () # config.json key -> (attribute, validator, default) _KEYS = { @@ -660,6 +677,8 @@ class AutotagConfig: _valid_suggestion_limit, DEFAULT_SUGGESTION_LIMIT, ), + # #431: words whose suggested tags are never offered. + "skipped_tags": ("SKIPPED_TAGS", _valid_skipped_tags, ()), } @classmethod @@ -681,6 +700,7 @@ class AutotagConfig: } if defaults["template"] == DEFAULT_TAG_TEMPLATE: del defaults["template"] + defaults["skipped_tags"] = list(defaults["skipped_tags"]) return {"defaults": defaults} @classmethod @@ -698,11 +718,7 @@ class AutotagConfig: try: setattr(cls, attr, validate(defaults[key])) except ValueError as error: - config_logger.warning( - f"autotag.defaults.{key} {error}; using {default!r}" - if key != "template" - else f"autotag.defaults.template {error}; using the built-in one" - ) + config_logger.warning(_fallback_warning(key, error, default)) setattr(cls, attr, default) diff --git a/py/tagging_defaults.py b/py/tagging_defaults.py index 575a65af..e76d4aca 100644 --- a/py/tagging_defaults.py +++ b/py/tagging_defaults.py @@ -4,8 +4,13 @@ Server-free on purpose: ``py.api`` must import without ComfyUI's ``server`` module, which ``py/config.py`` needs; config re-exports all of this. """ +import csv import math -from typing import Any +import os +import re +import threading +import unicodedata +from typing import Any, Dict, Iterable, List, Set, Tuple # The tagging engines (``py/autotag.py`` MODELS) and the options they take. WD14_MODEL_TYPES = frozenset({"wd14-swinv2", "wd14-vit"}) @@ -88,3 +93,132 @@ def validate_template(value: Any, name: str = "instructions") -> str: if len(encoded) > MAX_INSTRUCTION_BYTES: raise ValueError(f"{name} {'are' if name == 'instructions' else 'is'} too long") return value + + +# Skipped tags (#431): words whose suggested tags are never offered. +MAX_SKIPPED_TAGS = 200 +MAX_SKIPPED_TAG_CHARS = 50 +_WORD_SEPARATORS = re.compile(r"[ _-]+") +_FORBIDDEN_IN_ENTRY = re.compile(r"[,\x00-\x1f\x7f-\x9f\u2028\u2029]") + + +def tag_words(text: str) -> Tuple[str, ...]: + """The words of a tag or a skipped entry: NFC, case-folded, split on runs + of space, ``_`` and ``-``. Every other character belongs to a word.""" + folded = unicodedata.normalize("NFC", text).casefold() + return tuple(word for word in _WORD_SEPARATORS.split(folded) if word) + + +def validate_skipped_tags(value: Any) -> List[str]: + """The Skipped tags list, each entry trimmed, or ValueError with the + sentence the System page shows (entries are numbered from 1).""" + if not isinstance(value, list): + raise ValueError("Skipped tags must be a list of words.") + if len(value) > MAX_SKIPPED_TAGS: + raise ValueError(f"Skipped tags: at most {MAX_SKIPPED_TAGS} entries.") + entries: List[str] = [] + seen: Dict[Tuple[str, ...], int] = {} + for number, raw in enumerate(value, 1): + if not isinstance(raw, str): + raise ValueError(f"Skipped tags: entry {number} must be text.") + entry = raw.strip() + if not entry: + raise ValueError(f"Skipped tags: entry {number} is empty.") + if len(entry) > MAX_SKIPPED_TAG_CHARS: + raise ValueError( + f"Skipped tags: entry {number} must be at most " + f"{MAX_SKIPPED_TAG_CHARS} characters." + ) + if _FORBIDDEN_IN_ENTRY.search(entry): + raise ValueError( + f"Skipped tags: entry {number} must not contain a comma " + "or a line break." + ) + words = tag_words(entry) + if not words: + raise ValueError(f"Skipped tags: entry {number} is empty.") + if words in seen: + raise ValueError( + f"Skipped tags: entry {number} repeats entry {seen[words]}." + ) + seen[words] = number + entries.append(entry) + return entries + + +class SkipMatcher: + """Whether a suggested tag contains a skipped entry's words, consecutive + and whole (#431, 17B). Compiled once per request or job; the cost of a + check depends on the tag's words, not on the number of entries.""" + + def __init__(self, entries: Iterable[str] = ()): + self._entries = frozenset(filter(None, (tag_words(e) for e in entries))) + self._longest = max((len(words) for words in self._entries), default=0) + + def __bool__(self) -> bool: + return bool(self._entries) + + def matches(self, name: str) -> bool: + if not self._entries: + return False + words = tag_words(name) + for start in range(len(words)): + stop = min(len(words), start + self._longest) + for end in range(start + 1, stop + 1): + if words[start:end] in self._entries: + return True + return False + + def counting(self) -> "CountingSkip": + """A per-request view that also remembers which names it hid.""" + return CountingSkip(self) + + +class CountingSkip: + """A :class:`SkipMatcher` that records the distinct names it hid, so the + Tag image dialog can say how many skipped tags were not suggested.""" + + def __init__(self, matcher: SkipMatcher): + self._matcher = matcher + self.hidden: Set[str] = set() + + def matches(self, name: str) -> bool: + if self._matcher.matches(name): + self.hidden.add(name.casefold()) + return True + return False + + +# A WD14 label list's tags the engine can suggest: general (0) and character (4). +WD14_SUGGESTED_CATEGORIES = frozenset({"0", "4"}) +_label_cache: Dict[str, Tuple[Tuple[int, int], Tuple[str, ...]]] = {} +_label_cache_lock = threading.Lock() + + +def read_wd14_label_rows(path) -> List[Dict[str, str]]: + """Every row of a WD14 ``selected_tags.csv``, read as UTF-8 and closed + before returning (the handle never outlives the call, for Windows).""" + with open(path, "r", encoding="utf-8", newline="") as handle: + return list(csv.DictReader(handle)) + + +def wd14_label_names(path) -> Tuple[str, ...]: + """The names a WD14 label list can suggest (categories 0 and 4), cached + by ``(mtime_ns, size)``. OSError and ValueError propagate (the caller + skips a missing or unreadable list).""" + key = os.fspath(path) + stat = os.stat(key) + stamp = (stat.st_mtime_ns, stat.st_size) + with _label_cache_lock: + cached = _label_cache.get(key) + if cached is not None and cached[0] == stamp: + return cached[1] + names = tuple( + name + for row in read_wd14_label_rows(key) + if row.get("category", "0") in WD14_SUGGESTED_CATEGORIES + and (name := (row.get("name") or "").strip()) + ) + with _label_cache_lock: + _label_cache[key] = (stamp, names) + return names diff --git a/tests/browser/server.py b/tests/browser/server.py index 65616c67..5d56771f 100644 --- a/tests/browser/server.py +++ b/tests/browser/server.py @@ -85,6 +85,9 @@ class TestModel: suggestions: list | None = None # The engine's model table (``py/autotag.py`` MODELS), set at startup. models_config: dict = {} + # Where the WD14 label lists live (the Skipped tags preview, #431), set at + # startup; the tagging fixture writes or removes a small list there. + models_dir = None def get_models_status(self) -> dict: from py.autotag import package_fields @@ -115,21 +118,26 @@ class TestModel: return ["generated", Path(image_path).parent.parent.name] def generate_tag_suggestions( - self, image_path: str, *, limit: int, **kwargs: object + self, image_path: str, *, limit: int, skip=None, **kwargs: object ) -> list[tuple[str, float | None]]: - """Fixed scored suggestions (WD14) or unscored ones (language models).""" + """Fixed scored suggestions (WD14) or unscored ones (language models), + without the ones ``skip`` hides before the limit, as the engine does + (#431).""" scored = self.loaded is not None and self.loaded.startswith("wd14") if self.suggestions is not None: - return [ + pairs = [ (name, confidence if scored else None) - for name, confidence in self.suggestions[:limit] + for name, confidence in self.suggestions ] - names = ["sandstone", "desert", "arch", "sky", "rock", "dune", "canyon"] - names += [f"tag_{index:02d}" for index in range(100)] - return [ - (name, round(0.98 - index * 0.008, 3) if scored else None) - for index, name in enumerate(names[:limit]) - ] + else: + names = ["sandstone", "desert", "arch", "sky", "rock", "dune", "canyon"] + names += [f"tag_{index:02d}" for index in range(100)] + pairs = [ + (name, round(0.98 - index * 0.008, 3) if scored else None) + for index, name in enumerate(names) + ] + kept = [pair for pair in pairs if skip is None or not skip.matches(pair[0])] + return kept[:limit] def download_model(self, model_type: str, progress_callback=None) -> bool: raise AssertionError("browser tests never download a model") @@ -233,6 +241,7 @@ def create_app(root: Path) -> web.Application: TestModel.models_config = autotag.MODELS model = TestModel() + model.models_dir = root / "models" autotag.get_autotag_service = lambda: model api = PromptManagerAPI() api.add_routes(routes) diff --git a/tests/browser/system-tagging-concept.spec.mjs b/tests/browser/system-tagging-concept.spec.mjs index ff79595c..f9062216 100644 --- a/tests/browser/system-tagging-concept.spec.mjs +++ b/tests/browser/system-tagging-concept.spec.mjs @@ -33,6 +33,8 @@ test.afterEach(async ({ page, request, browserName }) => { const PREVIEW_URL = '/prompt_manager/autotag/batch/preview'; const MODEL_OPTIONS = ['WD14 SwinV2 · ~400 MB', 'WD14 ViT · ~400 MB', 'GGUF JoyCaption · ~4.5 GB', 'HuggingFace JoyCaption · ~16 GB']; const SUGGESTIONS = 'From 1 to 100: the most suggestions Generate shows for one image.'; +const SKIPPED_DESCRIPTION = 'Suggested tags that contain one of these words are hidden. Words are separated by spaces, _ or -; ' + + 'other characters such as ( ) belong to the word.'; const BATCH = 'Tag each output image from its own pixels with the model above. Prompts are not changed. Review the scope first.'; /** Runs in the page: Auto-tagging's surface under `mainSelector`: rows, field, actions, operation, geometry and tokens. */ @@ -70,6 +72,16 @@ function measureTagging(mainSelector) { ...css(actions, 'display', 'flexWrap', 'columnGap', 'justifyContent'), primary: css(actions.querySelector('.ca-primary'), 'backgroundColor', 'color', 'fontSize', 'paddingTop', 'paddingLeft'), plain: css(actions.querySelector('button:not(.ca-primary)'), 'backgroundColor', 'color', 'fontSize', 'borderTopColor') }, + // D202 (#431): the Skipped tags block (the concept has none). + skipped: (block => block && { tree: [...block.children].map(name), key: block.dataset.key, + text: [...block.firstElementChild.children].map(node => [node.localName, text(node), node.localName === 'label' ? node.htmlFor : null]), + add: [...block.querySelector('.skip-add').children].map(node => [name(node), node.id, node.placeholder ?? null, text(node)]), + preview: [text(block.querySelector('.skip-preview')), block.querySelector('.skip-preview').getAttribute('role')], + ...css(block, 'paddingTop', 'paddingBottom', 'borderTopWidth', 'borderTopColor'), + label: css(block.querySelector('label'), 'fontSize', 'fontWeight', 'color'), + description: css(block.querySelector('.setting-block-text p'), 'fontSize', 'color', 'marginTop'), + input: css(block.querySelector('input'), 'fontSize', 'backgroundColor', 'color', 'borderTopColor') })( + surface.querySelector(':scope > .setting-block')), operations: [...surface.querySelectorAll(':scope > .operation')].map(row => ({ tree: [...row.children].map(name), title: text(row.querySelector('h3')), description: text(row.querySelector('p')), actions: [...row.querySelectorAll('.row-actions > button')].map(item => [text(item), item.className]) })), @@ -108,8 +120,9 @@ function mapTaggingConcept(reference, { template, phone }) { const [preview] = reference.actions.buttons; return { ...reference, - // D133 adds a row after the thresholds; D134 adds the operation after the actions. - tree: [...reference.tree.slice(0, 6), 'div.setting-row', ...reference.tree.slice(6), 'div.operation'], + // D133 adds a row after the thresholds, D202 the Skipped tags block after it; D134 adds the operation after the + // actions. + tree: [...reference.tree.slice(0, 6), 'div.setting-row', 'div.setting-block', ...reference.tree.slice(6), 'div.operation'], rows: [ // D132 (OQ5, OQ6): the four real engines with their download size, and the chosen one's live status. { ...model, text: [model.text[0], ['p', 'Downloaded. Not loaded.', null]], @@ -121,6 +134,20 @@ function mapTaggingConcept(reference, { template, phone }) { { ...general, text: [['label', 'Suggestions per image', 'tag-suggestions'], ['p', SUGGESTIONS, null]], control: { ...general.control, id: 'tag-suggestions', value: '10' } }, ], + // D202 (#431): the Skipped tags block in the concept's setting-row tokens: its label and description as a row's, + // the row's padding and rule above, the concept's field input; an empty list says so. + skipped: { tree: ['div.setting-block-text', 'div.skip-add', 'p.skip-problem.small', 'div.skip-chips.tags', 'p.skip-preview.small'], + key: 'autotag_skipped_tags', + text: [['label', 'Skipped tags', 'tag-skipped-input'], ['p', SKIPPED_DESCRIPTION, null]], + add: [['input', 'tag-skipped-input', 'Add a word or words', ''], ['button', 'tag-skipped-add', '', 'Add']], + preview: ['No tags are skipped.', 'status'], + paddingTop: reference.row.paddingTop, paddingBottom: reference.row.paddingBottom, + borderTopWidth: reference.row.borderTopWidth, borderTopColor: reference.row.borderTopColor, + label: reference.label, + description: { fontSize: reference.description.fontSize, color: reference.description.color, + marginTop: reference.description.marginTop }, + input: { fontSize: '12px', backgroundColor: reference.field.textarea.backgroundColor, color: reference.field.textarea.color, + borderTopColor: reference.field.textarea.borderTopColor } }, // The saved template (the server's default until one is saved) replaces the concept's sample text. field: { ...reference.field, textarea: { ...reference.field.textarea, value: template } }, // D132: Download (for an engine already on disk, "Download again") and Unload replace the two simulated jobs. @@ -198,12 +225,13 @@ test('auto-tagging comparison detects mutations', async ({ page, request }) => { const expected = mapTaggingConcept(concept, saved); const production = await productionTagging(page, 'dark'); expect(diffInventory(expected, production)).toEqual([]); - // Unmapped, the section differs only where D132, D133 and D134 are recorded (and the saved template's text). + // Unmapped, the section differs only where D132, D133, D134 and D202 are recorded (and the saved template's text). expect(diffInventory(concept, production).map(item => item.path.split('.').slice(0, 3).join('.')) .filter((path, index, all) => all.indexOf(path) === index).sort()).toEqual([ 'actions.buttons.1', 'actions.buttons.2', 'field.textarea.value', 'operations.0.actions', 'operations.0.description', 'operations.0.title', 'operations.0.tree', 'operations.length', 'rows.0.control', 'rows.0.text', 'rows.4.control', 'rows.4.controlBelow', - 'rows.4.flexWrap', 'rows.4.text', 'rows.4.tree', 'rows.length', 'tree.6', 'tree.7', 'tree.8', 'tree.9', 'tree.length'].sort()); + 'rows.4.flexWrap', 'rows.4.text', 'rows.4.tree', 'rows.length', 'skipped', 'tree.6', 'tree.7', 'tree.8', 'tree.9', 'tree.10', + 'tree.length'].sort()); const measure = () => page.evaluate(measureTagging, '#workspace-main'); const paths = async () => diffInventory(expected, await measure()).map(item => item.path); // A removed row. diff --git a/tests/browser/workspace-system-tagging.spec.mjs b/tests/browser/workspace-system-tagging.spec.mjs index 5a5a640a..eff83024 100644 --- a/tests/browser/workspace-system-tagging.spec.mjs +++ b/tests/browser/workspace-system-tagging.spec.mjs @@ -418,3 +418,85 @@ test('a model that is not downloaded cannot be reviewed for a batch; the button await expect(dialog(page)).toHaveCount(0); expect(page.modelCalls.filter(call => call.includes('/batch/preview'))).toEqual([]); }); + +// D202 (#431): Skipped tags: add (button and Enter), the preview over the library's tags and the WD14 label list, the +// per-chip counts, remove, Save preferences and a reload; a list the server refuses highlights the block. +const SKIPPED_PREVIEW = '/prompt_manager/autotag/skipped-tags/preview'; +const skippedBlock = page => surface(page).locator(':scope > .setting-block[data-setting="tag-skipped"]'); +const skippedInput = page => page.locator('#tag-skipped-input'); +const skippedChips = page => skippedBlock(page).locator('.skip-chips .chip.tag-suggestion'); +const skippedPreview = page => skippedBlock(page).locator('.skip-preview'); + +test('skipped tags add, preview, remove, save and reload (D202)', async ({ page, request }) => { + await openTagging(page); + await expect(skippedBlock(page).locator('label')).toHaveText('Skipped tags'); + await expect(skippedBlock(page)).toHaveAttribute('data-key', 'autotag_skipped_tags'); + await expect(skippedPreview(page)).toHaveText('No tags are skipped.'); + await expect(skippedPreview(page)).toHaveAttribute('role', 'status'); + // The label list on disk (blurry, blurry_background, signature, …) and the library tags (warm_light, …). + let answer = page.waitForResponse(item => item.url().endsWith(SKIPPED_PREVIEW)); + await skippedInput(page).fill('blurry'); + await skippedInput(page).press('Enter'); + expect((await answer).status()).toBe(200); + await expect(skippedInput(page)).toHaveValue(''); + await expect(skippedPreview(page)).toHaveText('Hides 2 tags from suggestions: blurry, blurry_background. ' + + 'Tags already on images are not changed.'); + answer = page.waitForResponse(item => item.url().endsWith(SKIPPED_PREVIEW)); + await skippedInput(page).fill('light'); + await page.getByRole('button', { name: 'Add', exact: true }).click(); + await answer; + await expect(skippedPreview(page)).toHaveText('Hides 3 tags from suggestions: warm_light, blurry, blurry_background. ' + + 'Tags already on images are not changed.'); + await expect(skippedChips(page)).toHaveText(['blurry2×', 'light1×']); + await expect(skippedChips(page).first()).toHaveAttribute('title', 'Hides 2 tags'); + // An entry that hides nothing is muted; a duplicate is not added and the line under the input says so. + answer = page.waitForResponse(item => item.url().endsWith(SKIPPED_PREVIEW)); + await skippedInput(page).fill('zebra'); + await skippedInput(page).press('Enter'); + await answer; + await expect(skippedChips(page).last()).toHaveClass(/\bnone\b/); + await expect(skippedChips(page).last()).toHaveAttribute('title', 'Hides no known tag'); + await skippedInput(page).fill('Blurry'); + await skippedInput(page).press('Enter'); + await expect(page.locator('#tag-skipped-problem')).toHaveText('Already in the list.'); + await expect(skippedInput(page)).toHaveAttribute('aria-invalid', 'true'); + await skippedInput(page).fill('a, b'); + await skippedInput(page).press('Enter'); + await expect(page.locator('#tag-skipped-problem')).toHaveText('No commas or line breaks.'); + // Remove zebra, then save. + await skippedChips(page).last().getByRole('button', { name: 'Remove zebra', exact: true }).click(); + await expect(skippedChips(page)).toHaveCount(2); + await expect(status(page)).toHaveText('Unsaved changes.'); + await save(page).click(); + await expect(status(page)).toHaveText('Saved to the server configuration.'); + expect((await serverJson(request, SETTINGS_URL)).settings.autotag_skipped_tags).toEqual(['blurry', 'light']); + await openTagging(page); + await expect(skippedChips(page)).toHaveText(['blurry2×', 'light1×']); + // Removing a chip and adding it back is no change. + await skippedChips(page).last().getByRole('button', { name: 'Remove light', exact: true }).click(); + await expect(status(page)).toHaveText('Unsaved changes.'); + await skippedInput(page).fill('light'); + await skippedInput(page).press('Enter'); + await expect(status(page)).toHaveText('No unsaved changes.'); +}); + +test('skipped tags preview checks library tags only without a label list, and a refused save highlights it', async ({ page, request }) => { + await expect(await request.post('/__test__/tagging/defaults', { data: { general: 0.35, character: 0.85, limit: 10, + labels: false } })).toBeOK(); + await openTagging(page); + const answer = page.waitForResponse(item => item.url().endsWith(SKIPPED_PREVIEW)); + await skippedInput(page).fill('blurry'); + await skippedInput(page).press('Enter'); + await answer; + await expect(skippedPreview(page)).toHaveText('Hides 0 tags from suggestions. Checked against library tags only; no WD14 ' + + 'label list is downloaded. Tags already on images are not changed.'); + // The server is the authority: a list it refuses keeps the draft, says why and highlights the block. + await page.route(`**${SETTINGS_URL}`, route => (route.request().method() === 'POST' + ? route.fulfill({ status: 400, contentType: 'application/json', + body: JSON.stringify({ success: false, error: 'Skipped tags: entry 1 is empty.' }) }) + : route.fallback())); + await save(page).click(); + await expect(status(page)).toHaveText('Skipped tags: entry 1 is empty.'); + await expect(skippedBlock(page)).toHaveAttribute('aria-invalid', 'true'); + await page.unrouteAll({ behavior: 'ignoreErrors' }); +}); diff --git a/tests/browser/workspace-tagging.spec.mjs b/tests/browser/workspace-tagging.spec.mjs index b61abbca..b3b2cef6 100644 --- a/tests/browser/workspace-tagging.spec.mjs +++ b/tests/browser/workspace-tagging.spec.mjs @@ -849,3 +849,15 @@ test('a failed listing re-read after a save says so and keeps the cards (M2)', a await expect(page.locator('#gallery-continuation [aria-disabled="true"]')).toHaveCount(0); await page.unroute(failing); }); + +// #431: the saved Skipped tags hide matching suggestions before the limit (WD14 fills it from lower scores), and the +// chip hint says how many were not suggested. +test('suggestions omit skipped tags and say how many were hidden', async ({ page, request }) => { + await expect(await request.post('/__test__/tagging/defaults', { data: { general: 0.35, character: 0.85, limit: 4, + skipped: ['desert', 'rock'] } })).toBeOK(); + await openTag(page); + const answer = await (await generated(page)).json(); + expect(answer.skipped).toBe(2); + await expect(chips(page).locator(':scope > span:first-child')).toHaveText(['sandstone', 'arch', 'sky', 'dune']); + await expect(dialog(page).locator('#tag-suggestions > p.small')).toHaveText(`${CHIP_HINT} 2 skipped tags were not suggested.`); +}); diff --git a/tests/browser/workspace_tagging_fixture.py b/tests/browser/workspace_tagging_fixture.py index 4fe5bce9..26afa4eb 100644 --- a/tests/browser/workspace_tagging_fixture.py +++ b/tests/browser/workspace_tagging_fixture.py @@ -226,6 +226,7 @@ def register_tagging_commands( return download["result"] _set_defaults(model, DEFAULTS) # the server starts on the standard ones + _write_labels(model, True) @routes.post("/__test__/tagging/reset") async def reset(request: web.Request) -> web.Response: @@ -244,6 +245,7 @@ def register_tagging_commands( model.loaded = None restore_download() _set_defaults(model, DEFAULTS) + await api._run_in_executor(_write_labels, model, True) # A test that ends with the dialog open leaves its capture behind; drop # every capture so the next test starts below the store's limits. await api._run_in_executor(api.close_annotation_captures) @@ -353,13 +355,22 @@ def register_tagging_commands( ``{"general": g, "character": c, "limit": n}``: the WD14 thresholds (in [0, 1]) and the persisted suggestion count (1..100), so a test can - tell the server's values from the dialog's built-in ones. Reset - restores the standard 0.35 / 0.85 / 10. + tell the server's values from the dialog's built-in ones. Optional + ``skipped`` sets the saved Skipped tags (#431) and ``labels`` (true or + false) writes or removes the fake WD14 SwinV2 label list the Skipped + tags preview reads. Reset restores the standard 0.35 / 0.85 / 10, no + skipped tags and the label list. """ data = await request.json() if request.can_read_body else None if request.query or not _valid_defaults(data): raise web.HTTPBadRequest() _set_defaults(model, (data["general"], data["character"], data["limit"])) + if "skipped" in data: + from py.config import AutotagConfig + + AutotagConfig.SKIPPED_TAGS = tuple(data["skipped"]) + if "labels" in data: + await api._run_in_executor(_write_labels, model, data["labels"]) return web.json_response({"success": True}) @routes.get("/__test__/tagging/captures") @@ -394,6 +405,33 @@ def _set_defaults(model, values) -> None: AutotagConfig.MODEL_TYPE = "wd14-swinv2" AutotagConfig.EXISTING = "skip" AutotagConfig.TEMPLATE = model.default_prompt + AutotagConfig.SKIPPED_TAGS = () + + +# The fake WD14 SwinV2 label list (general 0, character 4, rating 9): the +# names the Skipped tags preview checks beside the library's tags (#431). +FAKE_LABELS = ( + ("blurry", "0"), + ("blurry_background", "0"), + ("signature", "0"), + ("watermark", "0"), + ("t-shirt", "0"), + ("half-closed_eyes", "0"), + ("saber_(fate)", "4"), + ("general", "9"), +) + + +def _write_labels(model, present: bool) -> None: + """Write (or remove) the fake label list under the fake models folder.""" + path = Path(model.models_dir) / "WD14-SwinV2" / "selected_tags.csv" + if not present: + path.unlink(missing_ok=True) + return + path.parent.mkdir(parents=True, exist_ok=True) + lines = ["tag_id,name,category,count"] + lines += [f"{i},{name},{cat},1" for i, (name, cat) in enumerate(FAKE_LABELS)] + path.write_text("\n".join(lines) + "\n", encoding="utf-8") def _valid_defaults(data: object) -> bool: @@ -402,9 +440,20 @@ def _valid_defaults(data: object) -> bool: and not isinstance(value, bool) and 0 <= value <= 1 ) + optional = {"skipped", "labels"} + if isinstance(data, dict) and "skipped" in data: + try: + from py.tagging_defaults import validate_skipped_tags + + validate_skipped_tags(data["skipped"]) + except ValueError: + return False + if isinstance(data, dict) and not isinstance(data.get("labels", True), bool): + return False return ( isinstance(data, dict) - and set(data) == {"general", "character", "limit"} + and {"general", "character", "limit"} <= set(data) + and set(data) <= {"general", "character", "limit"} | optional and unit(data["general"]) and unit(data["character"]) and type(data["limit"]) is int diff --git a/tests/fixtures/legacy_contract_snapshot.json b/tests/fixtures/legacy_contract_snapshot.json index 2ba298bf..4fa2a827 100644 --- a/tests/fixtures/legacy_contract_snapshot.json +++ b/tests/fixtures/legacy_contract_snapshot.json @@ -105,6 +105,7 @@ "POST /prompt_manager/autotag/batch/preview", "POST /prompt_manager/autotag/download/{model_type}", "POST /prompt_manager/autotag/single", + "POST /prompt_manager/autotag/skipped-tags/preview", "POST /prompt_manager/autotag/start", "POST /prompt_manager/autotag/unload", "POST /prompt_manager/bulk/category", @@ -188,6 +189,7 @@ "settings.autotag_existing", "settings.autotag_general_threshold", "settings.autotag_model_type", + "settings.autotag_skipped_tags", "settings.autotag_suggestion_limit", "settings.autotag_template", "settings.cpu_count", diff --git a/tests/js/workspace-image-tagging-state.test.js b/tests/js/workspace-image-tagging-state.test.js index 0f4a033a..97a4f183 100644 --- a/tests/js/workspace-image-tagging-state.test.js +++ b/tests/js/workspace-image-tagging-state.test.js @@ -936,3 +936,17 @@ test('#515: text typed before Retry after a failed first observation follows the assert.equal(c.state.manual, 'Minimal, warm_light, dunes'); assert.equal(c.state.statusText, ''); }); + +test('the chip hint says how many skipped tags were not suggested (#431)', async () => { + const { skippedNote } = await load(); + assert.equal(skippedNote(0), ''); + assert.equal(skippedNote(undefined), ''); + assert.equal(skippedNote(1), ' 1 skipped tag was not suggested.'); + assert.equal(skippedNote(2), ' 2 skipped tags were not suggested.'); + for (const [skipped, hint] of [[2, 'Remove anything you do not want to add. 2 skipped tags were not suggested.'], + [0, 'Remove anything you do not want to add.'], ['3', 'Remove anything you do not want to add.']]) { + const { c } = await opened({ [`POST ${SUGGEST}`]: () => response(200, { ...suggested(), skipped }) }); + await c.generate(); + assert.equal(c.state.chipHint, hint, String(skipped)); + } +}); diff --git a/tests/js/workspace-system-skipped-tags.test.js b/tests/js/workspace-system-skipped-tags.test.js new file mode 100644 index 00000000..3a7ea101 --- /dev/null +++ b/tests/js/workspace-system-skipped-tags.test.js @@ -0,0 +1,247 @@ +// System › Auto-tagging "Skipped tags" (#431, D202): the add input, the chips, the client checks and the preview line. +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const skippedModule = import('../../web/js/workspace/system-skipped-tags.mjs'); +const stateModule = import('../../web/js/workspace/system-state.mjs'); +const coreModule = import('../../web/js/shared/toolkit/core.js'); + +/** A minimal DOM: elements with children, attributes, listeners (aborted by their signal) and focus. */ +function fakeDOM() { + const previous = global.document; + class Element { + constructor(tag) { + this.tagName = tag.toUpperCase(); + this.children = []; + this.attributes = {}; + this.listeners = []; + this.dataset = {}; + this.className = ''; + this.id = ''; + this.value = ''; + this.title = ''; + this.disabled = false; + this._text = ''; + } + get localName() { return this.tagName.toLowerCase(); } + get textContent() { return this._text + this.children.map(child => (typeof child === 'string' ? child : child.textContent)).join(''); } + set textContent(text) { this.children = []; this._text = String(text); } + append(...nodes) { for (const node of nodes) { if (typeof node !== 'string') node.parentNode = this; this.children.push(node); } } + replaceChildren(...nodes) { this.children = []; this._text = ''; this.append(...nodes); } + querySelectorAll(selector) { + assert.equal(selector, 'button'); + const found = []; + const walk = node => { for (const child of node.children) if (typeof child !== 'string') { if (child.tagName === 'BUTTON') found.push(child); walk(child); } }; + walk(this); + return found; + } + focus() { global.document.activeElement = this; } + setAttribute(name, value) { this.attributes[name] = String(value); } + getAttribute(name) { return Object.hasOwn(this.attributes, name) ? this.attributes[name] : null; } + removeAttribute(name) { delete this.attributes[name]; } + addEventListener(type, callback, options = {}) { + if (options.signal?.aborted) return; + const entry = { type, callback }; + this.listeners.push(entry); + options.signal?.addEventListener('abort', () => { this.listeners = this.listeners.filter(item => item !== entry); }); + } + dispatch(type, event = {}) { + return Promise.all(this.listeners.filter(item => item.type === type).map(({ callback }) => callback({ type, target: this, ...event }))); + } + } + global.document = { createElement: tag => new Element(tag), activeElement: null }; + const walk = (node, found = []) => { found.push(node); node.children.forEach(child => typeof child !== 'string' && walk(child, found)); return found; }; + return { find: (root, predicate) => walk(root).find(predicate), restore() { global.document = previous; } }; +} + +const tick = () => new Promise(resolve => setTimeout(resolve, 5)); + +async function mounted({ saved = ['blurry'], answer, host = true } = {}) { + const { skippedTagsBlock } = await skippedModule; + const { createSystemDrafts } = await stateModule; + const { createScope } = await coreModule; + const dom = fakeDOM(); + const drafts = createSystemDrafts({ + readBrowser: () => ({}), readRuntime: () => ({}), + readHost: async () => ({ values: host ? { autotag_skipped_tags: saved } : {}, revision: 'sha256:one' }), + writeBrowser: async () => ({}), writeHost: async () => ({ success: true, persisted: true }), writeRuntime: async () => ({}), + }); + const scope = createScope(); + const posts = []; + const api = { + post: (url, body, options) => { + posts.push({ url, body, signal: options.signal }); + if (answer) return answer(body, options); + return Promise.resolve({ success: true, count: body.entries.length, sample: body.entries, sample_limit: 12, + per_entry: body.entries.map((entry, index) => ({ entry, count: index })), sources: { library: 3, wd14: ['WD14 SwinV2'] } }); + }, + }; + const block = skippedTagsBlock({ api, scope, drafts, delay: 1 }); + scope.cleanup(drafts.subscribe(block.repaint)); + block.repaint(); + await drafts.reloadHost(); + const byId = id => dom.find(block.node, node => node.id === id); + const chips = () => dom.find(block.node, node => node.className === 'tags skip-chips'); + const preview = () => dom.find(block.node, node => node.className === 'small skip-preview'); + const add = async text => { byId('tag-skipped-input').value = text; await byId('tag-skipped-add').dispatch('click'); }; + return { dom, drafts, scope, block, byId, chips, preview, posts, add }; +} + +test('words split like the server: NFC, lower case, space, _ and - only', async () => { + const { entryWords } = await skippedModule; + assert.deepEqual(entryWords('Half-closed_eyes'), ['half', 'closed', 'eyes']); + assert.deepEqual(entryWords('saber_(fate)'), ['saber', '(fate)']); + assert.deepEqual(entryWords(' _ - '), []); + assert.deepEqual(entryWords('CAFÉ'), ['café']); +}); + +test('the client checks say why an entry is not added', async () => { + const { entryProblem, SKIPPED_COPY } = await skippedModule; + assert.equal(entryProblem(' sky ', ['blurry']), ''); + assert.equal(entryProblem(' ', []), SKIPPED_COPY.empty); + assert.equal(entryProblem('_--', []), 'Enter a word.'); + assert.equal(entryProblem('a, b', []), 'No commas or line breaks.'); + assert.equal(entryProblem('a\nb', []), 'No commas or line breaks.'); + assert.equal(entryProblem('x'.repeat(51), []), 'At most 50 characters.'); + assert.equal(entryProblem('🏜'.repeat(50), []), ''); + assert.equal(entryProblem('Blurry-Background', ['blurry background']), 'Already in the list.'); + assert.equal(entryProblem('sky', Array.from({ length: 200 }, (_, i) => `w${i}`)), 'The list is full (200).'); +}); + +test('the preview line states the count, the sample and the sources', async () => { + const { previewText, chipCount } = await skippedModule; + assert.deepEqual(previewText({ count: 15, sample: ['blurry', 'signature'], sources: { wd14: ['WD14 SwinV2'] } }), { + lead: 'Hides 15 tags from suggestions', rest: ': blurry, signature and 13 more. Tags already on images are not changed.' }); + assert.deepEqual(previewText({ count: 1, sample: ['blurry'], sources: { wd14: [] } }), { + lead: 'Hides 1 tag from suggestions', + rest: ': blurry. Checked against library tags only; no WD14 label list is downloaded. Tags already on images are not changed.' }); + assert.deepEqual(previewText({ count: 0, sample: [], sources: { wd14: ['WD14 ViT'] } }), { + lead: 'Hides 0 tags from suggestions', rest: '. Tags already on images are not changed.' }); + assert.deepEqual(previewText(null).lead, 'Hides 0 tags from suggestions'); + assert.deepEqual(chipCount(3), { title: 'Hides 3 tags', text: '3' }); + assert.deepEqual(chipCount(1), { title: 'Hides 1 tag', text: '1' }); + assert.deepEqual(chipCount(0), { title: 'Hides no known tag', text: 'none' }); + assert.equal(chipCount(undefined), null); +}); + +test('the block shows the saved list, previews it and carries its setting keys', async () => { + const view = await mounted({ saved: ['blurry', 'signature'] }); + try { + assert.equal(view.block.node.dataset.setting, 'tag-skipped'); + assert.equal(view.block.node.dataset.key, 'autotag_skipped_tags'); + assert.equal(view.preview().textContent, 'Checking…'); + await tick(); + assert.deepEqual(view.posts.map(post => [post.url, post.body]), + [['/prompt_manager/autotag/skipped-tags/preview', { entries: ['blurry', 'signature'] }]]); + assert.equal(view.preview().textContent, 'Hides 2 tags from suggestions: blurry, signature. Tags already on images are not changed.'); + const chips = view.chips().children; + assert.deepEqual(chips.map(chip => [chip.className, chip.title, chip.textContent]), [ + ['chip tag-suggestion none', 'Hides no known tag', 'blurrynone×'], + ['chip tag-suggestion', 'Hides 1 tag', 'signature1×']]); + assert.equal(chips[1].children[2].getAttribute('aria-label'), 'Remove signature'); + } finally { view.scope.dispose(); view.dom.restore(); } +}); + +test('add and remove draft the list; a list set back is clean; Enter adds', async () => { + const view = await mounted(); + try { + await view.add(' closed eyes '); + assert.deepEqual(view.drafts.get('host', 'autotag_skipped_tags'), ['blurry', 'closed eyes']); + assert.equal(view.byId('tag-skipped-input').value, ''); + assert.equal(view.drafts.dirty(), true); + view.byId('tag-skipped-input').value = 'sky'; + let prevented = false; + await view.byId('tag-skipped-input').dispatch('keydown', { key: 'Enter', preventDefault: () => { prevented = true; } }); + assert.equal(prevented, true); + await view.byId('tag-skipped-input').dispatch('keydown', { key: 'a' }); + assert.deepEqual(view.drafts.get('host', 'autotag_skipped_tags'), ['blurry', 'closed eyes', 'sky']); + // Remove the two added chips: the list equals the saved one and nothing is unsaved (sameDraft). + const removeButton = index => view.chips().children[index].children.at(-1); + removeButton(2).focus(); + await removeButton(2).dispatch('click'); + assert.equal(global.document.activeElement, removeButton(1), 'focus moves to the neighbour'); + await removeButton(1).dispatch('click'); + assert.deepEqual(view.drafts.get('host', 'autotag_skipped_tags'), ['blurry']); + assert.equal(view.drafts.dirty(), false); + await tick(); + // Only the last list of a quick series is asked for. + assert.deepEqual(view.posts.map(post => post.body.entries), [['blurry']]); + } finally { view.scope.dispose(); view.dom.restore(); } +}); + +test('a refused entry is not added and the line under the input says why', async () => { + const view = await mounted(); + try { + const problem = () => view.byId('tag-skipped-problem').textContent; + await view.add('Blurry'); + assert.equal(problem(), 'Already in the list.'); + assert.equal(view.byId('tag-skipped-input').getAttribute('aria-invalid'), 'true'); + assert.equal(view.byId('tag-skipped-input').value, 'Blurry'); + await view.byId('tag-skipped-input').dispatch('input'); + assert.equal(problem(), ''); + assert.equal(view.byId('tag-skipped-input').getAttribute('aria-invalid'), null); + await view.add('a,b'); + assert.equal(problem(), 'No commas or line breaks.'); + await view.add(' '); + assert.equal(problem(), 'Enter a word.'); + assert.deepEqual(view.drafts.get('host', 'autotag_skipped_tags'), ['blurry']); + } finally { view.scope.dispose(); view.dom.restore(); } +}); + +test('a full list disables Add and says so', async () => { + const view = await mounted({ saved: Array.from({ length: 200 }, (_, i) => `w${i}`) }); + try { + assert.equal(view.byId('tag-skipped-add').disabled, true); + assert.equal(view.byId('tag-skipped-problem').textContent, 'The list is full (200).'); + await view.chips().children[0].children.at(-1).dispatch('click'); + assert.equal(view.byId('tag-skipped-add').disabled, false); + assert.equal(view.byId('tag-skipped-problem').textContent, ''); + } finally { view.scope.dispose(); view.dom.restore(); } +}); + +test('an empty list says no tags are skipped without a request', async () => { + const view = await mounted({ saved: [] }); + try { + await tick(); + assert.equal(view.preview().textContent, 'No tags are skipped.'); + assert.deepEqual(view.posts, []); + } finally { view.scope.dispose(); view.dom.restore(); } +}); + +test('a failed preview says so; a newer list aborts the older request', async () => { + let calls = 0; + const view = await mounted({ answer: (body, options) => { + calls += 1; + if (calls === 1) return new Promise((_resolve, reject) => options.signal.addEventListener('abort', () => reject(new Error('aborted')))); + return Promise.reject(Object.assign(new Error('busy'), { code: 'busy' })); + } }); + try { + await tick(); + assert.equal(view.posts.length, 1); + await view.add('sky'); + assert.equal(view.posts[0].signal.aborted, true); + await tick(); + assert.equal(view.preview().textContent, 'The preview could not be read.'); + // The list stays editable. + assert.equal(view.byId('tag-skipped-input').disabled, false); + } finally { view.scope.dispose(); view.dom.restore(); } +}); + +test('before the settings are read the controls are disabled', async () => { + const view = await mounted({ host: false }); + try { + assert.equal(view.byId('tag-skipped-input').disabled, true); + assert.equal(view.byId('tag-skipped-add').disabled, true); + await view.add('sky'); + assert.equal(view.drafts.get('host', 'autotag_skipped_tags'), undefined); + } finally { view.scope.dispose(); view.dom.restore(); } +}); + +test('leaving the section aborts a request in flight and the pending timer', async () => { + const view = await mounted({ answer: (_body, options) => new Promise((_r, reject) => options.signal.addEventListener('abort', () => reject(new Error('x')))) }); + await tick(); + assert.equal(view.posts.length, 1); + view.scope.dispose(); + assert.equal(view.posts[0].signal.aborted, true); + view.dom.restore(); +}); diff --git a/tests/js/workspace-system-state.test.js b/tests/js/workspace-system-state.test.js index edef02d7..2331b574 100644 --- a/tests/js/workspace-system-state.test.js +++ b/tests/js/workspace-system-state.test.js @@ -402,11 +402,12 @@ test('a partial host save keeps what saved, adopts its revision and fails only t assert.equal(drafts.statusText(), 'missing can\'t be used: not found. Remove it or reconnect it, then save.'); // The next save carries the revision the saved part produced: no false "changed elsewhere". domains.outcome.host = null; - drafts.set('host', 'gallery_root_paths', ['a']); + // (#431: a list equal to the saved one is no change, so a different one is drafted.) + drafts.set('host', 'gallery_root_paths', ['a', 'b']); const second = await drafts.save(); assert.equal(second.host, 'saved'); assert.equal(domains.calls.at(-1)[2].expected_revision, 'sha256:lora'); - assert.deepEqual(domains.calls.at(-1)[1], { gallery_root_paths: ['a'] }); + assert.deepEqual(domains.calls.at(-1)[1], { gallery_root_paths: ['a', 'b'] }); // Without a server reason, the status says some were saved and the highlighted ones were not. domains.outcome.host = () => { throw Object.assign(new Error('boom'), { partial: { saved: ['x'], revision: 'sha256:three' } }); }; drafts.set('host', 'x', 1); @@ -429,3 +430,39 @@ test('runtime adapter maps config.level to runtime_level and back', async () => assert.deepEqual(runtimeBody({ runtime_level: 'WARNING' }), { level: 'WARNING' }); assert.deepEqual(runtimeBody({ runtime_level: 'CRITICAL', console_logging: true }), { level: 'CRITICAL', console_logging: true }); }); + +test('sameDraft compares arrays and plain objects by value, keys in any order (#431)', async () => { + const { sameDraft } = await source; + assert.equal(sameDraft(1, 1), true); + assert.equal(sameDraft(Number.NaN, Number.NaN), true); + assert.equal(sameDraft(0, -0), false); + assert.equal(sameDraft(['a', 'b'], ['a', 'b']), true); + assert.equal(sameDraft(['a', 'b'], ['b', 'a']), false); + assert.equal(sameDraft([], []), true); + assert.equal(sameDraft([], {}), false); + assert.equal(sameDraft({ gguf: 'x', hf: 'y' }, { hf: 'y', gguf: 'x' }), true); + assert.equal(sameDraft([{ id: 'a', name: 'n' }], [{ name: 'n', id: 'a' }]), true); + assert.equal(sameDraft({ a: 1 }, { a: 2 }), false); + assert.equal(sameDraft('a', ['a']), false); + assert.equal(sameDraft(null, {}), false); + assert.equal(sameDraft(new Date(0), new Date(0)), false); +}); + +test('a list draft set back to the saved list is clean, and a save cleans it (#431)', async () => { + const domains = adapters({ host: { autotag_skipped_tags: ['blurry'] } }); + const drafts = await loaded(domains); + drafts.set('host', 'autotag_skipped_tags', ['blurry', 'signature']); + assert.equal(drafts.dirty('host'), true); + // Removing the added chip builds a new array equal to the saved one. + drafts.set('host', 'autotag_skipped_tags', ['blurry']); + assert.equal(drafts.dirty('host'), false); + drafts.set('host', 'autotag_skipped_tags', ['signature']); + await drafts.save(); + assert.equal(drafts.dirty('host'), false); + assert.deepEqual(drafts.saved('host').autotag_skipped_tags, ['signature']); + // A reload whose list equals the draft's (a new array) keeps nothing dirty. + drafts.set('host', 'autotag_skipped_tags', ['sky']); + domains.readHost = async () => ({ values: { autotag_skipped_tags: ['sky'] }, revision: 'sha256:three' }); + await drafts.load('host'); + assert.equal(drafts.dirty('host'), false); +}); diff --git a/tests/js/workspace-system-tagging.test.js b/tests/js/workspace-system-tagging.test.js index 317b9149..7697edd7 100644 --- a/tests/js/workspace-system-tagging.test.js +++ b/tests/js/workspace-system-tagging.test.js @@ -201,7 +201,7 @@ test('opening the section reads the model status once; the select waits for it', assert.deepEqual(byId('tag-model').options.map(option => option.textContent), ['WD14 SwinV2 · ~400 MB', 'WD14 ViT · ~400 MB', 'GGUF JoyCaption · ~4.5 GB', 'HuggingFace JoyCaption · ~16 GB']); assert.deepEqual(surface.children.map(node => node.className), - ['', '', 'setting-row', 'setting-row', 'setting-row', 'setting-row', 'setting-row', 'field', 'row-actions', 'operation']); + ['', '', 'setting-row', 'setting-row', 'setting-row', 'setting-row', 'setting-row', 'setting-block', 'field', 'row-actions', 'operation']); } finally { scope.dispose(); dom.restore(); } const failed = await mounted({ modelsFail: true }); try { diff --git a/tests/test_api_annotation_suggest.py b/tests/test_api_annotation_suggest.py index 7d1b3539..b874356a 100644 --- a/tests/test_api_annotation_suggest.py +++ b/tests/test_api_annotation_suggest.py @@ -73,6 +73,7 @@ class FakeTagger: self.calls = [] self.read_paths = [] self.read_bytes = [] + self.skips = [] self.download_model = MagicMock(side_effect=AssertionError("downloaded")) self.load_error = None self.block = None # (entered, proceed) events @@ -115,8 +116,10 @@ class FakeTagger: prompt=None, general_threshold=None, character_threshold=None, + skip=None, ): self.read_paths.append(image_path) + self.skips.append(skip) with open(image_path, "rb") as handle: self.read_bytes.append(handle.read()) self.calls.append( @@ -132,9 +135,11 @@ class FakeTagger: entered, proceed = self.block entered.set() proceed.wait(WAIT) + # As the engine does (#431): a skipped tag uses up no slot. + kept = [pair for pair in SCORED if skip is None or not skip.matches(pair[0])] if self.loaded.startswith("wd14"): - return list(SCORED[:limit]) - return [(name, None) for name, _ in SCORED[:limit]] + return list(kept[:limit]) + return [(name, None) for name, _ in kept[:limit]] class SuggestTestCase(AnnotationAPITestCase): diff --git a/tests/test_api_autotag.py b/tests/test_api_autotag.py index 1ef568b2..d8cb6978 100644 --- a/tests/test_api_autotag.py +++ b/tests/test_api_autotag.py @@ -129,8 +129,10 @@ class FakeAutotagService: prompt=None, general_threshold=None, character_threshold=None, + skip=None, ): - """The suggestion entry point (#379): the fixed tags, at most ``limit``.""" + """The suggestion entry point (#379): the fixed tags, at most ``limit``, + without the ones ``skip`` hides (#431).""" self.suggest_calls += 1 tags = self.generate_tags( image_path, @@ -138,6 +140,7 @@ class FakeAutotagService: general_threshold=general_threshold, character_threshold=character_threshold, ) + tags = [name for name in tags if skip is None or not skip.matches(name)] return [(name, None) for name in tags[:limit]] diff --git a/tests/test_autotag_batch.py b/tests/test_autotag_batch.py index 4b63d2f9..4374a31d 100644 --- a/tests/test_autotag_batch.py +++ b/tests/test_autotag_batch.py @@ -54,6 +54,8 @@ class PerImageTagger(HookedTagger): names = self.by_bytes.get(self.read_bytes[-1]) if names is None: return result + skip = kwargs.get("skip") # as the engine does (#431) + names = [name for name in names if skip is None or not skip.matches(name)] return [(name, 0.9) for name in names][: kwargs["limit"]] diff --git a/tests/test_config.py b/tests/test_config.py index f2d9a353..145b339e 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -444,6 +444,7 @@ class TestAutotagConfig(unittest.TestCase): "CHARACTER_THRESHOLD", "TEMPLATE", "SUGGESTION_LIMIT", + "SKIPPED_TAGS", ) } @@ -487,6 +488,7 @@ class TestAutotagConfig(unittest.TestCase): "general_threshold": 0.35, "character_threshold": 0.85, "suggestion_limit": 10, + "skipped_tags": [], } }, ) @@ -526,6 +528,7 @@ class TestAutotagConfig(unittest.TestCase): "character_threshold": 0.7, "template": "Tags only.", "suggestion_limit": 15, + "skipped_tags": ("blurry", "closed eyes"), } fallback = { "model_type": "wd14-swinv2", @@ -534,6 +537,7 @@ class TestAutotagConfig(unittest.TestCase): "character_threshold": 0.85, "template": config_module.DEFAULT_TAG_TEMPLATE, "suggestion_limit": 10, + "skipped_tags": (), } bad_values = { "model_type": ["wd14-convnext", 3, None], @@ -544,6 +548,8 @@ class TestAutotagConfig(unittest.TestCase): # 4000 characters are at most 16,000 UTF-8 bytes, so the 16 KiB # byte cap can only be met past the character cap. "template": ["", " ", "x" * 4001, "\U0001f600" * 4097, "\ud800", 7], + # #431: the whole list falls back; entries are never dropped one by one. + "skipped_tags": ["blurry", ["a,b"], ["sky", "Sky"], [""], ["x"] * 201], } for key, values in bad_values.items(): for value in values: @@ -552,7 +558,7 @@ class TestAutotagConfig(unittest.TestCase): with tempfile.TemporaryDirectory() as tmpdir: path = os.path.join(tmpdir, "config.json") with open(path, "w") as f: - json.dump({"autotag": {"defaults": saved}}, f) + json.dump({"autotag": {"defaults": saved}}, f, default=list) with self.assertLogs( "prompt_manager.config", level="WARNING" ) as logs: @@ -565,6 +571,26 @@ class TestAutotagConfig(unittest.TestCase): } self.assertEqual(loaded, dict(good, **{key: fallback[key]})) + def test_skipped_tags_load_trimmed_and_warn_once_when_damaged(self): + """#431: the list is kept as typed (trimmed); a damaged list is + replaced by no skipped tags with one warning naming the reason.""" + PromptManagerConfig.update_config( + {"autotag": {"defaults": {"skipped_tags": [" blurry ", "(fate)"]}}} + ) + self.assertEqual(self.config_class.SKIPPED_TAGS, ("blurry", "(fate)")) + with self.assertLogs("prompt_manager.config", level="WARNING") as logs: + PromptManagerConfig.update_config( + {"autotag": {"defaults": {"skipped_tags": ["sky", "a,b"]}}} + ) + self.assertEqual(self.config_class.SKIPPED_TAGS, ()) + self.assertEqual( + [record.getMessage() for record in logs.records], + [ + "autotag.defaults.skipped_tags entry 2 must not contain a comma" + " or a line break; using no skipped tags" + ], + ) + def test_tagging_validators_moved_with_their_messages(self): validate_threshold = config_module.validate_threshold validate_template = config_module.validate_template diff --git a/tests/test_settings_sources.py b/tests/test_settings_sources.py index 2fda8459..ef05eea7 100644 --- a/tests/test_settings_sources.py +++ b/tests/test_settings_sources.py @@ -1025,6 +1025,7 @@ class TestAutotagDefaultsSettings(SourcesTestCase): "CHARACTER_THRESHOLD", "TEMPLATE", "SUGGESTION_LIMIT", + "SKIPPED_TAGS", ) SAVED = { "autotag_model_type": "gguf", @@ -1069,6 +1070,7 @@ class TestAutotagDefaultsSettings(SourcesTestCase): "CHARACTER_THRESHOLD": 0.8, "TEMPLATE": "Tags only.", "SUGGESTION_LIMIT": 15, + "SKIPPED_TAGS": (), }, ) settings = (await self._get())["settings"] @@ -1166,3 +1168,64 @@ class TestAutotagDefaultsSettings(SourcesTestCase): self.assertEqual(status, 400, data) self.assertEqual(data["error"], message) self.assertEqual((self._config_bytes(), self._runtime()), before) + + async def test_skipped_tags_save_trimmed_and_read_back(self): + """#431: the list is saved as typed (trimmed), published at once and + read back by GET /settings.""" + self._write_config({"custom": {"keep": True}}) + settings = (await self._get())["settings"] + self.assertEqual(settings["autotag_skipped_tags"], []) + revision = (await self._get())["revision"] + status, data = await self._post( + "/prompt_manager/settings", + { + "expected_revision": revision, + "autotag_skipped_tags": [" blurry ", "closed eyes", "(fate)"], + }, + ) + self.assertEqual(status, 200, data) + saved = self._read_config() + self.assertEqual( + saved["autotag"]["defaults"], + {"skipped_tags": ["blurry", "closed eyes", "(fate)"]}, + ) + self.assertEqual(saved["custom"], {"keep": True}) + self.assertEqual(self.autotag.SKIPPED_TAGS, ("blurry", "closed eyes", "(fate)")) + settings = (await self._get())["settings"] + self.assertEqual( + settings["autotag_skipped_tags"], ["blurry", "closed eyes", "(fate)"] + ) + + async def test_bad_skipped_tags_are_refused_with_their_sentence(self): + self._write_config({"custom": {"keep": True}}) + revision = (await self._get())["revision"] + before = (self._config_bytes(), self._runtime()) + for value, message in ( + (["sky", " "], "Skipped tags: entry 2 is empty."), + (["x" * 51], "Skipped tags: entry 1 must be at most 50 characters."), + ( + ["a, b"], + "Skipped tags: entry 1 must not contain a comma or a line break.", + ), + (["blurry", "Blurry"], "Skipped tags: entry 2 repeats entry 1."), + ([f"w{i}" for i in range(201)], "Skipped tags: at most 200 entries."), + ): + with self.subTest(value=value[:3]): + status, data = await self._post( + "/prompt_manager/settings", + {"expected_revision": revision, "autotag_skipped_tags": value}, + ) + self.assertEqual(status, 400, data) + self.assertEqual(data["error"], message) + self.assertEqual((self._config_bytes(), self._runtime()), before) + + async def test_skipped_tags_without_a_revision_are_refused(self): + """A list saved without expected_revision could merge a stale copy.""" + self._write_config({"custom": {"keep": True}}) + before = (self._config_bytes(), self._runtime()) + status, data = await self._post( + "/prompt_manager/settings", {"autotag_skipped_tags": ["blurry"]} + ) + self.assertEqual(status, 400, data) + self.assertEqual(data["error"], "Reload the page and try again.") + self.assertEqual((self._config_bytes(), self._runtime()), before) diff --git a/tests/test_skipped_tags.py b/tests/test_skipped_tags.py new file mode 100644 index 00000000..9db3c68e --- /dev/null +++ b/tests/test_skipped_tags.py @@ -0,0 +1,271 @@ +"""Skipped tags (#431): the words whose suggested tags are never offered. + +The normalizer, the entry validation, the whole-word matcher, the WD14 label +reader and the filter in ``AutoTagService.generate_tag_suggestions``. No model +is loaded: ``_scored_tags`` is replaced by a fixed list. +""" + +import os +import tempfile +import unicodedata +import unittest +from pathlib import Path +from unittest.mock import patch + +from py.tagging_defaults import ( + MAX_SKIPPED_TAG_CHARS, + MAX_SKIPPED_TAGS, + SkipMatcher, + tag_words, + validate_skipped_tags, + wd14_label_names, +) + +# The design's matching table: (entry, tag, hidden). +MATCHING = ( + ("blurry", "blurry", True), + ("blurry", "blurry_background", True), + ("blurry", "very_blurry", True), + ("blurry", "slightly_blurry_background", True), + ("blurry", "unblurry", False), + ("blur", "blurry", False), + ("blurry background", "blurry_background", True), + ("blurry background", "Blurry-Background", True), + ("background blurry", "blurry_background", False), + ("shirt", "t-shirt", True), + ("shirt", "shirt_lift", True), + ("closed eyes", "half-closed_eyes", True), + ("girl", "1girl", False), + ("girl", "2girls", False), + ("girls", "multiple_girls", True), + ("fate", "saber_(fate)", False), + ("(fate)", "saber_(fate)", True), + ("saber", "saber_(fate)", True), + (":d", ":d", True), + ("café", unicodedata.normalize("NFD", "café"), True), +) + + +class TestWords(unittest.TestCase): + def test_words_split_only_on_space_underscore_and_hyphen(self): + self.assertEqual(tag_words("Half-closed_eyes"), ("half", "closed", "eyes")) + self.assertEqual(tag_words("saber_(fate)"), ("saber", "(fate)")) + self.assertEqual(tag_words(" a__b--c d "), ("a", "b", "c", "d")) + self.assertEqual(tag_words("_-- _"), ()) + self.assertEqual(tag_words(":d"), (":d",)) + + def test_words_are_nfc_and_case_folded(self): + self.assertEqual( + tag_words("CAFÉ"), tag_words(unicodedata.normalize("NFD", "café")) + ) + self.assertEqual(tag_words("Straße"), ("strasse",)) + + +class TestMatcher(unittest.TestCase): + def test_every_row_of_the_matching_table(self): + for entry, tag, hidden in MATCHING: + with self.subTest(entry=entry, tag=tag): + self.assertIs(SkipMatcher([entry]).matches(tag), hidden) + + def test_a_tag_without_words_never_matches(self): + matcher = SkipMatcher(["blurry"]) + for tag in ("", "_", "- _"): + with self.subTest(tag=tag): + self.assertFalse(matcher.matches(tag)) + + def test_an_empty_matcher_hides_nothing_and_is_false(self): + self.assertFalse(SkipMatcher()) + self.assertFalse(SkipMatcher([]).matches("blurry")) + self.assertTrue(SkipMatcher(["blurry"])) + + def test_entries_of_different_lengths_are_all_checked(self): + matcher = SkipMatcher(["sky", "closed eyes", "very long entry here"]) + self.assertTrue(matcher.matches("blue_sky")) + self.assertTrue(matcher.matches("half-closed_eyes")) + self.assertTrue(matcher.matches("a_very_long_entry_here_b")) + self.assertFalse(matcher.matches("very_long_entry")) + + def test_counting_view_records_the_distinct_names_it_hid(self): + counting = SkipMatcher(["blurry"]).counting() + self.assertTrue(counting.matches("blurry")) + self.assertTrue(counting.matches("Blurry")) + self.assertTrue(counting.matches("blurry_background")) + self.assertFalse(counting.matches("sky")) + self.assertEqual(counting.hidden, {"blurry", "blurry_background"}) + + +class TestValidation(unittest.TestCase): + def test_entries_are_trimmed_and_kept_as_typed(self): + self.assertEqual( + validate_skipped_tags([" Blurry ", "closed eyes", "(fate)"]), + ["Blurry", "closed eyes", "(fate)"], + ) + self.assertEqual(validate_skipped_tags([]), []) + + def test_caps_count_code_points_after_trimming(self): + self.assertEqual( + validate_skipped_tags([" " + "🏜" * MAX_SKIPPED_TAG_CHARS + " "]), + ["🏜" * MAX_SKIPPED_TAG_CHARS], + ) + entries = [f"word{index}" for index in range(MAX_SKIPPED_TAGS)] + self.assertEqual(validate_skipped_tags(entries), entries) + + def test_every_refusal_sentence(self): + cases = ( + ("blurry", "Skipped tags must be a list of words."), + ( + [f"w{index}" for index in range(MAX_SKIPPED_TAGS + 1)], + "Skipped tags: at most 200 entries.", + ), + (["ok", 3], "Skipped tags: entry 2 must be text."), + (["ok", " "], "Skipped tags: entry 2 is empty."), + (["_ --"], "Skipped tags: entry 1 is empty."), + (["x" * 51], "Skipped tags: entry 1 must be at most 50 characters."), + ( + ["a, b"], + "Skipped tags: entry 1 must not contain a comma or a line break.", + ), + ( + ["a\nb"], + "Skipped tags: entry 1 must not contain a comma or a line break.", + ), + ( + ["a\x7fb"], + "Skipped tags: entry 1 must not contain a comma or a line break.", + ), + ( + ["blurry", "sky", "Blurry"], + "Skipped tags: entry 3 repeats entry 1.", + ), + ( + ["blurry background", "blurry-Background"], + "Skipped tags: entry 2 repeats entry 1.", + ), + ) + for value, message in cases: + with self.subTest(value=value): + with self.assertRaises(ValueError) as caught: + validate_skipped_tags(value) + self.assertEqual(str(caught.exception), message) + + +class TestWd14Labels(unittest.TestCase): + def setUp(self): + directory = tempfile.TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.path = Path(directory.name) / "selected_tags.csv" + + def write(self, rows): + lines = ["tag_id,name,category,count"] + lines += [f"{i},{name},{category},1" for i, (name, category) in enumerate(rows)] + self.path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + def test_only_general_and_character_names_are_read(self): + self.write( + [ + ("blurry", "0"), + ("general", "9"), + ("saber_(fate)", "4"), + ("artist", "1"), + ("café", "0"), + ] + ) + self.assertEqual( + wd14_label_names(self.path), ("blurry", "saber_(fate)", "café") + ) + + def test_the_list_is_read_again_only_when_the_file_changes(self): + self.write([("blurry", "0")]) + with patch( + "py.tagging_defaults.read_wd14_label_rows", + wraps=__import__( + "py.tagging_defaults", fromlist=["x"] + ).read_wd14_label_rows, + ) as reader: + self.assertEqual(wd14_label_names(self.path), ("blurry",)) + self.assertEqual(wd14_label_names(self.path), ("blurry",)) + self.assertEqual(reader.call_count, 1) + self.write([("blurry", "0"), ("sky", "0")]) + os.utime(self.path, ns=(1, 2)) + self.assertEqual(wd14_label_names(self.path), ("blurry", "sky")) + self.assertEqual(reader.call_count, 2) + + def test_a_missing_list_raises_for_the_caller_to_skip(self): + with self.assertRaises(OSError): + wd14_label_names(self.path) + + +class FilterService: + """``AutoTagService`` with a fixed model output instead of a model.""" + + def __init__(self, scored): + from py.autotag import AutoTagService + + self.service = AutoTagService.__new__(AutoTagService) + self.service._scored_tags = lambda *args, **kwargs: list(scored) + + def suggest(self, limit, skip=None): + return self.service.generate_tag_suggestions( + "image.png", limit=limit, skip=skip + ) + + +class TestFilter(unittest.TestCase): + def test_skipped_tags_do_not_use_up_slots(self): + """WD14: the next-best tags above the thresholds fill the limit.""" + scored = [ + ("blurry", 0.99), + ("sky", 0.9), + ("blurry_background", 0.8), + ("cloud", 0.7), + ("sun", 0.6), + ("sea", 0.5), + ] + service = FilterService(scored) + self.assertEqual( + service.suggest(3, SkipMatcher(["blurry"])), + [("sky", 0.9), ("cloud", 0.7), ("sun", 0.6)], + ) + self.assertEqual(service.suggest(3), scored[:3]) + + def test_a_language_model_may_return_fewer_than_the_limit(self): + service = FilterService([("blurry", None), ("sky", None), ("signature", None)]) + self.assertEqual( + service.suggest(10, SkipMatcher(["blurry", "signature"])), [("sky", None)] + ) + + def test_order_is_invalid_name_then_skip_then_dedupe_then_limit(self): + hidden = [] + + class Recording: + def matches(self, name): + hidden.append(name) + return name.startswith("skip") + + service = FilterService( + [ + ("bad,name", None), + (" Sky ", None), + ("skip_me", None), + ("sky", None), + ("cloud", None), + ("sun", None), + ] + ) + self.assertEqual( + service.suggest(2, Recording()), [("Sky", None), ("cloud", None)] + ) + # The refused name never reaches the matcher; trimmed names do; the + # duplicate is checked after the skip; nothing after the limit. + self.assertEqual(hidden, ["Sky", "skip_me", "sky", "cloud"]) + + def test_the_counting_view_reports_hidden_names(self): + counting = SkipMatcher(["blurry"]).counting() + FilterService([("blurry", 0.9), ("sky", 0.8), ("Blurry", 0.7)]).suggest( + 5, counting + ) + self.assertEqual(counting.hidden, {"blurry"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_skipped_tags_routes.py b/tests/test_skipped_tags_routes.py new file mode 100644 index 00000000..a21a9013 --- /dev/null +++ b/tests/test_skipped_tags_routes.py @@ -0,0 +1,280 @@ +"""Skipped tags on every suggestion path, and their preview (#431). + +Tag image (``/image-annotations/suggest``), Auto-tag selected (the +``image-tags`` job), the System batch (the ``autotag`` job), the legacy +``/autotag/start`` and ``/autotag/single`` all hide a suggested tag whose +words contain a skipped entry; a job runs with the list it started with. +``POST /autotag/skipped-tags/preview`` counts what a list would hide among +the library's tags and the downloaded WD14 label lists, writing nothing. +""" + +import tempfile +from pathlib import Path + +try: + from test_autotag_legacy import LegacyTestCase +except ImportError: + from tests.test_autotag_legacy import LegacyTestCase + +from py.config import AutotagConfig + +PREVIEW = "/prompt_manager/autotag/skipped-tags/preview" + + +class SkippedTestCase(LegacyTestCase): + async def get_application(self): + app = await super().get_application() + self.addCleanup( + setattr, AutotagConfig, "SKIPPED_TAGS", AutotagConfig.SKIPPED_TAGS + ) + AutotagConfig.SKIPPED_TAGS = () + return app + + def job_lines(self, job): + return [line.split(" ", 1)[1] for line in job.log] + + +class TestEveryPathHidesSkippedTags(SkippedTestCase): + async def test_tag_image_suggestions_omit_skipped_tags_and_count_them(self): + AutotagConfig.SKIPPED_TAGS = ("sandstone", "tag 001") + observed = await self.observed() + + body = await self.suggest_ok(self.suggest_body(observed)) + + names = [row["name"] for row in body["suggestions"]] + self.assertNotIn("sandstone", names) + self.assertNotIn("tag_001", names) + # Skipped tags use up no slot: the next ones fill the limit. + self.assertEqual(len(names), 10) + self.assertEqual(names[:3], ["desert", "tag_000", "tag_002"]) + self.assertEqual(body["skipped"], 2) + + async def test_tag_image_without_skipped_tags_reports_zero(self): + observed = await self.observed() + body = await self.suggest_ok(self.suggest_body(observed)) + self.assertEqual(body["suggestions"][0]["name"], "sandstone") + self.assertEqual(body["skipped"], 0) + + async def test_auto_tag_selected_runs_with_the_list_of_its_start(self): + self.png("a.png") + self.png("b.png", color=(1, 2, 3)) + AutotagConfig.SKIPPED_TAGS = ("desert",) + + def save_mid_run(_path): + # A save while the job runs does not change that job. + AutotagConfig.SKIPPED_TAGS = ("sandstone",) + + self.tagger.on_generate = save_mid_run + options = { + "general_threshold": 0.35, + "character_threshold": 0.85, + "suggestion_limit": 2, + } + + job = await self.run_job(self.auto_body(["a.png", "b.png"], options=options)) + + self.assertEqual(job.state, "finished") + self.assertEqual(self.stored("a.png")["tags"], ["sandstone", "tag_000"]) + self.assertEqual(self.stored("b.png")["tags"], ["sandstone", "tag_000"]) + self.assertIn("Skipped tags: 1 entry", self.job_lines(job)) + + async def test_the_system_batch_hides_skipped_tags_and_logs_the_list(self): + self.image("a.png", (1, 2, 3), sees=["bird", "blurry_background", "sky"]) + AutotagConfig.SKIPPED_TAGS = ("blurry", "watermark") + + job = await self.run_batch(existing="merge") + + self.assertEqual(self.stored("a.png")["tags"], ["bird", "sky"]) + self.assertIn("Skipped tags: 2 entries", self.job_lines(job)) + + AutotagConfig.SKIPPED_TAGS = () + self.image("b.png", (4, 5, 6), sees=["blurry"]) + await self.relist() + job = await self.run_batch(existing="merge") + self.assertEqual(self.stored("b.png")["tags"], ["blurry"]) + self.assertIn("Skipped tags: none", self.job_lines(job)) + + async def test_the_legacy_batch_hides_skipped_tags(self): + self.image("a.png", (1, 2, 3), sees=["bird", "signature"]) + AutotagConfig.SKIPPED_TAGS = ("signature",) + + response, frames = await self.legacy() + + self.assertEqual(response.status, 200) + self.assertEqual(frames[-1]["type"], "complete", frames[-1]) + self.assertEqual(self.stored("a.png")["tags"], ["bird"]) + + async def test_the_legacy_single_review_hides_skipped_tags(self): + prompt = self.prompt("a prompt") + image = self.link( + prompt, self.image("a.png", (1, 2, 3), sees=["bird", "t-shirt"]) + ) + AutotagConfig.SKIPPED_TAGS = ("shirt",) + + status, data = await self.single({"image_id": image, "model_type": "wd14-vit"}) + + self.assertEqual(status, 200, data) + self.assertEqual(data["tags"], ["bird"]) + + async def test_hand_typed_and_stored_tags_are_never_filtered(self): + path = self.image("a.png", (1, 2, 3), sees=["bird"]) + self.saved_set("a.png", path.read_bytes(), ["blurry_hand_tag"]) + AutotagConfig.SKIPPED_TAGS = ("blurry",) + + await self.run_batch(existing="merge") + job = await self.run_job(self.add_body(["a.png"], ["blurry"])) + + self.assertEqual(job.state, "finished") + self.assertEqual( + self.stored("a.png")["tags"], ["blurry_hand_tag", "bird", "blurry"] + ) + + +def write_labels(directory, subdir, rows): + path = Path(directory) / subdir / "selected_tags.csv" + path.parent.mkdir(parents=True, exist_ok=True) + lines = ["tag_id,name,category,count"] + lines += [f"{i},{name},{category},1" for i, (name, category) in enumerate(rows)] + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +class TestPreview(SkippedTestCase): + async def asyncSetUp(self): + await super().asyncSetUp() + models = tempfile.TemporaryDirectory() + self.addCleanup(models.cleanup) + self.models_dir = Path(models.name) + self.tagger.models_dir = self.models_dir + for name in ("Blurry", "portrait", "my_signature_style"): + self.prompt(f"prompt with {name}", tags=[name]) + + async def preview(self, entries): + response = await self.client.post(PREVIEW, json={"entries": entries}) + return response.status, await self.json_of(response) + + async def test_library_tags_only_when_no_label_list_is_downloaded(self): + status, data = await self.preview(["blurry", "signature", "watermark"]) + + self.assertEqual(status, 200, data) + self.assertEqual( + data, + { + "success": True, + "count": 2, + "sample": ["Blurry", "my_signature_style"], + "sample_limit": 12, + "per_entry": [ + {"entry": "blurry", "count": 1}, + {"entry": "signature", "count": 1}, + {"entry": "watermark", "count": 0}, + ], + "sources": {"library": 3, "wd14": []}, + }, + ) + + async def test_both_label_lists_join_the_library_tags(self): + write_labels( + self.models_dir, + "WD14-SwinV2", + [ + ("blurry", "0"), + ("blurry_background", "0"), + ("watermark", "0"), + ("general", "9"), + ("saber_(fate)", "4"), + ], + ) + write_labels( + self.models_dir, + "WD14-ViT", + [ + ("blurry_foreground", "0"), + ("watermark", "0"), + ("fate_(series)", "3"), + ], + ) + + status, data = await self.preview(["blurry", "watermark", "(fate)", "general"]) + + self.assertEqual(status, 200, data) + # Library spellings first (case-folded duplicates are one tag), then the + # label lists' names, each part sorted case-folded; rating and copyright + # rows are never suggested, so never counted. + self.assertEqual( + data["sample"], + [ + "Blurry", + "blurry_background", + "blurry_foreground", + "saber_(fate)", + "watermark", + ], + ) + self.assertEqual(data["count"], 5) + self.assertEqual( + data["per_entry"], + [ + {"entry": "blurry", "count": 3}, + {"entry": "watermark", "count": 1}, + {"entry": "(fate)", "count": 1}, + {"entry": "general", "count": 0}, + ], + ) + self.assertEqual( + data["sources"], {"library": 3, "wd14": ["WD14 SwinV2", "WD14 ViT"]} + ) + + async def test_the_sample_holds_at_most_twelve(self): + write_labels( + self.models_dir, + "WD14-SwinV2", + [(f"sky_{index:02d}", "0") for index in range(20)], + ) + status, data = await self.preview(["sky"]) + self.assertEqual(status, 200, data) + self.assertEqual(data["count"], 20) + self.assertEqual(data["sample"], [f"sky_{index:02d}" for index in range(12)]) + + async def test_a_bad_list_is_refused_with_the_save_sentence(self): + for body, message in ( + ({"entries": ["sky", "Sky"]}, "Skipped tags: entry 2 repeats entry 1."), + ( + {"entries": ["a,b"]}, + "Skipped tags: entry 1 must not contain a comma or a line break.", + ), + ({"entries": "sky"}, "Skipped tags must be a list of words."), + ): + with self.subTest(body=body): + response = await self.client.post(PREVIEW, json=body) + self.assertEqual(response.status, 400) + self.assertEqual((await response.json())["error"], message) + response = await self.client.post(PREVIEW, json={"entries": [], "x": 1}) + self.assertEqual(response.status, 400) + + async def test_preview_loads_no_model_opens_no_image_and_writes_nothing(self): + write_labels(self.models_dir, "WD14-SwinV2", [("blurry", "0")]) + self.image("a.png", (1, 2, 3)) + before = self.prompt_rows() + + status, data = await self.preview(["blurry"]) + + self.assertEqual(status, 200, data) + self.assertEqual(self.tagger.load_calls, []) + self.assertEqual(self.tagger.read_paths, []) + self.assertEqual(self.prompt_rows(), before) + self.assertEqual(self.api._jobs.list(), []) + + async def test_preview_is_busy_while_an_exclusive_job_runs(self): + async with self.api._jobs.hold( + "restore", "Restore library", admission="exclusive" + ): + status, data = await self.preview(["blurry"]) + self.assertEqual(status, 409, data) + self.assertEqual(data["code"], "busy") + self.assertEqual(data["running"]["title"], "Restore library") + + +if __name__ == "__main__": + import unittest + + unittest.main() diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json index eb695acc..72ef55d5 100644 --- a/tools/vendor/frontend-coverage-baseline.json +++ b/tools/vendor/frontend-coverage-baseline.json @@ -629,6 +629,12 @@ "functions": 95, "statements": 98 }, + "workspace/system-skipped-tags.mjs": { + "lines": 80, + "branches": 80, + "functions": 80, + "statements": 80 + }, "workspace/system-state.mjs": { "lines": 98, "branches": 94, diff --git a/web/js/workspace/image-tagging-state.mjs b/web/js/workspace/image-tagging-state.mjs index 20d0c6d8..b1703a11 100644 --- a/web/js/workspace/image-tagging-state.mjs +++ b/web/js/workspace/image-tagging-state.mjs @@ -257,11 +257,18 @@ function initialState(source, preset) { image: null, savedTags: [], tagRevision: 0, currentTags: null, manual: '', model: preset?.model ?? MODEL_OPTIONS[0].id, options: {...DEFAULTS, ...preset?.options}, generated: false, suggestions: [], suggestionsText: COPY.modelChanged, statusText: '', generateSupported: false, generateReason: null, toast: null, + skippedCount: 0, // Bumped whenever the controller itself rewrites the option fields or the manual draft, never while typing. optionsRevision: 0, draftRevision: 0, }; } +/** #431: how many suggestions the saved Skipped tags hid, after the chip hint ('' when none). */ +export function skippedNote(count) { + if (!Number.isSafeInteger(count) || count <= 0) return ''; + return count === 1 ? ' 1 skipped tag was not suggested.' : ` ${count.toLocaleString('en-US')} skipped tags were not suggested.`; +} + /** Derived labels and permissions; never stored independently of the fields they follow. */ function derive(state) { const editable = EDITABLE.has(state.phase); @@ -269,7 +276,7 @@ function derive(state) { ...state, primaryLabel: state.generated ? 'Approve selected tags' : 'Save manual tags', countText: state.generated ? `${state.suggestions.length} ${noun(state.suggestions.length, 'suggestion')}` : 'Not generated', - chipHint: state.suggestions.length ? COPY.chipHint : '', + chipHint: state.suggestions.length ? COPY.chipHint + skippedNote(state.skippedCount) : '', canSave: editable, canGenerate: editable && state.generateSupported, canRetry: RETRYABLE.has(state.phase), @@ -445,13 +452,14 @@ export function createImageTaggingController({api, target, source = 'images', on const options = requestOptions(model, state.options); const stale = () => closed || serial !== generateSerial; set({phase: 'generating', statusText: COPY.generating}); - let rows; + let rows, skippedCount; try { const value = await api.post(SUGGEST_URL, { target: {...imageTarget}, capture_token: capture.token, expected_epoch: epoch, model_type: model, options, }, {signal: controller.signal}); if (stale()) return false; rows = validateSuggestions(value, options.suggestion_limit); + skippedCount = Number.isSafeInteger(value.skipped) && value.skipped > 0 ? value.skipped : 0; } catch (error) { if (stale()) return false; const phase = isApiError(error) && REOPEN[error.code]; @@ -460,7 +468,7 @@ export function createImageTaggingController({api, target, source = 'images', on } generateAbort = null; set({ - phase: 'ready', generated: true, suggestions: rows, suggestionsText: rows.length ? '' : COPY.empty, + phase: 'ready', generated: true, suggestions: rows, suggestionsText: rows.length ? '' : COPY.empty, skippedCount, statusText: `Suggestions from ${modelOf(model).label}. Approve tags to save.`, }); return true; diff --git a/web/js/workspace/system-skipped-tags.mjs b/web/js/workspace/system-skipped-tags.mjs new file mode 100644 index 00000000..3fb0e989 --- /dev/null +++ b/web/js/workspace/system-skipped-tags.mjs @@ -0,0 +1,246 @@ +/** + * System › Auto-tagging "Skipped tags" (#431, D202): words whose suggested tags are never offered, on every suggestion + * path (the server filters; hand-typed and stored tags are never touched). + * + * A `.setting-block` after "Suggestions per image": the label and description, an "Add a word or words" input + * (`#tag-skipped-input`) with Add (Enter adds too), the entries as removable chips in the Tag image dialog's D83 chip + * markup, and a preview line (`role="status"`) that says how many known tags the list hides + * (`POST /prompt_manager/autotag/skipped-tags/preview`: the library's tags plus the downloaded WD14 label lists). The + * block carries `data-setting="tag-skipped"` and `data-key="autotag_skipped_tags"`, so a refused save highlights it. + * The list is the host draft `autotag_skipped_tags` (an array), saved by the one Save row. The client checks repeat the + * server's rules (the server is the authority): an empty, too long, comma or duplicate entry is not added and the line + * under the input says why; the list holds at most 200 entries. + */ +import { createButton } from '../shared/toolkit/core.js'; + +export const SKIPPED_KEY = 'autotag_skipped_tags'; +export const PREVIEW_URL = '/prompt_manager/autotag/skipped-tags/preview'; +export const MAX_ENTRIES = 200; +export const MAX_ENTRY_CHARS = 50; +export const PREVIEW_DELAY_MS = 300; +export const SAMPLE_LIMIT = 12; + +export const SKIPPED_COPY = Object.freeze({ + label: 'Skipped tags', + description: 'Suggested tags that contain one of these words are hidden. Words are separated by spaces, _ or -; ' + + 'other characters such as ( ) belong to the word.', + placeholder: 'Add a word or words', + add: 'Add', + chips: 'Skipped tags', + empty: 'Enter a word.', + comma: 'No commas or line breaks.', + long: `At most ${MAX_ENTRY_CHARS} characters.`, + duplicate: 'Already in the list.', + full: `The list is full (${MAX_ENTRIES}).`, + none: 'No tags are skipped.', + checking: 'Checking…', + failed: 'The preview could not be read.', + unchanged: 'Tags already on images are not changed.', + libraryOnly: 'Checked against library tags only; no WD14 label list is downloaded.', + hidesNothing: 'Hides no known tag', +}); + +const plural = (count, word) => `${count.toLocaleString('en-US')} ${word}${count === 1 ? '' : 's'}`; + +/** The words of an entry or a tag as the server splits them: NFC, lower case, split on space, `_` and `-`. */ +export const entryWords = text => String(text ?? '').normalize('NFC').toLowerCase().split(/[ _-]+/).filter(Boolean); +const entryKey = text => entryWords(text).join(' '); + +/** Why `raw` cannot be added to `entries` (the line under the input), or '' when it can. */ +export function entryProblem(raw, entries) { + const entry = String(raw ?? '').trim(); + if (!entryWords(entry).length) return SKIPPED_COPY.empty; + // Commas and control characters (a pasted line break), as the server refuses them. + if (/[,\u0000-\u001f\u007f-\u009f\u2028\u2029]/u.test(entry)) return SKIPPED_COPY.comma; + if ([...entry].length > MAX_ENTRY_CHARS) return SKIPPED_COPY.long; + const key = entryKey(entry); + if (entries.some(item => entryKey(item) === key)) return SKIPPED_COPY.duplicate; + if (entries.length >= MAX_ENTRIES) return SKIPPED_COPY.full; + return ''; +} + +/** A chip's title and count text for an entry the last preview counted (`count` undefined: not counted yet). */ +export function chipCount(count) { + if (!Number.isSafeInteger(count)) return null; + return count ? { title: `Hides ${plural(count, 'tag')}`, text: count.toLocaleString('en-US') } + : { title: SKIPPED_COPY.hidesNothing, text: 'none' }; +} + +/** + * The preview line for a preview answer (`{count, sample, sources}`): the bold lead "Hides N tags from suggestions", + * then the sample (": a, b and K more. ") and the closing sentences. Returns `{lead, rest}`. + */ +export function previewText(result) { + const count = Number.isSafeInteger(result?.count) && result.count > 0 ? result.count : 0; + const sample = Array.isArray(result?.sample) ? result.sample.filter(name => typeof name === 'string') : []; + const more = count - sample.length; + const listed = count && sample.length ? `: ${sample.join(', ')}${more > 0 ? ` and ${more.toLocaleString('en-US')} more` : ''}. ` : '. '; + const labels = Array.isArray(result?.sources?.wd14) && result.sources.wd14.length > 0; + return { + lead: `Hides ${plural(count, 'tag')} from suggestions`, + rest: `${listed}${labels ? '' : `${SKIPPED_COPY.libraryOnly} `}${SKIPPED_COPY.unchanged}`, + }; +} + +function el(tag, text, className) { + const node = document.createElement(tag); + if (text !== undefined) node.textContent = text; + if (className) node.className = className; + return node; +} + +/** + * Build the block; `drafts` is the System draft store, `api` the workspace client. Returns `{node, repaint}`: System + * calls `repaint` whenever the drafts change (the chips follow the drafted list, and a changed list is previewed + * `PREVIEW_DELAY_MS` after the last change, one request at a time: a newer list aborts the older request). + */ +export function skippedTagsBlock({ api, scope, drafts, delay = PREVIEW_DELAY_MS }) { + const block = el('div', undefined, 'setting-block'); + block.dataset.setting = 'tag-skipped'; + block.dataset.key = SKIPPED_KEY; + const text = el('div', undefined, 'setting-block-text'); + const label = el('label', SKIPPED_COPY.label); + label.htmlFor = 'tag-skipped-input'; + text.append(label, el('p', SKIPPED_COPY.description)); + const addRow = el('div', undefined, 'skip-add'); + const input = el('input'); + input.id = 'tag-skipped-input'; + input.autocomplete = 'off'; + input.placeholder = SKIPPED_COPY.placeholder; + input.maxLength = MAX_ENTRY_CHARS + 10; + input.setAttribute('aria-describedby', 'tag-skipped-problem'); + const add = createButton(SKIPPED_COPY.add); + add.id = 'tag-skipped-add'; + addRow.append(input, add); + const problem = el('p', '', 'small skip-problem'); + problem.id = 'tag-skipped-problem'; + problem.setAttribute('role', 'alert'); + const chips = el('div', undefined, 'tags skip-chips'); + chips.setAttribute('aria-label', SKIPPED_COPY.chips); + const preview = el('p', '', 'small skip-preview'); + preview.setAttribute('role', 'status'); + preview.setAttribute('aria-live', 'polite'); + block.append(text, addRow, problem, chips, preview); + + const entries = () => { + const value = drafts.get('host', SKIPPED_KEY); + return Array.isArray(value) ? value : null; + }; + let counts = new Map(); + let shown = null; // the list the chips show (JSON) + let pending = null; // the list the preview line is (or is being) made for (JSON) + let timer = null; + let request = null; + scope.cleanup(() => { clearTimeout(timer); request?.abort(); }); + + const say = message => { + problem.textContent = message; + if (message) input.setAttribute('aria-invalid', 'true'); + else input.removeAttribute('aria-invalid'); + }; + const setList = next => drafts.set('host', SKIPPED_KEY, next); + + function addEntry() { + const list = entries(); + if (!list) return; + const message = entryProblem(input.value, list); + say(message); + if (message) return; + const entry = input.value.trim(); + input.value = ''; + setList([...list, entry]); + } + + function removeEntry(index) { + const list = entries(); + if (!list) return; + const buttons = [...chips.querySelectorAll?.('button') ?? []]; + const focused = buttons.indexOf(document.activeElement); + setList(list.filter((_entry, at) => at !== index)); + // The removed chip's focus moves to its neighbour's ×, else to the input. + if (focused >= 0) { + const left = [...chips.querySelectorAll?.('button') ?? []]; + (left[Math.min(focused, left.length - 1)] ?? input).focus?.(); + } + } + + function renderChips(list) { + chips.replaceChildren(...list.map((entry, index) => { + const count = chipCount(counts.get(entry)); + const chip = el('span', undefined, `chip tag-suggestion${count?.text === 'none' ? ' none' : ''}`); + if (count) chip.title = count.title; + chip.append(el('span', entry)); + if (count) chip.append(el('span', count.text, 'tag-confidence')); + const remove = createButton('×'); + remove.setAttribute('aria-label', `Remove ${entry}`); + scope.listen(remove, 'click', () => removeEntry(index)); + chip.append(remove); + return chip; + })); + } + + function showPreview(result) { + const { lead, rest } = previewText(result); + preview.replaceChildren(el('b', lead), rest); + } + + async function runPreview(list) { + request?.abort(); + const controller = new AbortController(); + request = controller; + try { + const result = await api.post(PREVIEW_URL, { entries: list }, { signal: controller.signal }); + if (controller.signal.aborted || scope.signal.aborted) return; + counts = new Map((Array.isArray(result?.per_entry) ? result.per_entry : []) + .filter(row => typeof row?.entry === 'string').map(row => [row.entry, row.count])); + showPreview(result); + renderChips(entries() ?? list); + } catch { + if (controller.signal.aborted || scope.signal.aborted) return; + preview.textContent = SKIPPED_COPY.failed; + } finally { + if (request === controller) request = null; + } + } + + function schedule(list) { + clearTimeout(timer); + request?.abort(); + request = null; + if (!list.length) { + counts = new Map(); + preview.textContent = SKIPPED_COPY.none; + return; + } + preview.textContent = SKIPPED_COPY.checking; + timer = setTimeout(() => runPreview(list), delay); + } + + function repaint() { + const list = entries(); + input.disabled = add.disabled = list === null; + if (list === null) return; + const full = list.length >= MAX_ENTRIES; + add.disabled = full; + if (full && !problem.textContent) say(SKIPPED_COPY.full); + if (!full && problem.textContent === SKIPPED_COPY.full) say(''); + const key = JSON.stringify(list); + if (key !== shown) { + shown = key; + renderChips(list); + } + if (key !== pending) { + pending = key; + schedule(list); + } + } + + scope.listen(add, 'click', addEntry); + scope.listen(input, 'keydown', event => { + if (event.key !== 'Enter') return; + event.preventDefault?.(); + addEntry(); + }); + scope.listen(input, 'input', () => { if (problem.textContent !== SKIPPED_COPY.full) say(''); }); + return { node: block, repaint }; +} diff --git a/web/js/workspace/system-state.mjs b/web/js/workspace/system-state.mjs index eb666c9d..be57e156 100644 --- a/web/js/workspace/system-state.mjs +++ b/web/js/workspace/system-state.mjs @@ -76,6 +76,22 @@ export function statusText(outcome) { return saved.join(' ') || SETTINGS_STATUS.clean; } +const plain = value => value !== null && typeof value === 'object' + && (Array.isArray(value) || Object.getPrototypeOf(value) === Object.prototype); +/** JSON with every object's keys sorted, so key order never makes two values differ. */ +const canonical = value => JSON.stringify(value, (key, item) => (item && typeof item === 'object' && !Array.isArray(item) + ? Object.fromEntries(Object.keys(item).sort().map(name => [name, item[name]])) : item)); + +/** + * #431: whether a draft equals a saved value: `Object.is`, or both are arrays or plain objects with the same JSON (keys + * sorted). A list setting (Skipped tags) whose added entry was removed again is clean, not a new array that differs. + */ +export function sameDraft(a, b) { + if (Object.is(a, b)) return true; + if (!plain(a) || !plain(b) || Array.isArray(a) !== Array.isArray(b)) return false; + return canonical(a) === canonical(b); +} + function requireDomain(domain) { if (!DRAFT_DOMAINS.includes(domain)) throw new TypeError(`Unknown settings domain: ${domain}`); } @@ -112,7 +128,7 @@ export function createSystemDrafts(adapters) { values = await (domain === 'browser' ? adapters.readBrowser() : adapters.readRuntime()); } baseline[domain] = { ...(values ?? {}) }; - for (const [key, value] of drafts[domain]) if (Object.is(baseline[domain][key], value)) drafts[domain].delete(key); + for (const [key, value] of drafts[domain]) if (sameDraft(baseline[domain][key], value)) drafts[domain].delete(key); notify(); return { ...baseline[domain] }; } @@ -124,7 +140,7 @@ export function createSystemDrafts(adapters) { function set(domain, key, value) { requireDomain(domain); - if (Object.is(baseline[domain][key], value)) drafts[domain].delete(key); + if (sameDraft(baseline[domain][key], value)) drafts[domain].delete(key); else drafts[domain].set(key, value); failedKeys[domain].delete(key); outcome = null; @@ -153,7 +169,7 @@ export function createSystemDrafts(adapters) { function commit(domain, written) { for (const [key, value] of written) { baseline[domain][key] = value; - if (drafts[domain].has(key) && Object.is(drafts[domain].get(key), value)) drafts[domain].delete(key); + if (drafts[domain].has(key) && sameDraft(drafts[domain].get(key), value)) drafts[domain].delete(key); } } diff --git a/web/js/workspace/system-tagging.mjs b/web/js/workspace/system-tagging.mjs index 6a96f177..a958b22a 100644 --- a/web/js/workspace/system-tagging.mjs +++ b/web/js/workspace/system-tagging.mjs @@ -9,6 +9,7 @@ * - "Existing tags", "General-tag confidence", "Character-tag confidence": the concept's labels and descriptions * verbatim (they state the shipped defaults); host keys `autotag_existing`, `autotag_general_threshold`, * `autotag_character_threshold`. D133 adds "Suggestions per image" (`autotag_suggestion_limit`, 1–100) after them. + * - D202 (#431): the "Skipped tags" block after it (system-skipped-tags.mjs, host key `autotag_skipped_tags`). * - The `.field` "LLM tag template" (`#tag-template`, `autotag_template`). * - `.row-actions`: "Preview tagging review" (`ca-primary`) opens the Tag image dialog for the image selected in Images * with the section's current model and values, aria-disabled without one (the D115 sentence) or with an invalid @@ -35,6 +36,7 @@ import { selectedImage } from './images.mjs'; import { NO_SELECTION } from './vocabulary.mjs'; import { showWorkspaceToast } from './toast.mjs'; import { navigateTo } from './state.mjs'; +import { skippedTagsBlock } from './system-skipped-tags.mjs'; export const MODELS_URL = '/prompt_manager/autotag/models'; export const UNLOAD_URL = '/prompt_manager/autotag/unload'; @@ -410,6 +412,9 @@ export function mountTagging(container, { api, activity, scope, drafts, notify = // D133: the persisted suggestion count, which the concept does not show. add(settingRow({ id: 'tag-suggestions', label: 'Suggestions per image', description: SUGGESTIONS_DESCRIPTION, control: { type: 'number', min: 1, max: 100, step: 1 } }), 'tag-suggestions'); + // D202 (#431): the words whose suggested tags are hidden on every suggestion path. + const skipped = skippedTagsBlock({ api, scope, drafts }); + surface.append(skipped.node); const field = el('div', undefined, 'field'); const templateLabel = el('label', 'LLM tag template'); @@ -454,6 +459,7 @@ export function mountTagging(container, { api, activity, scope, drafts, notify = const known = () => MODEL_IDS.includes(chosen()); const repaint = () => { + skipped.repaint(); const values = current(); const unread = values.autotag_model_type === undefined; const failed = drafts.hostState?.() === 'failed' && unread; diff --git a/web/js/workspace/workspace.css b/web/js/workspace/workspace.css index bfcd4df7..eedce8b8 100644 --- a/web/js/workspace/workspace.css +++ b/web/js/workspace/workspace.css @@ -443,12 +443,12 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc suggestions are removable chips, each the concept's `.chip` corner (4px; the later `.tag-suggestion` row rule would give 5px) around the row's 12px text in its checked suggestion row's tokens (`.tag-suggestion:has(input:checked)`, s1655): a name, the confidence and a × button. */ -#tag-suggestions .chip.tag-suggestion { display: inline-flex; gap: 6px; padding: 2px 2px 2px 8px; margin-bottom: 0; cursor: default; color: inherit; border-color: var(--ca-accent); background: var(--soft-blue); border-radius: 4px; /* D83 */ } -#tag-suggestions .chip.tag-suggestion .tag-confidence { margin-inline-start: 0; /* D83 */ } -.ca-tools #tag-suggestions .chip.tag-suggestion > button { min-block-size: 24px; min-inline-size: 24px; padding: 0 6px; /* D83 */ } +#tag-suggestions .chip.tag-suggestion, .skip-chips .chip.tag-suggestion { display: inline-flex; gap: 6px; padding: 2px 2px 2px 8px; margin-bottom: 0; cursor: default; color: inherit; border-color: var(--ca-accent); background: var(--soft-blue); border-radius: 4px; /* D83 */ } +#tag-suggestions .chip.tag-suggestion .tag-confidence, .skip-chips .chip.tag-suggestion .tag-confidence { margin-inline-start: 0; /* D83 */ } +.ca-tools #tag-suggestions .chip.tag-suggestion > button, .ca-tools .skip-chips .chip.tag-suggestion > button { min-block-size: 24px; min-inline-size: 24px; padding: 0 6px; /* D83 */ } @media (pointer: coarse) { /* D83: the chip's × keeps the concept's 40px coarse target. */ - .ca-tools #tag-suggestions .chip.tag-suggestion > button { min-block-size: 40px; min-inline-size: 40px; } + .ca-tools #tag-suggestions .chip.tag-suggestion > button, .ca-tools .skip-chips .chip.tag-suggestion > button { min-block-size: 40px; min-inline-size: 40px; } } /* Needs review (#249 PR F): the concept's Metadata review page (app.js a749–760) in its own classes. */ @@ -492,6 +492,24 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc @media (max-width: 700px) { #workspace-main .setting-row :is(#send-destination, #renderer) { max-width: 160px !important; /* D106 */ } } +/* D202 (#431): System › Auto-tagging "Skipped tags", a setting whose control (an add input, the chips and the preview + line) is wider than a `.setting-row` column: the row's label and description tokens over the full width, the chips + in the Tag image dialog's D83 chip markup (an entry that hides no known tag is muted). */ +.setting-block { padding: 17px 0; border-top: 1px solid var(--line); /* D202 */ } +.setting-block-text label { font-size: 12px; font-weight: 500; display: block; /* D202 */ } +.setting-block-text p { font-size: 11px; color: var(--ca-muted); margin-top: 4px; max-width: 640px; /* D202 */ } +.setting-block[aria-invalid="true"] { box-shadow: inset 3px 0 0 var(--red); padding-inline-start: 12px; /* D202 */ } +.skip-add { display: flex; gap: 8px; margin-top: 12px; max-width: 460px; /* D202 */ } +.skip-add input { flex: 1; min-width: 0; font-size: 12px; /* D202 */ } +.skip-problem { color: var(--red); margin-top: 6px; /* D202 */ } +.skip-problem:empty, .skip-chips:empty { display: none; /* D202 */ } +.skip-chips .chip.tag-suggestion.none { border-color: var(--line); background: var(--ca-field); color: var(--ca-muted); /* D202 */ } +.skip-preview { margin-top: 12px; max-width: 760px; /* D202 */ } +.skip-preview b { font-weight: 500; color: var(--ca-text); /* D202 */ } +@media (max-width: 700px) { + .skip-add { max-width: none; /* D202 */ } +} + /* D132: at phone width the concept's 120px cap cuts every engine label ("WD14 SwinV2 · ~400 MB"); the Tagging model select alone keeps its natural width there (its widest engine label) and, with no room beside the row text, wraps under it. Existing tags (#398) never shrinks for its row text and, past the cap, keeps its natural width beside the