Files
ComfyAssets-ComfyUI_PromptM…/py/api/thumbnail_settings.py
T
Vito Sansevero dc8b86751e feat(thumbnails): thumbnail format, quality and size as server settings
Thumbnail format (webp default, jxl, jpeg), quality (1-100, default 80)
and size limit (128-2048 px, default 512) are server settings in the
config.json thumbnails section, saved with the settings revision. The
never-read gallery thumbnail_size is gone: older saves wrote its unused
default 256 into config.json.

One module names previews (<stem>_thumb.webp/.jxl/.jpg) and finds the
preview a source owns, preferring the configured format; legacy names
are still found. The encoder module writes every preview atomically and
verifies it decodes. JPEG XL needs no new dependency: Pillow (natively
or with pillow_jxl), ffmpeg with libjxl, or cjxl; without one the
settings refuse jxl (400 jxl-unavailable) and GET says why. Encoder
processes are tracked, bounded and killed at shutdown.
2026-10-24 12:41:06 -07:00

187 lines
6.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Thumbnail settings and names (#317): the one place every preview writer reads.
Settings (System › Appearance, ``POST /prompt_manager/settings``, config.json
``thumbnails``): ``thumbnail_format`` (``webp`` default, ``jxl``, ``jpeg``),
``thumbnail_quality`` (1–100, default 80) and ``thumbnail_edge`` (the longest
edge, 128–2048 px, default 512). ``current()`` is the snapshot a writer takes
once per preview; automatic previews, the Activity job, the history route and
the legacy gallery routes all go through it.
Names: a media file ``<rel>/<stem>.<ext>`` previews as
``thumbnails/<rel>/<stem>_thumb<extension of the format>`` (``.webp``,
``.jxl``, ``.jpg``), images and videos alike. Two files with one stem in one
folder share that name (ruling R2). Older releases kept the source's own
extension (``.png``, ``.jpeg``, ``.gif``, ``.webp``, ``.jpg``; ``.jpg`` for a
video): those are read, served and converted, never written. A preview with a
format extension (``.webp``, ``.jxl``, ``.jpg``) belongs to every same-stem
source; a legacy-only one (``.png``, ``.jpeg``, ``.gif``) only to a source
with that extension (``owns``, ruling R4). Lookups take the first existing
preview the source owns, preferring the configured format.
"""
import os
from dataclasses import dataclass
from pathlib import Path
FORMATS = ("webp", "jxl", "jpeg")
FORMAT_EXTENSIONS = {"webp": ".webp", "jxl": ".jxl", "jpeg": ".jpg"}
FORMAT_LABELS = {"webp": "WebP", "jxl": "JPEG XL", "jpeg": "JPEG"}
DEFAULT_FORMAT = "webp"
DEFAULT_QUALITY = 80
DEFAULT_EDGE = 512
MIN_QUALITY, MAX_QUALITY = 1, 100
MIN_EDGE, MAX_EDGE = 128, 2048
# Extensions a format writes, then the ones only older releases wrote.
WRITTEN_EXTENSIONS = (".webp", ".jxl", ".jpg")
LEGACY_EXTENSIONS = (".png", ".jpeg", ".gif")
THUMBNAIL_EXTENSIONS = WRITTEN_EXTENSIONS + LEGACY_EXTENSIONS
CONTENT_TYPES = {
".webp": "image/webp",
".jxl": "image/jxl",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
}
THUMB_SUFFIX = "_thumb"
@dataclass(frozen=True)
class ThumbnailSettings:
"""One preview's settings: the format, the quality and the longest edge."""
format: str
quality: int
edge: int
@property
def extension(self):
return FORMAT_EXTENSIONS[self.format]
@property
def size(self):
return (self.edge, self.edge)
def describe(self):
"""``WebP · quality 80 · 512 px`` (log lines and the job's scope)."""
return f"{FORMAT_LABELS[self.format]} · quality {self.quality} · {self.edge} px"
def current():
"""The configured settings now (read once per preview or per run)."""
from ..config import PromptManagerConfig
fmt = PromptManagerConfig.THUMBNAIL_FORMAT
return ThumbnailSettings(
fmt if fmt in FORMATS else DEFAULT_FORMAT,
int(PromptManagerConfig.THUMBNAIL_QUALITY),
int(PromptManagerConfig.THUMBNAIL_EDGE),
)
def _whole(value, low, high, message):
if type(value) is not int or not low <= value <= high:
raise ValueError(message)
return value
def validate_format(value):
if not isinstance(value, str) or value not in FORMATS:
raise ValueError('thumbnail_format must be "webp", "jxl" or "jpeg"')
return value
def validate_quality(value):
return _whole(
value,
MIN_QUALITY,
MAX_QUALITY,
f"thumbnail_quality must be a whole number from {MIN_QUALITY} to {MAX_QUALITY}",
)
def validate_edge(value):
return _whole(
value,
MIN_EDGE,
MAX_EDGE,
"thumbnail_edge must be a whole number of pixels "
f"from {MIN_EDGE} to {MAX_EDGE}",
)
# ── names ────────────────────────────────────────────────────────────
def thumbnail_stem_rel(rel_path):
"""``thumbnails/<rel without extension>_thumb`` (POSIX)."""
return f"thumbnails/{Path(rel_path).with_suffix('').as_posix()}{THUMB_SUFFIX}"
def thumbnail_rel(rel_path, extension=None):
"""The preview of *rel_path* in *extension* (the configured one when None)."""
return thumbnail_stem_rel(rel_path) + (extension or current().extension)
def owns(thumb_extension, source_extension):
"""Whether a ``_thumb<thumb_extension>`` preview can belong to a source
with *source_extension* (ruling R4)."""
thumb_extension = thumb_extension.lower()
if thumb_extension in WRITTEN_EXTENSIONS:
return True
if thumb_extension in LEGACY_EXTENSIONS:
return thumb_extension == source_extension.lower()
return False
def _legacy_extension(source_extension):
from .inspection_service import VIDEO_EXTENSIONS
source_extension = source_extension.lower()
return ".jpg" if source_extension in VIDEO_EXTENSIONS else source_extension
def candidate_extensions(source_extension, preferred=None):
"""The preview extensions a source owns, in lookup order: the configured
one, the name an older release gave it, then the other written ones."""
preferred = preferred or current().extension
ordered = [preferred, _legacy_extension(source_extension), *WRITTEN_EXTENSIONS]
seen = []
for extension in ordered:
if extension not in seen and owns(extension, source_extension):
seen.append(extension)
return tuple(seen)
def _is_preview_file(path):
try:
return not path.is_symlink() and path.is_file()
except OSError:
return False
def _owned(root, rel_path, preferred):
rel_path = Path(rel_path)
stem = Path(root) / thumbnail_stem_rel(rel_path)
for extension in candidate_extensions(rel_path.suffix, preferred):
path = stem.with_name(stem.name + extension)
if _is_preview_file(path):
yield path
def owned_thumbnails(root, rel_path, preferred=None):
"""Every existing preview of *rel_path* under *root*, in lookup order."""
return list(_owned(root, rel_path, preferred))
def existing_thumbnail(root, rel_path, preferred=None):
"""The preview a listing shows for *rel_path*: the first owned one that
exists (the configured format first), or None."""
return next(_owned(root, rel_path, preferred), None)
def content_type(path):
"""The Content-Type of a preview file by its extension, or None."""
return CONTENT_TYPES.get(os.path.splitext(str(path))[1].lower())