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

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

Auto-tag holds the shared model gate for the whole job (409 model-busy at
start when it is held), loads the model once and never downloads one: an
unavailable model fails the job before the first file. The registry gains
blocker() so a start answers busy before taking the gate.
This commit is contained in:
Vito Sansevero
2026-10-22 15:32:18 -07:00
parent 195a1d33ac
commit 62298bcd0c
7 changed files with 1160 additions and 22 deletions
+3
View File
@@ -7,6 +7,7 @@ Split into domain-specific mixins for maintainability:
- LoggingRoutesMixin: log management endpoints
- AutotagRoutesMixin: auto-tagging model management and tagging
- JobRoutesMixin: the session job registry behind Activity (py/api/jobs.py)
- ImageTagJobsMixin: bulk Add tags / Auto-tag as image-tags jobs
"""
import asyncio
@@ -32,6 +33,7 @@ from .history import HistoryRoutesMixin
from .images import ImageRoutesMixin
from .admin import AdminRoutesMixin
from .job_routes import JobRoutesMixin
from .image_tag_jobs import ImageTagJobsMixin
from .jobs import JobRegistry
from .logging_routes import LoggingRoutesMixin
from .autotag_routes import AutotagRoutesMixin
@@ -260,6 +262,7 @@ class PromptManagerAPI(
ImageRoutesMixin,
AdminRoutesMixin,
JobRoutesMixin,
ImageTagJobsMixin,
LoggingRoutesMixin,
AutotagRoutesMixin,
LoraIntegrationMixin,
+446
View File
@@ -0,0 +1,446 @@
"""Bulk image tagging as an Activity job: Add tags and Auto-tag (#249 D1).
``POST /prompt_manager/jobs/image-tags`` starts one ``image-tags`` job over
1..500 listed images, named by the listing identity ``{root_index,
root_scope, path}``. The job is record-changing (it writes image tag sets and
new vocabulary names), so it never runs beside the metadata scan.
- **Add tags** (``mode: "add"``) hashes each current file and unions a fixed
tag set into the record of exactly those bytes.
- **Auto-tag** (``mode: "auto"``) copies each still image into a private
capture, asks one legacy engine for its top N tags on the capture, re-hashes
the current file and unions the suggestions only when the bytes are still
the captured ones. It holds the shared model gate for the whole job, loads
the model once and never downloads one.
Every step reuses the Tag dialog's pieces: the capture store, the model gate,
the suggestion worker and the storage call ``add_image_tags``. Files are
handled one at a time and the job stops at a file boundary when cancelled,
keeping what it already wrote. Rows and log lines carry root-relative paths
and public root labels only.
"""
import functools
import os
from dataclasses import dataclass
from aiohttp import web
from .. import model_gate
from .admin import _plural
from .annotation_suggest import (
OptionError,
unavailable_message,
validate_options,
)
from .annotations import (
_MESSAGES,
_error,
_pinned_suggestions,
_settled,
_submit,
_target,
)
from .autotag_routes import model_busy_response
from .images import _public_path
from .job_routes import busy_response
from .jobs import IMAGE_TAG_GROUP_NAMES, IMAGE_TAG_JOB_KIND, JobBusy
from .prompts import bad_request
try:
from ...database.annotations import ImageTagLimitError, normalize_image_tags
from ...database.library_commands import LibraryCommandError
from ...utils.media_capture import (
CaptureCapacity,
CaptureChanged,
CaptureDenied,
CaptureError,
CaptureTooLarge,
)
from ...utils.video_metadata import is_video_path
except ImportError:
from database.annotations import ImageTagLimitError, normalize_image_tags
from database.library_commands import LibraryCommandError
from utils.media_capture import (
CaptureCapacity,
CaptureChanged,
CaptureDenied,
CaptureError,
CaptureTooLarge,
)
from utils.video_metadata import is_video_path
MAX_JOB_TARGETS = 500
# 500 targets of up to ~1.1 KiB each plus the options: the annotation routes'
# 64 KiB bound is for one target, too small for a full selection.
MAX_JOB_BODY_BYTES = 1024 * 1024
ADD_TITLE = "Add tags to images"
AUTO_TITLE = "Auto-tag images"
GATE_OWNER_KIND = "image-tag-job"
_FIELDS = {
"add": frozenset({"mode", "targets", "tags"}),
"auto": frozenset({"mode", "targets", "model_type", "options"}),
}
_VERBS = {"add": "Add tags", "auto": "Auto-tag"}
_LOG_PREFIXES = {"tagged": "+", "unchanged": "=", "skipped": "-", "errors": "!"}
NO_NEW_TAGS = "No new tags"
VIDEO_SKIPPED = "Video: manual tags only"
TOO_LARGE = "Too large to verify"
FILE_CHANGED = "File changed"
FILE_NOT_FOUND = "File not found"
NOT_A_STILL = "Not a supported still image"
INFERENCE_FAILED = "Auto-tagging failed. Check server logs for details."
SAVE_FAILED = "The tags could not be saved. Check server logs for details."
READ_FAILED = "The file could not be read."
SOURCE_CHANGED = "The source changed. Refresh the image collection."
STORAGE_RESTORED = "Prompt storage was restored; start the job again."
class _JobStopped(Exception):
"""No later file can be written (prompt storage was restored)."""
@dataclass(frozen=True)
class _Spec:
"""A validated start request."""
mode: str
targets: tuple
tags: tuple = ()
model_type: str | None = None
options: dict | None = None
@property
def title(self) -> str:
return ADD_TITLE if self.mode == "add" else AUTO_TITLE
def scope(self) -> dict:
scope = {"mode": self.mode, "images": len(self.targets)}
if self.mode == "auto":
scope["model_type"] = self.model_type
return scope
def _parse_targets(raw):
"""Unique validated targets in request order, or ``(None, error)``."""
if not isinstance(raw, list) or not 1 <= len(raw) <= MAX_JOB_TARGETS:
return None, bad_request(f"targets must list 1 to {MAX_JOB_TARGETS} images")
seen, targets = set(), []
for item in raw:
target, error = _target(item)
if error is not None:
return None, error
if target not in seen:
seen.add(target)
targets.append(target)
return tuple(targets), None
def _parse_add(targets, data):
try:
tags = normalize_image_tags(data["tags"])
except ImageTagLimitError as exc:
return None, _error(400, "tag-limit", str(exc))
except ValueError as exc:
return None, bad_request(str(exc))
if not tags:
return None, bad_request("tags must name at least one tag")
return _Spec("add", targets, tags=tuple(tags)), None
def _parse_auto(targets, data):
from ..config import MAX_SUGGESTION_LIMIT, AutotagConfig
try:
model_type, options = validate_options(
data["model_type"],
data["options"],
default_limit=AutotagConfig.suggestion_limit(),
max_limit=MAX_SUGGESTION_LIMIT,
)
except OptionError as exc:
return None, bad_request(str(exc))
return _Spec("auto", targets, model_type=model_type, options=options), None
def parse_image_tag_request(data: dict):
"""A :class:`_Spec` for a start body, or ``(None, error response)``.
``{mode:"add", targets, tags}`` or ``{mode:"auto", targets, model_type,
options}`` exactly; options follow the Tag dialog's Generate rules.
"""
fields = _FIELDS.get(data.get("mode"))
if fields is None:
return None, bad_request('mode must be "add" or "auto"')
if set(data) != fields:
return None, bad_request(
"Request must contain exactly " + ", ".join(sorted(fields))
)
targets, error = _parse_targets(data["targets"])
if error is not None:
return None, error
if data["mode"] == "add":
return _parse_add(targets, data)
return _parse_auto(targets, data)
def _capture_outcome(exc: CaptureError, root, relative: str):
"""The (group, detail) of a file the capture store refused to read."""
if isinstance(exc, CaptureTooLarge):
return "skipped", TOO_LARGE
if isinstance(exc, CaptureDenied):
return "skipped", FILE_NOT_FOUND
if isinstance(exc, CaptureChanged):
path = os.path.join(root, *relative.split("/"))
return "skipped", FILE_CHANGED if os.path.lexists(path) else FILE_NOT_FOUND
if isinstance(exc, CaptureCapacity):
return "errors", _MESSAGES["review-capacity"]
return "errors", READ_FAILED
def _load_model(service, model_type: str, use_gpu: bool) -> None:
"""Worker thread: load (or swap to) *model_type*; never downloads."""
if service.is_model_loaded() and service.get_loaded_model_type() == model_type:
return
if service.load_model(model_type, use_gpu) is False:
raise RuntimeError(f"{model_type} could not be loaded")
class ImageTagJobsMixin:
"""The ``image-tags`` job kind: start route and runner."""
async def start_image_tag_job(self, request: web.Request) -> web.Response:
"""Start bulk tagging (202), or 409 ``busy`` / ``model-busy``.
Every target's root scope is checked before the job starts
(``source-changed``). Auto-tag takes the model gate here, so a start
while anything else holds it is refused at once.
"""
data, error = await self._read_json_body(request, max_bytes=MAX_JOB_BODY_BYTES)
if error is not None:
return error
spec, error = parse_image_tag_request(data)
if error is not None:
return error
for index, scope in dict.fromkeys((t[0], t[1]) for t in spec.targets):
_root, error = self._scoped_root(index, scope)
if error is not None:
return error
epoch = await self._run_in_executor(self.db.get_execution_epoch)
# A running job in the way answers first: it says which job to wait for.
blocker = self._jobs.blocker(IMAGE_TAG_JOB_KIND, record_changing=True)
if blocker is not None:
return busy_response(JobBusy(blocker))
owner = None
if spec.mode == "auto":
owner = model_gate.new_owner(GATE_OWNER_KIND)
if not model_gate.try_acquire(owner):
return model_busy_response()
try:
job = self._jobs.start(
IMAGE_TAG_JOB_KIND,
spec.title,
record_changing=True,
scope=spec.scope(),
runner=functools.partial(self._run_image_tag_job, spec, epoch, owner),
groups=IMAGE_TAG_GROUP_NAMES,
)
except JobBusy as busy:
if owner is not None:
model_gate.release(owner)
return busy_response(busy)
if owner is not None:
# Also when the task is cancelled before its runner ever starts.
job.task.add_done_callback(lambda _task: model_gate.release(owner))
return web.json_response({"success": True, "job": job.summary()}, status=202)
# -- runner ----------------------------------------------------------------
async def _run_image_tag_job(self, spec: _Spec, epoch: str, owner, job) -> None:
verb = _VERBS[spec.mode]
total = len(spec.targets)
job.update(total=total, status_line=f"Processing file 0 of {total:,}")
started = f"{verb} started · {_plural(total, 'image')}"
if spec.mode == "auto":
started += f" · {spec.model_type}"
job.log_line(started)
try:
service = None
if spec.mode == "auto":
service = await self._prepare_tagging_model(job, spec, owner)
if service is None:
job.finish("failed")
return
if not await self._tag_each_file(job, spec, epoch, owner, service):
job.log_line(f"{verb} stopped · {STORAGE_RESTORED}")
job.finish("failed")
return
state = "cancelled" if job.cancelled else "finished"
job.log_line(f"{verb} {state} · {_plural(job.processed, 'file')} processed")
job.publish({"type": state, "progress": job.progress})
job.finish(state)
finally:
if owner is not None:
model_gate.release(owner)
async def _tag_each_file(self, job, spec, epoch, owner, service) -> bool:
"""Tag the targets in order; False when the job had to stop."""
total = len(spec.targets)
for number, target in enumerate(spec.targets, 1):
if job.cancelled:
break
job.update(status_line=f"Processing file {number:,} of {total:,}")
job.publish({"type": "progress", "progress": job.progress})
stopped = False
try:
group, detail = await self._tag_one_file(
spec, epoch, owner, service, target
)
except _JobStopped:
stopped, group, detail = True, "errors", STORAGE_RESTORED
except Exception:
self.logger.exception("Image tagging failed for one file")
group, detail = "errors", SAVE_FAILED
self._record_tag_outcome(job, target, group, detail)
job.update(processed=number, progress=number * 100 // total)
if stopped:
return False
return True
async def _prepare_tagging_model(self, job, spec: _Spec, owner):
"""The loaded service, or None (logged) when the job cannot run."""
from ..autotag import MODELS, get_autotag_service
service = get_autotag_service()
status = await self._run_in_executor(service.get_models_status)
if not status.get(spec.model_type, {}).get("downloaded"):
name = MODELS.get(spec.model_type, {}).get("name", spec.model_type)
message = unavailable_message(name)
job.update(status_line=message)
job.log_line(f"! {message}")
return None
try:
await model_gate.run(
owner,
_load_model,
service,
spec.model_type,
spec.options["use_gpu"],
)
except Exception:
self.logger.exception("The auto-tag job could not load its model")
job.update(status_line=INFERENCE_FAILED)
job.log_line(f"! {INFERENCE_FAILED}")
return None
return service
async def _tag_one_file(self, spec, epoch, owner, service, target):
"""The (group, detail) of one target; raises _JobStopped on a restore."""
index, scope, relative = target
root, error = self._scoped_root(index, scope)
if error is not None:
return "errors", SOURCE_CHANGED
store = self._capture_store()
if spec.mode == "add":
try:
facts = await self._run_in_executor(store.verify, root, relative)
except CaptureError as exc:
return _capture_outcome(exc, root, relative)
return await self._union_image_tags(facts, list(spec.tags), epoch)
if is_video_path(relative):
return "skipped", VIDEO_SKIPPED
try:
capture = await _settled(
_submit(
store.capture,
root,
relative,
epoch=epoch,
scope=scope,
root_index=index,
),
lambda made: made is not None and store.release(made.token),
)
except CaptureError as exc:
return _capture_outcome(exc, root, relative)
try:
return await self._auto_tag_capture(
store, capture, spec, epoch, owner, service, root, relative
)
finally:
store.release(capture.token)
async def _auto_tag_capture(
self, store, capture, spec, epoch, owner, service, root, relative
):
"""Suggest on the capture, then union only onto the same current bytes."""
if not capture.inferable:
return "skipped", NOT_A_STILL
if store.acquire(capture.token) is None:
raise _JobStopped # every capture was invalidated (restore)
try:
pairs = await model_gate.run(
owner,
_pinned_suggestions,
store,
capture.token,
service,
capture,
spec.model_type,
spec.options,
on_skipped=functools.partial(store.release_use, capture.token),
)
except Exception:
self.logger.exception("Auto-tagging failed for one file")
return "errors", INFERENCE_FAILED
try:
facts = await self._run_in_executor(store.verify, root, relative)
except CaptureError as exc:
return _capture_outcome(exc, root, relative)
if facts.locator != capture.locator or facts.sha256 != capture.sha256:
return "skipped", FILE_CHANGED
names = [str(name) for name, _score in pairs]
if not names:
return "unchanged", NO_NEW_TAGS
return await self._union_image_tags(facts, names, epoch)
async def _union_image_tags(self, facts, tags: list, epoch: str):
"""``add_image_tags`` on the verified bytes, as a (group, detail)."""
try:
receipt = await self._run_in_executor(
self.db.add_image_tags,
locator=facts.locator,
sha256=facts.sha256,
size=facts.size,
mtime_ns=facts.mtime_ns,
expected_epoch=epoch,
tags=tags,
)
except LibraryCommandError as exc:
if exc.code == "storage-changed":
raise _JobStopped from None
if exc.code == "tag-limit":
return "errors", _MESSAGES["tag-limit"]
self.logger.warning(f"Image tags not saved ({exc.code})")
return "errors", SAVE_FAILED
except ValueError:
self.logger.exception("Image tags were refused")
return "errors", SAVE_FAILED
added = receipt.get("added", 0)
if added:
return "tagged", f"Added {_plural(added, 'tag')}"
return "unchanged", NO_NEW_TAGS
def _record_tag_outcome(self, job, target, group: str, detail: str) -> None:
index, _scope, relative = target
roots = list(self._get_all_output_dirs())
label = _public_path(roots[index]) if index < len(roots) else ""
where = f"{label}/{relative}" if label else relative
job.record(
group,
{"path": relative, "root": label, "root_index": index, "detail": detail},
f"{_LOG_PREFIXES[group]} {where} · {detail}",
)
+23 -13
View File
@@ -1,8 +1,9 @@
"""Session job routes (Activity): list, inspect, follow and cancel jobs.
The registry itself is ``py/api/jobs.py``; the scan job's runner is in
``py/api/admin.py``. Every response leaves out absolute server paths: job
rows already carry root-relative paths and public root labels.
The registry itself is ``py/api/jobs.py``. The scan job's runner is in
``py/api/admin.py``, the image tagging job's in ``py/api/image_tag_jobs.py``.
Every response leaves out absolute server paths: job rows already carry
root-relative paths and public root labels.
"""
import contextlib
@@ -42,6 +43,20 @@ async def _scan_scope(request):
return scope if scope in SCAN_SCOPES else None
def busy_response(busy: JobBusy):
"""``409 busy``, naming the running job that holds the slot."""
running = busy.running
return web.json_response(
{
"success": False,
"code": "busy",
"error": str(busy),
"running": {"id": running.id, "title": running.title},
},
status=409,
)
def _summary_event(job):
return f"data: {json.dumps(job.summary())}\n\n".encode("utf-8")
@@ -58,6 +73,10 @@ class JobRoutesMixin:
async def start_scan_job_route(request):
return await self.start_scan_job(request)
@routes.post("/prompt_manager/jobs/image-tags")
async def start_image_tag_job_route(request):
return await self.start_image_tag_job(request)
@routes.get("/prompt_manager/jobs/{job_id}")
async def job_detail_route(request):
return await self.job_detail(request)
@@ -121,16 +140,7 @@ class JobRoutesMixin:
try:
job = self._start_scan_job(files)
except JobBusy as busy:
running = busy.running
return web.json_response(
{
"success": False,
"code": "busy",
"error": str(busy),
"running": {"id": running.id, "title": running.title},
},
status=409,
)
return busy_response(busy)
return web.json_response({"success": True, "job": job.summary()}, status=202)
async def job_events(self, request):
+22 -9
View File
@@ -9,8 +9,9 @@ as a snapshot and then every event published after it, until the job ends.
Jobs live only in server memory and are gone after a restart. The registry
keeps the running jobs and this session's last ``MAX_FINISHED_JOBS`` finished
ones. Record-changing jobs (the metadata scan, and later maintenance, cleanup
and restore) never run together, and one job of a kind runs at a time.
ones. Record-changing jobs (the metadata scan, bulk image tagging, and later
maintenance, cleanup and restore) never run together, and one job of a kind
runs at a time.
"""
import asyncio
@@ -54,6 +55,11 @@ SCAN_GROUPS = {
"failed": "errors",
}
# Bulk image tagging (#249 D1): Add tags and Auto-tag over selected images.
# Record-changing: it writes image tag sets and new vocabulary names.
IMAGE_TAG_JOB_KIND = "image-tags"
IMAGE_TAG_GROUP_NAMES = ("tagged", "unchanged", "skipped", "errors")
def _relative_path(path) -> str:
"""``path`` as a root-relative POSIX path; an absolute one keeps its name."""
@@ -292,13 +298,9 @@ class JobRegistry:
running or when ``record_changing`` and another record-changing job
runs. Must be called from the event loop.
"""
same_kind = self.active(kind)
if same_kind is not None:
raise JobBusy(same_kind)
if record_changing:
for job in self._jobs.values():
if job.running and job.record_changing:
raise JobBusy(job)
blocker = self.blocker(kind, record_changing=record_changing)
if blocker is not None:
raise JobBusy(blocker)
self._prune()
job = Job(
kind, title, record_changing=record_changing, scope=scope, groups=groups
@@ -307,6 +309,17 @@ class JobRegistry:
job.task = asyncio.ensure_future(self._run(job, runner))
return job
def blocker(self, kind, *, record_changing):
"""The running job a new ``kind`` job would be refused for, or None."""
same_kind = self.active(kind)
if same_kind is not None:
return same_kind
if record_changing:
for job in self._jobs.values():
if job.running and job.record_changing:
return job
return None
async def _run(self, job, runner):
try:
await runner(job)
+9
View File
@@ -462,6 +462,15 @@ class TestUiRoutes(CoreRoutesTestCase):
):
self.assertIn(entry, table)
async def test_image_tag_job_route_is_registered_beside_the_scan_job(self):
table = {
(route.method, route.resource.canonical)
for route in self.app.router.routes()
if route.resource is not None
}
self.assertIn(("POST", "/prompt_manager/jobs/scan"), table)
self.assertIn(("POST", "/prompt_manager/jobs/image-tags"), table)
class TestStaticRoutes(CoreRoutesTestCase):
+648
View File
@@ -0,0 +1,648 @@
"""Bulk image tagging as an Activity job: Add tags and Auto-tag (#249 D1).
``POST /prompt_manager/jobs/image-tags`` starts one record-changing
``image-tags`` job over 1..500 listed images. Add tags unions a fixed set
into each file's byte record; Auto-tag runs one legacy engine on a private
capture of each still image (under the shared model gate, never
downloading) and unions its top N suggestions. No real model is loaded: the
engine is B2's fake service.
"""
import asyncio
import hashlib
import json
import sqlite3
import threading
import unittest
from unittest.mock import patch
try:
from test_api_annotation_suggest import SCORED, WD14, FakeTagger, SuggestTestCase
from test_api_images import make_png
except ImportError:
from tests.test_api_annotation_suggest import (
SCORED,
WD14,
FakeTagger,
SuggestTestCase,
)
from tests.test_api_images import make_png
from database.execution_history_schema import rotate_history_epoch
from py import model_gate
from py.api import jobs as jobs_module
from py.config import AutotagConfig
START = "/prompt_manager/jobs/image-tags"
WAIT = 30.0
KIND = getattr(jobs_module, "IMAGE_TAG_JOB_KIND", "image-tags")
GROUPS = ("tagged", "unchanged", "skipped", "errors")
class HookedTagger(FakeTagger):
"""The B2 fake plus a hook that runs inside each inference."""
def __init__(self):
super().__init__()
self.on_generate = None
def generate_tag_suggestions(self, image_path, **kwargs):
result = super().generate_tag_suggestions(image_path, **kwargs)
if self.on_generate is not None:
self.on_generate(image_path)
return result
class ImageTagJobTestCase(SuggestTestCase):
async def get_application(self):
app = await super().get_application()
self.tagger = HookedTagger()
patcher = patch("py.autotag.get_autotag_service", return_value=self.tagger)
patcher.start()
self.addCleanup(patcher.stop)
return app
async def tearDownAsync(self):
# Every job of the test has ended before the app goes away.
for job in self.api._jobs.list():
if job.task is not None and not job.task.done():
job.task.cancel()
await asyncio.gather(job.task, return_exceptions=True)
await super().tearDownAsync()
# -- helpers ---------------------------------------------------------
def png(self, path, size=(8, 6), color=(200, 120, 40)):
make_png(self.output_dir / path, size=size, color=color)
return self.output_dir / path
def digest(self, path):
return hashlib.sha256((self.output_dir / path).read_bytes()).hexdigest()
def stored(self, path):
return self.api.db.read_image_tags(self.locator(path), self.digest(path))
def add_body(self, paths, tags, **changes):
return {
"mode": "add",
"targets": [self.target(p) for p in paths],
"tags": list(tags),
**changes,
}
def auto_body(self, paths, model_type="wd14-vit", options=None, **changes):
return {
"mode": "auto",
"targets": [self.target(p) for p in paths],
"model_type": model_type,
"options": dict(WD14) if options is None else options,
**changes,
}
async def start(self, body):
response = await self.client.post(START, json=body)
return response.status, await self.json_of(response)
async def start_ok(self, body):
status, data = await self.start(body)
self.assertEqual(status, 202, data)
return self.api._jobs.get(data["job"]["id"])
async def run_job(self, body):
job = await self.start_ok(body)
await asyncio.wait_for(asyncio.shield(job.task), WAIT)
return job
def rows(self, job, group):
return [(row["path"], row["detail"]) for row in job.outcomes[group]]
async def wait_for(self, event):
ok = await asyncio.get_running_loop().run_in_executor(None, event.wait, WAIT)
self.assertTrue(ok, "the fake engine was never entered")
class TestAddTags(ImageTagJobTestCase):
async def test_add_job_unions_tags_on_each_target_and_records_outcomes(self):
for name in ("a.png", "b.png", "c.png"):
self.png(name, color=(len(name), ord(name[0]), 9))
self.saved_set("a.png", (self.output_dir / "a.png").read_bytes(), ["keep_me"])
self.saved_set(
"c.png", (self.output_dir / "c.png").read_bytes(), ["Sandstone", "desert"]
)
job = await self.run_job(
self.add_body(
["a.png", "b.png", "c.png", "gone.png"], ["sandstone", "desert"]
)
)
self.assertEqual(job.kind, KIND)
self.assertEqual(job.title, "Add tags to images")
self.assertEqual(job.state, "finished")
self.assertTrue(job.record_changing)
self.assertEqual(job.scope, {"mode": "add", "images": 4})
self.assertEqual(
job.counters, {"tagged": 2, "unchanged": 1, "skipped": 1, "errors": 0}
)
self.assertEqual(
self.stored("a.png")["tags"], ["keep_me", "sandstone", "desert"]
)
self.assertEqual(self.stored("b.png")["tags"], ["sandstone", "desert"])
# The stored spelling wins; nothing new means no write.
self.assertEqual(self.stored("c.png")["tags"], ["Sandstone", "desert"])
self.assertEqual(self.stored("c.png")["tag_revision"], 1)
self.assertEqual(
self.rows(job, "tagged"),
[("a.png", "Added 2 tags"), ("b.png", "Added 2 tags")],
)
self.assertEqual(self.rows(job, "unchanged"), [("c.png", "No new tags")])
self.assertEqual(self.rows(job, "skipped"), [("gone.png", "File not found")])
self.assertEqual(job.outcomes["tagged"][0]["root_index"], 0)
self.assertEqual((job.processed, job.total, job.progress), (4, 4, 100))
self.assertTrue(any("+ " in line and "a.png" in line for line in job.log))
self.assertTrue(job.log[-1].endswith("Add tags finished · 4 files processed"))
# Add tags never needs the model.
self.assertEqual(self.tagger.load_calls, [])
self.assertIsNone(model_gate.holder())
async def test_add_tags_vocabulary_names_become_global_tags(self):
self.png("a.png")
await self.run_job(self.add_body(["a.png"], ["brand_new_tag"]))
names = set(self.api.db.get_all_tags())
self.assertIn("brand_new_tag", names)
async def test_add_over_the_tag_cap_is_an_error_row_with_zero_writes(self):
self.png("a.png")
full = [f"t{i:03d}" for i in range(100)]
self.saved_set("a.png", (self.output_dir / "a.png").read_bytes(), full)
job = await self.run_job(self.add_body(["a.png"], ["one_more"]))
self.assertEqual(job.counters["errors"], 1)
self.assertEqual(
self.rows(job, "errors"),
[("a.png", "An image can carry at most 100 tags.")],
)
self.assertEqual(self.stored("a.png")["tags"], full)
async def test_video_skipped_in_auto_and_tagged_in_add(self):
(self.output_dir / "clip.mp4").write_bytes(b"\x00\x00\x00\x18ftypmp42" * 8)
added = await self.run_job(self.add_body(["clip.mp4"], ["motion"]))
self.assertEqual(self.rows(added, "tagged"), [("clip.mp4", "Added 1 tag")])
self.assertEqual(self.stored("clip.mp4")["tags"], ["motion"])
auto = await self.run_job(self.auto_body(["clip.mp4"]))
self.assertEqual(
self.rows(auto, "skipped"), [("clip.mp4", "Video: manual tags only")]
)
self.assertEqual(self.tagger.calls, [])
self.assertEqual(self.stored("clip.mp4")["tags"], ["motion"])
async def test_not_a_still_image_and_too_large_are_skipped(self):
(self.output_dir / "broken.png").write_bytes(b"not an image")
self.png("big.png")
self.api._capture_store().max_file = 16
auto = await self.run_job(self.auto_body(["big.png"]))
self.assertEqual(
self.rows(auto, "skipped"), [("big.png", "Too large to verify")]
)
self.api._capture_store().max_file = 2**20
auto = await self.run_job(self.auto_body(["broken.png"]))
self.assertEqual(
self.rows(auto, "skipped"), [("broken.png", "Not a supported still image")]
)
self.api._capture_store().max_file = 16
added = await self.run_job(self.add_body(["big.png"], ["x"]))
self.assertEqual(
self.rows(added, "skipped"), [("big.png", "Too large to verify")]
)
self.assertEqual(self.tagger.calls, [])
class TestAutoTag(ImageTagJobTestCase):
async def test_auto_job_applies_the_requested_count_per_file_without_removing_existing( # noqa: E501
self,
):
self.png("a.png")
self.png("b.png", color=(1, 2, 3))
self.saved_set("a.png", (self.output_dir / "a.png").read_bytes(), ["keep_me"])
options = {**WD14, "suggestion_limit": 2}
job = await self.run_job(self.auto_body(["a.png", "b.png"], options=options))
self.assertEqual(job.title, "Auto-tag images")
self.assertEqual(
job.scope, {"mode": "auto", "images": 2, "model_type": "wd14-vit"}
)
self.assertEqual(job.state, "finished")
self.assertEqual(
self.api.db.read_image_tags(self.locator("a.png"), self.digest("a.png"))[
"tags"
],
["keep_me", "sandstone", "desert"],
)
self.assertEqual(self.stored("b.png")["tags"], ["sandstone", "desert"])
self.assertEqual(
job.counters, {"tagged": 2, "unchanged": 0, "skipped": 0, "errors": 0}
)
# Inference read the private captures only, never the current files.
for path in self.tagger.read_paths:
self.assertNotIn(str(self.output_dir), path)
self.assertEqual([call["limit"] for call in self.tagger.calls], [2, 2])
# limit 3: at most three names are added to a file.
self.png("c.png", color=(9, 9, 9))
options = {**WD14, "suggestion_limit": 3}
job = await self.run_job(self.auto_body(["c.png"], options=options))
self.assertEqual(self.stored("c.png")["tags"], [name for name, _ in SCORED[:3]])
self.assertEqual(self.rows(job, "tagged"), [("c.png", "Added 3 tags")])
# Omitted: the configured default.
AutotagConfig.SUGGESTION_LIMIT = 4
self.png("d.png", color=(7, 7, 7))
await self.run_job(self.auto_body(["d.png"]))
self.assertEqual(self.tagger.calls[-1]["limit"], 4)
self.assertEqual(len(self.stored("d.png")["tags"]), 4)
# Suggestions the file already carries add nothing.
job = await self.run_job(self.auto_body(["d.png"]))
self.assertEqual(self.rows(job, "unchanged"), [("d.png", "No new tags")])
async def test_auto_job_holds_gate_and_never_downloads(self):
self.png("a.png")
self.png("b.png", color=(1, 2, 3))
entered, proceed = threading.Event(), threading.Event()
self.tagger.block = (entered, proceed)
job = await self.start_ok(self.auto_body(["a.png", "b.png"]))
await self.wait_for(entered)
holder = model_gate.holder()
self.assertIsNotNone(holder)
self.assertTrue(holder.startswith("image-tag-job:"), holder)
# A legacy route cannot swap the model under the running job.
response = await self.client.post("/prompt_manager/autotag/unload")
self.assertEqual(response.status, 409)
self.assertEqual((await response.json())["code"], "model-busy")
proceed.set()
await asyncio.wait_for(asyncio.shield(job.task), WAIT)
self.assertEqual(job.counters["tagged"], 2)
self.assertEqual(self.tagger.load_calls, [("wd14-vit", True)])
self.tagger.download_model.assert_not_called()
self.assertIsNone(model_gate.holder())
async def test_model_unavailable_fails_the_job_before_the_first_file(self):
self.png("a.png")
self.tagger.downloaded["wd14-vit"] = False
job = await self.run_job(self.auto_body(["a.png"]))
self.assertEqual(job.state, "failed")
self.assertEqual(job.processed, 0)
self.assertEqual(job.counters, dict.fromkeys(GROUPS, 0))
self.assertTrue(
any(
line.endswith(
"is not downloaded. Download it from the legacy Auto-tag page."
)
for line in job.log
),
job.log,
)
self.assertEqual(self.tagger.load_calls, [])
self.tagger.download_model.assert_not_called()
self.assertIsNone(model_gate.holder())
self.assertEqual(self.api._capture_store().stats()["captures"], 0)
async def test_model_load_failure_fails_the_job_and_frees_the_gate(self):
self.png("a.png")
self.tagger.load_error = RuntimeError("/secret/models/wd14 exploded")
job = await self.run_job(self.auto_body(["a.png"]))
self.assertEqual(job.state, "failed")
self.assertEqual(job.processed, 0)
self.assertNotIn("/secret", json.dumps(job.detail()))
self.assertIsNone(model_gate.holder())
async def test_inference_failure_is_an_error_row_and_the_job_goes_on(self):
self.png("a.png")
self.png("b.png", color=(1, 2, 3))
failing = {"first": True}
def explode(_path):
if failing.pop("first", False):
raise RuntimeError("/secret/path boom")
self.tagger.on_generate = explode
job = await self.run_job(self.auto_body(["a.png", "b.png"]))
self.assertEqual(job.counters["errors"], 1)
self.assertEqual(job.counters["tagged"], 1)
self.assertEqual(
self.rows(job, "errors"),
[("a.png", "Auto-tagging failed. Check server logs for details.")],
)
self.assertNotIn("/secret", json.dumps(job.report()))
self.assertIsNone(model_gate.holder())
async def test_changed_file_is_skipped_not_tagged(self):
self.png("a.png")
before = self.digest("a.png")
def rewrite(_capture_path):
make_png(self.output_dir / "a.png", size=(8, 6), color=(1, 1, 1))
self.tagger.on_generate = rewrite
job = await self.run_job(self.auto_body(["a.png"]))
self.assertEqual(self.rows(job, "skipped"), [("a.png", "File changed")])
self.assertEqual(job.counters["tagged"], 0)
locator = self.locator("a.png")
self.assertEqual(self.api.db.read_image_tags(locator, before)["tags"], [])
self.assertEqual(self.stored("a.png")["tags"], [])
async def test_captures_released_and_handles_closed(self):
try:
from open_handles import assert_closed
except ImportError:
from tests.open_handles import assert_closed
for index in range(3):
self.png(f"f{index}.png", color=(index, 0, 0))
await self.run_job(self.auto_body([f"f{index}.png" for index in range(3)]))
store = self.api._capture_store()
self.assertEqual(store.stats()["captures"], 0)
assert_closed(self, store.directory)
assert_closed(self, str(self.output_dir))
class TestStartAndExclusion(ImageTagJobTestCase):
async def test_start_returns_model_busy_or_job_busy(self):
self.png("a.png")
other = model_gate.new_owner("legacy-batch")
self.assertTrue(model_gate.try_acquire(other))
try:
status, body = await self.start(self.auto_body(["a.png"]))
self.assertEqual((status, body["code"]), (409, "model-busy"))
self.assertEqual(self.api._jobs.list(), [])
# Add tags needs no model: it starts while the gate is held.
job = await self.run_job(self.add_body(["a.png"], ["x"]))
self.assertEqual(job.state, "finished")
finally:
model_gate.release(other)
entered, proceed = threading.Event(), threading.Event()
self.tagger.block = (entered, proceed)
job = await self.start_ok(self.auto_body(["a.png"]))
await self.wait_for(entered)
try:
for body in (self.add_body(["a.png"], ["y"]), self.auto_body(["a.png"])):
resp = await self.client.post(START, json=body)
body = await resp.json()
self.assertEqual((resp.status, body["code"]), (409, "busy"))
self.assertEqual(
body["running"], {"id": job.id, "title": "Auto-tag images"}
)
finally:
proceed.set()
await asyncio.wait_for(asyncio.shield(job.task), WAIT)
# A refused start never leaves the gate taken.
self.assertIsNone(model_gate.holder())
async def test_record_changing_excludes_running_scan(self):
self.png("a.png")
gate = asyncio.Event()
async def scan_runner(job):
await gate.wait()
scan = self.api._jobs.start(
"scan",
"Scan for metadata",
record_changing=True,
scope={"files": "new"},
runner=scan_runner,
)
try:
status, body = await self.start(self.add_body(["a.png"], ["x"]))
self.assertEqual((status, body["code"]), (409, "busy"))
self.assertEqual(body["running"]["title"], "Scan for metadata")
status, body = await self.start(self.auto_body(["a.png"]))
self.assertEqual((status, body["code"]), (409, "busy"))
self.assertIsNone(model_gate.holder())
finally:
gate.set()
await scan.task
# The other way round: a running tagging job keeps the scan out.
entered, proceed = threading.Event(), threading.Event()
self.tagger.block = (entered, proceed)
job = await self.start_ok(self.auto_body(["a.png"]))
await self.wait_for(entered)
try:
resp = await self.client.post("/prompt_manager/jobs/scan", json={})
self.assertEqual(resp.status, 409)
self.assertEqual((await resp.json())["running"]["id"], job.id)
finally:
proceed.set()
await asyncio.wait_for(asyncio.shield(job.task), WAIT)
async def test_a_start_that_loses_the_slot_race_frees_the_gate(self):
self.png("a.png")
gate = asyncio.Event()
async def scan_runner(job):
await gate.wait()
scan = self.api._jobs.start(
"scan",
"Scan for metadata",
record_changing=True,
scope={},
runner=scan_runner,
)
try:
# The pre-check saw a free slot; registry.start still refuses.
with patch.object(self.api._jobs, "blocker", side_effect=[None, scan]):
status, body = await self.start(self.auto_body(["a.png"]))
self.assertEqual((status, body["code"]), (409, "busy"))
self.assertIsNone(model_gate.holder())
finally:
gate.set()
await scan.task
async def test_cancel_stops_at_file_boundary_and_keeps_written_files(self):
for index in range(3):
self.png(f"f{index}.png", color=(index, 5, 5))
entered, proceed = threading.Event(), threading.Event()
self.tagger.block = (entered, proceed)
job = await self.start_ok(self.auto_body([f"f{i}.png" for i in range(3)]))
await self.wait_for(entered)
response = await self.client.post(f"/prompt_manager/jobs/{job.id}/cancel")
self.assertTrue((await response.json())["cancellation_requested"])
proceed.set()
await asyncio.wait_for(asyncio.shield(job.task), WAIT)
self.assertEqual(job.state, "cancelled")
self.assertEqual(job.counters["tagged"], 1)
self.assertEqual(job.processed, 1)
self.assertEqual(len(self.stored("f0.png")["tags"]), 10)
self.assertEqual(self.stored("f1.png")["tags"], [])
self.assertEqual(len(self.tagger.calls), 1)
self.assertTrue(job.log[-1].endswith("Auto-tag cancelled · 1 file processed"))
self.assertIsNone(model_gate.holder())
self.assertEqual(self.api._capture_store().stats()["captures"], 0)
async def test_stale_root_scope_refuses_the_start(self):
self.png("a.png")
body = self.add_body(["a.png"], ["x"])
body["targets"][0]["root_scope"] = "0" * 64
status, data = await self.start(body)
self.assertEqual((status, data["code"]), (409, "source-changed"))
self.assertEqual(self.api._jobs.list(), [])
async def test_restore_during_the_job_stops_it_without_writes(self):
self.png("a.png")
self.png("b.png", color=(1, 2, 3))
def rotate(_path):
conn = sqlite3.connect(self._temp_db.name)
try:
rotate_history_epoch(conn)
finally:
conn.close()
self.tagger.on_generate = rotate
job = await self.run_job(self.auto_body(["a.png", "b.png"]))
self.assertEqual(job.state, "failed")
self.assertEqual(job.counters["tagged"], 0)
self.assertEqual(
self.rows(job, "errors"),
[("a.png", "Prompt storage was restored; start the job again.")],
)
self.assertEqual(self.stored("a.png")["tags"], [])
self.assertEqual(len(self.tagger.calls), 1)
self.assertIsNone(model_gate.holder())
async def test_invalid_bodies_rejected(self):
self.png("a.png")
target = self.target("a.png")
bad_bodies = [
{"mode": "add", "targets": [], "tags": ["x"]},
{"mode": "add", "targets": [target] * 501, "tags": ["x"]},
{"mode": "add", "targets": "a.png", "tags": ["x"]},
{"mode": "add", "targets": [{**target, "path": "../a.png"}], "tags": ["x"]},
{"mode": "add", "targets": [{**target, "root_index": True}], "tags": ["x"]},
{"mode": "add", "targets": [{**target, "extra": 1}], "tags": ["x"]},
{"mode": "add", "targets": [{**target, "path": "a.txt"}], "tags": ["x"]},
{"mode": "add", "targets": [target], "tags": []},
{"mode": "add", "targets": [target], "tags": "x"},
{"mode": "add", "targets": [target], "tags": ["bad\nname"]},
{"mode": "add", "targets": [target]},
{"mode": "add", "targets": [target], "tags": ["x"], "extra": 1},
{"mode": "add", "targets": [target], "tags": ["x"], "model_type": "hf"},
{"mode": "remove", "targets": [target], "tags": ["x"]},
{"targets": [target], "tags": ["x"]},
{"mode": "auto", "targets": [target], "model_type": "wd14-vit"},
{"mode": "auto", "targets": [target], "model_type": "x", "options": {}},
{
"mode": "auto",
"targets": [target],
"model_type": "wd14-vit",
"options": {},
},
{
"mode": "auto",
"targets": [target],
"model_type": "wd14-vit",
"options": {**WD14, "general_threshold": 2},
},
{
"mode": "auto",
"targets": [target],
"model_type": "wd14-vit",
"options": {**WD14, "suggestion_limit": 0},
},
{
"mode": "auto",
"targets": [target],
"model_type": "gguf",
"options": dict(WD14),
},
{
"mode": "auto",
"targets": [target],
"model_type": "wd14-vit",
"options": dict(WD14),
"tags": ["x"],
},
]
for body in bad_bodies:
with self.subTest(body=json.dumps(body)[:120]):
status, data = await self.start(body)
self.assertIn(status, (400, 403), data)
self.assertFalse(data["success"])
too_many = {
"mode": "add",
"targets": [target],
"tags": [f"t{i}" for i in range(101)],
}
status, data = await self.start(too_many)
self.assertEqual((status, data["code"]), (400, "tag-limit"))
response = await self.client.post(START, data="not json")
self.assertEqual(response.status, 400)
self.assertEqual(self.api._jobs.list(), [])
self.assertIsNone(model_gate.holder())
async def test_duplicate_targets_are_tagged_once(self):
self.png("a.png")
job = await self.run_job(self.add_body(["a.png", "a.png"], ["x"]))
self.assertEqual(job.scope["images"], 1)
self.assertEqual(job.counters["tagged"], 1)
async def test_rows_and_report_carry_no_absolute_paths(self):
self.png("sub/a.png")
self.png("sub/b.png", color=(3, 3, 3))
job = await self.run_job(
self.auto_body(["sub/a.png", "sub/b.png", "sub/missing.png"])
)
for url in (
START.rsplit("/", 1)[0],
f"/prompt_manager/jobs/{job.id}",
f"/prompt_manager/jobs/{job.id}/report",
):
response = await self.client.get(url)
self.assertEqual(response.status, 200)
body = json.loads(await response.text())
self.assert_no_paths(body)
detail = job.detail()
self.assertEqual(
[row["path"] for row in detail["outcomes"]["tagged"]],
["sub/a.png", "sub/b.png"],
)
self.assertTrue(
all(row["root"] == "output" for row in detail["outcomes"]["tagged"])
)
self.assertTrue(any("output/sub/a.png" in line for line in detail["log"]))
if __name__ == "__main__":
unittest.main()
+9
View File
@@ -12,6 +12,7 @@ import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from py.api import jobs as jobs_module # noqa: E402
from py.api.jobs import ( # noqa: E402
DEFAULT_SCAN_SCOPE,
LOG_CAP_LINE,
@@ -415,6 +416,14 @@ class TestJobOutcomesAndLog(unittest.TestCase):
SCAN_GROUP_NAMES, ("added", "known", "review", "no_metadata", "errors")
)
def test_image_tag_job_kind_and_groups(self):
# #249 D1: bulk Add tags / Auto-tag, counter-tile order.
self.assertEqual(getattr(jobs_module, "IMAGE_TAG_JOB_KIND", None), "image-tags")
self.assertEqual(
getattr(jobs_module, "IMAGE_TAG_GROUP_NAMES", None),
("tagged", "unchanged", "skipped", "errors"),
)
if __name__ == "__main__":
unittest.main()