feat(system): run thumbnail generation as an Activity job
GET /prompt_manager/images/thumbnails/plan counts what a run would do
(targets, fresh, remaining past the cap, source labels) and writes
nothing. POST /prompt_manager/jobs/thumbnails {mode, size, quality}
starts the "thumbnails" job: admission guarded, cancellable at a file
boundary, outcome groups generated / skipped / errors. size is the
longest edge (128-2048), quality the encoder, and rebuild writes fresh
previews again through the same atomic rename, so a failed file keeps
its old preview and originals are only read.
The legacy gallery stream now starts or attaches to the same job and
streams its unchanged frames. A client that goes away no longer stops
the work; the gallery's Cancel asks the server to cancel the job.
This commit is contained in:
@@ -8,6 +8,7 @@ Split into domain-specific mixins for maintainability:
|
||||
- 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
|
||||
- ThumbnailJobsMixin: thumbnail plan, thumbnail job and the gallery stream on it
|
||||
- SystemRoutesMixin: System health check, Overview and usage statistics
|
||||
"""
|
||||
|
||||
@@ -37,6 +38,7 @@ from .admin import AdminRoutesMixin
|
||||
from .settings_routes import SettingsRoutesMixin
|
||||
from .job_routes import JobRoutesMixin
|
||||
from .image_tag_jobs import ImageTagJobsMixin
|
||||
from .thumbnail_jobs import ThumbnailJobsMixin
|
||||
from .system import SystemRoutesMixin
|
||||
from .jobs import JobRegistry
|
||||
from .logging_routes import LoggingRoutesMixin
|
||||
@@ -272,6 +274,7 @@ class PromptManagerAPI(
|
||||
SettingsRoutesMixin,
|
||||
JobRoutesMixin,
|
||||
ImageTagJobsMixin,
|
||||
ThumbnailJobsMixin,
|
||||
SystemRoutesMixin,
|
||||
LoggingRoutesMixin,
|
||||
AutotagRoutesMixin,
|
||||
|
||||
+125
-172
@@ -20,7 +20,7 @@ from typing import Callable, NamedTuple
|
||||
from aiohttp import web
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
|
||||
from .jobs import THUMBNAILS_JOB_KIND
|
||||
from .jobs import MAX_OUTCOME_ROWS, THUMBNAILS_JOB_KIND
|
||||
from .prompts import bad_request, parse_page_params, publish_image_paths
|
||||
from .prompts import thumbnail_url_for
|
||||
from .prompts import safe_error_message as _safe_error
|
||||
@@ -235,6 +235,8 @@ MEDIA_EXTENSIONS = GALLERY_IMAGE_EXTENSIONS | VIDEO_EXTENSIONS
|
||||
# Caps on request-triggered filesystem work.
|
||||
MAX_SCAN_DEPTH = 12
|
||||
MAX_THUMBNAIL_FILES = 10_000
|
||||
# Already-fresh files a thumbnail job lists under Skipped (its counter stays exact).
|
||||
MAX_FRESH_LISTED = MAX_OUTCOME_ROWS
|
||||
|
||||
|
||||
def _worker_threads():
|
||||
@@ -271,15 +273,31 @@ def _atomic_save(img, dst, fmt=None, **save_kwargs):
|
||||
tmp.unlink()
|
||||
|
||||
|
||||
def _write_image_thumbnail(src, dst, thumbnail_size):
|
||||
"""Resize *src* into *dst* (blocking PIL work), atomically."""
|
||||
# Encoder settings per thumbnail quality (the System thumbnail dialog's
|
||||
# "Medium · optimized", "High · original", "Low · fastest"). The legacy
|
||||
# routes always encode as "medium", which is what they always wrote.
|
||||
THUMBNAIL_ENCODINGS = {
|
||||
"low": {"quality": 70, "optimize": False},
|
||||
"medium": {"quality": 85, "optimize": True},
|
||||
"high": {"quality": 95, "optimize": True},
|
||||
}
|
||||
DEFAULT_THUMBNAIL_ENCODING = THUMBNAIL_ENCODINGS["medium"]
|
||||
|
||||
|
||||
def _write_image_thumbnail(src, dst, thumbnail_size, encoding=None):
|
||||
"""Resize *src* into *dst* (blocking PIL work), atomically.
|
||||
|
||||
``encoding`` is one of THUMBNAIL_ENCODINGS (medium when None); a PNG
|
||||
preview keeps only its ``optimize`` flag.
|
||||
"""
|
||||
encoding = encoding or DEFAULT_THUMBNAIL_ENCODING
|
||||
with Image.open(src) as img:
|
||||
if img.mode in ("RGBA", "LA", "P"):
|
||||
img = img.convert("RGB")
|
||||
img.thumbnail(thumbnail_size, Image.Resampling.LANCZOS)
|
||||
save_kwargs = {"quality": 85, "optimize": True}
|
||||
save_kwargs = dict(encoding)
|
||||
if dst.suffix.lower() == ".png":
|
||||
save_kwargs = {"optimize": True}
|
||||
save_kwargs = {"optimize": encoding["optimize"]}
|
||||
_atomic_save(img, dst, **save_kwargs)
|
||||
|
||||
|
||||
@@ -932,6 +950,10 @@ class ImageRoutesMixin:
|
||||
async def thumbnail_status_route(request):
|
||||
return await self.get_thumbnail_status(request)
|
||||
|
||||
@routes.get("/prompt_manager/images/thumbnails/plan")
|
||||
async def thumbnail_plan_route(request):
|
||||
return await self.get_thumbnail_plan(request)
|
||||
|
||||
@routes.post("/prompt_manager/images/generate-thumbnails")
|
||||
async def generate_thumbnails_route(request):
|
||||
return await self.generate_thumbnails(request)
|
||||
@@ -1804,15 +1826,30 @@ class ImageRoutesMixin:
|
||||
self.logger.info(payload["message"])
|
||||
return payload
|
||||
|
||||
def _plan_gallery_thumbnails(self, output_dirs: list[Path]) -> dict:
|
||||
"""Plan every gallery root with one shared per-run generation cap."""
|
||||
combined = {"targets": [], "skipped": 0, "remaining": 0}
|
||||
def _plan_gallery_thumbnails(
|
||||
self, output_dirs: list[Path], rebuild: bool = False
|
||||
) -> dict:
|
||||
"""Plan every gallery root with one shared per-run generation cap.
|
||||
|
||||
See ``_plan_thumbnails``; ``fresh_files`` keeps at most
|
||||
MAX_FRESH_LISTED files across the roots.
|
||||
"""
|
||||
combined = {
|
||||
"targets": [],
|
||||
"skipped": 0,
|
||||
"remaining": 0,
|
||||
"fresh": 0,
|
||||
"fresh_files": [],
|
||||
}
|
||||
for root in output_dirs:
|
||||
root = Path(root)
|
||||
plan = self._plan_thumbnails(root, root / "thumbnails")
|
||||
plan = self._plan_thumbnails(root, root / "thumbnails", rebuild=rebuild)
|
||||
available = max(0, MAX_THUMBNAIL_FILES - len(combined["targets"]))
|
||||
combined["targets"].extend(plan["targets"][:available])
|
||||
combined["skipped"] += plan["skipped"]
|
||||
combined["fresh"] += plan["fresh"]
|
||||
room = max(0, MAX_FRESH_LISTED - len(combined["fresh_files"]))
|
||||
combined["fresh_files"].extend(plan["fresh_files"][:room])
|
||||
combined["remaining"] += plan["remaining"] + max(
|
||||
0, len(plan["targets"]) - available
|
||||
)
|
||||
@@ -1845,21 +1882,26 @@ class ImageRoutesMixin:
|
||||
{"success": False, "error": _safe_error(exc)}, status=500
|
||||
)
|
||||
|
||||
def _plan_thumbnails(self, output_path, thumbnails_dir):
|
||||
def _plan_thumbnails(self, output_path, thumbnails_dir, rebuild=False):
|
||||
"""Decide which media files need a thumbnail (blocking walk).
|
||||
|
||||
Returns ``{"targets": [(src, dst, is_video)], "skipped": n, "remaining": n}``.
|
||||
Files whose thumbnail is already newer than the source are counted as
|
||||
skipped and never queued, so a large library that is mostly done only
|
||||
pays for the files that changed. The walk is bounded like the gallery
|
||||
listing (MAX_SCAN_DEPTH, MAX_GALLERY_FILES, no symlinks) and at most
|
||||
MAX_THUMBNAIL_FILES pending files are queued per run; ``remaining``
|
||||
counts the pending files beyond that cap.
|
||||
Returns ``{"targets": [(src, dst, is_video)], "skipped": n,
|
||||
"remaining": n, "fresh": n, "fresh_files": [src]}``. Files whose
|
||||
thumbnail is already newer than the source are ``fresh``. Without
|
||||
``rebuild`` they are counted as skipped and never queued, so a large
|
||||
library that is mostly done only pays for the files that changed;
|
||||
``fresh_files`` lists the first MAX_FRESH_LISTED of them. With
|
||||
``rebuild`` every file is a target (nothing is skipped). The walk is
|
||||
bounded like the gallery listing (MAX_SCAN_DEPTH, MAX_GALLERY_FILES,
|
||||
no symlinks) and at most MAX_THUMBNAIL_FILES targets are queued per
|
||||
run; ``remaining`` counts the targets beyond that cap.
|
||||
"""
|
||||
output_path = Path(output_path)
|
||||
targets = []
|
||||
skipped = 0
|
||||
remaining = 0
|
||||
fresh = 0
|
||||
fresh_files = []
|
||||
for src in _iter_media_files(output_path, MAX_GALLERY_FILES):
|
||||
suffix = src.suffix.lower()
|
||||
is_video = suffix in VIDEO_EXTENSIONS
|
||||
@@ -1870,8 +1912,12 @@ class ImageRoutesMixin:
|
||||
self.logger.warning(f"Skipping thumbnail outside safe dir: {src.name}")
|
||||
continue
|
||||
if _thumbnail_is_fresh(src, dst):
|
||||
skipped += 1
|
||||
continue
|
||||
fresh += 1
|
||||
if not rebuild:
|
||||
skipped += 1
|
||||
if len(fresh_files) < MAX_FRESH_LISTED:
|
||||
fresh_files.append(src)
|
||||
continue
|
||||
if len(targets) >= MAX_THUMBNAIL_FILES:
|
||||
remaining += 1
|
||||
continue
|
||||
@@ -1881,42 +1927,70 @@ class ImageRoutesMixin:
|
||||
f"Thumbnail run capped at {MAX_THUMBNAIL_FILES} new files; "
|
||||
f"{remaining} more will be generated on the next run"
|
||||
)
|
||||
return {"targets": targets, "skipped": skipped, "remaining": remaining}
|
||||
return {
|
||||
"targets": targets,
|
||||
"skipped": skipped,
|
||||
"remaining": remaining,
|
||||
"fresh": fresh,
|
||||
"fresh_files": fresh_files,
|
||||
}
|
||||
|
||||
def _thumbnail_targets(self, output_path, thumbnails_dir):
|
||||
"""Pending (src, dst, is_video) triples; see _plan_thumbnails."""
|
||||
return self._plan_thumbnails(output_path, thumbnails_dir)["targets"]
|
||||
|
||||
def _generate_batch(self, targets, thumbnail_size):
|
||||
def _generate_batch(self, targets, thumbnail_size, **options):
|
||||
"""Create thumbnails for ``targets`` on the configured worker threads.
|
||||
|
||||
Blocking; returns one result dict per target, in order.
|
||||
Blocking; returns one result dict per target, in order. ``options``
|
||||
go to ``_generate_one`` (``encoding``, ``rebuild``, ``stop``).
|
||||
"""
|
||||
|
||||
def one(target):
|
||||
src, dst, is_video = target
|
||||
return self._generate_one(src, dst, thumbnail_size, is_video)
|
||||
return self._generate_one(src, dst, thumbnail_size, is_video, **options)
|
||||
|
||||
return map_parallel(one, targets, _worker_threads())
|
||||
|
||||
def _generate_one(self, src, dst, thumbnail_size, is_video):
|
||||
"""Create one thumbnail (blocking). Never raises; returns a result dict."""
|
||||
def _generate_one(
|
||||
self,
|
||||
src,
|
||||
dst,
|
||||
thumbnail_size,
|
||||
is_video,
|
||||
encoding=None,
|
||||
rebuild=False,
|
||||
stop=None,
|
||||
):
|
||||
"""Create one thumbnail (blocking). Never raises; returns a result dict.
|
||||
|
||||
``stop()`` true before the file starts gives ``action: "cancelled"``
|
||||
and touches nothing (a job's file boundary). ``rebuild`` writes a
|
||||
fresh preview again and never replaces an existing video preview with
|
||||
the placeholder. Every write is atomic, so a failure keeps the old
|
||||
preview.
|
||||
"""
|
||||
result = {
|
||||
"file": src.name,
|
||||
"dir": src.parent.name,
|
||||
"type": "video" if is_video else "image",
|
||||
}
|
||||
try:
|
||||
if stop is not None and stop():
|
||||
return {**result, "action": "cancelled"}
|
||||
if not src.is_file():
|
||||
return {**result, "action": "error", "error": "File no longer exists"}
|
||||
if _thumbnail_is_fresh(src, dst):
|
||||
if not rebuild and _thumbnail_is_fresh(src, dst):
|
||||
return {**result, "action": "skipped"}
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
if is_video:
|
||||
if not self._generate_video_thumbnail(src, dst, thumbnail_size):
|
||||
made = self._generate_video_thumbnail(
|
||||
src, dst, thumbnail_size, encoding, keep_existing=rebuild
|
||||
)
|
||||
if not made:
|
||||
return {**result, "action": "error", "error": "Video decode failed"}
|
||||
else:
|
||||
_write_image_thumbnail(src, dst, thumbnail_size)
|
||||
_write_image_thumbnail(src, dst, thumbnail_size, encoding)
|
||||
return {**result, "action": "generated"}
|
||||
except UnidentifiedImageError:
|
||||
return {**result, "action": "error", "error": "Not a valid image file"}
|
||||
@@ -1924,147 +1998,6 @@ class ImageRoutesMixin:
|
||||
self.logger.warning(f"Thumbnail failed for {src.name}", exc_info=True)
|
||||
return {**result, "action": "error", "error": _safe_error(e)}
|
||||
|
||||
async def generate_thumbnails_with_progress(self, request):
|
||||
"""Generate thumbnails with Server-Sent Events progress updates (POST).
|
||||
|
||||
``quality`` is read from the query string so a POST-capable SSE
|
||||
client can keep using the same URL shape. Every blocking step (scan,
|
||||
PIL/ffmpeg) runs in the executor; the event loop only writes SSE
|
||||
frames between files.
|
||||
"""
|
||||
thumbnail_size = self._thumbnail_size(request.query.get("quality", "medium"))
|
||||
response = web.StreamResponse(
|
||||
status=200,
|
||||
headers={
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
)
|
||||
busy = self._jobs.blocker(THUMBNAILS_JOB_KIND, admission="guarded")
|
||||
if busy is not None:
|
||||
# The gallery reads this stream: refuse with its error event.
|
||||
await response.prepare(request)
|
||||
message = f"{busy.title} already running"
|
||||
await self._emit_sse(
|
||||
response, "error", {"error": message, "message": message}
|
||||
)
|
||||
return response
|
||||
async with self._jobs.hold(
|
||||
THUMBNAILS_JOB_KIND, THUMBNAIL_GENERATION_TITLE, admission="guarded"
|
||||
) as job:
|
||||
await response.prepare(request)
|
||||
if not await self._thumbnail_progress_stream(response, thumbnail_size):
|
||||
job.finish("failed") # an error event ended the stream
|
||||
return response
|
||||
|
||||
async def _thumbnail_progress_stream(self, response, thumbnail_size) -> bool:
|
||||
"""Stream one generation run; False when it ended with an error event."""
|
||||
try:
|
||||
output_dirs = self._get_all_output_dirs()
|
||||
if not output_dirs:
|
||||
await self._emit_sse(
|
||||
response, "error", {"error": "ComfyUI output directory not found"}
|
||||
)
|
||||
return False
|
||||
|
||||
await self._emit_sse(
|
||||
response,
|
||||
"status",
|
||||
{"phase": "scanning", "message": "Scanning for images and videos..."},
|
||||
)
|
||||
plan = await self._run_in_executor(
|
||||
self._plan_gallery_thumbnails, output_dirs
|
||||
)
|
||||
await self._stream_thumbnails(
|
||||
response,
|
||||
plan["targets"],
|
||||
thumbnail_size,
|
||||
skipped=plan["skipped"],
|
||||
remaining=plan["remaining"],
|
||||
)
|
||||
self.invalidate_gallery_cache()
|
||||
return True
|
||||
except Exception:
|
||||
self.logger.exception("Thumbnail generation failed")
|
||||
await self._emit_sse(
|
||||
response,
|
||||
"error",
|
||||
{
|
||||
"error": "An internal error occurred",
|
||||
"message": "Thumbnail generation failed. Check server logs.",
|
||||
},
|
||||
)
|
||||
return False
|
||||
|
||||
async def _stream_thumbnails(
|
||||
self, response, targets, thumbnail_size, skipped=0, remaining=0
|
||||
):
|
||||
"""Process pending *targets* in worker-thread batches, emitting SSE per file.
|
||||
|
||||
``skipped`` files already had fresh thumbnails and ``remaining`` were
|
||||
left for the next run; both only feed the start/complete payloads.
|
||||
"""
|
||||
total = len(targets)
|
||||
video_count = sum(1 for _, _, is_video in targets if is_video)
|
||||
message = (
|
||||
f"Found {total - video_count} images and {video_count} videos to process"
|
||||
)
|
||||
if skipped:
|
||||
message += f" ({skipped} already have thumbnails)"
|
||||
connected = await self._emit_sse(
|
||||
response,
|
||||
"start",
|
||||
{
|
||||
"total_images": total,
|
||||
"skipped": skipped,
|
||||
"remaining": remaining,
|
||||
"phase": "processing",
|
||||
"image_count": total - video_count,
|
||||
"video_count": video_count,
|
||||
"message": message,
|
||||
},
|
||||
)
|
||||
if not connected:
|
||||
return
|
||||
stats = _new_thumbnail_stats()
|
||||
stats["skipped"] = skipped
|
||||
start = _time.monotonic()
|
||||
batch_size = _thumbnail_batch_size(_worker_threads())
|
||||
index = 0
|
||||
for batch_start in range(0, total, batch_size):
|
||||
batch = targets[batch_start : batch_start + batch_size]
|
||||
results = await self._run_in_executor(
|
||||
self._generate_batch, batch, thumbnail_size
|
||||
)
|
||||
for result in results:
|
||||
index += 1
|
||||
_record_thumbnail_result(stats, result)
|
||||
if result["action"] == "error":
|
||||
await self._emit_sse(
|
||||
response,
|
||||
"file_error",
|
||||
{"file": result["file"], "error": result["error"]},
|
||||
)
|
||||
connected = await self._emit_sse(
|
||||
response,
|
||||
"progress",
|
||||
_thumbnail_progress_payload(index, total, stats, result, start),
|
||||
)
|
||||
if not connected:
|
||||
self.logger.info("Thumbnail client disconnected; stopping early")
|
||||
return
|
||||
await asyncio.sleep(0) # let other requests run between batches
|
||||
payload = _thumbnail_complete_payload(
|
||||
total + skipped + remaining,
|
||||
stats,
|
||||
start,
|
||||
remaining=remaining,
|
||||
processed=total,
|
||||
)
|
||||
await self._emit_sse(response, "complete", payload)
|
||||
self.logger.info(payload["message"])
|
||||
|
||||
async def _emit_sse(self, response, event, data) -> bool:
|
||||
"""Write one SSE frame. Returns False once the client has gone away."""
|
||||
try:
|
||||
@@ -2075,8 +2008,22 @@ class ImageRoutesMixin:
|
||||
self.logger.warning(f"Failed to send SSE message: {e}")
|
||||
return False
|
||||
|
||||
def _generate_video_thumbnail(self, video_path, thumbnail_path, thumbnail_size):
|
||||
"""Generate thumbnail from video file. Returns True if successful."""
|
||||
def _generate_video_thumbnail(
|
||||
self,
|
||||
video_path,
|
||||
thumbnail_path,
|
||||
thumbnail_size,
|
||||
encoding=None,
|
||||
keep_existing=False,
|
||||
):
|
||||
"""Generate thumbnail from video file. Returns True if successful.
|
||||
|
||||
``encoding`` is one of THUMBNAIL_ENCODINGS (medium when None). With
|
||||
``keep_existing`` an existing preview is kept, and the run fails,
|
||||
when no frame could be decoded, rather than replaced by the
|
||||
placeholder.
|
||||
"""
|
||||
encoding = encoding or DEFAULT_THUMBNAIL_ENCODING
|
||||
try:
|
||||
# Try using OpenCV first (most reliable)
|
||||
try:
|
||||
@@ -2100,7 +2047,7 @@ class ImageRoutesMixin:
|
||||
|
||||
img = Image.fromarray(frame_rgb)
|
||||
img.thumbnail(thumbnail_size, Image.Resampling.LANCZOS)
|
||||
_atomic_save(img, thumbnail_path, "JPEG", quality=85, optimize=True)
|
||||
_atomic_save(img, thumbnail_path, "JPEG", **encoding)
|
||||
self.logger.debug(
|
||||
f"Generated video thumbnail using OpenCV: {thumbnail_path}"
|
||||
)
|
||||
@@ -2148,6 +2095,12 @@ class ImageRoutesMixin:
|
||||
except (NameError, OSError):
|
||||
pass
|
||||
|
||||
if keep_existing and thumbnail_path.exists():
|
||||
self.logger.warning(
|
||||
f"No frame decoded for {video_path.name}; keeping its preview"
|
||||
)
|
||||
return False
|
||||
|
||||
# Last resort: create a placeholder thumbnail
|
||||
try:
|
||||
from PIL import ImageDraw, ImageFont
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""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``, the image tagging job's in ``py/api/image_tag_jobs.py``.
|
||||
``py/api/admin.py``, the image tagging job's in ``py/api/image_tag_jobs.py``
|
||||
and the thumbnail job's in ``py/api/thumbnail_jobs.py``.
|
||||
Every response leaves out absolute server paths: job rows already carry
|
||||
root-relative paths and public root labels.
|
||||
"""
|
||||
@@ -129,6 +130,10 @@ class JobRoutesMixin:
|
||||
async def start_image_tag_job_route(request):
|
||||
return await self.start_image_tag_job(request)
|
||||
|
||||
@routes.post("/prompt_manager/jobs/thumbnails")
|
||||
async def start_thumbnail_job_route(request):
|
||||
return await self.start_thumbnail_job(request)
|
||||
|
||||
@routes.get("/prompt_manager/jobs/{job_id}")
|
||||
async def job_detail_route(request):
|
||||
return await self.job_detail(request)
|
||||
|
||||
@@ -72,6 +72,9 @@ SCAN_GROUPS = {
|
||||
IMAGE_TAG_JOB_KIND = "image-tags"
|
||||
IMAGE_TAG_GROUP_NAMES = ("tagged", "unchanged", "skipped", "errors")
|
||||
|
||||
# Thumbnail generation (#249 System S5): previews written, already fresh, failed.
|
||||
THUMBNAIL_GROUP_NAMES = ("generated", "skipped", "errors")
|
||||
|
||||
ADMISSIONS = ("read", "guarded", "records")
|
||||
# What each admission class is refused beside (besides its own kind).
|
||||
_REFUSED_BESIDE = {
|
||||
@@ -247,6 +250,32 @@ class Job:
|
||||
)
|
||||
self.log_line(log_line)
|
||||
|
||||
def record_counted(self, group, count, rows=()):
|
||||
"""Count ``count`` outcomes in ``group`` at once, listing ``rows``.
|
||||
|
||||
For outcomes that need no log line each (a thumbnail job's already
|
||||
fresh previews): the counter is exact, ``rows`` (``record``'s row
|
||||
shape) are listed up to MAX_OUTCOME_ROWS, and nothing is logged.
|
||||
"""
|
||||
if group not in self.counters:
|
||||
raise ValueError(f"Unknown outcome group {group!r} for {self.kind}")
|
||||
self.counters[group] += max(0, int(count))
|
||||
listed = self.outcomes[group]
|
||||
for row in rows:
|
||||
if len(listed) >= MAX_OUTCOME_ROWS:
|
||||
break
|
||||
index = row.get("root_index")
|
||||
if isinstance(index, bool) or not isinstance(index, int) or index < 0:
|
||||
index = None
|
||||
listed.append(
|
||||
{
|
||||
"path": _relative_path(row.get("path")),
|
||||
"root": str(row.get("root") or ""),
|
||||
"root_index": index,
|
||||
"detail": str(row.get("detail") or ""),
|
||||
}
|
||||
)
|
||||
|
||||
def finish(self, state="finished"):
|
||||
"""End the job in ``state`` and release every subscriber.
|
||||
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
"""Thumbnail generation as an Activity job (#249 System S5, Task 12).
|
||||
|
||||
- ``GET /prompt_manager/images/thumbnails/plan?mode=&size=&quality=`` counts
|
||||
what a run would do over every gallery root and writes nothing:
|
||||
``{success, targets, fresh, remaining, roots, mode, size, quality}``.
|
||||
- ``POST /prompt_manager/jobs/thumbnails {mode, size, quality}`` starts the
|
||||
``thumbnails`` job (202 ``{job}``). Its admission class is ``guarded``: it
|
||||
never runs beside a record-changing job, and one thumbnail job runs at a
|
||||
time. It stops at a file boundary when cancelled, keeping the previews it
|
||||
wrote. Its outcome groups are ``generated``, ``skipped`` (already fresh)
|
||||
and ``errors``.
|
||||
|
||||
``mode`` is ``missing`` (previews that are absent or older than their file)
|
||||
or ``rebuild`` (every preview again, fresh ones too). ``size`` is the
|
||||
preview's longest edge, 128 to 2048 pixels. ``quality`` picks the encoder
|
||||
settings (``THUMBNAIL_ENCODINGS``). Every preview is written next to the old
|
||||
one and renamed over it, so nothing is cleared first and a failure keeps the
|
||||
old preview; originals are only read.
|
||||
|
||||
The legacy gallery stream ``POST /images/generate-thumbnails/progress``
|
||||
starts this job (its quality preset still sizes the previews) or attaches to
|
||||
the running one, and streams the job's legacy ``status``/``start``/
|
||||
``progress``/``file_error``/``complete`` frames. A client that goes away no
|
||||
longer stops the work: the job outlives the request, as a scan does.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from .admin import _plural, _scan_location, _scan_roots, _scan_status_line
|
||||
from .images import (
|
||||
THUMBNAIL_ENCODINGS,
|
||||
_BodyTooLarge,
|
||||
_new_thumbnail_stats,
|
||||
_read_json_body,
|
||||
_record_thumbnail_result,
|
||||
_thumbnail_batch_size,
|
||||
_thumbnail_complete_payload,
|
||||
_thumbnail_progress_payload,
|
||||
_worker_threads,
|
||||
)
|
||||
from .job_routes import busy_response
|
||||
from .jobs import THUMBNAIL_GROUP_NAMES, THUMBNAILS_JOB_KIND, JobBusy
|
||||
|
||||
THUMBNAIL_JOB_TITLE = "Thumbnail generation"
|
||||
THUMBNAIL_MODES = ("missing", "rebuild")
|
||||
THUMBNAIL_QUALITIES = tuple(THUMBNAIL_ENCODINGS)
|
||||
MIN_THUMBNAIL_EDGE = 128
|
||||
MAX_THUMBNAIL_EDGE = 2048
|
||||
# The concept's "Preview size limit" default.
|
||||
DEFAULT_THUMBNAIL_EDGE = 512
|
||||
DEFAULT_THUMBNAIL_OPTIONS = {
|
||||
"mode": "missing",
|
||||
"size": DEFAULT_THUMBNAIL_EDGE,
|
||||
"quality": "medium",
|
||||
}
|
||||
_OPTIONS_ERROR = (
|
||||
'mode must be "missing" or "rebuild", quality "low", "medium" or "high", '
|
||||
f"and size a whole number of pixels from {MIN_THUMBNAIL_EDGE} "
|
||||
f"to {MAX_THUMBNAIL_EDGE}"
|
||||
)
|
||||
CANCELLED_ERROR = "Thumbnail generation was cancelled"
|
||||
_MODE_LOG = {"missing": "missing previews", "rebuild": "every preview"}
|
||||
# Outcome rows: what happened to each file's preview.
|
||||
_DETAILS = {
|
||||
"generated": "Preview written",
|
||||
"skipped": "Preview already up to date",
|
||||
}
|
||||
_LOG_PREFIXES = {"generated": "+", "errors": "!"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ThumbnailOptions:
|
||||
"""One run's choices: the mode, the preview's longest edge and quality."""
|
||||
|
||||
mode: str
|
||||
edge: int
|
||||
quality: str
|
||||
|
||||
@property
|
||||
def size(self):
|
||||
return (self.edge, self.edge)
|
||||
|
||||
@property
|
||||
def encoding(self):
|
||||
return THUMBNAIL_ENCODINGS[self.quality]
|
||||
|
||||
@property
|
||||
def rebuild(self):
|
||||
return self.mode == "rebuild"
|
||||
|
||||
def scope(self):
|
||||
return {"mode": self.mode, "size": self.edge, "quality": self.quality}
|
||||
|
||||
|
||||
def _edge(value, *, from_query):
|
||||
"""The preview edge as an int, or None when it is not one in range."""
|
||||
if from_query and isinstance(value, str):
|
||||
if not value.isascii() or not value.isdigit() or len(value) > 5:
|
||||
return None
|
||||
value = int(value)
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
return None
|
||||
return value if MIN_THUMBNAIL_EDGE <= value <= MAX_THUMBNAIL_EDGE else None
|
||||
|
||||
|
||||
def thumbnail_options(raw, *, from_query=False):
|
||||
"""``(ThumbnailOptions, None)`` from a body or query, or ``(None, message)``.
|
||||
|
||||
Absent fields take ``DEFAULT_THUMBNAIL_OPTIONS``. Query values are
|
||||
strings, so a query ``size`` may be decimal digits; a JSON ``size`` must
|
||||
be an int.
|
||||
"""
|
||||
if not isinstance(raw, dict) and not hasattr(raw, "get"):
|
||||
return None, _OPTIONS_ERROR
|
||||
mode = raw.get("mode", DEFAULT_THUMBNAIL_OPTIONS["mode"])
|
||||
quality = raw.get("quality", DEFAULT_THUMBNAIL_OPTIONS["quality"])
|
||||
edge = _edge(raw.get("size", DEFAULT_THUMBNAIL_EDGE), from_query=from_query)
|
||||
if mode not in THUMBNAIL_MODES or quality not in THUMBNAIL_QUALITIES:
|
||||
return None, _OPTIONS_ERROR
|
||||
if edge is None:
|
||||
return None, _OPTIONS_ERROR
|
||||
return ThumbnailOptions(mode, edge, quality), None
|
||||
|
||||
|
||||
def _invalid(message):
|
||||
return web.json_response(
|
||||
{"success": False, "code": "invalid-request", "error": message}, status=400
|
||||
)
|
||||
|
||||
|
||||
def _frame(event, data):
|
||||
"""A legacy stream frame as the job publishes it."""
|
||||
return {"event": event, "data": data}
|
||||
|
||||
|
||||
class ThumbnailJobsMixin:
|
||||
"""The thumbnail plan, the thumbnail job and the legacy stream on it."""
|
||||
|
||||
async def get_thumbnail_plan(self, request):
|
||||
"""What a thumbnail run would do (read-only; capped like a run)."""
|
||||
options, error = thumbnail_options(request.query, from_query=True)
|
||||
if error:
|
||||
return _invalid(error)
|
||||
try:
|
||||
output_dirs = await self._run_in_executor(self._get_all_output_dirs)
|
||||
plan = await self._run_in_executor(
|
||||
self._plan_gallery_thumbnails, output_dirs, rebuild=options.rebuild
|
||||
)
|
||||
except Exception:
|
||||
self.logger.exception("Thumbnail plan failed")
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"code": None,
|
||||
"error": "The thumbnail plan could not be read",
|
||||
},
|
||||
status=500,
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"targets": len(plan["targets"]),
|
||||
"fresh": plan["fresh"],
|
||||
"remaining": plan["remaining"],
|
||||
"roots": [self._public_path(root) for root in output_dirs],
|
||||
**options.scope(),
|
||||
},
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
async def start_thumbnail_job(self, request):
|
||||
"""Start the thumbnail job (202), or 409 naming the job in the way."""
|
||||
body = {}
|
||||
if request.can_read_body:
|
||||
try:
|
||||
body = await _read_json_body(request)
|
||||
except _BodyTooLarge:
|
||||
return web.json_response(
|
||||
{"success": False, "code": "too-large", "error": "Body too large"},
|
||||
status=413,
|
||||
)
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return _invalid(_OPTIONS_ERROR)
|
||||
options, error = thumbnail_options(body)
|
||||
if error:
|
||||
return _invalid(error)
|
||||
try:
|
||||
job = self._start_thumbnail_job(options)
|
||||
except JobBusy as busy:
|
||||
return busy_response(busy)
|
||||
return web.json_response({"success": True, "job": job.summary()}, status=202)
|
||||
|
||||
def _start_thumbnail_job(self, options):
|
||||
"""Register the thumbnail job synchronously; raises JobBusy."""
|
||||
return self._jobs.start(
|
||||
THUMBNAILS_JOB_KIND,
|
||||
THUMBNAIL_JOB_TITLE,
|
||||
admission="guarded",
|
||||
cancellable=True,
|
||||
scope=options.scope(),
|
||||
runner=functools.partial(self._run_thumbnail_job, options=options),
|
||||
groups=THUMBNAIL_GROUP_NAMES,
|
||||
)
|
||||
|
||||
# ── the job ───────────────────────────────────────────────────────
|
||||
|
||||
async def _run_thumbnail_job(self, job, options):
|
||||
try:
|
||||
state = await self._thumbnail_job_body(job, options)
|
||||
except Exception:
|
||||
self.logger.exception("Thumbnail generation failed")
|
||||
job.update(status_line="Thumbnail generation stopped on an internal error.")
|
||||
job.log_line("Thumbnails failed · internal error")
|
||||
job.publish(
|
||||
_frame(
|
||||
"error",
|
||||
{
|
||||
"error": "An internal error occurred",
|
||||
"message": "Thumbnail generation failed. Check server logs.",
|
||||
},
|
||||
)
|
||||
)
|
||||
state = "failed"
|
||||
job.finish(state)
|
||||
|
||||
async def _thumbnail_job_body(self, job, options):
|
||||
"""Plan, then generate in worker batches; returns the final state."""
|
||||
output_dirs = await self._run_in_executor(self._get_all_output_dirs)
|
||||
if not output_dirs:
|
||||
message = "ComfyUI output directory not found"
|
||||
job.update(status_line=message)
|
||||
job.log_line(f"Thumbnails failed · {message}")
|
||||
job.publish(_frame("error", {"error": message}))
|
||||
return "failed"
|
||||
folders = _plural(len(output_dirs), "output folder")
|
||||
job.scope["output_folders"] = len(output_dirs)
|
||||
job.update(status_line=f"Looking for media files in {folders}")
|
||||
job.log_line(
|
||||
f"Thumbnails started · {folders} · {_MODE_LOG[options.mode]} · "
|
||||
f"{options.edge} px · {options.quality}"
|
||||
)
|
||||
job.publish(
|
||||
_frame(
|
||||
"status",
|
||||
{"phase": "scanning", "message": "Scanning for images and videos..."},
|
||||
)
|
||||
)
|
||||
plan = await self._run_in_executor(
|
||||
self._plan_gallery_thumbnails, output_dirs, rebuild=options.rebuild
|
||||
)
|
||||
roots = _scan_roots(output_dirs)
|
||||
self._record_fresh(job, plan, roots)
|
||||
stats, start = await self._generate_thumbnail_targets(job, options, plan, roots)
|
||||
self.invalidate_gallery_cache()
|
||||
generated = _plural(stats["generated"], "preview")
|
||||
errors = _plural(len(stats["errors"]), "error")
|
||||
if job.cancelled:
|
||||
job.log_line(f"Thumbnails cancelled · {generated} written")
|
||||
job.publish(
|
||||
_frame("error", {"error": CANCELLED_ERROR, "message": CANCELLED_ERROR})
|
||||
)
|
||||
return "cancelled"
|
||||
payload = _thumbnail_complete_payload(
|
||||
len(plan["targets"]) + plan["skipped"] + plan["remaining"],
|
||||
stats,
|
||||
start,
|
||||
remaining=plan["remaining"],
|
||||
processed=len(plan["targets"]),
|
||||
)
|
||||
job.update(progress=100)
|
||||
if plan["remaining"]:
|
||||
job.log_line(
|
||||
f"{plan['remaining']:,} more previews are left for the next run"
|
||||
)
|
||||
job.log_line(f"Thumbnails finished · {generated} written · {errors}")
|
||||
self.logger.info(payload["message"])
|
||||
job.publish(_frame("complete", payload))
|
||||
return "finished"
|
||||
|
||||
def _record_fresh(self, job, plan, roots):
|
||||
"""List the already fresh previews under Skipped (no log line each)."""
|
||||
rows = []
|
||||
for src in plan["fresh_files"]:
|
||||
path, label, index = _scan_location(src, roots)
|
||||
rows.append(
|
||||
{
|
||||
"path": path,
|
||||
"root": label,
|
||||
"root_index": index,
|
||||
"detail": _DETAILS["skipped"],
|
||||
}
|
||||
)
|
||||
job.record_counted("skipped", plan["skipped"], rows)
|
||||
if plan["skipped"]:
|
||||
job.log_line(f"= {plan['skipped']:,} previews already up to date")
|
||||
|
||||
async def _generate_thumbnail_targets(self, job, options, plan, roots):
|
||||
"""Generate ``plan``'s targets in worker batches, publishing each file."""
|
||||
targets = plan["targets"]
|
||||
total = len(targets)
|
||||
video_count = sum(1 for _, _, is_video in targets if is_video)
|
||||
message = (
|
||||
f"Found {total - video_count} images and {video_count} videos to process"
|
||||
)
|
||||
if plan["skipped"]:
|
||||
message += f" ({plan['skipped']} already have thumbnails)"
|
||||
job.update(total=total, status_line=_scan_status_line(0, total, 0, 0))
|
||||
job.publish(
|
||||
_frame(
|
||||
"start",
|
||||
{
|
||||
"total_images": total,
|
||||
"skipped": plan["skipped"],
|
||||
"remaining": plan["remaining"],
|
||||
"phase": "processing",
|
||||
"image_count": total - video_count,
|
||||
"video_count": video_count,
|
||||
"message": message,
|
||||
},
|
||||
)
|
||||
)
|
||||
stats = _new_thumbnail_stats()
|
||||
stats["skipped"] = plan["skipped"]
|
||||
start = time.monotonic()
|
||||
batch_size = _thumbnail_batch_size(_worker_threads())
|
||||
generate = {
|
||||
"encoding": options.encoding,
|
||||
"rebuild": options.rebuild,
|
||||
"stop": lambda: job.cancelled,
|
||||
}
|
||||
index = 0
|
||||
batches = 0
|
||||
for batch_start in range(0, total, batch_size):
|
||||
if job.cancelled:
|
||||
break
|
||||
batch = targets[batch_start : batch_start + batch_size]
|
||||
results = await self._run_in_executor(
|
||||
self._generate_batch, batch, options.size, **generate
|
||||
)
|
||||
for (src, _dst, _video), result in zip(batch, results):
|
||||
if result["action"] == "cancelled":
|
||||
continue
|
||||
index += 1
|
||||
_record_thumbnail_result(stats, result)
|
||||
self._record_thumbnail_outcome(job, src, result, roots)
|
||||
job.update(processed=index, progress=index * 100 // total)
|
||||
if result["action"] == "error":
|
||||
job.publish(
|
||||
_frame(
|
||||
"file_error",
|
||||
{"file": result["file"], "error": result["error"]},
|
||||
)
|
||||
)
|
||||
job.publish(
|
||||
_frame(
|
||||
"progress",
|
||||
_thumbnail_progress_payload(index, total, stats, result, start),
|
||||
)
|
||||
)
|
||||
batches += 1
|
||||
job.update(
|
||||
status_line=_scan_status_line(
|
||||
index, total, batches, time.monotonic() - start
|
||||
)
|
||||
)
|
||||
await asyncio.sleep(0) # let other requests run between batches
|
||||
return stats, start
|
||||
|
||||
@staticmethod
|
||||
def _record_thumbnail_outcome(job, src, result, roots):
|
||||
path, label, index = _scan_location(src, roots)
|
||||
where = f"{label}/{path}" if label else path
|
||||
if result["action"] == "error":
|
||||
group, detail = "errors", result.get("error") or "failed"
|
||||
elif result["action"] == "skipped":
|
||||
group, detail = "skipped", _DETAILS["skipped"]
|
||||
else:
|
||||
group, detail = "generated", _DETAILS["generated"]
|
||||
row = {"path": path, "root": label, "root_index": index, "detail": detail}
|
||||
if group == "skipped":
|
||||
job.record_counted(group, 1, [row])
|
||||
return
|
||||
job.record(group, row, f"{_LOG_PREFIXES[group]} {where} · {detail}")
|
||||
|
||||
# ── the legacy gallery stream ─────────────────────────────────────
|
||||
|
||||
async def generate_thumbnails_with_progress(self, request):
|
||||
"""The gallery's thumbnail stream (POST, server-sent events).
|
||||
|
||||
Starts the thumbnail job with the gallery's quality preset as the
|
||||
preview size (missing previews, medium encoding, as before), or
|
||||
attaches to the running thumbnail job, and streams its frames. A
|
||||
legacy hold (the blocking generate or clear) or a record-changing
|
||||
job refuses it with the stream's error frame. The job goes on when
|
||||
the client goes away.
|
||||
"""
|
||||
thumbnail_size = self._thumbnail_size(request.query.get("quality", "medium"))
|
||||
response = web.StreamResponse(
|
||||
status=200,
|
||||
headers={
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
)
|
||||
running = self._jobs.active(THUMBNAILS_JOB_KIND)
|
||||
job = running if running is not None and running.cancellable else None
|
||||
busy = None
|
||||
if job is None:
|
||||
busy = self._jobs.blocker(THUMBNAILS_JOB_KIND, admission="guarded")
|
||||
await response.prepare(request)
|
||||
if busy is None and job is None:
|
||||
# The gallery's preset sizes the previews; medium encodes them as
|
||||
# the gallery always did.
|
||||
options = ThumbnailOptions("missing", thumbnail_size[0], "medium")
|
||||
try:
|
||||
job = self._start_thumbnail_job(options)
|
||||
except JobBusy as refused:
|
||||
busy = refused.running
|
||||
if busy is not None:
|
||||
message = f"{busy.title} already running"
|
||||
await self._emit_sse(
|
||||
response, "error", {"error": message, "message": message}
|
||||
)
|
||||
return response
|
||||
# No await between the start and the first read: the stream subscribes
|
||||
# before the job's runner publishes its first frame.
|
||||
events = job.events()
|
||||
try:
|
||||
async for payload in events:
|
||||
frame = (
|
||||
f"event: {payload['event']}\ndata: {json.dumps(payload['data'])}"
|
||||
)
|
||||
await response.write(f"{frame}\n\n".encode("utf-8"))
|
||||
except Exception as exc: # the client went away; the job goes on
|
||||
self.logger.info(
|
||||
f"Thumbnail progress client disconnected ({exc}); "
|
||||
"the thumbnails continue in the background"
|
||||
)
|
||||
finally:
|
||||
await events.aclose()
|
||||
return response
|
||||
Reference in New Issue
Block a user