Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90c5848b13 | ||
|
|
5981e1d9e3 | ||
|
|
66f8071198 | ||
|
|
a20e59f674 |
+48
@@ -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**: <up to 12>[ 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`,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -40,6 +40,12 @@ 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
|
||||
# WD14 label lists the preview found missing or unreadable (logged once each).
|
||||
_MISSING_LABEL_LISTS = set()
|
||||
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 +253,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 +709,86 @@ 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:
|
||||
if path not in _MISSING_LABEL_LISTS: # logged once per list
|
||||
_MISSING_LABEL_LISTS.add(path)
|
||||
self.logger.debug(f"No WD14 label list for {model_type}: {exc}")
|
||||
continue
|
||||
_MISSING_LABEL_LISTS.discard(path)
|
||||
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 +1186,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 +1204,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 +1245,7 @@ class AutotagRoutesMixin:
|
||||
capture,
|
||||
model_type,
|
||||
options,
|
||||
skip,
|
||||
on_skipped=functools.partial(store.release_use, capture.token),
|
||||
)
|
||||
except MissingBackendPackages as exc:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
+10
-10
@@ -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
|
||||
|
||||
+21
-5
@@ -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)
|
||||
|
||||
|
||||
|
||||
+139
-1
@@ -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,136 @@ 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 (missing) and ValueError (unreadable)
|
||||
propagate; the caller skips that 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]
|
||||
try:
|
||||
rows = read_wd14_label_rows(key)
|
||||
except csv.Error as exc: # a damaged list (a NUL byte, an oversized field)
|
||||
raise ValueError(f"unreadable label list: {exc}") from None
|
||||
names = tuple(
|
||||
name
|
||||
for row in rows
|
||||
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
|
||||
|
||||
+19
-10
@@ -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)
|
||||
|
||||
@@ -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.getAttribute('placeholder'), 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', null, '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,15 @@ 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).
|
||||
expect(diffInventory(concept, production).map(item => item.path.split('.').slice(0, 3).join('.'))
|
||||
// Unmapped, the section differs only where D132, D133, D134 and D202 are recorded (and the saved template's text).
|
||||
// (The concept has no Skipped tags block, so every path under it is one D202 difference.)
|
||||
expect(diffInventory(concept, production).map(item => (item.path.startsWith('skipped') ? 'skipped'
|
||||
: 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.
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
|
||||
@@ -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.`);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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]]
|
||||
|
||||
|
||||
|
||||
@@ -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"]]
|
||||
|
||||
|
||||
|
||||
+27
-1
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
"""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 csv
|
||||
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_damaged_list_is_a_value_error_for_the_caller_to_skip(self):
|
||||
# A field over the csv module's size limit raises csv.Error.
|
||||
big = "x" * (csv.field_size_limit() + 1)
|
||||
self.path.write_text(
|
||||
f"tag_id,name,category,count\n1,{big},0,1\n", encoding="utf-8"
|
||||
)
|
||||
with self.assertRaises(ValueError):
|
||||
wd14_label_names(self.path)
|
||||
|
||||
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()
|
||||
@@ -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()
|
||||
@@ -164,6 +164,7 @@ ROUTE_AUDIT = {
|
||||
"PATCH /prompt_manager/saved-views/{view_id}",
|
||||
"PATCH /prompt_manager/vocabulary/tags/{tag_id}",
|
||||
"POST /prompt_manager/autotag/download/{model_type}",
|
||||
"POST /prompt_manager/autotag/skipped-tags/preview",
|
||||
"POST /prompt_manager/autotag/unload",
|
||||
"POST /prompt_manager/bulk/category",
|
||||
"POST /prompt_manager/bulk/delete",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.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('role', 'group');
|
||||
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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user