"""Automatic thumbnails (#249): previews are a cache the server keeps itself. The approved mock has no repeated global thumbnail prompt (``docs/concepts/workspace/README.md``), so the workspace never asks. Every listing gives an image or video a thumbnail URL (``media_thumbnail_url``), and the previews behind those URLs are made here, in three ways: - **On first view.** ``GET /prompt_manager/images/serve/thumbnails/_thumb `` for a preview that does not exist yet maps it back to its media file under the same root (text containment first, then a regular file inside the root, never through a link), makes the preview and serves it. At most ``ON_DEMAND_LIMIT`` views write at once and one flight runs per file: a second view of a preview being made waits for it. When the preview cannot be made the original is served (``Cache-Control: no-store``) with its own content type. A file that cannot be decoded is remembered for this session (keyed by its path, size and mtime) so it is not tried again on every view, and counted for the System Overview (``failed`` in ``/images/thumbnails/status``). A preview another program holds open (Windows) is not remembered: the next view tries again. - **On capture.** The image monitor hands every new output it settles to ``queue_capture_thumbnail``, which queues its preview for one background worker thread (``AutoThumbnailer``). Each wake takes at most Worker threads (``WORKER_THREADS``) files and writes them on that many threads; enqueuing is O(1), so the monitor and ComfyUI's queue never wait. A file already queued is not queued again, the queue is bounded (``MAX_QUEUED``) and a preview left out is made on its first view instead. - **After a scan.** A scan that added files checks the missing previews; from ``SCAN_THUMBNAIL_JOB_THRESHOLD`` up it starts the thumbnail Activity job (missing only, visible and cancellable) instead of writing them silently, and fewer go through the worker. Every preview is written by ``_generate_one`` with the thumbnail job's default size and quality (``DEFAULT_THUMBNAIL_EDGE``, medium), into a temp sibling renamed over the old one, so a reader never sees a partial file. Holds: automatic previews only write files under ``thumbnails/`` and never touch the database, like the thumbnail job, so they run beside a scan, image tagging and the Needs review recording (S5 M7). They are not written while a job runs that replaces the database or removes media and previews: a restore, a duplicate removal, a sources change, the gallery's thumbnail clear or an ``exclusive`` job (``_auto_thumbnail_blocker``). A view then gets the original and a queued file is left for its first view. """ import asyncio import collections import os import threading from pathlib import Path from aiohttp import web from .images import ( GALLERY_IMAGE_EXTENSIONS, MEDIA_EXTENSIONS, PREVIEW_IN_USE, PREVIEW_NOT_WRITABLE, THUMBNAIL_CLEAR_TITLE, THUMBNAIL_ENCODINGS, VIDEO_EXTENSIONS, _has_traversal, _is_within, _thumbnail_is_fresh, _worker_threads, media_thumbnail_rel, ) from .jobs import ( DUPLICATE_CLEANUP_JOB_KIND, RESTORE_JOB_KIND, THUMBNAILS_JOB_KIND, JobBusy, ) from .settings_routes import SOURCE_CHANGE_JOB_KIND from .thumbnail_jobs import DEFAULT_THUMBNAIL_EDGE, ThumbnailOptions try: from ...utils.parallel import map_parallel except ImportError: # tests import the package top-level from utils.parallel import map_parallel AUTO_THUMBNAIL_EDGE = DEFAULT_THUMBNAIL_EDGE AUTO_THUMBNAIL_SIZE = (AUTO_THUMBNAIL_EDGE, AUTO_THUMBNAIL_EDGE) AUTO_THUMBNAIL_ENCODING = THUMBNAIL_ENCODINGS["medium"] # Views that may write a preview at the same time; more wait for a slot. ON_DEMAND_LIMIT = 3 # How long a view waits for a slot or another view's flight before it is # answered with the original (the next view tries again). ON_DEMAND_WAIT_SECONDS = 20 # Files the capture worker holds; more are made on their first view. MAX_QUEUED = 10_000 # Undecodable files remembered per session (oldest forgotten first). MAX_FAILURES_REMEMBERED = 4096 # Missing previews after a scan from which the visible thumbnail job starts: # a fresh install or a newly added source. Fewer go through the worker. SCAN_THUMBNAIL_JOB_THRESHOLD = 200 # Jobs beside which no automatic preview is written (see the module docstring). AUTO_REFUSED_KINDS = frozenset( {RESTORE_JOB_KIND, DUPLICATE_CLEANUP_JOB_KIND, SOURCE_CHANGE_JOB_KIND} ) READY, FAILED, BUSY, DEFERRED = "ready", "failed", "busy", "deferred" # _generate_one errors that say nothing about the file: not remembered. _TRANSIENT_ERRORS = frozenset( {PREVIEW_IN_USE, PREVIEW_NOT_WRITABLE, "File no longer exists"} ) def _fingerprint(src): """``(path, size, mtime_ns)`` of *src*, or None when it cannot be read.""" try: stat = os.stat(src) except OSError: return None return (os.path.normcase(str(src)), stat.st_size, stat.st_mtime_ns) def _is_preview_request(filepath): """Text check only: ``thumbnails/<...>/_thumb``.""" parts = filepath.replace("\\", "/").split("/") if len(parts) < 2 or parts[0] != "thumbnails" or "thumbnails" in parts[1:-1]: return False stem, extension = os.path.splitext(parts[-1]) return ( stem.endswith("_thumb") and len(stem) > len("_thumb") and extension == extension.lower() and extension in GALLERY_IMAGE_EXTENSIONS ) def _regular_file(path): """True for a regular file that is not a link (one lstat).""" try: return not path.is_symlink() and path.is_file() except OSError: return False def _thumbnail_source(root, filepath): """``(src, dst, is_video)`` for the preview *filepath* under *root*, or None. *filepath* passed ``_is_preview_request`` and the serve route's traversal check. The source has the preview's name without ``_thumb`` and an extension that previews as the requested one (any case); a ``.jpg`` may also be a video's. It must be a regular file inside *root*. ``dst`` is None when the ``thumbnails`` folder lies outside *root* (a link): the original is then served and nothing is written there. """ root = Path(root) parts = filepath.replace("\\", "/").split("/") folder = root.joinpath(*parts[1:-1]) stem, extension = os.path.splitext(parts[-1]) base = stem[: -len("_thumb")] wanted = [extension] + (sorted(VIDEO_EXTENSIONS) if extension == ".jpg" else []) names = [base + ext for ext in wanted] + [base + ext.upper() for ext in wanted] found = next((folder / n for n in names if _regular_file(folder / n)), None) if found is None: found = _scan_folder(folder, base, set(wanted)) if found is None or not _is_within(found, root): return None rel = Path(*parts[1:-1], found.name) dst = root / media_thumbnail_rel(rel) if dst != root.joinpath(*parts): return None if not _is_within(root / "thumbnails", root) or not _is_within( dst, root / "thumbnails" ): dst = None # a preview folder linked elsewhere is never written return found, dst, found.suffix.lower() in VIDEO_EXTENSIONS def _scan_folder(folder, base, extensions): """A mixed-case extension (``.Png``): one listing of *folder*, or None.""" try: with os.scandir(folder) as entries: for entry in entries: stem, extension = os.path.splitext(entry.name) if stem == base and extension.lower() in extensions: path = Path(entry.path) if _regular_file(path): return path except OSError: return None return None class AutoThumbnailer: """Single-flight preview writes for views, and the capture worker. ``generate(src, dst, is_video)`` writes one preview and returns ``_generate_one``'s result dict; ``may_write()`` says whether automatic previews may be written now. Thread-safe: views call ``ensure`` on the executor, the monitor calls ``enqueue`` from its own thread. """ def __init__( self, generate, may_write, logger, *, workers=_worker_threads, on_demand_limit=ON_DEMAND_LIMIT, max_queued=MAX_QUEUED, start_worker=True, ): self._generate = generate self._may_write = may_write self._logger = logger self._workers = workers self._max_queued = max_queued self._start_worker = start_worker self._changed = threading.Condition() self._inflight = {} self._followers = 0 self._slot_waiters = 0 self._batch = 0 self._failures = collections.OrderedDict() self._slots = threading.BoundedSemaphore(on_demand_limit) self._queue = collections.deque() self._queued = set() self._worker = None self._closed = False self._busy = False # ── views ───────────────────────────────────────────────────────── @property def failure_count(self): with self._changed: return len(self._failures) def ensure(self, src, dst, is_video, *, on_demand=True): """Make *dst* for *src* unless it is fresh; returns the outcome. ``ready`` (the preview is there), ``failed`` (the file cannot be previewed, remembered), ``busy`` (no slot in time, a transient write error, or another flight that did not finish) or ``deferred`` (a hold refuses automatic writes). Blocking: call it off the event loop. """ if _thumbnail_is_fresh(src, dst): return READY fingerprint = _fingerprint(src) if fingerprint is None: return BUSY key = os.path.normcase(str(dst)) with self._changed: if fingerprint in self._failures: return FAILED flight = self._inflight.get(key) if flight is None: self._inflight[key] = done = threading.Event() else: self._followers += 1 self._changed.notify_all() if flight is not None: try: flight.wait(ON_DEMAND_WAIT_SECONDS) finally: with self._changed: self._followers -= 1 return self._settled(src, dst, fingerprint) try: return self._lead(src, dst, is_video, fingerprint, on_demand) finally: with self._changed: self._inflight.pop(key, None) self._changed.notify_all() done.set() def _settled(self, src, dst, fingerprint): if _thumbnail_is_fresh(src, dst): return READY with self._changed: return FAILED if fingerprint in self._failures else BUSY def _lead(self, src, dst, is_video, fingerprint, on_demand): if not self._may_write(): return DEFERRED if on_demand and not self._take_slot(): return BUSY try: if _thumbnail_is_fresh(src, dst): return READY result = self._generate(src, dst, is_video) finally: if on_demand: self._slots.release() if result.get("action") in ("generated", "skipped"): return READY error = result.get("error") or "failed" if error in _TRANSIENT_ERRORS: return BUSY with self._changed: self._failures[fingerprint] = error while len(self._failures) > MAX_FAILURES_REMEMBERED: self._failures.popitem(last=False) self._logger.info( f"No preview for {os.path.basename(str(src))} ({error}); " "the original is shown" ) return FAILED def _take_slot(self): if self._slots.acquire(blocking=False): return True with self._changed: self._slot_waiters += 1 self._changed.notify_all() try: return self._slots.acquire(timeout=ON_DEMAND_WAIT_SECONDS) finally: with self._changed: self._slot_waiters -= 1 # ── the capture worker ──────────────────────────────────────────── def enqueue(self, src, dst, is_video): """Queue *dst*'s preview for the worker; False when already queued, full or closed. Never blocks on a write.""" key = os.path.normcase(str(dst)) with self._changed: if self._closed or key in self._queued: return False if len(self._queue) >= self._max_queued: return False self._queued.add(key) self._queue.append((key, Path(src), Path(dst), bool(is_video))) self._busy = True if self._start_worker and ( self._worker is None or not self._worker.is_alive() ): self._worker = threading.Thread( target=self._run, name="PromptManagerThumbnailWorker", daemon=True, ) self._worker.start() self._changed.notify_all() return True def _run(self): while True: with self._changed: while not self._queue and not self._closed: self._busy = False self._changed.notify_all() self._changed.wait() if self._closed: self._busy = False self._changed.notify_all() return take = min(len(self._queue), max(1, int(self._workers() or 1))) batch = [self._queue.popleft() for _ in range(take)] self._batch = len(batch) self._changed.notify_all() try: map_parallel(self._write_queued, batch, len(batch)) finally: with self._changed: self._batch = 0 for key, *_rest in batch: self._queued.discard(key) def _write_queued(self, item): _key, src, dst, is_video = item try: self.ensure(src, dst, is_video, on_demand=False) except Exception: # one file never stops the worker self._logger.warning( f"Automatic preview failed for {src.name}", exc_info=True ) def close(self): """Stop the worker after its current batch; queued files are dropped.""" with self._changed: self._closed = True self._queue.clear() self._queued.clear() self._changed.notify_all() worker = self._worker if worker is not None and worker is not threading.current_thread(): worker.join(ON_DEMAND_WAIT_SECONDS) # ── waits (tests and shutdown; never timers) ────────────────────── def _wait_for(self, predicate, timeout): with self._changed: return self._changed.wait_for(predicate, timeout) def wait_idle(self, timeout=None): """True once the worker has nothing queued or in hand.""" return self._wait_for(lambda: not self._busy, timeout) def wait_followers(self, count, timeout=None): """True once *count* views wait on another view's flight.""" return self._wait_for(lambda: self._followers >= count, timeout) def wait_slot_waiters(self, count, timeout=None): """True once *count* views wait for a write slot.""" return self._wait_for(lambda: self._slot_waiters >= count, timeout) def wait_batch(self, size, timeout=None): """True once the worker holds a batch of *size* files.""" return self._wait_for(lambda: self._batch == size, timeout) class AutoThumbnailMixin: """Automatic previews on the API: views, the monitor's queue, scans.""" _auto_thumbnailer = None _auto_thumbnailer_lock = threading.Lock() def _auto_thumbnails(self): """This API's AutoThumbnailer, created on first use.""" with AutoThumbnailMixin._auto_thumbnailer_lock: if self._auto_thumbnailer is None: self._auto_thumbnailer = AutoThumbnailer( self._write_auto_thumbnail, lambda: self._auto_thumbnail_blocker() is None, self.logger, ) return self._auto_thumbnailer def close_auto_thumbnails(self): """Stop the capture worker and forget it (shutdown, a test's reset). Waits for the batch in hand, so nothing writes into the output tree after it returns; the next use starts a fresh one. """ with AutoThumbnailMixin._auto_thumbnailer_lock: thumbnailer, self._auto_thumbnailer = self._auto_thumbnailer, None if thumbnailer is not None: thumbnailer.close() def _auto_thumbnail_failures(self): thumbnailer = self._auto_thumbnailer return 0 if thumbnailer is None else thumbnailer.failure_count def _write_auto_thumbnail(self, src, dst, is_video): return self._generate_one( src, dst, AUTO_THUMBNAIL_SIZE, is_video, encoding=AUTO_THUMBNAIL_ENCODING ) def _auto_thumbnail_blocker(self): """The running job automatic previews wait for, or None (any thread).""" for job in self._jobs.running(): if job.admission == "exclusive" or job.kind in AUTO_REFUSED_KINDS: return job if job.kind == THUMBNAILS_JOB_KIND and job.title == THUMBNAIL_CLEAR_TITLE: return job return None def _history_preview(self, src, dst, is_video): """A recorded run's missing preview, made like a view's (blocking).""" return self._auto_thumbnails().ensure(Path(src), Path(dst), is_video) # ── on first view ───────────────────────────────────────────────── def _preview_on_demand(self, root, filepath): """``(outcome, src, dst)`` for a missing preview under *root*, or None.""" located = _thumbnail_source(root, filepath) if located is None: return None src, dst, is_video = located if dst is None: return DEFERRED, src, dst outcome = self._auto_thumbnails().ensure(src, dst, is_video) if outcome == READY and not dst.is_file(): outcome = BUSY # removed under us: show the original this once return outcome, src, dst async def _serve_missing_thumbnail(self, output_dirs, filepath): """The made preview, or the original when it cannot be made; None when *filepath* names no preview of a media file in *output_dirs*.""" if _has_traversal(filepath) or not _is_preview_request(filepath): return None for root in output_dirs: answer = await self._run_in_executor( self._preview_on_demand, Path(root), filepath ) if answer is None: continue outcome, src, dst = answer if outcome == READY: return self._file_response(dst) response = web.FileResponse(src) response.headers["Cache-Control"] = "no-store" return response return None # ── on capture ──────────────────────────────────────────────────── def queue_capture_thumbnail(self, image_path): """Queue the preview of a new output (the monitor's thread). Returns ``queued``, ``fresh`` (its preview is current) or ``skipped`` (not a media file under a current root, a link, a preview itself, or already queued). Never writes on the caller's thread. """ located = self._capture_target(image_path) if located is None: return "skipped" src, dst, is_video = located if _thumbnail_is_fresh(src, dst): return "fresh" if self._auto_thumbnails().enqueue(src, dst, is_video): return "queued" return "skipped" def _capture_target(self, image_path): path = Path(os.path.abspath(str(image_path))) if path.suffix.lower() not in MEDIA_EXTENSIONS or not _regular_file(path): return None folded = os.path.normcase(str(path)) for root in self._get_all_output_dirs(): root = Path(os.path.abspath(str(root))) base = os.path.normcase(str(root)).rstrip("\\/") + os.sep if not folded.startswith(base) or not _is_within(path, root): continue rel = Path(os.path.relpath(path, root)) if "thumbnails" in rel.parts[:-1]: return None if not _is_within(root / "thumbnails", root): return None dst = root / media_thumbnail_rel(rel) return path, dst, path.suffix.lower() in VIDEO_EXTENSIONS return None # ── after a scan ────────────────────────────────────────────────── async def _thumbnails_after_scan(self, job, output_dirs=None): """Hand a finished scan's missing previews on; returns what it did. None (the scan added nothing), ``job`` (the thumbnail job started for ``SCAN_THUMBNAIL_JOB_THRESHOLD`` or more missing previews), ``busy`` (a job refused it; the previews are made on view), ``queued`` (fewer, through the worker) or ``none`` (nothing missing). It counts the sources the scan walked (``output_dirs``, narrowed to the job's ``roots``; every source when None). The server log says which. """ if job.counters.get("added", 0) <= 0: return None if output_dirs is None: output_dirs = await self._run_in_executor(self._get_all_output_dirs) roots = job.scope.get("roots") if roots: output_dirs = [output_dirs[i] for i in roots if 0 <= i < len(output_dirs)] plan = await self._run_in_executor(self._plan_gallery_thumbnails, output_dirs) missing = len(plan["targets"]) + plan["remaining"] if missing >= SCAN_THUMBNAIL_JOB_THRESHOLD: options = ThumbnailOptions("missing", AUTO_THUMBNAIL_EDGE, "medium") try: self._start_thumbnail_job(options) except JobBusy as busy: self.logger.info( f"{missing:,} previews missing after the scan; the thumbnail " f"job did not start while {busy.running.title} runs" ) return "busy" self.logger.info( f"{missing:,} previews missing after the scan; thumbnail job started" ) return "job" if not missing: return "none" thumbnailer = self._auto_thumbnails() for src, dst, is_video in plan["targets"]: thumbnailer.enqueue(src, dst, is_video) self.logger.info(f"{missing:,} previews missing after the scan; queued") return "queued" async def _hand_off_scan_thumbnails(self, job, output_dirs=None): """``_thumbnails_after_scan`` for the scan runner: never raises.""" try: return await self._thumbnails_after_scan(job, output_dirs) except asyncio.CancelledError: raise except Exception: self.logger.warning("Previews after the scan failed", exc_info=True) return None