feat(thumbnails): every preview writer and lookup follows the thumbnail settings

Automatic previews (capture and first view), the thumbnail job, the
legacy gallery routes and the history route write <stem>_thumb.webp
(or .jxl/.jpg) with the configured quality and size limit, for images
and videos alike: video frames, ffmpeg's included (a PNG on stdout,
never upscaled), and the placeholder are encoded like images.

Listings, duplicates, enrichment, the autotag scan and the history
route show any preview the file owns, the current format first, so
older _thumb.png previews keep working until converted. A request for
an older name answers the current preview. Previews are served with
an explicit image/webp or image/jxl type and the same cache headers.

The plan and status count missing, outdated (another format) and
ready previews; the missing job never writes beside an outdated one.
Removing a file removes its previews in every format; the clear
removes .webp and .jxl too. Encoder processes are killed at shutdown.

A preview is only a placeholder: inspection refuses paths under
thumbnails/ and always reads the original, and the inspector cover
accepts the listing's preview in any format the file owns.
This commit is contained in:
Vito Sansevero
2026-10-24 13:19:24 -07:00
parent dc8b86751e
commit 737d3c8845
23 changed files with 1210 additions and 630 deletions
+11 -7
View File
@@ -630,16 +630,20 @@ class PromptManagerAPI(
f"{url_quote(rel_path.as_posix(), safe='/')}"
)
# Check for thumbnail
rel_no_ext = rel_path.with_suffix("")
thumb_rel = (
f"thumbnails/{rel_no_ext.as_posix()}_thumb{rel_path.suffix}"
)
# Any preview the file owns, the current format first (#317)
from . import thumbnail_settings
from .prompts import thumbnail_url_for
thumbnail_url = thumbnail_url_for(
thumb_rel, output_path / thumb_rel
existing = thumbnail_settings.existing_thumbnail(
output_path, rel_path
)
thumbnail_url = None
if existing is not None:
thumb_rel = (
thumbnail_settings.thumbnail_stem_rel(rel_path)
+ existing.suffix
)
thumbnail_url = thumbnail_url_for(thumb_rel, existing)
if thumbnail_url:
image["thumbnail_url"] = thumbnail_url
break # Found matching root, stop searching
+43 -28
View File
@@ -18,6 +18,7 @@ from pathlib import Path
from aiohttp import web
from .prompts import thumbnail_url_for
from . import thumbnail_settings
from .jobs import (
DEFAULT_SCAN_SCOPE,
SCAN_GROUP_NAMES,
@@ -146,39 +147,48 @@ def _physical_file_key(path, stat):
return ("path", os.path.normcase(os.path.realpath(str(path))))
def _thumbnail_rel_path(rel_path, thumbnail_ext):
"""Relative path of the thumbnail the gallery would generate for ``rel_path``."""
return f"thumbnails/{rel_path.with_suffix('').as_posix()}_thumb{thumbnail_ext}"
def _media_thumbnail_rel_path(rel_path):
"""The thumbnail of a media file: a video's preview is a ``.jpg``."""
extension = rel_path.suffix.lower()
return _thumbnail_rel_path(
rel_path, ".jpg" if extension in VIDEO_EXTENSIONS else rel_path.suffix
)
def _other_sources(output_path, rel_path):
"""Whether another media file shares *rel_path*'s stem in its folder."""
folder = Path(output_path) / Path(rel_path).parent
stem = Path(rel_path).stem
own = Path(rel_path).suffix.lower()
for extension in IMAGE_EXTENSIONS + VIDEO_EXTENSIONS:
if extension == own:
continue
for name in (stem + extension, stem + extension.upper()):
if (folder / name).is_file():
return True
return False
def _remove_thumbnail(output_path, rel_path, logger=None):
"""Remove the thumbnail of ``rel_path`` under ``output_path``, if any.
"""Remove the previews of ``rel_path`` under ``output_path``, if any.
Shared by the legacy duplicate delete and the reviewed removal job. A
thumbnail that is a link, or one that cannot be removed, is left alone
(a stale preview never fails a removal). Returns whether one was removed.
Shared by the legacy duplicate delete and the reviewed removal job.
Every preview the file owns goes, in any format (#317); a preview name
another same-stem file still shares (``x_thumb.webp`` of ``x.png`` and
``x.mp4``, ruling R2) stays for it. A preview that is a link, or one that
cannot be removed, is left alone (a stale preview never fails a removal).
Returns whether one was removed.
"""
output_path = Path(output_path)
thumbnail_path = output_path / _media_thumbnail_rel_path(Path(rel_path))
try:
if thumbnail_path.is_symlink() or not thumbnail_path.is_file():
return False
os.remove(thumbnail_path)
except OSError as e:
rel_path = Path(rel_path)
shared = _other_sources(output_path, rel_path)
removed = False
for thumbnail_path in thumbnail_settings.owned_thumbnails(output_path, rel_path):
extension = thumbnail_path.suffix.lower()
if shared and extension in thumbnail_settings.WRITTEN_EXTENSIONS:
continue
try:
os.remove(thumbnail_path)
except OSError as e:
if logger is not None:
logger.warning(f"Could not delete thumbnail for {rel_path}: {e}")
continue
removed = True
if logger is not None:
logger.warning(f"Could not delete thumbnail for {rel_path}: {e}")
return False
if logger is not None:
logger.debug(f"Deleted associated thumbnail: {thumbnail_path}")
return True
logger.debug(f"Deleted associated thumbnail: {thumbnail_path}")
return removed
async def _read_json(request):
@@ -845,8 +855,13 @@ class AdminRoutesMixin:
extension = media_path.suffix.lower()
is_video = extension in VIDEO_EXTENSIONS
thumb_rel = _thumbnail_rel_path(rel_path, ".jpg" if is_video else extension)
thumbnail_url = thumbnail_url_for(thumb_rel, output_path / thumb_rel)
existing = thumbnail_settings.existing_thumbnail(output_path, rel_path)
thumbnail_url = None
if existing is not None:
thumb_rel = (
thumbnail_settings.thumbnail_stem_rel(rel_path) + existing.suffix
)
thumbnail_url = thumbnail_url_for(thumb_rel, existing)
return {
"id": str(hash(str(media_path))),
+10 -6
View File
@@ -10,6 +10,7 @@ from urllib.parse import quote
from .jobs import AUTOTAG_BATCH_JOB_KIND
from .prompts import thumbnail_url_for
from . import thumbnail_settings
from .. import model_gate
from aiohttp import web
@@ -792,13 +793,16 @@ class AutotagRoutesMixin:
rel_path = image_path.relative_to(output_path)
thumbnail_url = None
if has_thumbnails:
thumb_rel = (
f"thumbnails/{rel_path.with_suffix('').as_posix()}"
f"_thumb{image_path.suffix}"
)
thumbnail_url = thumbnail_url_for(
thumb_rel, output_path / thumb_rel
# Any preview the file owns, the current format first (#317)
existing = thumbnail_settings.existing_thumbnail(
output_path, rel_path
)
if existing is not None:
thumb_rel = (
thumbnail_settings.thumbnail_stem_rel(rel_path)
+ existing.suffix
)
thumbnail_url = thumbnail_url_for(thumb_rel, existing)
images.append(
{
"filename": image_path.name,
+10 -10
View File
@@ -15,7 +15,9 @@ from typing import BinaryIO
from aiohttp import web
from . import thumbnail_settings
from .images import _canonical, _public_path, SERVABLE_EXTENSIONS, VIDEO_EXTENSIONS
from .images import media_thumbnail_rel
try:
from ...utils.execution_outputs import (
@@ -338,15 +340,11 @@ def _prepare_current_media(
if thumbnail:
relative = target.relative_to(resolved.gallery_root)
is_video = target.suffix.lower() in VIDEO_EXTENSIONS
extension = ".jpg" if is_video else target.suffix.lower()
preview = (
resolved.gallery_root
/ "thumbnails"
/ f"{relative.with_suffix('').as_posix()}_thumb{extension}"
)
try:
preview.stat()
except FileNotFoundError:
# #317: any preview the file owns (the current format first); a
# missing one is made under the current format's name.
preview = thumbnail_settings.existing_thumbnail(resolved.gallery_root, relative)
if preview is None:
preview = resolved.gallery_root / media_thumbnail_rel(relative)
if ensure_preview is not None and _preview_writable(
resolved.gallery_root, preview
):
@@ -362,7 +360,9 @@ def _prepare_current_media(
return _OpenedCurrentMedia(
handle,
os.fstat(handle.fileno()).st_size,
mimetypes.guess_type(target.name)[0] or "application/octet-stream",
thumbnail_settings.content_type(target)
or mimetypes.guess_type(target.name)[0]
or "application/octet-stream",
)
except BaseException:
handle.close()
+298 -264
View File
@@ -6,10 +6,11 @@ import binascii
import functools
import hashlib
import hmac
import io
import json
import os
import re
import threading
import subprocess # nosec B404 - ffmpeg frames through run_encoder, fixed argv
import time as _time
import urllib.parse
from bisect import bisect_left, bisect_right
@@ -21,6 +22,18 @@ from aiohttp import web
from PIL import Image, UnidentifiedImageError
from .jobs import MAX_OUTCOME_ROWS, THUMBNAILS_JOB_KIND
from . import thumbnail_settings
from .thumbnail_encode import (
PREVIEW_HELD,
EncoderClosed,
PreviewWriteHeld,
replace_unless_held,
run_encoder,
temp_sibling,
thumbnail_frame,
writer_settings,
write_thumbnail,
)
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
@@ -212,6 +225,13 @@ def _has_traversal(relative):
return ".." in re.split(r"[\\/]+", relative)
def _is_thumbnail_cache_path(relative):
"""True when *relative* names something in a root's ``thumbnails/``
cache (any case, either separator): never inspected as a media file."""
first = re.split(r"[\\/]+", relative.lstrip("\\/"), maxsplit=1)[0]
return first.casefold() == "thumbnails"
def _forbidden(message="Access denied"):
return web.json_response({"success": False, "error": message}, status=403)
@@ -256,55 +276,10 @@ def _thumbnail_batch_size(workers):
return max(4, workers * 2)
def _temp_sibling(dst):
"""Hidden temp path next to *dst* that keeps its extension (for format sniffing)."""
dst = Path(dst)
token = f"{os.getpid()}-{threading.get_ident()}"
return dst.with_name(f".{dst.stem}.part-{token}{dst.suffix}")
class PreviewWriteHeld(Exception):
"""A preview write stopped before its rename: ``guard()`` said no (#249)."""
# The result error of a write held before its rename (never remembered).
PREVIEW_HELD = "Preview writes are held while another job runs"
def _replace_unless_held(tmp, dst, guard=None):
"""Rename *tmp* over *dst*, unless ``guard()`` is false (PreviewWriteHeld)."""
if guard is not None and not guard():
raise PreviewWriteHeld(PREVIEW_HELD)
os.replace(tmp, dst)
def _atomic_save(img, dst, fmt=None, *, guard=None, **save_kwargs):
"""Save a PIL image to *dst* without a partially written file ever being
visible at that path: write a temp sibling, then rename over it.
``guard()`` (automatic previews) is asked again just before the rename;
false leaves *dst* untouched and raises PreviewWriteHeld.
"""
tmp = _temp_sibling(dst)
try:
img.save(tmp, fmt, **save_kwargs)
_replace_unless_held(tmp, dst, guard)
finally:
if tmp.exists():
tmp.unlink()
# 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"]
# ffmpeg's JPEG scale (-q:v, 2 best to 31 worst) for each encoding's quality.
_FFMPEG_QSCALE = {95: 2, 85: 4, 70: 8}
# Preview files are written by thumbnail_encode in the configured format
# (thumbnail_settings, #317); these names stay importable from here.
_temp_sibling = temp_sibling
_replace_unless_held = replace_unless_held
# A preview that could not be replaced (#249 S5 M6). Windows refuses to rename
# over a file another process has open (WinError 5 or 32), such as a preview
@@ -321,26 +296,15 @@ def _preview_write_error(exc):
return PREVIEW_NOT_WRITABLE
def _ffmpeg_qscale(encoding):
"""ffmpeg's ``-q:v`` for a THUMBNAIL_ENCODINGS entry."""
return str(_FFMPEG_QSCALE.get(encoding["quality"], 4))
def _write_image_thumbnail(src, dst, settings, guard=None, track=None):
"""Write *src*'s preview into *dst* with *settings* (blocking, atomic).
def _write_image_thumbnail(src, dst, thumbnail_size, encoding=None, guard=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. ``guard`` see _atomic_save.
The first frame, fitted inside the edge, in the configured format;
returns the path written (see thumbnail_encode.write_thumbnail).
"""
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 = dict(encoding)
if dst.suffix.lower() == ".png":
save_kwargs = {"optimize": encoding["optimize"]}
_atomic_save(img, dst, guard=guard, **save_kwargs)
frame = thumbnail_frame(img, settings.edge)
return write_thumbnail(frame, dst, settings, guard=guard, track=track)
def _video_placeholder_image(thumbnail_size):
@@ -452,6 +416,58 @@ def _thumbnail_complete_payload(total, stats, start, remaining=0, processed=None
}
# The per-file states a thumbnail plan counts (#317).
_PLAN_COUNTS = ("skipped", "remaining", "fresh", "outdated", "missing")
def _preview_names(folders, folder):
"""{normcased name: mtime} of the regular files in *folder*, listed
once per plan (a missing folder is empty)."""
names = folders.get(folder)
if names is None:
names = {}
try:
with os.scandir(folder) as entries:
for entry in entries:
try:
if entry.is_file(follow_symlinks=False):
stat = entry.stat(follow_symlinks=False)
names[os.path.normcase(entry.name)] = stat.st_mtime
except OSError:
continue
except OSError:
pass
folders[folder] = names
return names
def _preview_state(src, folder, stem, settings, folders):
"""fresh, outdated or missing for *src* (see _plan_thumbnails)."""
names = _preview_names(folders, folder)
try:
src_mtime = src.stat().st_mtime
except OSError:
return "missing"
current = names.get(os.path.normcase(stem + settings.extension))
others = [
names[key]
for key in (
os.path.normcase(stem + extension)
for extension in thumbnail_settings.candidate_extensions(
src.suffix, settings.extension
)
if extension != settings.extension
)
if key in names
]
current_fresh = current is not None and current >= src_mtime
if current_fresh and not others:
return "fresh"
if others and (current_fresh or max(others) >= src_mtime):
return "outdated"
return "missing"
def _thumbnail_is_fresh(src, dst):
"""True when ``dst`` exists and is at least as new as ``src`` (blocking stat)."""
try:
@@ -774,39 +790,42 @@ def _iter_media_files(output_path, max_files):
yield root_path / name
def media_thumbnail_rel(rel_path):
"""``thumbnails/<rel>_thumb<ext>`` (POSIX) for the media file *rel_path*.
The extension is lower case, and a video's preview is a ``.jpg``: the
name every thumbnail writer (the plan, the job, automatic previews) uses.
"""
rel_path = Path(rel_path)
extension = rel_path.suffix.lower()
return (
f"thumbnails/{rel_path.with_suffix('').as_posix()}_thumb"
f"{'.jpg' if extension in VIDEO_EXTENSIONS else extension}"
)
def media_thumbnail_rel(rel_path, settings=None):
"""``thumbnails/<rel>_thumb<ext>`` (POSIX) for the media file *rel_path*:
the name the preview gets when it is written now (#317), images and
videos alike (``writer_settings``' extension)."""
settings = settings or writer_settings()
return thumbnail_settings.thumbnail_rel(rel_path, settings.extension)
def media_thumbnail(output_path, rel_path, root_index, *, root_scope=None):
"""``(thumbnail_url, ready)`` for *rel_path* under *output_path* (one
blocking stat). The Images listing, the Needs review queue cards and the
"""``(thumbnail_url, ready)`` for *rel_path* under *output_path* (a few
blocking stats). The Images listing, the Needs review queue cards and the
Library use the same URL (#249 F, M10).
An existing preview's URL carries its mtime as ``v=`` and ``ready`` is
True. A missing one is still listed (without ``v=``): the serve route
makes it on first view and falls back when it cannot
(py/api/thumbnail_auto.py), so no card loads the full file because its
preview was not made yet. Media inside a nested ``thumbnails`` folder is
never previewed: ``(None, False)``, and its card shows the original.
An existing preview the file owns (the configured format first, then an
older name such as ``_thumb.png``, #317) is listed with its mtime as
``v=`` and ``ready`` True. A missing one is still listed under the name
it will get (without ``v=``): the serve route makes it on first view and
falls back when it cannot (py/api/thumbnail_auto.py), so no card loads the
full file because its preview was not made yet. Media inside a nested
``thumbnails`` folder is never previewed: ``(None, False)``, and its card
shows the original.
"""
rel_path = Path(rel_path)
if "thumbnails" in rel_path.parts[:-1]:
return None, False
thumb_rel = media_thumbnail_rel(rel_path)
thumbnail_url = thumbnail_url_for(thumb_rel, Path(output_path) / thumb_rel)
settings = writer_settings()
existing = thumbnail_settings.existing_thumbnail(
output_path, rel_path, settings.extension
)
thumbnail_url = None
if existing is not None:
thumb_rel = thumbnail_settings.thumbnail_stem_rel(rel_path) + existing.suffix
thumbnail_url = thumbnail_url_for(thumb_rel, existing)
ready = thumbnail_url is not None
if thumbnail_url is None:
thumb_rel = media_thumbnail_rel(rel_path, settings)
quoted = urllib.parse.quote(thumb_rel, safe="/")
thumbnail_url = f"/prompt_manager/images/serve/{quoted}?root={root_index}"
else:
@@ -1733,6 +1752,11 @@ class ImageRoutesMixin:
def _file_response(path):
response = web.FileResponse(path)
response.headers["Cache-Control"] = "public, max-age=3600"
# #317: name the image types explicitly; the platform's mimetypes
# table (the Windows registry) may not know .webp or .jxl.
content_type = thumbnail_settings.content_type(path)
if content_type is not None:
response.headers["Content-Type"] = content_type
return response
@staticmethod
@@ -1758,7 +1782,12 @@ class ImageRoutesMixin:
filepath = request.match_info["filepath"]
if _has_traversal(filepath):
return _forbidden()
if Path(filepath).suffix.lower() not in SERVABLE_EXTENSIONS:
suffix = Path(filepath).suffix.lower()
if suffix not in SERVABLE_EXTENSIONS and not (
# A JPEG XL preview is served from thumbnails/ only (#317).
suffix in thumbnail_settings.THUMBNAIL_EXTENSIONS
and _is_thumbnail_cache_path(filepath)
):
return _forbidden("Only media files can be served")
output_dirs = self._select_root(roots, request.query.get("root"))
@@ -1873,22 +1902,14 @@ class ImageRoutesMixin:
{"success": False, "error": _safe_error(e)}, status=500
)
# Fixed quality presets: clients choose a name, never a raw pixel size.
THUMBNAIL_SIZES = {"low": (150, 150), "medium": (300, 300), "high": (600, 600)}
@classmethod
def _thumbnail_size(cls, quality):
"""Map a quality preset name to a pixel size; anything else is medium."""
if not isinstance(quality, str):
return cls.THUMBNAIL_SIZES["medium"]
return cls.THUMBNAIL_SIZES.get(quality, cls.THUMBNAIL_SIZES["medium"])
async def generate_thumbnails(self, request):
"""Generate every thumbnail in one executor job (POST, blocking).
Holds the registry as guarded work: refused while a record-changing
job runs, and such jobs are refused while it runs. A body that cannot
be read is refused before the hold, so it is never listed as a job.
The legacy ``quality`` preset is accepted and ignored: previews follow
the server's thumbnail settings (#317 R3).
"""
data, error = await _json_body_or_error(request)
if error is not None:
@@ -1902,8 +1923,6 @@ class ImageRoutesMixin:
async def _generate_thumbnails(self, data):
try:
thumbnail_size = self._thumbnail_size(data.get("quality", "medium"))
output_dirs = self._get_all_output_dirs()
if not output_dirs:
return web.json_response(
@@ -1912,7 +1931,7 @@ class ImageRoutesMixin:
)
result = await self._run_in_executor(
self._generate_gallery_thumbnails_sync, output_dirs, thumbnail_size
self._generate_gallery_thumbnails_sync, output_dirs
)
self.invalidate_gallery_cache()
return web.json_response(result)
@@ -1923,30 +1942,30 @@ class ImageRoutesMixin:
{"success": False, "error": _safe_error(e)}, status=500
)
def _generate_thumbnails_sync(self, output_path, thumbnails_dir, thumbnail_size):
def _generate_thumbnails_sync(self, output_path, thumbnails_dir, settings=None):
"""Blocking thumbnail generation loop (run in executor)."""
settings = writer_settings(settings)
start = _time.monotonic()
plan = self._plan_thumbnails(output_path, thumbnails_dir)
payload = self._execute_thumbnail_plan(plan, thumbnail_size, start)
plan = self._plan_thumbnails(output_path, thumbnails_dir, settings=settings)
payload = self._execute_thumbnail_plan(plan, settings, start)
payload["thumbnails_path"] = _public_path(thumbnails_dir)
return payload
def _generate_gallery_thumbnails_sync(
self, output_dirs: list[Path], thumbnail_size: tuple[int, int]
self, output_dirs: list[Path], settings=None
) -> dict:
settings = writer_settings(settings)
start = _time.monotonic()
plan = self._plan_gallery_thumbnails(output_dirs)
payload = self._execute_thumbnail_plan(plan, thumbnail_size, start)
plan = self._plan_gallery_thumbnails(output_dirs, settings=settings)
payload = self._execute_thumbnail_plan(plan, settings, start)
payload["thumbnails_path"] = _public_path(output_dirs[0] / "thumbnails")
return payload
def _execute_thumbnail_plan(
self, plan: dict, thumbnail_size: tuple[int, int], start: float
) -> dict:
def _execute_thumbnail_plan(self, plan: dict, settings, start: float) -> dict:
targets = plan["targets"]
stats = _new_thumbnail_stats()
stats["skipped"] = plan["skipped"]
for result in self._generate_batch(targets, thumbnail_size):
for result in self._generate_batch(targets, settings):
_record_thumbnail_result(stats, result)
payload = _thumbnail_complete_payload(
len(targets) + plan["skipped"] + plan["remaining"],
@@ -1960,7 +1979,11 @@ class ImageRoutesMixin:
return payload
def _plan_gallery_thumbnails(
self, output_dirs: list[Path], rebuild: bool = False, stop=None
self,
output_dirs: list[Path],
mode: str = "missing",
stop=None,
settings=None,
) -> dict:
"""Plan every gallery root with one shared per-run generation cap.
@@ -1968,33 +1991,32 @@ class ImageRoutesMixin:
MAX_FRESH_LISTED files across the roots. ``stop()`` true ends the walk
at the next file (a cancelled job); the partial plan is returned.
"""
combined = {
"targets": [],
"skipped": 0,
"remaining": 0,
"fresh": 0,
"fresh_files": [],
}
settings = writer_settings(settings)
combined = {key: 0 for key in _PLAN_COUNTS}
combined.update(targets=[], fresh_files=[])
for root in output_dirs:
if stop is not None and stop():
break
root = Path(root)
plan = self._plan_thumbnails(
root, root / "thumbnails", rebuild=rebuild, stop=stop
root, root / "thumbnails", mode=mode, stop=stop, settings=settings
)
available = max(0, MAX_THUMBNAIL_FILES - len(combined["targets"]))
combined["targets"].extend(plan["targets"][:available])
combined["skipped"] += plan["skipped"]
combined["fresh"] += plan["fresh"]
for key in _PLAN_COUNTS:
combined[key] += plan[key]
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
)
combined["remaining"] += max(0, len(plan["targets"]) - available)
return combined
async def get_thumbnail_status(self, request: web.Request) -> web.Response:
"""Count pending thumbnails off-loop without generating files."""
"""Count previews off-loop without generating files.
``pending`` previews are missing, ``outdated`` ones exist in another
format than the one written now (Convert outdated, #317) and ``ready``
counts every file with a preview, outdated or not.
"""
try:
output_dirs = self._get_all_output_dirs()
plan = await self._run_in_executor(
@@ -2008,6 +2030,8 @@ class ImageRoutesMixin:
"total_images": pending + plan["skipped"],
"pending": pending,
"ready": plan["skipped"],
"outdated": plan["outdated"],
"format": writer_settings().format,
# Files whose automatic preview failed this session (#249).
"failed": self._auto_thumbnail_failures(),
"collection_id": hashlib.sha256(collection.encode()).hexdigest()[
@@ -2022,76 +2046,81 @@ class ImageRoutesMixin:
{"success": False, "error": _safe_error(exc)}, status=500
)
def _plan_thumbnails(self, output_path, thumbnails_dir, rebuild=False, stop=None):
"""Decide which media files need a thumbnail (blocking walk).
def _plan_thumbnails(
self, output_path, thumbnails_dir, mode="missing", stop=None, settings=None
):
"""Decide which media files need a preview (blocking walk).
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. ``stop()`` true
ends the walk before the next file.
Each file is ``fresh`` (its preview in the format written now is at
least as new as the file, and no other owned preview is left),
``outdated`` (an owned preview in another format: a legacy
``_thumb.png`` or a format switched away from, #317) or ``missing``.
``mode`` picks the targets: ``missing`` the missing ones (fresh and
outdated are ``skipped`` and never queued, so a large library that is
mostly done only pays for the files that changed), ``rebuild`` every
file, ``convert`` the outdated ones (missing ones are left to the
missing run). Returns ``{"targets": [(src, dst, is_video)], "skipped",
"remaining", "fresh", "outdated", "missing", "fresh_files": [src]}``;
``fresh_files`` lists the first MAX_FRESH_LISTED fresh files. The walk
is bounded like the gallery listing (MAX_SCAN_DEPTH, MAX_GALLERY_FILES,
no symlinks), each preview folder is listed once, and at most
MAX_THUMBNAIL_FILES targets are queued per run; ``remaining`` counts
the targets beyond that cap. ``stop()`` true ends the walk before the
next file.
"""
settings = writer_settings(settings)
output_path = Path(output_path)
targets = []
skipped = 0
remaining = 0
fresh = 0
fresh_files = []
thumbnails_dir = Path(thumbnails_dir)
plan = {key: 0 for key in _PLAN_COUNTS}
plan.update(targets=[], fresh_files=[])
folders = {}
for src in _iter_media_files(output_path, MAX_GALLERY_FILES):
if stop is not None and stop():
break
suffix = src.suffix.lower()
is_video = suffix in VIDEO_EXTENSIONS
rel_no_ext = src.relative_to(output_path).with_suffix("")
thumb_suffix = ".jpg" if is_video else suffix
dst = thumbnails_dir / f"{rel_no_ext.as_posix()}_thumb{thumb_suffix}"
stem = f"{rel_no_ext.name}{thumbnail_settings.THUMB_SUFFIX}"
dst = thumbnails_dir / rel_no_ext.parent / (stem + settings.extension)
if not _is_within(dst, thumbnails_dir):
self.logger.warning(f"Skipping thumbnail outside safe dir: {src.name}")
continue
if _thumbnail_is_fresh(src, dst):
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
state = _preview_state(src, dst.parent, stem, settings, folders)
plan[state] += 1
target = (
mode == "rebuild"
or (mode == "missing" and state == "missing")
or (mode == "convert" and state == "outdated")
)
if not target:
plan["skipped"] += 1
if state == "fresh" and len(plan["fresh_files"]) < MAX_FRESH_LISTED:
plan["fresh_files"].append(src)
continue
targets.append((src, dst, is_video))
if remaining:
if len(plan["targets"]) >= MAX_THUMBNAIL_FILES:
plan["remaining"] += 1
continue
is_video = src.suffix.lower() in VIDEO_EXTENSIONS
plan["targets"].append((src, dst, is_video))
if plan["remaining"]:
self.logger.warning(
f"Thumbnail run capped at {MAX_THUMBNAIL_FILES} new files; "
f"{remaining} more will be generated on the next run"
f"{plan['remaining']} more will be generated on the next run"
)
return {
"targets": targets,
"skipped": skipped,
"remaining": remaining,
"fresh": fresh,
"fresh_files": fresh_files,
}
return plan
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, **options):
def _generate_batch(self, targets, settings, **options):
"""Create thumbnails for ``targets`` on the configured worker threads.
Blocking; returns one result dict per target, in order. ``options``
go to ``_generate_one`` (``encoding``, ``rebuild``, ``stop``).
go to ``_generate_one`` (``rebuild``, ``stop``).
"""
def one(target):
src, dst, is_video = target
return self._generate_one(src, dst, thumbnail_size, is_video, **options)
return self._generate_one(src, dst, settings, is_video, **options)
return map_parallel(one, targets, _worker_threads())
@@ -2099,9 +2128,8 @@ class ImageRoutesMixin:
self,
src,
dst,
thumbnail_size,
settings,
is_video,
encoding=None,
rebuild=False,
stop=None,
guard=None,
@@ -2109,35 +2137,40 @@ class ImageRoutesMixin:
):
"""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. ``guard()`` false just before the rename gives ``action:
"error"`` with ``held: True`` and writes nothing; ``track(process)``
is handed the ffmpeg process (both for automatic previews, #249). An
error result carries ``decode: True`` when the file itself cannot be
decoded (an I/O error does not).
*settings* (``thumbnail_settings.ThumbnailSettings``) give the format,
quality and edge; *dst* takes the format's extension. ``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 and verified, so a failure keeps
the old preview. ``guard()`` false just before the rename gives
``action: "error"`` with ``held: True`` and writes nothing;
``track(process)`` is handed every encoder process (both for automatic
previews, #249). An error result carries ``decode: True`` when the
file itself cannot be decoded (an I/O error does not). A generated
result names the file written as ``path``.
"""
result = {
"file": src.name,
"dir": src.parent.name,
"type": "video" if is_video else "image",
}
dst = Path(dst)
if dst.suffix != settings.extension:
dst = dst.with_suffix(settings.extension)
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 not rebuild and _thumbnail_is_fresh(src, dst):
return {**result, "action": "skipped"}
return {**result, "action": "skipped", "path": str(dst)}
dst.parent.mkdir(parents=True, exist_ok=True)
if is_video:
made = self._generate_video_thumbnail(
src,
dst,
thumbnail_size,
encoding,
settings,
keep_existing=rebuild,
guard=guard,
track=track,
@@ -2149,9 +2182,10 @@ class ImageRoutesMixin:
"error": "Video decode failed",
"decode": True,
}
written = made if isinstance(made, Path) else dst
else:
_write_image_thumbnail(src, dst, thumbnail_size, encoding, guard)
return {**result, "action": "generated"}
written = _write_image_thumbnail(src, dst, settings, guard, track)
return {**result, "action": "generated", "path": str(written)}
except PreviewWriteHeld:
return {**result, "action": "error", "error": PREVIEW_HELD, "held": True}
except UnidentifiedImageError:
@@ -2186,41 +2220,44 @@ class ImageRoutesMixin:
self,
video_path,
thumbnail_path,
thumbnail_size,
encoding=None,
settings,
keep_existing=False,
*,
guard=None,
track=None,
):
"""Generate thumbnail from video file. Returns True if successful.
"""Write a video's preview; returns the path written, or False.
Tries OpenCV, then ffmpeg, then writes the placeholder frame: a
decoder that cannot open or read the clip (an AV1 file OpenCV was
built without) falls through to the next one (#249 I1). ``encoding``
is one of THUMBNAIL_ENCODINGS (medium when None); it encodes the
OpenCV frame, ffmpeg's frame (as ``-q:v``) and the placeholder alike.
Both decoders fit the frame inside ``thumbnail_size`` keeping its
aspect ratio. With ``keep_existing`` an existing preview is kept, and
the run fails, when no frame could be decoded, rather than replaced
by the placeholder. A PermissionError while replacing the preview
propagates (``_generate_one`` names it), as does PreviewWriteHeld
when ``guard()`` refuses the rename. ``track(process)`` is handed the
ffmpeg process so a shutdown can kill it.
The frame comes from OpenCV, else ffmpeg, else the placeholder frame:
a decoder that cannot open or read the clip (an AV1 file OpenCV was
built without) falls through to the next one (#249 I1). Every frame
is fitted inside the edge and encoded in *settings*' format like an
image's preview (#317). With ``keep_existing`` an existing preview is
kept, and the run fails, when no frame could be decoded, rather than
replaced by the placeholder. A PermissionError while replacing the
preview propagates (``_generate_one`` names it), as do
PreviewWriteHeld when ``guard()`` refuses the rename and
EncoderClosed at shutdown. ``track(process)`` is handed the ffmpeg
process so a shutdown can kill it.
"""
encoding = encoding or DEFAULT_THUMBNAIL_ENCODING
try:
frame = self._opencv_frame(video_path, thumbnail_size)
frame = self._opencv_frame(video_path, settings.size)
source = "OpenCV"
if frame is None:
frame = self._ffmpeg_frame(video_path, settings.size, track)
source = "ffmpeg"
if frame is not None:
_atomic_save(frame, thumbnail_path, "JPEG", guard=guard, **encoding)
self.logger.debug(
f"Generated video thumbnail using OpenCV: {thumbnail_path}"
written = write_thumbnail(
thumbnail_frame(frame, settings.edge),
thumbnail_path,
settings,
guard=guard,
track=track,
)
return True
if self._ffmpeg_frame(
video_path, thumbnail_path, thumbnail_size, encoding, guard, track
):
return True
self.logger.debug(
f"Generated video thumbnail using {source}: {written}"
)
return written
if keep_existing and thumbnail_path.exists():
self.logger.warning(
f"No frame decoded for {video_path.name}; keeping its preview"
@@ -2228,20 +2265,23 @@ class ImageRoutesMixin:
return False
# Last resort: the placeholder frame.
try:
img = _video_placeholder_image(thumbnail_size)
_atomic_save(img, thumbnail_path, "JPEG", guard=guard, **encoding)
self.logger.debug(
f"Generated placeholder video thumbnail: {thumbnail_path}"
written = write_thumbnail(
_video_placeholder_image(settings.size),
thumbnail_path,
settings,
guard=guard,
track=track,
)
return True
except (PermissionError, PreviewWriteHeld):
self.logger.debug(f"Generated placeholder video thumbnail: {written}")
return written
except (PermissionError, PreviewWriteHeld, EncoderClosed):
raise
except Exception as e:
self.logger.warning(
f"Failed to create placeholder thumbnail for {video_path}: {e}"
)
return False
except (PermissionError, PreviewWriteHeld):
except (PermissionError, PreviewWriteHeld, EncoderClosed):
raise
except Exception as e:
self.logger.error(
@@ -2275,15 +2315,14 @@ class ImageRoutesMixin:
self.logger.debug(f"OpenCV could not read {video_path}: {e}")
return None
def _ffmpeg_frame(
self, video_path, thumbnail_path, thumbnail_size, encoding, guard, track
):
"""Write ffmpeg's frame at 1 s into *thumbnail_path*; True on success."""
import subprocess # nosec B404 - fixed argv, no shell
tmp_path = _temp_sibling(thumbnail_path)
def _ffmpeg_frame(self, video_path, thumbnail_size, track=None):
"""ffmpeg's frame at 1 s, fitted to *thumbnail_size*, as a PIL image
(a PNG on stdout, never a temp file), or None. EncoderClosed (the
shutdown) propagates."""
cmd = [
"ffmpeg",
"-v",
"error",
"-i",
str(video_path),
"-ss",
@@ -2291,45 +2330,36 @@ class ImageRoutesMixin:
"-vframes",
"1",
"-vf",
f"scale={thumbnail_size[0]}:{thumbnail_size[1]}"
# Fit inside the edge, never upscale a small clip.
f"scale='min(iw,{thumbnail_size[0]})':'min(ih,{thumbnail_size[1]})'"
":force_original_aspect_ratio=decrease",
"-q:v",
_ffmpeg_qscale(encoding),
"-y",
str(tmp_path),
"-f",
"image2pipe",
"-vcodec",
"png",
"-",
]
try:
process = subprocess.Popen( # nosec B603 B607 - fixed argv
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
if track is not None:
track(process)
try:
_out, stderr = process.communicate(timeout=30)
except subprocess.TimeoutExpired:
process.kill()
process.communicate()
return False
if process.returncode == 0 and tmp_path.exists():
_replace_unless_held(tmp_path, thumbnail_path, guard)
self.logger.debug(
f"Generated video thumbnail using ffmpeg: {thumbnail_path}"
)
return True
self.logger.warning(f"ffmpeg failed for {video_path}: {stderr}")
return False
except (FileNotFoundError, OSError) as e:
if isinstance(e, PermissionError) and tmp_path.exists():
raise
return False
finally:
try:
tmp_path.unlink()
except OSError:
pass
done = run_encoder(cmd, track=track)
except EncoderClosed:
raise
except (OSError, subprocess.SubprocessError) as e:
self.logger.debug(f"ffmpeg could not read {video_path}: {e}")
return None
if done.returncode != 0 or not done.stdout:
detail = done.stderr or b""
if isinstance(detail, bytes):
detail = detail.decode("utf-8", "replace")
detail = detail[-300:]
self.logger.warning(f"ffmpeg failed for {video_path}: {detail}")
return None
try:
frame = Image.open(io.BytesIO(done.stdout))
frame.load()
return frame
except Exception as e:
self.logger.warning(f"ffmpeg gave no readable frame for {video_path}: {e}")
return None
async def clear_thumbnails(self, request: web.Request) -> web.Response:
"""Clear generated thumbnails across gallery roots, preserving originals.
@@ -2423,7 +2453,8 @@ class ImageRoutesMixin:
path = Path(root) / name
if (
"_thumb" not in name.lower()
or path.suffix.lower() not in GALLERY_IMAGE_EXTENSIONS
or path.suffix.lower()
not in thumbnail_settings.THUMBNAIL_EXTENSIONS
or path.is_symlink()
):
continue
@@ -2746,6 +2777,9 @@ class ImageRoutesMixin:
or re.match(r"^[a-zA-Z]:", relative)
):
return _forbidden()
if _is_thumbnail_cache_path(relative):
# A preview is a placeholder: it carries no metadata (#317).
return _forbidden("Previews are not inspected; inspect the original")
path = Path(roots[index]) / relative
denied = _media_access_error(path, [roots[index]])
if denied is not None:
+48 -24
View File
@@ -33,9 +33,11 @@ previews behind those URLs are made here, in three ways:
(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.
Every preview is written by ``_generate_one`` with the server's thumbnail
settings (format, quality, size limit; ``thumbnail_settings``, #317), into a
temp sibling verified and renamed over the old one, so a reader never sees a
partial file. A request for an older preview name (``_thumb.png``) is answered
with the preview in the current format.
Holds: automatic previews only write files under ``thumbnails/`` and never
touch the database, like the thumbnail job, so they run beside a scan, image
@@ -63,11 +65,10 @@ from pathlib import Path
from aiohttp import web
from . import thumbnail_settings
from .images import (
GALLERY_IMAGE_EXTENSIONS,
MEDIA_EXTENSIONS,
THUMBNAIL_CLEAR_TITLE,
THUMBNAIL_ENCODINGS,
VIDEO_EXTENSIONS,
_has_traversal,
_is_within,
@@ -83,16 +84,22 @@ from .jobs import (
JobBusy,
)
from .settings_routes import SOURCE_CHANGE_JOB_KIND
from .thumbnail_jobs import DEFAULT_THUMBNAIL_EDGE, ThumbnailOptions
from .thumbnail_encode import shutdown_encoders, writer_settings
from .thumbnail_jobs import 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"]
# The placeholder frame a video without a preview is answered with (JPEG
# bytes, never written to disk); written previews follow the settings.
PLACEHOLDER_SIZE = (thumbnail_settings.DEFAULT_EDGE, thumbnail_settings.DEFAULT_EDGE)
PLACEHOLDER_QUALITY = 85
# Source extensions in the order a shared preview name looks for them (R2).
_SOURCE_ORDER = (".png", ".jpg", ".jpeg", ".webp", ".gif") + tuple(
sorted(VIDEO_EXTENSIONS)
)
# Previews views may write at once (the size of the view pool).
ON_DEMAND_LIMIT = 3
# View requests that may wait on the gate; one more gets its fallback at once.
@@ -146,8 +153,8 @@ def _dst_key(dst):
def video_placeholder_jpeg():
"""The placeholder frame as JPEG bytes (a video whose preview is missing)."""
buffer = io.BytesIO()
_video_placeholder_image(AUTO_THUMBNAIL_SIZE).save(
buffer, "JPEG", **AUTO_THUMBNAIL_ENCODING
_video_placeholder_image(PLACEHOLDER_SIZE).save(
buffer, "JPEG", quality=PLACEHOLDER_QUALITY, optimize=True
)
return buffer.getvalue()
@@ -171,7 +178,7 @@ def _is_preview_request(filepath):
stem.endswith("_thumb")
and len(stem) > len("_thumb")
and extension == extension.lower()
and extension in GALLERY_IMAGE_EXTENSIONS
and extension in thumbnail_settings.THUMBNAIL_EXTENSIONS
)
@@ -184,12 +191,27 @@ def _regular_file(path):
def _candidates(parts):
"""``(folder parts, base, extensions)`` of a checked preview request."""
"""``(folder parts, base, source extensions)`` of a checked preview
request: every media extension that owns the requested preview name
(``thumbnail_settings.owns``), images first (ruling R2)."""
stem, extension = os.path.splitext(parts[-1])
wanted = [extension] + (sorted(VIDEO_EXTENSIONS) if extension == ".jpg" else [])
wanted = [
source
for source in _SOURCE_ORDER
if source in MEDIA_EXTENSIONS and thumbnail_settings.owns(extension, source)
]
return parts[1:-1], stem[: -len("_thumb")], wanted
def _current_preview(root, parts):
"""The preview *parts* names, in the format written now (#317): a request
for an older name (``_thumb.png`` before a conversion) is answered with
the current file."""
_folder, base, _wanted = _candidates(parts)
name = base + thumbnail_settings.THUMB_SUFFIX + writer_settings().extension
return Path(root).joinpath(*parts[:-1], name)
def _quick_source(root, parts):
"""``(src, is_video)`` by exact name only (no folder listing), or None."""
root = Path(root)
@@ -207,11 +229,12 @@ def _thumbnail_source(root, parts):
"""``(src, dst, is_video)`` for the preview *parts* under *root*, or None.
*parts* passed ``_is_preview_request``. The source has the preview's name
without ``_thumb`` and an extension that previews as the requested one
(any case: exact names first, then one listing of the folder); a ``.jpg``
may also be a video's. It must be a regular file inside *root*. ``dst``
is None when the preview's folder resolves outside ``root/thumbnails`` (a
link): nothing is written there.
without ``_thumb`` and an extension that owns the requested one (any case:
exact names first, then one listing of the folder; ruling R4). It must be
a regular file inside *root*. ``dst`` is the preview in the format written
now (#317), whatever extension was requested; it is None when the
preview's folder resolves outside ``root/thumbnails`` (a link): nothing is
written there.
"""
root = Path(root)
folder_parts, base, wanted = _candidates(parts)
@@ -224,7 +247,7 @@ def _thumbnail_source(root, parts):
return None
rel = Path(*folder_parts, found.name)
dst = root / media_thumbnail_rel(rel)
if dst != root.joinpath(*parts):
if dst != _current_preview(root, parts):
return None
if not _is_within(root / "thumbnails", root) or not _is_within(
dst, root / "thumbnails"
@@ -605,6 +628,8 @@ class AutoThumbnailMixin:
thumbnailer.close()
if pool is not None:
pool.shutdown(wait=False, cancel_futures=True)
if final:
shutdown_encoders() # kill JPEG XL / ffmpeg encoders still running
def _auto_thumbnail_failures(self):
thumbnailer = self._auto_thumbnailer
@@ -614,9 +639,8 @@ class AutoThumbnailMixin:
return self._generate_one(
src,
dst,
AUTO_THUMBNAIL_SIZE,
writer_settings(),
is_video,
encoding=AUTO_THUMBNAIL_ENCODING,
stop=stop,
guard=guard,
track=track,
@@ -730,7 +754,7 @@ class AutoThumbnailMixin:
if quick is None:
continue
return self._preview_response(BUSY, quick[0], None, quick[1])
dst = root.joinpath(*parts)
dst = _current_preview(root, parts)
if outcome == READY:
return self._file_response(dst)
located = await gate.loop.run_in_executor(
@@ -807,7 +831,7 @@ class AutoThumbnailMixin:
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")
options = ThumbnailOptions("missing", writer_settings())
try:
self._start_thumbnail_job(options)
except JobBusy as busy:
+14
View File
@@ -34,10 +34,13 @@ import os
import shutil
import subprocess # nosec B404 - fixed argv lists, never a shell
import threading
from dataclasses import replace
from pathlib import Path
from PIL import Image
from . import thumbnail_settings
try:
from ...utils.logging_config import get_logger
except ImportError: # tests import the package top-level
@@ -383,6 +386,17 @@ def _jxl_fallback():
)
def writer_settings(settings=None):
"""The settings a preview is written with now: the configured ones, but
WebP while JPEG XL has no encoder (logged once). Its extension names the
file, so "outdated" means a preview this server would not write now."""
settings = settings or thumbnail_settings.current()
if settings.format == "jxl" and detect_jxl_encoder() is None:
_jxl_fallback()
return replace(settings, format="webp")
return settings
def write_thumbnail(frame, dst, settings, *, guard=None, track=None):
"""Encode *frame* into *dst* in ``settings``' format; returns the path
written (``.webp`` when JPEG XL fell back). Atomic and verified; raises
+56 -85
View File
@@ -1,29 +1,31 @@
"""Thumbnail generation as an Activity job (#249 System S5, Task 12).
"""Thumbnail generation as an Activity job (#249 System S5, Task 12; #317).
- ``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``.
- ``GET /prompt_manager/images/thumbnails/plan?mode=`` counts what a run
would do over every gallery root and writes nothing: ``{success, targets,
fresh, outdated, remaining, roots, mode, format, quality, size}``.
- ``POST /prompt_manager/jobs/thumbnails {mode}`` 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.
or ``rebuild`` (every preview again, fresh ones too). Format, quality and
size limit are the server's thumbnail settings (``thumbnail_settings``,
#317), read once when the job starts and shown in its scope; a ``size`` or
``quality`` an older client sends is ignored. Every preview is written next
to the old one, verified 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. Its first frame, ``job {id, started}``, names that job and
whether this stream started it (the gallery cancels by that id, and only a
job it started); then come 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.
starts this job (missing previews, the server's settings; its ``quality``
preset is ignored, #317 R3) or attaches to the running one. Its first frame,
``job {id, started}``, names that job and whether this stream started it (the
gallery cancels by that id, and only a job it started); then come 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
@@ -36,7 +38,6 @@ 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,
@@ -48,24 +49,12 @@ from .images import (
)
from .job_routes import busy_response
from .jobs import THUMBNAIL_GROUP_NAMES, THUMBNAILS_JOB_KIND, JobBusy
from .thumbnail_encode import writer_settings
from .thumbnail_settings import ThumbnailSettings
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}"
)
_OPTIONS_ERROR = 'mode must be "missing" or "rebuild"'
CANCELLED_ERROR = "Thumbnail generation was cancelled"
CANCELLED_BEFORE_WRITING = "Cancelled before any preview was written"
NOTHING_TO_PREVIEW = "No images or videos to preview"
@@ -80,56 +69,35 @@ _LOG_PREFIXES = {"generated": "+", "errors": "!"}
@dataclass(frozen=True)
class ThumbnailOptions:
"""One run's choices: the mode, the preview's longest edge and quality."""
"""One run's choices: the mode and the thumbnail settings it writes with."""
mode: str
edge: int
quality: str
@property
def size(self):
return (self.edge, self.edge)
@property
def encoding(self):
return THUMBNAIL_ENCODINGS[self.quality]
settings: ThumbnailSettings
@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
return {
"mode": self.mode,
"format": self.settings.format,
"quality": self.settings.quality,
"size": self.settings.edge,
}
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.
An absent mode is ``missing``; the settings are the server's now.
"""
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:
mode = raw.get("mode", "missing")
if mode not in THUMBNAIL_MODES:
return None, _OPTIONS_ERROR
if edge is None:
return None, _OPTIONS_ERROR
return ThumbnailOptions(mode, edge, quality), None
return ThumbnailOptions(mode, writer_settings()), None
def _invalid(message):
@@ -168,7 +136,10 @@ class ThumbnailJobsMixin:
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
self._plan_gallery_thumbnails,
output_dirs,
options.mode,
settings=options.settings,
)
except Exception:
self.logger.exception("Thumbnail plan failed")
@@ -185,6 +156,7 @@ class ThumbnailJobsMixin:
"success": True,
"targets": len(plan["targets"]),
"fresh": plan["fresh"],
"outdated": plan["outdated"],
"remaining": plan["remaining"],
"roots": [self._public_path(root) for root in output_dirs],
**options.scope(),
@@ -265,7 +237,7 @@ class ThumbnailJobsMixin:
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}"
f"{options.settings.describe()}"
)
job.publish(
_frame(
@@ -276,8 +248,9 @@ class ThumbnailJobsMixin:
plan = await self._run_in_executor(
self._plan_gallery_thumbnails,
output_dirs,
rebuild=options.rebuild,
options.mode,
stop=lambda: job.cancelled,
settings=options.settings,
)
if job.cancelled: # M2: stopped during the walk, before any start frame
return self._end_cancelled(job, 0, CANCELLED_BEFORE_WRITING)
@@ -367,7 +340,6 @@ class ThumbnailJobsMixin:
start = time.monotonic()
batch_size = _thumbnail_batch_size(_worker_threads())
generate = {
"encoding": options.encoding,
"rebuild": options.rebuild,
"stop": lambda: job.cancelled,
}
@@ -378,7 +350,7 @@ class ThumbnailJobsMixin:
break
batch = targets[batch_start : batch_start + batch_size]
results = await self._run_in_executor(
self._generate_batch, batch, options.size, **generate
self._generate_batch, batch, options.settings, **generate
)
for (src, _dst, _video), result in zip(batch, results):
if result["action"] == "cancelled":
@@ -430,14 +402,13 @@ class ThumbnailJobsMixin:
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
Starts the thumbnail job (missing previews with the server's
thumbnail settings; the gallery's ``quality`` preset is ignored, R3),
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={
@@ -449,7 +420,7 @@ class ThumbnailJobsMixin:
await response.prepare(request)
# M3: read the registry after the last await, so a job that started
# while the headers went out is attached to, not refused.
job, started, busy = self._legacy_stream_job(thumbnail_size)
job, started, busy = self._legacy_stream_job()
if busy is not None:
message = f"{busy.title} already running"
await self._emit_sse(
@@ -476,12 +447,12 @@ class ThumbnailJobsMixin:
await events.aclose()
return response
def _legacy_stream_job(self, thumbnail_size):
def _legacy_stream_job(self):
"""``(job, started, busy)`` for the legacy stream, synchronously.
Attaches to the running cancellable thumbnail job, or starts one with
the gallery's preset as the size (missing previews, medium encoding,
as the gallery always wrote), or names the job in the way.
Attaches to the running cancellable thumbnail job, or starts one for
the missing previews with the server's settings, or names the job in
the way.
"""
running = self._jobs.active(THUMBNAILS_JOB_KIND)
if running is not None and running.cancellable:
@@ -490,5 +461,5 @@ class ThumbnailJobsMixin:
if busy is not None:
return None, False, busy
# blocker() said free and nothing awaited since, so the start is admitted.
options = ThumbnailOptions("missing", thumbnail_size[0], "medium")
options = ThumbnailOptions("missing", writer_settings())
return self._start_thumbnail_job(options), True, None
+19 -3
View File
@@ -164,10 +164,26 @@ def _is_preview_file(path):
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):
extensions = list(candidate_extensions(rel_path.suffix, preferred))
if rel_path.suffix != rel_path.suffix.lower() and owns(
rel_path.suffix, rel_path.suffix
):
# An older release kept the source's own spelling (``b_thumb.JPG``).
extensions.append(rel_path.suffix)
seen = set()
for extension in extensions:
path = stem.with_name(stem.name + extension)
if _is_preview_file(path):
yield path
if not _is_preview_file(path):
continue
try:
stat = path.stat()
identity = (stat.st_dev, stat.st_ino) if stat.st_ino else str(path)
except OSError:
continue
if identity in seen:
continue # the same file under another case (Windows, macOS)
seen.add(identity)
yield path
def owned_thumbnails(root, rel_path, preferred=None):
+6 -1
View File
@@ -355,7 +355,12 @@ def create_app(root: Path) -> web.Application:
with Image.new("RGB", (64, 64), (100, 80, 120)) as image:
image.save(root / "output" / name, pnginfo=info)
os.utime(root / "output" / name, (time.time() - 100, time.time() - 100))
api._generate_gallery_thumbnails_sync(api._get_all_output_dirs(), (150, 150))
from py.api.thumbnail_settings import ThumbnailSettings
# 150 px previews in the default format (#317 thumbnail settings).
api._generate_gallery_thumbnails_sync(
api._get_all_output_dirs(), ThumbnailSettings("webp", 80, 150)
)
if include_video:
# Seed after thumbnail generation to exercise a real unthumbnailed video.
video = root / "output" / "clip.webm"
+2 -2
View File
@@ -399,7 +399,7 @@ test('real video media has a usable card and native playback cleans up on close
await expect(await request.post('/__test__/reset', { data: { include_video: true } })).toBeOK();
const media = await (await request.get('/prompt_manager/images/output?limit=40')).json();
// #249: a video with no preview yet still lists its preview URL; the server makes the frame on first view.
expect(media.images.find(image => image.filename === 'clip.webm').thumbnail_url).toMatch(/\/thumbnails\/clip_thumb\.jpg\?root=0/);
expect(media.images.find(image => image.filename === 'clip.webm').thumbnail_url).toMatch(/\/thumbnails\/clip_thumb\.webp\?root=0/);
await page.goto(workspace);
await refine(page, { Media: 'video' });
await expect(cards(page)).toHaveCount(1);
@@ -722,7 +722,7 @@ test('refresh releases the previous inspector cover source', async ({ page, requ
await card.getByRole('button', { name: 'Inspect clip.webm', exact: true }).click();
// #249: a video's cover is its preview frame, not the video.
const cover = details(page).locator('img.detail-cover');
await expect(cover).toHaveAttribute('src', /\/thumbnails\/clip_thumb\.jpg\?/);
await expect(cover).toHaveAttribute('src', /\/thumbnails\/clip_thumb\.webp\?/);
await expect(details(page).locator('video')).toHaveCount(0);
await cover.evaluate(element => { window.__oldInspectorCover = element; });
await rescan(page);
+4 -4
View File
@@ -44,7 +44,7 @@ test('a page of the Images grid requests previews, never originals, in every lay
const listing = await (await request.get('/prompt_manager/images/output?limit=40')).json();
const fresh = listing.images.filter(image => image.path.startsWith('unthumbnailed/'));
expect(fresh).toHaveLength(6);
for (const image of fresh) expect(image.thumbnail_url).toMatch(/\/thumbnails\/unthumbnailed\/plain_\d{3}_thumb\.png\?root=0/);
for (const image of fresh) expect(image.thumbnail_url).toMatch(/\/thumbnails\/unthumbnailed\/plain_\d{3}_thumb\.webp\?root=0/);
const seen = recordMedia(page);
await page.goto(images);
await expect(cards(page)).toHaveCount(8);
@@ -73,7 +73,7 @@ test('the inspector cover is the preview and only the media viewer loads the ori
const card = cards(page).filter({ hasText: 'plain_001.png' });
await card.getByRole('button', { name: 'Inspect plain_001.png', exact: true }).click();
const cover = details(page).locator('img.detail-cover');
await expect(cover).toHaveAttribute('src', /\/thumbnails\/unthumbnailed\/plain_001_thumb\.png\?/);
await expect(cover).toHaveAttribute('src', /\/thumbnails\/unthumbnailed\/plain_001_thumb\.webp\?/);
await settled(page);
await cover.evaluate(image => image.decode().catch(() => {}));
expect(originals(seen)).toEqual([]);
@@ -88,7 +88,7 @@ test('a video card shows its preview frame instead of loading the video', async
const seen = recordMedia(page);
await page.goto(images);
const card = cards(page).filter({ hasText: 'clip.webm' });
await expect(card.locator('.media-art > .image-open > img')).toHaveAttribute('src', /\/thumbnails\/clip_thumb\.jpg\?/);
await expect(card.locator('.media-art > .image-open > img')).toHaveAttribute('src', /\/thumbnails\/clip_thumb\.webp\?/);
await expect(card.locator('video')).toHaveCount(0);
await settled(page);
expect(originals(seen)).toEqual([]);
@@ -107,7 +107,7 @@ test('Library cards and the shared gallery request previews only', async ({ page
test('a video whose preview cannot be loaded swaps back to its own element', async ({ page, request }) => {
await expect(await request.post('/__test__/reset', { data: { include_video: true } })).toBeOK();
await page.route(url => url.pathname.endsWith('/thumbnails/clip_thumb.jpg'), route => route.fulfill({ status: 404, body: '' }));
await page.route(url => url.pathname.endsWith('/thumbnails/clip_thumb.webp'), route => route.fulfill({ status: 404, body: '' }));
await page.goto(images);
const card = cards(page).filter({ hasText: 'clip.webm' });
await expect(card.locator('.media-art > .image-open > video')).toHaveCount(1);
+6 -1
View File
@@ -513,7 +513,12 @@ def seed_workspace_concept(
tags = [row[0] for row in conn.execute("SELECT name FROM tags ORDER BY id")]
if tuple(counts) != (len(CONCEPT_PROMPTS), CONCEPT_RUN_COUNT, CONCEPT_RUN_COUNT):
raise RuntimeError(f"Concept fixture run counts are not real: {tuple(counts)}")
api._generate_gallery_thumbnails_sync(api._get_all_output_dirs(), (150, 150))
from py.api.thumbnail_settings import ThumbnailSettings
# 150 px previews in the default format (#317 thumbnail settings).
api._generate_gallery_thumbnails_sync(
api._get_all_output_dirs(), ThumbnailSettings("webp", 80, 150)
)
api.invalidate_gallery_cache()
return {
"epoch": epoch,
+6 -1
View File
@@ -148,7 +148,12 @@ def seed_tagging(api: PromptManagerAPI, root: Path, extra_images: int = 0) -> di
image_id = db.link_image_to_prompt(positive_id, str(output / relative))
if kind == "linked":
db.link_image_to_pair(pair_id, image_id, {"source": "fixture"})
api._generate_gallery_thumbnails_sync(api._get_all_output_dirs(), (150, 150))
from py.api.thumbnail_settings import ThumbnailSettings
# 150 px previews in the default format (#317 thumbnail settings).
api._generate_gallery_thumbnails_sync(
api._get_all_output_dirs(), ThumbnailSettings("webp", 80, 150)
)
api.invalidate_gallery_cache()
return {
"positive_id": positive_id,
+6 -1
View File
@@ -164,5 +164,10 @@ def seed_workspace_visual(api: PromptManagerAPI, root: Path) -> None:
path = root / directory / category.lower() / filename
_write_art(path, index, positive, negatives[offset % len(negatives)])
api.db.link_image_to_prompt(prompt_id, str(path))
api._generate_gallery_thumbnails_sync(api._get_all_output_dirs(), (150, 150))
from py.api.thumbnail_settings import ThumbnailSettings
# 150 px previews in the default format (#317 thumbnail settings).
api._generate_gallery_thumbnails_sync(
api._get_all_output_dirs(), ThumbnailSettings("webp", 80, 150)
)
api.invalidate_gallery_cache()
@@ -913,6 +913,26 @@ test('record carries the listing thumbnail as its preview, never another file (#
assert.deepEqual(toRecord(data, video, extractGenerationFacts).preview, { url: video.thumbnail_url });
});
test('the preview may be any thumbnail format the file owns (#317)', () => {
const toRecord = contract('toInspectionRecord'), entry = image();
const base = '/prompt_manager/images/serve/thumbnails/folder/a%20%23%25_thumb';
for (const extension of ['.webp', '.jxl', '.jpg', '.png']) {
const thumbnail_url = `${base}${extension}?v=3&root=0&root_scope=${scopeA}`;
assert.deepEqual(toRecord(envelope(entry), { ...entry, thumbnail_url }, extractGenerationFacts).preview, { url: thumbnail_url }, extension);
}
// Not a thumbnail format, upper case, no extension, or a legacy name another file owns: no preview.
for (const extension of ['.txt', '.WEBP', '', '.gif', '.jpeg', '.webp.png']) {
const thumbnail_url = `${base}${extension}?root=0&root_scope=${scopeA}`;
assert.equal(toRecord(envelope(entry), { ...entry, thumbnail_url }, extractGenerationFacts).preview, null, extension);
}
const video = { root_index: 0, path: 'clip.MP4', filename: 'clip.MP4', url: `/prompt_manager/images/serve/clip.MP4?root=0&root_scope=${scopeA}`, root_scope: scopeA };
const data = envelope(video); data.file = { ...data.file, media_type: 'video', extension: '.mp4', format: 'MP4' };
for (const [extension, owned] of [['.webp', true], ['.jxl', true], ['.jpg', true], ['.png', false]]) {
const thumbnail_url = `/prompt_manager/images/serve/thumbnails/clip_thumb${extension}?root=0&root_scope=${scopeA}`;
assert.deepEqual(toRecord(data, { ...video, thumbnail_url }, extractGenerationFacts).preview, owned ? { url: thumbnail_url } : null, extension);
}
});
test("names the server quotes (parentheses, '!*') still match their original and preview (#249 M11)", () => {
const toRecord = contract('toInspectionRecord');
const entry = { root_index: 0, path: "folder/a (1)!'*.png", filename: "a (1)!'*.png", root_scope: scopeA,
+4 -3
View File
@@ -431,10 +431,11 @@ class HistoryOutputAPITests(ImageAPITestCase):
self.assertEqual(int(response.headers["Content-Length"]), len(payload))
self.assertEqual(await response.read(), b"")
# A missing preview is made on first view (#249): an undecodable video
# gets the placeholder frame, never the whole file.
preview = self.output_dir / "thumbnails" / "clip_thumb.jpg"
# gets the placeholder frame, never the whole file, written in the
# thumbnail format (#317, WebP by default).
preview = self.output_dir / "thumbnails" / "clip_thumb.webp"
response = await self.current(descriptor["thumbnail_url"])
self.assertEqual(response.headers["Content-Type"], "image/jpeg")
self.assertEqual(response.headers["Content-Type"], "image/webp")
self.assertEqual(await response.read(), preview.read_bytes())
self.assertLess(preview.stat().st_size, len(payload))
make_png(preview)
+54 -38
View File
@@ -33,7 +33,8 @@ from aiohttp.test_utils import AioHTTPTestCase # noqa: E402
from PIL import Image # noqa: E402
from database.operations import PromptDatabase # noqa: E402
import py.api.images as images_module # noqa: E402
import py.api.images as images_module
from py.api.thumbnail_settings import ThumbnailSettings # noqa: E402
from py.api import PromptManagerAPI # noqa: E402
from py.api.prompts import MAX_PAGE_LIMIT # noqa: E402
from py.config import GalleryConfig # noqa: E402
@@ -397,7 +398,7 @@ class ThumbnailTestCase(ImageAPITestCase):
self.api._run_in_executor = spy
return calls
def _thumb_path(self, name, suffix=".png"):
def _thumb_path(self, name, suffix=".webp"):
return self.output_dir / "thumbnails" / f"{name}_thumb{suffix}"
@@ -588,14 +589,15 @@ class TestThumbnailProgressStream(ThumbnailTestCase):
events = parse_sse(await resp.text())
self.assertEqual(events[-1][0], "error")
async def test_unknown_quality_falls_back_to_medium(self):
make_png(self.output_dir / "one.png", size=(400, 400))
async def test_any_quality_preset_uses_the_server_size_limit(self):
# #317 R3: the server's thumbnail settings size every preview.
make_png(self.output_dir / "one.png", size=(1000, 1000))
resp = await self.client.request("POST", f"{self.PROGRESS_URL}?quality=huge")
await resp.text()
with Image.open(self._thumb_path("one")) as thumb:
self.assertEqual(max(thumb.size), 300)
self.assertEqual(max(thumb.size), 512)
class TestGenerateThumbnailsPost(ThumbnailTestCase):
@@ -910,7 +912,8 @@ class TestThumbnailFilesAreNeverSeenHalfWritten(ThumbnailTestCase):
make_png(self.output_dir / "a.png", size=(64, 64))
thumbs = self.output_dir / "thumbnails"
thumbs.mkdir()
dst = thumbs / "a_thumb.png"
dst = thumbs / "a_thumb.webp"
settings = ThumbnailSettings("webp", 80, 32)
def crash_mid_write(self_img, fp, *args, **kwargs):
# Simulates an interrupted encoder: bytes on disk, then failure.
@@ -920,14 +923,14 @@ class TestThumbnailFilesAreNeverSeenHalfWritten(ThumbnailTestCase):
with patch.object(Image.Image, "save", crash_mid_write):
with self.assertRaises(OSError):
images_module._write_image_thumbnail(
self.output_dir / "a.png", dst, (32, 32)
self.output_dir / "a.png", dst, settings
)
self.assertFalse(dst.exists(), "a half-written thumbnail was left in place")
self.assertEqual(os.listdir(thumbs), [])
images_module._write_image_thumbnail(self.output_dir / "a.png", dst, (32, 32))
images_module._write_image_thumbnail(self.output_dir / "a.png", dst, settings)
self.assertTrue(dst.is_file())
self.assertEqual(os.listdir(thumbs), ["a_thumb.png"])
self.assertEqual(os.listdir(thumbs), ["a_thumb.webp"])
def test_thumbnail_url_is_versioned_by_its_mtime(self):
import os
@@ -935,7 +938,9 @@ class TestThumbnailFilesAreNeverSeenHalfWritten(ThumbnailTestCase):
make_png(self.output_dir / "a.png", size=(64, 64))
self.api._generate_thumbnails_sync(
self.output_dir, self.output_dir / "thumbnails", (32, 32)
self.output_dir,
self.output_dir / "thumbnails",
ThumbnailSettings("webp", 80, 32),
)
thumb = self._thumb_path("a")
@@ -944,7 +949,7 @@ class TestThumbnailFilesAreNeverSeenHalfWritten(ThumbnailTestCase):
)
self.assertRegex(
entry["thumbnail_url"],
r"^/prompt_manager/images/serve/thumbnails/a_thumb\.png\?v=\d+&root=0$",
r"^/prompt_manager/images/serve/thumbnails/a_thumb\.webp\?v=\d+&root=0$",
)
first = int(re.search(r"v=(\d+)", entry["thumbnail_url"]).group(1))
self.assertEqual(first, int(thumb.stat().st_mtime))
@@ -960,7 +965,9 @@ class TestThumbnailFilesAreNeverSeenHalfWritten(ThumbnailTestCase):
async def test_versioned_thumbnail_url_is_served(self):
make_png(self.output_dir / "a.png", size=(64, 64))
self.api._generate_thumbnails_sync(
self.output_dir, self.output_dir / "thumbnails", (32, 32)
self.output_dir,
self.output_dir / "thumbnails",
ThumbnailSettings("webp", 80, 32),
)
entry = images_module._output_image_entry(
self.output_dir / "a.png", self.output_dir, 0
@@ -969,7 +976,7 @@ class TestThumbnailFilesAreNeverSeenHalfWritten(ThumbnailTestCase):
resp = await self.client.request("GET", entry["thumbnail_url"])
self.assertEqual(resp.status, 200)
self.assertEqual(resp.headers["Content-Type"], "image/png")
self.assertEqual(resp.headers["Content-Type"], "image/webp")
async def test_missing_image_404_is_not_cacheable(self):
resp = await self.client.request(
@@ -1024,7 +1031,9 @@ class TestMediaScanCaps(ImageAPITestCase):
for name in ("a", "b", "c"):
make_png(self.output_dir / f"{name}.png")
self.api._generate_thumbnails_sync(
self.output_dir, self.output_dir / "thumbnails", (64, 64)
self.output_dir,
self.output_dir / "thumbnails",
ThumbnailSettings("webp", 80, 64),
)
for name in ("d", "e", "f"):
make_png(self.output_dir / f"{name}.png")
@@ -1083,11 +1092,6 @@ class TestMediaScanCaps(ImageAPITestCase):
self.assertEqual(targets, [])
def test_thumbnail_size_ignores_non_string_quality(self):
self.assertEqual(self.api._thumbnail_size({"w": 99999}), (300, 300))
self.assertEqual(self.api._thumbnail_size(None), (300, 300))
self.assertEqual(self.api._thumbnail_size("low"), (150, 150))
class TestJsonBodyCaps(ImageAPITestCase):
"""JSON bodies are read in chunks and rejected past MAX_JSON_BODY_BYTES."""
@@ -1458,7 +1462,7 @@ class TestGalleryListing(ImageRouteCoverageCase):
class TestThumbnailUnits(ImageRouteCoverageCase):
SIZE = (64, 64)
SIZE = ThumbnailSettings("webp", 80, 64)
def test_generate_one_reports_vanished_source(self):
result = self.api._generate_one(
@@ -1471,11 +1475,9 @@ class TestThumbnailUnits(ImageRouteCoverageCase):
src = self.output_dir / "clip.mp4"
src.write_bytes(b"\x00")
with patch.object(self.api, "_generate_video_thumbnail", return_value=True):
ok = self.api._generate_one(src, self._dst("clip", ".jpg"), self.SIZE, True)
ok = self.api._generate_one(src, self._dst("clip"), self.SIZE, True)
with patch.object(self.api, "_generate_video_thumbnail", return_value=False):
bad = self.api._generate_one(
src, self._dst("clip", ".jpg"), self.SIZE, True
)
bad = self.api._generate_one(src, self._dst("clip"), self.SIZE, True)
self.assertEqual((ok["action"], ok["type"]), ("generated", "video"))
self.assertEqual(bad["action"], "error")
@@ -1498,7 +1500,7 @@ class TestThumbnailUnits(ImageRouteCoverageCase):
)
self.assertEqual(result["count"], 2)
self.assertTrue(self._dst("alpha").is_file())
self.assertTrue(self._dst("photo", ".jpg").is_file())
self.assertTrue(self._dst("photo").is_file())
async def test_emit_sse_swallows_write_errors(self):
response = MagicMock()
@@ -1521,14 +1523,15 @@ class TestThumbnailUnits(ImageRouteCoverageCase):
)
self.assertEqual(status, 500)
def _dst(self, name, suffix=".png"):
def _dst(self, name, suffix=".webp"):
return self.output_dir / "thumbnails" / f"{name}_thumb{suffix}"
class TestVideoThumbnailFallbacks(ImageRouteCoverageCase):
"""_generate_video_thumbnail degrades: cv2 -> ffmpeg -> placeholder."""
SIZE = (48, 48)
# JPEG keeps these checks format-specific; #317 settings carry the edge.
SIZE = ThumbnailSettings("jpeg", 85, 48)
@property
def video(self):
@@ -1581,7 +1584,7 @@ class TestVideoThumbnailFallbacks(ImageRouteCoverageCase):
)
self.assertTrue(ok)
with Image.open(self.thumb) as img:
self.assertEqual((img.format, img.size), ("JPEG", self.SIZE))
self.assertEqual((img.format, img.size), ("JPEG", self.SIZE.size))
def test_cv2_crash_is_contained(self):
cv2 = MagicMock()
@@ -1614,18 +1617,26 @@ class TestVideoThumbnailFallbacks(ImageRouteCoverageCase):
ok = self.api._generate_video_thumbnail(self.video, self.thumb, self.SIZE)
self.assertTrue(ok)
with Image.open(self.thumb) as img:
self.assertEqual(img.size, self.SIZE)
self.assertEqual(img.size, self.SIZE.size)
@staticmethod
def _process(returncode, stderr=""):
def _process(returncode, stderr=b"", stdout=b""):
process = MagicMock(returncode=returncode)
process.communicate.return_value = ("", stderr)
process.communicate.return_value = (stdout, stderr)
return process
@staticmethod
def _png_frame(size=(96, 64)):
import io
buffer = io.BytesIO()
Image.new("RGB", size, (200, 0, 0)).save(buffer, "PNG")
return buffer.getvalue()
def test_ffmpeg_failure_yields_placeholder(self):
with (
patch.dict(sys.modules, {"cv2": None}),
patch("subprocess.Popen", return_value=self._process(1, "no stream")),
patch("subprocess.Popen", return_value=self._process(1, b"no stream")),
):
ok = self.api._generate_video_thumbnail(self.video, self.thumb, self.SIZE)
self.assertTrue(ok)
@@ -1634,18 +1645,23 @@ class TestVideoThumbnailFallbacks(ImageRouteCoverageCase):
def test_ffmpeg_success(self):
with (
patch.dict(sys.modules, {"cv2": None}),
patch("subprocess.Popen", return_value=self._process(0)) as run,
patch(
"subprocess.Popen",
return_value=self._process(0, stdout=self._png_frame()),
) as run,
):
ok = self.api._generate_video_thumbnail(self.video, self.thumb, self.SIZE)
self.assertTrue(ok)
self.assertEqual(run.call_args.args[0][0], "ffmpeg")
with Image.open(self.thumb) as img:
# ffmpeg's frame (a PNG on stdout) fitted inside the edge (#317).
self.assertEqual((img.format, img.size), ("JPEG", (48, 32)))
def test_ffmpeg_is_tracked_and_a_hold_before_the_rename_writes_nothing(self):
tracked = []
def writing(cmd, **kwargs):
Path(cmd[-1]).write_bytes(b"\xff\xd8 frame")
return self._process(0)
return self._process(0, stdout=self._png_frame())
with (
patch.dict(sys.modules, {"cv2": None}),
@@ -2061,7 +2077,7 @@ class TestThumbnailStartupStatus(ThumbnailTestCase):
):
with self.subTest(route=route):
for root in (self.output_dir, second):
for thumb in (root / "thumbnails").glob("*_thumb.png"):
for thumb in (root / "thumbnails").glob("*_thumb.webp"):
thumb.unlink()
response = await self.client.post(route, json={})
if route == self.PROGRESS_URL:
@@ -2069,7 +2085,7 @@ class TestThumbnailStartupStatus(ThumbnailTestCase):
else:
data = await response.json()
self.assertEqual(data["count"], 2)
self.assertTrue((second / "thumbnails" / "b_thumb.png").exists())
self.assertTrue((second / "thumbnails" / "b_thumb.webp").exists())
status = await (await self.client.get(self.STATUS_URL)).json()
self.assertEqual(status["pending"], 0)
self.assertEqual(status["total_images"], 2)
@@ -2121,7 +2137,7 @@ class TestGalleryRootIdentity(ThumbnailTestCase):
root = roots[image["root_index"]]
for key, path in (
("url", root / "same.png"),
("thumbnail_url", root / "thumbnails" / "same_thumb.png"),
("thumbnail_url", root / "thumbnails" / "same_thumb.webp"),
):
response = await self.client.get(image[key])
self.assertEqual(response.status, 200)
+2 -1
View File
@@ -408,9 +408,10 @@ class TestQueueReads(ReviewRoutesTestCase):
scope = item["target"]["root_scope"]
self.assertEqual(
item["thumbnail_url"],
"/prompt_manager/images/serve/thumbnails/sub/f0_thumb.png"
"/prompt_manager/images/serve/thumbnails/sub/f0_thumb.webp"
f"?root=0&root_scope={scope}",
)
# A legacy preview name is still listed until it is converted (#317).
thumb = self.output_dir / "thumbnails" / "sub" / "f0_thumb.png"
thumb.parent.mkdir(parents=True)
write_png(thumb, prompt=ambiguous_graph("cat", "dog"), color=(1, 2, 3))
+81 -63
View File
@@ -143,15 +143,15 @@ class TestListingsAlwaysCarryAThumbnailUrl(AutoThumbnailCase):
entry = _entry(self.api, self.output_dir, "sub/new.png")
self.assertEqual(
entry["thumbnail_url"],
f"{SERVE}thumbnails/sub/new_thumb.png?root=0",
f"{SERVE}thumbnails/sub/new_thumb.webp?root=0",
)
self.assertFalse((self.output_dir / "thumbnails").exists(), "no write")
def test_missing_video_preview_is_a_jpeg_url(self):
def test_missing_video_preview_url_is_in_the_configured_format(self):
(self.output_dir / "clip.mp4").write_bytes(b"not really a video")
entry = _entry(self.api, self.output_dir, "clip.mp4")
self.assertEqual(
entry["thumbnail_url"], f"{SERVE}thumbnails/clip_thumb.jpg?root=0"
entry["thumbnail_url"], f"{SERVE}thumbnails/clip_thumb.webp?root=0"
)
def test_listing_says_whether_the_preview_file_exists(self):
@@ -160,7 +160,7 @@ class TestListingsAlwaysCarryAThumbnailUrl(AutoThumbnailCase):
self.assertIs(
_entry(self.api, self.output_dir, "a.png")["thumbnail_ready"], False
)
make_png(self.output_dir / "thumbnails" / "a_thumb.png")
make_png(self.output_dir / "thumbnails" / "a_thumb.webp")
self.assertIs(
_entry(self.api, self.output_dir, "a.png")["thumbnail_ready"], True
)
@@ -174,11 +174,11 @@ class TestListingsAlwaysCarryAThumbnailUrl(AutoThumbnailCase):
def test_existing_preview_keeps_its_mtime_version(self):
make_png(self.output_dir / "a.png", size=(64, 64))
make_png(self.output_dir / "thumbnails" / "a_thumb.png")
make_png(self.output_dir / "thumbnails" / "a_thumb.webp")
entry = _entry(self.api, self.output_dir, "a.png")
self.assertRegex(
entry["thumbnail_url"],
r"^/prompt_manager/images/serve/thumbnails/a_thumb\.png\?v=\d+&root=0$",
r"^/prompt_manager/images/serve/thumbnails/a_thumb\.webp\?v=\d+&root=0$",
)
async def test_workspace_listing_never_returns_a_null_thumbnail(self):
@@ -190,7 +190,7 @@ class TestListingsAlwaysCarryAThumbnailUrl(AutoThumbnailCase):
urls = [image["thumbnail_url"] for image in data["images"]]
self.assertEqual(len(urls), 3)
for url in urls:
self.assertIn("_thumb.png", url)
self.assertIn("_thumb.webp", url)
class TestThumbnailOnFirstView(AutoThumbnailCase):
@@ -201,31 +201,33 @@ class TestThumbnailOnFirstView(AutoThumbnailCase):
resp = await self.client.get(url)
self.assertEqual(resp.status, 200)
self.assertEqual(resp.headers["Content-Type"], "image/png")
self.assertEqual(resp.headers["Content-Type"], "image/webp")
body = await resp.read()
thumb = self.output_dir / "thumbnails" / "sub" / "big_thumb.png"
thumb = self.output_dir / "thumbnails" / "sub" / "big_thumb.webp"
self.assertTrue(thumb.is_file())
self.assertEqual(body, thumb.read_bytes())
with Image.open(thumb) as img:
self.assertEqual(max(img.size), auto.AUTO_THUMBNAIL_EDGE)
self.assertEqual(max(img.size), 512)
# No temp sibling is ever left next to the preview.
self.assertEqual(os.listdir(thumb.parent), ["big_thumb.png"])
self.assertEqual(os.listdir(thumb.parent), ["big_thumb.webp"])
async def test_upper_case_extension_source_is_found(self):
make_png(self.output_dir / "Shot.PNG", size=(64, 64))
url = _entry(self.api, self.output_dir, "Shot.PNG")["thumbnail_url"]
self.assertIn("thumbnails/Shot_thumb.png", url)
self.assertIn("thumbnails/Shot_thumb.webp", url)
resp = await self.client.get(url)
self.assertEqual(resp.status, 200)
self.assertTrue((self.output_dir / "thumbnails" / "Shot_thumb.png").is_file())
self.assertTrue((self.output_dir / "thumbnails" / "Shot_thumb.webp").is_file())
async def test_video_preview_is_generated_as_jpeg(self):
async def test_video_preview_is_generated_in_the_configured_format(self):
# #317: a video's preview is written like an image's (WebP default);
# an undecodable clip's preview is the placeholder frame.
(self.output_dir / "clip.mp4").write_bytes(b"not really a video")
url = _entry(self.api, self.output_dir, "clip.mp4")["thumbnail_url"]
resp = await self.client.get(url)
self.assertEqual(resp.status, 200)
self.assertEqual(resp.headers["Content-Type"], "image/jpeg")
self.assertTrue((self.output_dir / "thumbnails" / "clip_thumb.jpg").is_file())
self.assertEqual(resp.headers["Content-Type"], "image/webp")
self.assertTrue((self.output_dir / "thumbnails" / "clip_thumb.webp").is_file())
async def test_concurrent_views_generate_once(self):
make_png(self.output_dir / "a.png", size=(256, 256))
@@ -298,14 +300,15 @@ class TestThumbnailOnFirstView(AutoThumbnailCase):
for name in names:
stem = name[:-4]
self.assertTrue(
(self.output_dir / "thumbnails" / f"{stem}_thumb.png").is_file()
(self.output_dir / "thumbnails" / f"{stem}_thumb.webp").is_file()
)
async def test_requests_beyond_the_cap_get_the_fallback_at_once(self):
names = [f"c{i}.png" for i in range(auto.ON_DEMAND_LIMIT)]
for name in names + ["late.png"]:
make_png(self.output_dir / name, size=(64, 64))
(self.output_dir / "late.mp4").write_bytes(b"not a video")
# Another stem: same-stem files share one preview name (#317 R2).
(self.output_dir / "latev.mp4").write_bytes(b"not a video")
entered, release, state = self._gate_writes()
listings = []
real_scandir = auto.os.scandir
@@ -327,25 +330,25 @@ class TestThumbnailOnFirstView(AutoThumbnailCase):
for n in names
]
await self._writers(state, auto.ON_DEMAND_LIMIT)
image = await self.client.get(f"{SERVE}thumbnails/late_thumb.png?root=0")
image = await self.client.get(f"{SERVE}thumbnails/late_thumb.webp?root=0")
self.assertEqual(image.status, 200)
self.assertEqual(image.headers["Cache-Control"], "no-store")
self.assertEqual(
await image.read(), (self.output_dir / "late.png").read_bytes()
)
video = await self.client.get(f"{SERVE}thumbnails/late_thumb.jpg?root=0")
video = await self.client.get(f"{SERVE}thumbnails/latev_thumb.webp?root=0")
self.assertEqual(video.status, 200)
self.assertEqual(video.headers["Content-Type"], "image/jpeg")
self.assertEqual(video.headers["Cache-Control"], "no-store")
await video.read()
missing = await self.client.get(f"{SERVE}thumbnails/Nope_thumb.png?root=0")
missing = await self.client.get(f"{SERVE}thumbnails/Nope_thumb.webp?root=0")
self.assertEqual(missing.status, 404)
# M2: no folder is listed for a request the cap turned away.
self.assertEqual(listings, [])
release.set()
responses = [await task for task in tasks]
self.assertEqual([r.status for r in responses], [200] * len(names))
self.assertFalse((self.output_dir / "thumbnails" / "late_thumb.png").exists())
self.assertFalse((self.output_dir / "thumbnails" / "late_thumb.webp").exists())
async def test_mixed_case_folder_listing_runs_once_inside_admission(self):
make_png(self.output_dir / "Shot.Png", size=(64, 64))
@@ -357,13 +360,13 @@ class TestThumbnailOnFirstView(AutoThumbnailCase):
return real_scandir(path)
with patch.object(auto.os, "scandir", counting):
resp = await self.client.get(f"{SERVE}thumbnails/Shot_thumb.png?root=0")
resp = await self.client.get(f"{SERVE}thumbnails/Shot_thumb.webp?root=0")
self.assertEqual(resp.status, 200)
# A case-insensitive filesystem (macOS, Windows) finds it by its exact
# name; a case-sensitive one lists the folder once.
found_by_name = (self.output_dir / "Shot.png").exists()
self.assertEqual(len(listings), 0 if found_by_name else 1)
self.assertTrue((self.output_dir / "thumbnails" / "Shot_thumb.png").is_file())
self.assertTrue((self.output_dir / "thumbnails" / "Shot_thumb.webp").is_file())
async def test_broken_file_falls_back_to_the_original_and_is_remembered(self):
broken = self.output_dir / "broken.png"
@@ -380,7 +383,9 @@ class TestThumbnailOnFirstView(AutoThumbnailCase):
self.assertEqual(resp.headers["Cache-Control"], "no-store")
self.assertEqual(await resp.read(), broken.read_bytes())
self.assertEqual(calls, ["broken.png"], "a known failure is not retried")
self.assertFalse((self.output_dir / "thumbnails" / "broken_thumb.png").exists())
self.assertFalse(
(self.output_dir / "thumbnails" / "broken_thumb.webp").exists()
)
status = await (
await self.client.get("/prompt_manager/images/thumbnails/status")
@@ -394,7 +399,9 @@ class TestThumbnailOnFirstView(AutoThumbnailCase):
third = await self.client.get(url)
self.assertEqual(third.status, 200)
self.assertEqual(calls, ["broken.png", "broken.png"])
self.assertTrue((self.output_dir / "thumbnails" / "broken_thumb.png").is_file())
self.assertTrue(
(self.output_dir / "thumbnails" / "broken_thumb.webp").is_file()
)
async def test_locked_preview_is_not_remembered_as_a_failure(self):
make_png(self.output_dir / "a.png", size=(64, 64))
@@ -411,10 +418,10 @@ class TestThumbnailOnFirstView(AutoThumbnailCase):
resp = await self.client.get(url)
self.assertEqual(resp.status, 200)
self.assertTrue((self.output_dir / "thumbnails" / "a_thumb.png").is_file())
self.assertTrue((self.output_dir / "thumbnails" / "a_thumb.webp").is_file())
async def test_no_source_is_a_plain_uncacheable_404(self):
resp = await self.client.get(f"{SERVE}thumbnails/nope_thumb.png?root=0")
resp = await self.client.get(f"{SERVE}thumbnails/nope_thumb.webp?root=0")
self.assertEqual(resp.status, 404)
self.assertEqual(resp.headers["Cache-Control"], "no-store")
text = await resp.text()
@@ -424,13 +431,13 @@ class TestThumbnailOnFirstView(AutoThumbnailCase):
def test_drive_unc_and_absolute_segments_are_refused_as_text(self):
# M1: a drive or UNC segment would reset the anchor of a joined path.
for path in (
"thumbnails/D:/x_thumb.png",
"thumbnails/D:x_thumb.png",
"thumbnails/\\\\server\\share/x_thumb.png",
"thumbnails//server/share/x_thumb.png",
"/thumbnails/x_thumb.png",
"thumbnails/a/./x_thumb.png",
"thumbnails/C:\\Users\\x_thumb.png",
"thumbnails/D:/x_thumb.webp",
"thumbnails/D:x_thumb.webp",
"thumbnails/\\\\server\\share/x_thumb.webp",
"thumbnails//server/share/x_thumb.webp",
"/thumbnails/x_thumb.webp",
"thumbnails/a/./x_thumb.webp",
"thumbnails/C:\\Users\\x_thumb.webp",
):
with self.subTest(path=path):
self.assertFalse(auto._is_preview_request(path))
@@ -442,9 +449,9 @@ class TestThumbnailOnFirstView(AutoThumbnailCase):
patch.object(auto.os, "stat") as stat,
):
for path in (
"thumbnails/D:/x_thumb.png",
"thumbnails/\\\\server\\share/x_thumb.png",
"/thumbnails/x_thumb.png",
"thumbnails/D:/x_thumb.webp",
"thumbnails/\\\\server\\share/x_thumb.webp",
"/thumbnails/x_thumb.webp",
):
answer = await self.api._serve_missing_thumbnail(
[self.output_dir], path
@@ -457,8 +464,8 @@ class TestThumbnailOnFirstView(AutoThumbnailCase):
async def test_traversal_is_refused_before_any_filesystem_access(self):
with patch.object(auto, "_thumbnail_source") as source:
for path in (
"thumbnails/../../etc/passwd_thumb.png",
"thumbnails/..%2F..%2Fx_thumb.png",
"thumbnails/../../etc/passwd_thumb.webp",
"thumbnails/..%2F..%2Fx_thumb.webp",
):
resp = await self.client.get(f"{SERVE}{path}?root=0")
self.assertIn(resp.status, (403, 404))
@@ -468,7 +475,7 @@ class TestThumbnailOnFirstView(AutoThumbnailCase):
outside = Path(self._output_tmp.name) / "outside.png"
make_png(outside, size=(64, 64))
self._make_symlink(outside, self.output_dir / "link.png")
resp = await self.client.get(f"{SERVE}thumbnails/link_thumb.png?root=0")
resp = await self.client.get(f"{SERVE}thumbnails/link_thumb.webp?root=0")
self.assertEqual(resp.status, 404)
self.assertFalse((self.output_dir / "thumbnails").exists())
@@ -477,7 +484,7 @@ class TestThumbnailOnFirstView(AutoThumbnailCase):
elsewhere = Path(self._output_tmp.name) / "elsewhere"
elsewhere.mkdir()
self._make_symlink(elsewhere, self.output_dir / "thumbnails")
resp = await self.client.get(f"{SERVE}thumbnails/a_thumb.png?root=0")
resp = await self.client.get(f"{SERVE}thumbnails/a_thumb.webp?root=0")
self.assertEqual(resp.status, 200)
self.assertEqual(resp.headers["Cache-Control"], "no-store")
self.assertEqual(os.listdir(elsewhere), [])
@@ -524,7 +531,7 @@ class TestThumbnailOnFirstView(AutoThumbnailCase):
_entry(self.api, self.output_dir, name)["thumbnail_url"]
)
self.assertEqual(resp.status, 200)
thumb = self.output_dir / "thumbnails" / f"a{index}_thumb.png"
thumb = self.output_dir / "thumbnails" / f"a{index}_thumb.webp"
self.assertTrue(thumb.is_file())
job.finish("finished")
@@ -565,8 +572,12 @@ class TestVideoPreviewsNeverFallBackToTheVideo(AutoThumbnailCase):
cv2 = MagicMock()
cv2.VideoCapture.return_value.isOpened.return_value = False
with patch.dict(sys.modules, {"cv2": cv2}):
await self._assert_placeholder(f"{SERVE}thumbnails/clip_thumb.jpg?root=0")
self.assertTrue((self.output_dir / "thumbnails" / "clip_thumb.jpg").is_file())
resp = await self.client.get(f"{SERVE}thumbnails/clip_thumb.webp?root=0")
# The placeholder frame became the preview file, in the format (#317).
self.assertEqual(resp.status, 200)
self.assertEqual(resp.headers["Content-Type"], "image/webp")
made = self.output_dir / "thumbnails" / "clip_thumb.webp"
self.assertEqual(await resp.read(), made.read_bytes())
async def test_a_held_video_preview_is_the_placeholder_and_writes_nothing(self):
from py.api.jobs import RESTORE_JOB_KIND
@@ -574,7 +585,7 @@ class TestVideoPreviewsNeverFallBackToTheVideo(AutoThumbnailCase):
self._video()
self._hold(RESTORE_JOB_KIND)
resp = await self._assert_placeholder(
f"{SERVE}thumbnails/clip_thumb.jpg?root=0"
f"{SERVE}thumbnails/clip_thumb.webp?root=0"
)
self.assertEqual(resp.headers["Cache-Control"], "no-store")
self.assertFalse((self.output_dir / "thumbnails").exists())
@@ -585,7 +596,7 @@ class TestVideoPreviewsNeverFallBackToTheVideo(AutoThumbnailCase):
elsewhere.mkdir()
self._make_symlink(elsewhere, self.output_dir / "thumbnails")
resp = await self._assert_placeholder(
f"{SERVE}thumbnails/clip_thumb.jpg?root=0"
f"{SERVE}thumbnails/clip_thumb.webp?root=0"
)
self.assertEqual(resp.headers["Cache-Control"], "no-store")
self.assertEqual(os.listdir(elsewhere), [])
@@ -594,7 +605,7 @@ class TestVideoPreviewsNeverFallBackToTheVideo(AutoThumbnailCase):
self._video()
with patch.object(self.api, "_generate_video_thumbnail", return_value=False):
resp = await self._assert_placeholder(
f"{SERVE}thumbnails/clip_thumb.jpg?root=0"
f"{SERVE}thumbnails/clip_thumb.webp?root=0"
)
self.assertEqual(resp.headers["Cache-Control"], "no-store")
@@ -653,6 +664,9 @@ class TestWriteSafety(AutoThumbnailCase):
class TestShutdown(AutoThumbnailCase):
async def test_no_thread_remains_after_cleanup_and_capture_cannot_restart(self):
from py.api import thumbnail_encode
self.addCleanup(thumbnail_encode.reset_encoders)
path = make_png(self.output_dir / "a.png", size=(64, 64))
self.assertEqual(self.api.queue_capture_thumbnail(str(path)), "queued")
thumbnailer = self.api._auto_thumbnails()
@@ -676,6 +690,9 @@ class TestShutdown(AutoThumbnailCase):
self.assertEqual(self.api.queue_capture_thumbnail(str(other)), "skipped")
restarted = self.api._auto_thumbnailer
self.assertTrue(restarted is None or restarted._worker is None)
# #317: no encoder process (ffmpeg, cjxl) starts after the cleanup.
with self.assertRaises(thumbnail_encode.EncoderClosed):
thumbnail_encode.run_encoder([sys.executable, "-c", "pass"])
async def test_an_idle_worker_leaves(self):
path = make_png(self.output_dir / "a.png", size=(64, 64))
@@ -689,7 +706,7 @@ class TestShutdown(AutoThumbnailCase):
other = make_png(self.output_dir / "b.png", size=(64, 64))
self.assertEqual(self.api.queue_capture_thumbnail(str(other)), "queued")
await self._idle()
self.assertTrue((self.output_dir / "thumbnails" / "b_thumb.png").is_file())
self.assertTrue((self.output_dir / "thumbnails" / "b_thumb.webp").is_file())
async def test_a_reload_stops_the_old_worker(self):
entered, release, _state = self._gate_writes()
@@ -761,7 +778,7 @@ class TestHistorySharesTheAdmission(AutoThumbnailCase):
self.assertEqual(answers["history"], auto.READY)
resp = answers["view"]
self.assertEqual(resp.status, 200)
self.assertEqual(resp.headers["Content-Type"], "image/png")
self.assertEqual(resp.headers["Content-Type"], "image/webp")
self.assertEqual(await resp.read(), dst.read_bytes())
async def test_history_joining_a_view_flight_gets_the_outcome(self):
@@ -773,9 +790,10 @@ class TestHistorySharesTheAdmission(AutoThumbnailCase):
from py.api.jobs import RESTORE_JOB_KIND
make_png(self.output_dir / "c.png", size=(64, 64))
(self.output_dir / "c.mp4").write_bytes(b"AV1 stream no decoder here")
# Another stem: same-stem files share one preview name (#317 R2).
(self.output_dir / "cv.mp4").write_bytes(b"AV1 stream no decoder here")
self._hold(RESTORE_JOB_KIND)
for name, is_video in (("c.png", False), ("c.mp4", True)):
for name, is_video in (("c.png", False), ("cv.mp4", True)):
with self.subTest(name=name):
src = self.output_dir / name
url = _entry(self.api, self.output_dir, name)["thumbnail_url"]
@@ -797,9 +815,9 @@ class TestHistorySharesTheAdmission(AutoThumbnailCase):
def test_history_and_serve_route_share_one_flight_key(self):
root = self.output_dir
preview = root / "thumbnails" / "sub" / "a_thumb.png"
preview = root / "thumbnails" / "sub" / "a_thumb.webp"
self.assertEqual(
auto.flight_key(root, ("thumbnails", "sub", "a_thumb.png")),
auto.flight_key(root, ("thumbnails", "sub", "a_thumb.webp")),
auto.flight_key(
Path(os.path.normcase(os.path.realpath(root))),
preview.relative_to(root).parts,
@@ -813,7 +831,7 @@ class TestThumbnailOnCapture(AutoThumbnailCase):
outcome = self.api.queue_capture_thumbnail(str(path))
self.assertEqual(outcome, "queued")
await self._idle()
thumb = self.output_dir / "thumbnails" / "run" / "ComfyUI_0001_thumb.png"
thumb = self.output_dir / "thumbnails" / "run" / "ComfyUI_0001_thumb.webp"
self.assertTrue(thumb.is_file())
self.assertIn(
"v=",
@@ -837,7 +855,7 @@ class TestThumbnailOnCapture(AutoThumbnailCase):
async def test_files_outside_the_roots_and_previews_are_ignored(self):
outside = make_png(Path(self._output_tmp.name) / "x.png")
preview = make_png(self.output_dir / "thumbnails" / "y_thumb.png")
preview = make_png(self.output_dir / "thumbnails" / "y_thumb.webp")
text = self.output_dir / "notes.txt"
text.write_text("x")
for path in (outside, preview, text, self.output_dir / "missing.png"):
@@ -873,7 +891,7 @@ class TestThumbnailOnCapture(AutoThumbnailCase):
self.assertLessEqual(state["peak"], 2)
for i in range(5):
self.assertTrue(
(self.output_dir / "thumbnails" / f"w{i}_thumb.png").is_file()
(self.output_dir / "thumbnails" / f"w{i}_thumb.webp").is_file()
)
async def test_worker_survives_a_failing_write(self):
@@ -884,7 +902,7 @@ class TestThumbnailOnCapture(AutoThumbnailCase):
await self._idle()
self.api.queue_capture_thumbnail(str(good))
await self._idle()
self.assertTrue((self.output_dir / "thumbnails" / "good_thumb.png").is_file())
self.assertTrue((self.output_dir / "thumbnails" / "good_thumb.webp").is_file())
self.assertEqual(self.api._auto_thumbnails().failure_count, 1)
async def test_worker_writes_nothing_during_a_restore(self):
@@ -919,7 +937,7 @@ class TestThumbnailOnCapture(AutoThumbnailCase):
await self._idle()
for name in ("first", "second"):
self.assertTrue(
(self.output_dir / "thumbnails" / f"{name}_thumb.png").is_file()
(self.output_dir / "thumbnails" / f"{name}_thumb.webp").is_file()
)
async def test_a_worker_that_cannot_start_is_started_by_the_next_enqueue(self):
@@ -940,7 +958,7 @@ class TestThumbnailOnCapture(AutoThumbnailCase):
await self._idle()
for name in ("first", "second"):
self.assertTrue(
(self.output_dir / "thumbnails" / f"{name}_thumb.png").is_file()
(self.output_dir / "thumbnails" / f"{name}_thumb.webp").is_file()
)
def test_queue_is_bounded(self):
@@ -1041,7 +1059,7 @@ class TestScanHandsOffThumbnails(AutoThumbnailCase):
await self._idle()
for i in range(2):
self.assertTrue(
(self.output_dir / "thumbnails" / f"s{i}_thumb.png").is_file()
(self.output_dir / "thumbnails" / f"s{i}_thumb.webp").is_file()
)
async def test_a_scan_that_added_nothing_does_nothing(self):
@@ -1091,12 +1109,12 @@ class TestScanHandsOffThumbnails(AutoThumbnailCase):
outcome = await self.api._thumbnails_after_scan(job, [self.output_dir, other])
self.assertEqual(outcome, "queued")
await self._idle()
self.assertTrue((other / "thumbnails" / "o_thumb.png").is_file())
self.assertTrue((other / "thumbnails" / "o_thumb.webp").is_file())
self.assertFalse((self.output_dir / "thumbnails").exists())
async def test_nothing_missing_is_none(self):
make_png(self.output_dir / "s.png", size=(32, 32))
make_png(self.output_dir / "thumbnails" / "s_thumb.png")
make_png(self.output_dir / "thumbnails" / "s_thumb.webp")
self.assertEqual(
await self.api._thumbnails_after_scan(self._scan_job(1)), "none"
)
+400
View File
@@ -0,0 +1,400 @@
"""Every preview writer and lookup follows the thumbnail settings (#317).
Automatic previews (capture and first view), the Activity job, the legacy
gallery routes, the listings, the history route, duplicates, enrichment and
the clear all name previews ``<stem>_thumb.<ext of the configured format>``,
write them with the configured quality and size limit, and still find older
previews (``.png`` and friends) until they are converted.
A thumbnail is only a grid or cover placeholder: inspection always reads the
original, and never resolves a request to a file under ``thumbnails/``.
Ordering is driven by events, never by timers.
"""
import asyncio
import json
import os
import shutil
import subprocess
import sys
import unittest
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
try:
from tests.test_api_images import ThumbnailTestCase, make_png, parse_sse
except ImportError: # discovered with tests/ as the top-level directory
from test_api_images import ThumbnailTestCase, make_png, parse_sse
from PIL import Image, PngImagePlugin # noqa: E402
import py.api.images as images_module # noqa: E402
import py.api.thumbnail_auto as auto # noqa: E402
from py.api import thumbnail_encode # noqa: E402
from py.api.admin import _remove_thumbnail # noqa: E402
from py.config import PromptManagerConfig # noqa: E402
SERVE = "/prompt_manager/images/serve/"
STATUS = "/prompt_manager/images/thumbnails/status"
PLAN = "/prompt_manager/images/thumbnails/plan"
JOB = "/prompt_manager/jobs/thumbnails"
WAIT = 10
HAVE_FFMPEG = bool(shutil.which("ffmpeg"))
def graph(positive="portrait", negative="blurry"):
return {
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": positive}},
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": negative}},
"3": {
"class_type": "KSampler",
"inputs": {"positive": ["1", 0], "negative": ["2", 0]},
},
}
def png_with_prompt(path, size=(64, 64)):
info = PngImagePlugin.PngInfo()
info.add_text("prompt", json.dumps(graph()))
info.add_text("workflow", json.dumps({"nodes": [], "links": []}))
path.parent.mkdir(parents=True, exist_ok=True)
with Image.new("RGB", size, (9, 90, 200)) as image:
image.save(path, pnginfo=info)
return path
class FormatCase(ThumbnailTestCase):
async def asyncSetUp(self):
await super().asyncSetUp()
self._orig = PromptManagerConfig.get_config()
self.addCleanup(PromptManagerConfig.update_config, self._orig)
self._settings("webp", 80, 512)
thumbnail_encode.reset_encoders()
self.addCleanup(thumbnail_encode.reset_encoders)
self.addCleanup(self.api.close_auto_thumbnails)
def _settings(self, fmt, quality, edge):
PromptManagerConfig.update_config(
{"thumbnails": {"format": fmt, "quality": quality, "edge": edge}}
)
def _thumb(self, name, suffix=".webp"):
return self.output_dir / "thumbnails" / f"{name}_thumb{suffix}"
async def _idle(self):
loop = asyncio.get_running_loop()
thumbnailer = self.api._auto_thumbnails()
self.assertTrue(await loop.run_in_executor(None, thumbnailer.wait_idle, WAIT))
async def _capture(self, path):
self.assertEqual(self.api.queue_capture_thumbnail(path), "queued")
await self._idle()
async def _finished(self, job_id):
job = self.api._jobs.get(job_id)
await asyncio.wait_for(job.task, WAIT)
return job
def _format_of(self, path):
with Image.open(path) as img:
return img.format, img.size
class TestCaptureAndViewFollowTheSettings(FormatCase):
async def test_capture_writes_webp_at_the_size_limit(self):
self._settings("webp", 80, 200)
src = make_png(self.output_dir / "a.png", size=(800, 400))
await self._capture(src)
self.assertEqual(self._format_of(self._thumb("a")), ("WEBP", (200, 100)))
self.assertFalse(self._thumb("a", ".png").exists())
async def test_capture_writes_jpeg_when_configured(self):
self._settings("jpeg", 50, 300)
src = make_png(self.output_dir / "sub" / "b.png", size=(600, 600))
await self._capture(src)
made = self.output_dir / "thumbnails" / "sub" / "b_thumb.jpg"
self.assertEqual(self._format_of(made), ("JPEG", (300, 300)))
async def test_quality_reaches_the_encoder(self):
noisy = Image.effect_noise((400, 400), 80).convert("RGB")
for name, quality in (("low", 10), ("high", 95)):
noisy.save(self.output_dir / f"{name}.png")
self._settings("webp", quality, 512)
await self._capture(self.output_dir / f"{name}.png")
self.assertLess(
self._thumb("low").stat().st_size, self._thumb("high").stat().st_size
)
async def test_an_animated_source_previews_as_a_still(self):
path = self.output_dir / "anim.gif"
frames = [Image.new("RGB", (64, 64), c) for c in ((250, 0, 0), (0, 0, 250))]
frames[0].save(path, save_all=True, append_images=frames[1:], duration=40)
await self._capture(path)
with Image.open(self._thumb("anim")) as img:
self.assertEqual(getattr(img, "n_frames", 1), 1)
self.assertGreater(img.convert("RGB").getpixel((32, 32))[0], 200)
async def test_a_video_preview_is_the_configured_format(self):
frame = Image.new("RGB", (640, 360), (0, 200, 0))
clip = self.output_dir / "clip.mp4"
clip.write_bytes(b"not decoded")
with patch.object(self.api, "_opencv_frame", return_value=frame):
await self._capture(clip)
self.assertEqual(self._format_of(self._thumb("clip"))[0], "WEBP")
self.assertFalse(self._thumb("clip", ".jpg").exists())
@unittest.skipUnless(HAVE_FFMPEG, "no ffmpeg")
async def test_ffmpeg_video_frame_is_encoded_in_the_format(self):
clip = self.output_dir / "real.mp4"
subprocess.run(
["ffmpeg", "-v", "quiet", "-f", "lavfi", "-i",
"color=c=red:s=320x240:d=2", "-y", str(clip)],
check=True, timeout=60,
) # fmt: skip
with patch.object(self.api, "_opencv_frame", return_value=None):
await self._capture(clip)
fmt, size = self._format_of(self._thumb("real"))
self.assertEqual(fmt, "WEBP")
self.assertEqual(size, (320, 240))
async def test_view_makes_the_preview_and_sends_its_type(self):
make_png(self.output_dir / "v.png", size=(64, 64))
resp = await self.client.get(SERVE + "thumbnails/v_thumb.webp")
self.assertEqual(resp.status, 200)
self.assertEqual(resp.headers["Content-Type"], "image/webp")
self.assertEqual(resp.headers["Cache-Control"], "public, max-age=3600")
self.assertTrue(self._thumb("v").is_file())
async def test_a_jxl_preview_is_served_as_image_jxl(self):
make_png(self.output_dir / "j.png")
thumb = self._thumb("j", ".jxl")
thumb.parent.mkdir(parents=True, exist_ok=True)
thumb.write_bytes(b"\xff\x0a" + b"\x00" * 30)
resp = await self.client.get(SERVE + "thumbnails/j_thumb.jxl")
self.assertEqual(resp.status, 200)
self.assertEqual(resp.headers["Content-Type"], "image/jxl")
self.assertEqual(resp.headers["Cache-Control"], "public, max-age=3600")
async def test_a_converted_away_legacy_url_answers_the_current_preview(self):
make_png(self.output_dir / "c.png")
await self._capture(self.output_dir / "c.png")
resp = await self.client.get(SERVE + "thumbnails/c_thumb.png?v=1")
self.assertEqual(resp.status, 200)
self.assertEqual(resp.headers["Content-Type"], "image/webp")
self.assertFalse(self._thumb("c", ".png").exists())
async def test_preview_requests_keep_the_text_checks_for_new_extensions(self):
accepted = ("thumbnails/a_thumb.jxl", "thumbnails/s/a_thumb.webp")
for text in accepted:
self.assertTrue(auto._is_preview_request(text), text)
refused = (
"thumbnails/a_thumb.JXL",
"thumbnails\\a_thumb.jxl",
"thumbnails/C:a_thumb.webp",
"thumbnails//a_thumb.webp",
"thumbnails/./a_thumb.jxl",
"thumbnails/../a_thumb.jxl",
"thumbnails/thumbnails/a_thumb.webp",
"thumbnails/a_thumb.txt",
"thumbnails/_thumb.webp",
)
for text in refused:
self.assertFalse(auto._is_preview_request(text), text)
make_png(self.outside_png())
resp = await self.client.get(SERVE + "thumbnails/..%2F..%2Fout_thumb.webp")
self.assertIn(resp.status, (403, 404))
self.assertFalse((self.comfy_dir / "out_thumb.webp").exists())
def outside_png(self):
return self.comfy_dir / "out.png"
async def test_jxl_without_an_encoder_writes_webp_once(self):
self._settings("jxl", 80, 512)
src = make_png(self.output_dir / "x.png", size=(64, 64))
calls = []
original = self.api._generate_one
def counting(*args, **kwargs):
calls.append(1)
return original(*args, **kwargs)
self.api._generate_one = counting
with patch.object(thumbnail_encode, "detect_jxl_encoder", return_value=None):
await self._capture(src)
self.assertTrue(self._thumb("x").is_file())
self.assertFalse(self._thumb("x", ".jxl").exists())
self.assertEqual(self.api.queue_capture_thumbnail(src), "fresh")
resp = await self.client.get(SERVE + "thumbnails/x_thumb.jxl")
self.assertEqual(resp.headers["Content-Type"], "image/webp")
self.assertEqual(len(calls), 1)
class TestListingsFindAnyOwnedPreview(FormatCase):
def _listed(self, rel):
return images_module.media_thumbnail(self.output_dir, Path(rel), 0)
def test_missing_preview_lists_the_configured_name(self):
make_png(self.output_dir / "m.png")
url, ready = self._listed("m.png")
self.assertEqual(url, SERVE + "thumbnails/m_thumb.webp?root=0")
self.assertFalse(ready)
def test_a_legacy_preview_is_listed_until_a_current_one_exists(self):
make_png(self.output_dir / "l.png")
make_png(self._thumb("l", ".png"))
url, ready = self._listed("l.png")
self.assertTrue(url.startswith(SERVE + "thumbnails/l_thumb.png?v="))
self.assertTrue(ready)
make_png(self._thumb("l"))
url, _ready = self._listed("l.png")
self.assertTrue(url.startswith(SERVE + "thumbnails/l_thumb.webp?v="))
def test_a_video_lists_its_legacy_jpg(self):
(self.output_dir / "v.mp4").write_bytes(b"x")
make_png(self._thumb("v", ".jpg"))
url, ready = self._listed("v.mp4")
self.assertTrue(url.startswith(SERVE + "thumbnails/v_thumb.jpg?v="))
self.assertTrue(ready)
def test_duplicates_listing_and_enrichment_use_the_existing_preview(self):
src = make_png(self.output_dir / "d.png")
make_png(self._thumb("d"))
described = self.api._describe_media_file(src, self.output_dir, "hash")
self.assertTrue(
described["thumbnail_url"].startswith(SERVE + "thumbnails/d_thumb.webp")
)
images = self.api._enrich_images([{"image_path": str(src)}])
self.assertTrue(
images[0]["thumbnail_url"].startswith(SERVE + "thumbnails/d_thumb.webp")
)
class TestPlanStatusAndJobs(FormatCase):
async def test_status_counts_missing_outdated_and_ready(self):
make_png(self.output_dir / "new.png") # missing
make_png(self.output_dir / "old.png")
make_png(self._thumb("old", ".png")) # outdated (legacy PNG)
make_png(self.output_dir / "cur.png")
make_png(self._thumb("cur")) # current
body = await (await self.client.get(STATUS)).json()
self.assertEqual(
(body["pending"], body["outdated"], body["ready"], body["total_images"]),
(1, 1, 2, 3),
)
plan = await (await self.client.get(PLAN, params={"mode": "missing"})).json()
self.assertEqual((plan["targets"], plan["fresh"], plan["outdated"]), (1, 1, 1))
self.assertEqual(
(plan["format"], plan["quality"], plan["size"]), ("webp", 80, 512)
)
async def test_missing_job_writes_the_format_and_leaves_outdated_alone(self):
self._settings("webp", 70, 160)
make_png(self.output_dir / "n.png", size=(320, 320))
make_png(self.output_dir / "o.png")
legacy = make_png(self._thumb("o", ".png"))
before = legacy.read_bytes()
resp = await self.client.post(JOB, json={"mode": "missing"})
self.assertEqual(resp.status, 202)
job = await self._finished((await resp.json())["job"]["id"])
self.assertEqual(job.state, "finished")
self.assertEqual(job.scope["format"], "webp")
self.assertEqual(self._format_of(self._thumb("n")), ("WEBP", (160, 160)))
self.assertFalse(self._thumb("o").exists())
self.assertEqual(legacy.read_bytes(), before)
async def test_the_legacy_stream_uses_the_server_settings(self):
# R3: the gallery's quality preset no longer sizes the preview.
self._settings("webp", 80, 200)
make_png(self.output_dir / "g.png", size=(800, 800))
resp = await self.client.post(self.PROGRESS_URL + "?quality=high")
events = dict(parse_sse(await resp.text()))
self.assertEqual(events["complete"]["count"], 1)
self.assertEqual(self._format_of(self._thumb("g")), ("WEBP", (200, 200)))
async def test_the_blocking_legacy_generate_uses_the_server_settings(self):
self._settings("jpeg", 80, 140)
make_png(self.output_dir / "h.png", size=(700, 700))
resp = await self.client.post(
"/prompt_manager/images/generate-thumbnails", json={"quality": "low"}
)
self.assertEqual(resp.status, 200)
made = self.output_dir / "thumbnails" / "h_thumb.jpg"
self.assertEqual(self._format_of(made), ("JPEG", (140, 140)))
async def test_clear_removes_every_preview_format(self):
make_png(self.output_dir / "k.png")
for suffix in (".webp", ".png", ".jpg"):
make_png(self._thumb("k", suffix))
jxl = self._thumb("k", ".jxl")
jxl.write_bytes(b"\xff\x0a")
resp = await self.client.post("/prompt_manager/images/clear-thumbnails")
self.assertEqual(resp.status, 200)
self.assertEqual((await resp.json())["cleared_files"], 4)
self.assertTrue((self.output_dir / "k.png").exists())
class TestRemoval(FormatCase):
def test_removing_a_source_removes_its_previews_in_every_format(self):
make_png(self.output_dir / "r.png")
for suffix in (".png", ".webp"):
make_png(self._thumb("r", suffix))
_remove_thumbnail(self.output_dir, Path("r.png"))
self.assertFalse(self._thumb("r", ".png").exists())
self.assertFalse(self._thumb("r").exists())
def test_a_shared_preview_stays_while_another_source_owns_it(self):
# R2: r.png and r.mp4 share r_thumb.webp; the PNG's legacy preview is its own.
make_png(self.output_dir / "r.png")
(self.output_dir / "r.mp4").write_bytes(b"x")
make_png(self._thumb("r", ".png"))
make_png(self._thumb("r"))
_remove_thumbnail(self.output_dir, Path("r.png"))
self.assertFalse(self._thumb("r", ".png").exists())
self.assertTrue(self._thumb("r").exists())
class TestThumbnailsAreOnlyPlaceholders(FormatCase):
"""Maintainer constraint: metadata always comes from the original."""
async def test_inspection_reads_the_original_not_its_webp_preview(self):
png_with_prompt(self.output_dir / "image.png")
await self._capture(self.output_dir / "image.png")
self.assertTrue(self._thumb("image").is_file())
opened = []
original_open = Image.open
def recording(fp, *args, **kwargs):
opened.append(str(fp))
return original_open(fp, *args, **kwargs)
with patch.object(Image, "open", recording):
resp = await self.client.get(
"/prompt_manager/images/inspect/image.png", params={"root": "0"}
)
body = await resp.json()
self.assertEqual(resp.status, 200)
self.assertEqual(body["prompts"]["positive"]["text"], "portrait")
self.assertEqual(body["prompts"]["negative"]["text"], "blurry")
self.assertFalse([p for p in opened if "thumbnails" in p], opened)
async def test_inspection_never_resolves_a_thumbnail_path(self):
png_with_prompt(self.output_dir / "image.png")
png_with_prompt(self._thumb("image", ".png"))
await self._capture(self.output_dir / "image.png")
for rel in (
"thumbnails/image_thumb.webp",
"thumbnails/image_thumb.png",
"Thumbnails/image_thumb.webp",
):
resp = await self.client.get(
f"/prompt_manager/images/inspect/{rel}", params={"root": "0"}
)
self.assertIn(resp.status, (403, 404), rel)
self.assertNotIn("portrait", await resp.text())
if __name__ == "__main__":
unittest.main()
+92 -79
View File
@@ -117,6 +117,14 @@ class ThumbnailJobCase(ThumbnailTestCase):
self.addCleanup(patcher.stop)
return lines
def _use(self, fmt="webp", quality=80, edge=512):
"""Set the server's thumbnail settings for this test (#317)."""
saved = PromptManagerConfig.get_config()["thumbnails"]
self.addCleanup(PromptManagerConfig.update_config, {"thumbnails": saved})
PromptManagerConfig.update_config(
{"thumbnails": {"format": fmt, "quality": quality, "edge": edge}}
)
def _one_worker(self):
original = PromptManagerConfig.WORKER_THREADS
PromptManagerConfig.WORKER_THREADS = 1 # batches of 4
@@ -149,13 +157,10 @@ class TestThumbnailPlan(ThumbnailJobCase):
os.utime(fresh, (source_time + 10, source_time + 10))
before = _tree_state(self.comfy_dir)
self._use("webp", 80, 512)
with patch.object(images_module, "MAX_THUMBNAIL_FILES", 1):
missing = await self.client.get(
PLAN_URL, params={"mode": "missing", "size": "512", "quality": "high"}
)
rebuild = await self.client.get(
PLAN_URL, params={"mode": "rebuild", "size": "256", "quality": "low"}
)
missing = await self.client.get(PLAN_URL, params={"mode": "missing"})
rebuild = await self.client.get(PLAN_URL, params={"mode": "rebuild"})
self.assertEqual(missing.status, 200)
self.assertEqual(
@@ -164,40 +169,38 @@ class TestThumbnailPlan(ThumbnailJobCase):
"success": True,
"targets": 1,
"fresh": 1,
"outdated": 0,
"remaining": 1,
"roots": ["output"],
"mode": "missing",
"format": "webp",
"size": 512,
"quality": "high",
"quality": 80,
},
)
data = await rebuild.json()
# Rebuild regenerates fresh previews too: all three are work, one fits the cap.
self.assertEqual((data["targets"], data["fresh"], data["remaining"]), (1, 1, 2))
self.assertEqual(
(data["mode"], data["size"], data["quality"]), ("rebuild", 256, "low")
)
self.assertEqual(data["mode"], "rebuild")
self.assertEqual(_tree_state(self.comfy_dir), before)
self.assertEqual(self.api._jobs.list(), [])
async def test_plan_defaults_to_missing_previews_at_the_concept_size(self):
async def test_plan_defaults_to_missing_previews_with_the_settings(self):
self._use("webp", 80, 512)
make_png(self.output_dir / "a.png")
data = await (await self.client.get(PLAN_URL)).json()
self.assertEqual(
(data["mode"], data["size"], data["quality"], data["targets"]),
("missing", 512, "medium", 1),
(data["mode"], data["format"], data["size"], data["quality"]),
("missing", "webp", 512, 80),
)
self.assertEqual(data["targets"], 1)
async def test_size_bounds_validated(self):
async def test_mode_validated_and_size_quality_ignored(self):
# #317: format, quality and size limit are the server's settings; an
# older client's size or quality is ignored, an unknown mode refused.
self._use("webp", 80, 512)
make_png(self.output_dir / "a.png")
for params in (
{"size": "127"},
{"size": "2049"},
{"size": "512.5"},
{"size": "big"},
{"mode": "all"},
{"quality": "ultra"},
):
for params in ({"mode": "all"}, {"mode": ""}):
with self.subTest(params=params):
resp = await self.client.get(PLAN_URL, params=params)
body = await resp.json()
@@ -205,24 +208,14 @@ class TestThumbnailPlan(ThumbnailJobCase):
self.assertEqual(
(body["success"], body["code"]), (False, "invalid-request")
)
for body in (
{"size": 127},
{"size": 2049},
{"size": "512"},
{"size": 512.0},
{"size": True},
{"mode": "rebuild-all"},
{"quality": None},
[],
):
for body in ({"mode": "rebuild-all"}, {"mode": None}, []):
with self.subTest(body=body):
resp = await self.client.post(JOB_URL, json=body)
self.assertEqual(resp.status, 400)
self.assertEqual((await resp.json())["code"], "invalid-request")
for edge in (128, 2048):
with self.subTest(edge=edge):
resp = await self.client.get(PLAN_URL, params={"size": str(edge)})
self.assertEqual(resp.status, 200)
resp = await self.client.get(PLAN_URL, params={"size": "9999", "quality": "x"})
data = await resp.json()
self.assertEqual((data["size"], data["quality"]), (512, 80))
self.assertEqual(self.api._jobs.list(), [])
self.assertFalse(self._thumb_path("a").exists())
@@ -233,7 +226,8 @@ class TestThumbnailJob(ThumbnailJobCase):
make_png(self.output_dir / "sub" / "b.png", size=(64, 64))
(self.output_dir / "bad.png").write_bytes(b"\x89PNG not really")
status, body = await self._start(mode="missing", size=200, quality="medium")
self._use("webp", 80, 200)
status, body = await self._start(mode="missing", size=999, quality="high")
self.assertEqual(status, 202)
summary = body["job"]
@@ -244,7 +238,8 @@ class TestThumbnailJob(ThumbnailJobCase):
self.assertTrue(summary["cancellable"])
self.assertEqual(set(summary["counters"]), {"generated", "skipped", "errors"})
self.assertEqual(
summary["scope"], {"mode": "missing", "size": 200, "quality": "medium"}
summary["scope"],
{"mode": "missing", "format": "webp", "size": 200, "quality": 80},
)
job = await self._finished(summary["id"])
self.assertEqual(job.state, "finished")
@@ -279,7 +274,8 @@ class TestThumbnailJob(ThumbnailJobCase):
async def test_rebuild_never_touches_originals(self):
for name in ("a", "b"):
make_png(self.output_dir / f"{name}.png", size=(400, 400))
_, body = await self._start(size=128)
self._use("webp", 80, 128)
_, body = await self._start()
await self._finished(body["job"]["id"])
originals = {
name: hashlib.sha256(
@@ -288,7 +284,8 @@ class TestThumbnailJob(ThumbnailJobCase):
for name in ("a", "b")
}
_, body = await self._start(mode="rebuild", size=256, quality="high")
self._use("webp", 95, 256)
_, body = await self._start(mode="rebuild")
job = await self._finished(body["job"]["id"])
self.assertEqual(job.counters, {"generated": 2, "skipped": 0, "errors": 0})
@@ -315,12 +312,12 @@ class TestThumbnailJob(ThumbnailJobCase):
self.assertEqual(job.counters["errors"], 1)
self.assertEqual(self._thumb_path("a").read_bytes(), preview)
leftovers = [p.name for p in self._thumb_path("a").parent.iterdir()]
self.assertEqual(leftovers, ["a_thumb.png"]) # no temp file left
self.assertEqual(leftovers, ["a_thumb.webp"]) # no temp file left
async def test_rebuild_keeps_an_old_video_preview_instead_of_a_placeholder(self):
clip = self.output_dir / "clip.mp4"
clip.write_bytes(b"\x00")
preview = make_png(self.output_dir / "thumbnails" / "clip_thumb.jpg")
preview = make_png(self.output_dir / "thumbnails" / "clip_thumb.webp")
old = preview.read_bytes()
with (
patch.dict(sys.modules, {"cv2": None}),
@@ -339,7 +336,8 @@ class TestThumbnailJob(ThumbnailJobCase):
img.save(self.output_dir / "photo.jpg", "JPEG", quality=95)
sizes = {}
for quality in ("low", "high"):
_, body = await self._start(mode="rebuild", size=300, quality=quality)
self._use("jpeg", 40 if quality == "low" else 95, 300)
_, body = await self._start(mode="rebuild")
await self._finished(body["job"]["id"])
thumb = self._thumb_path("photo", ".jpg")
with Image.open(thumb) as image:
@@ -380,7 +378,7 @@ class TestThumbnailJob(ThumbnailJobCase):
self.assertEqual(len(written), 2)
for dst in written:
self.assertTrue(dst.is_file())
made = sorted((self.output_dir / "thumbnails").glob("*_thumb.png"))
made = sorted((self.output_dir / "thumbnails").glob("*_thumb.webp"))
self.assertEqual(made, sorted(written))
self.assertTrue(any("Thumbnails cancelled" in line for line in job.log))
@@ -511,16 +509,16 @@ class TestThumbnailJob(ThumbnailJobCase):
entered, release = threading.Event(), threading.Event()
self.addCleanup(release.set)
checked = []
real_fresh = images_module._thumbnail_is_fresh
real_state = images_module._preview_state
def gated_fresh(src, dst):
def gated_state(src, *args):
checked.append(src.name)
if len(checked) == 1:
entered.set()
release.wait(10)
return real_fresh(src, dst)
return real_state(src, *args)
with patch.object(images_module, "_thumbnail_is_fresh", gated_fresh):
with patch.object(images_module, "_preview_state", gated_state):
stream = asyncio.ensure_future(self.client.post(self.PROGRESS_URL))
await self._wait(entered)
job = self.api._jobs.active("thumbnails")
@@ -567,7 +565,7 @@ class TestThumbnailJob(ThumbnailJobCase):
with (
patch.dict(sys.modules, {"cv2": None}),
patch("subprocess.Popen", side_effect=FileNotFoundError("no ffmpeg")),
patch.object(images_module, "_atomic_save", refuse),
patch.object(images_module, "write_thumbnail", refuse),
):
_, body = await self._start()
job = await self._finished(body["job"]["id"])
@@ -586,52 +584,64 @@ class TestThumbnailJob(ThumbnailJobCase):
[row] = job.outcomes["errors"]
self.assertEqual(row["detail"], images_module.PREVIEW_NOT_WRITABLE)
async def test_ffmpeg_fallback_keeps_quality_and_aspect_ratio(self):
"""M1: without OpenCV, ffmpeg keeps the chosen quality and the aspect ratio."""
async def test_ffmpeg_fallback_keeps_the_aspect_ratio_and_the_settings(self):
"""M1: without OpenCV, ffmpeg's frame keeps its aspect ratio; #317: it is
read as a PNG on stdout and encoded with the server's settings."""
import io
(self.output_dir / "clip.mp4").write_bytes(b"\x00")
commands = []
frame = io.BytesIO()
Image.new("RGB", (640, 360), (0, 99, 0)).save(frame, "PNG")
def fake_ffmpeg(cmd, **_kwargs):
commands.append(cmd)
Path(cmd[-1]).write_bytes(b"jpeg")
done = type("Done", (), {"returncode": 0})()
done.communicate = lambda timeout=None: ("", "")
done.communicate = lambda input=None, timeout=None: (frame.getvalue(), b"")
done.poll = lambda: 0
done.kill = lambda: None
return done
for quality, qscale in (("high", "2"), ("medium", "4"), ("low", "8")):
with (
patch.dict(sys.modules, {"cv2": None}),
patch("subprocess.Popen", side_effect=fake_ffmpeg),
):
_, body = await self._start(mode="rebuild", size=640, quality=quality)
job = await self._finished(body["job"]["id"])
self.assertEqual(job.counters["generated"], 1, quality)
cmd = commands[-1]
self.assertNotIn("-s", cmd)
self.assertEqual(
cmd[cmd.index("-vf") + 1],
"scale=640:640:force_original_aspect_ratio=decrease",
)
self.assertEqual(cmd[cmd.index("-q:v") + 1], qscale, quality)
self._use("webp", 70, 320)
with (
patch.dict(sys.modules, {"cv2": None}),
patch("subprocess.Popen", side_effect=fake_ffmpeg),
):
_, body = await self._start(mode="rebuild")
job = await self._finished(body["job"]["id"])
self.assertEqual(job.counters["generated"], 1)
cmd = commands[-1]
self.assertNotIn("-q:v", cmd)
self.assertEqual(
cmd[cmd.index("-vf") + 1],
"scale='min(iw,320)':'min(ih,320)':force_original_aspect_ratio=decrease",
)
self.assertEqual(cmd[-5:], ["-f", "image2pipe", "-vcodec", "png", "-"])
with Image.open(self._thumb_path("clip")) as thumb:
self.assertEqual((thumb.format, thumb.size), ("WEBP", (320, 180)))
async def test_the_placeholder_uses_the_chosen_quality(self):
async def test_the_placeholder_uses_the_settings(self):
(self.output_dir / "clip.mp4").write_bytes(b"\x00")
saved = []
real_save = images_module._atomic_save
real_write = images_module.write_thumbnail
def recording(img, dst, fmt=None, **kwargs):
# The encoder settings (guard is the automatic previews' hold check).
saved.append({k: v for k, v in kwargs.items() if k != "guard"})
return real_save(img, dst, fmt, **kwargs)
def recording(frame, dst, settings, **kwargs):
saved.append(settings)
return real_write(frame, dst, settings, **kwargs)
self._use("jpeg", 33, 200)
with (
patch.dict(sys.modules, {"cv2": None}),
patch("subprocess.Popen", side_effect=FileNotFoundError("no ffmpeg")),
patch.object(images_module, "_atomic_save", recording),
patch.object(images_module, "write_thumbnail", recording),
):
_, body = await self._start(quality="low")
_, body = await self._start()
await self._finished(body["job"]["id"])
self.assertEqual(saved, [images_module.THUMBNAIL_ENCODINGS["low"]])
self.assertEqual(
[(x.format, x.quality, x.edge) for x in saved], [("jpeg", 33, 200)]
)
with Image.open(self._thumb_path("clip", ".jpg")) as thumb:
self.assertEqual(thumb.size, (200, 200))
# The legacy stream's frames and their keys, as the gallery page reads them.
@@ -680,6 +690,7 @@ class TestLegacyStreamOnTheJob(ThumbnailJobCase):
make_png(self.output_dir / "a.png", size=(400, 400))
make_png(self.output_dir / "b.png")
self._use("webp", 80, 200)
resp = await self.client.post(f"{self.PROGRESS_URL}?quality=low")
events = parse_sse(await resp.text())
@@ -696,7 +707,8 @@ class TestLegacyStreamOnTheJob(ThumbnailJobCase):
{"phase": "scanning", "message": "Scanning for images and videos..."},
)
with Image.open(self._thumb_path("a")) as thumb:
self.assertEqual(max(thumb.size), 150) # the legacy preset still sizes
# #317 R3: the server's size limit, not the legacy preset (low = 150).
self.assertEqual(max(thumb.size), 200)
[job] = self.api._jobs.list()
self.assertEqual(
(job.kind, job.title, job.state, job.cancellable),
@@ -766,7 +778,8 @@ class TestLegacyStreamOnTheJob(ThumbnailJobCase):
async def test_legacy_stream_attaches_to_a_running_job(self):
make_png(self.output_dir / "a.png")
entered, release = self._gate_generation()
_, started = await self._start(mode="missing", size=256)
self._use("webp", 80, 256)
_, started = await self._start(mode="missing")
await self._wait(entered)
job = self.api._jobs.get(started["job"]["id"])
+18 -9
View File
@@ -71,20 +71,28 @@ function downloadFor(image, required) {
}
}
// The server's video extensions (py/api/inspection_service.py VIDEO_EXTENSIONS): their preview is a JPEG.
// The server's video extensions (py/api/inspection_service.py VIDEO_EXTENSIONS).
const VIDEO_EXTENSIONS = new Set(['.mp4', '.webm', '.avi', '.mov', '.mkv', '.m4v', '.wmv']);
// #317: the formats a preview is written in (WebP, JPEG XL, JPEG), for images and videos alike.
const WRITTEN_PREVIEW_EXTENSIONS = ['.webp', '.jxl', '.jpg'];
/** `thumbnails/<path>_thumb<ext>` for a listed file, as the server names its preview (images.media_thumbnail_rel). */
function previewPath(path) {
/**
* Every `thumbnails/<path>_thumb<ext>` the server may list as this file's preview (py/api/thumbnail_settings.py
* `owns`): a written format, or the name an older release gave it (the source's own extension, `.jpg` for a video).
*/
function previewPaths(path) {
const slash = path.lastIndexOf('/'), dot = path.lastIndexOf('.');
const cut = dot > slash ? dot : path.length, extension = path.slice(cut).toLowerCase();
return `thumbnails/${path.slice(0, cut)}_thumb${VIDEO_EXTENSIONS.has(extension) ? '.jpg' : extension}`;
const cut = dot > slash ? dot : path.length, spelled = path.slice(cut), extension = spelled.toLowerCase();
const stem = `thumbnails/${path.slice(0, cut)}_thumb`;
const legacy = VIDEO_EXTENSIONS.has(extension) ? ['.jpg'] : [extension, spelled];
return new Set([...WRITTEN_PREVIEW_EXTENSIONS, ...legacy].filter(Boolean).map(ext => stem + ext));
}
/**
* #249: the listing's thumbnail of `image` as `{url}` for the inspector's cover, or null. Only the same-origin
* serve URL of this file's own preview under the same root and source scope is used; anything else keeps the
* original (a missing preview is made by the server on first view).
* serve URL of this file's own preview (any format it owns, #317) under the same root and source scope is used;
* anything else keeps the original (a missing preview is made by the server on first view). The cover is only a
* placeholder: metadata, downloads and the viewer always use the original.
*/
function previewFor(image) {
try {
@@ -93,9 +101,10 @@ function previewFor(image) {
const reference = new URL(location?.href || 'http://localhost/');
const resolveUrl = globalThis.window?.ApiClient?.resolveUrl || globalThis.ApiClient?.resolveUrl || (url => url);
const value = resolveUrl(image.thumbnail_url), url = new URL(value, reference);
const expected = new URL(resolveUrl(`/prompt_manager/images/serve/${previewPath(image.path).split('/').map(quoteSegment).join('/')}`), reference);
const expected = new Set([...previewPaths(image.path)].map(preview =>
new URL(resolveUrl(`/prompt_manager/images/serve/${preview.split('/').map(quoteSegment).join('/')}`), reference).pathname));
const scope = descriptorScope(image);
if (url.origin !== reference.origin || url.username || url.password || url.hash || url.pathname !== expected.pathname ||
if (url.origin !== reference.origin || url.username || url.password || url.hash || !expected.has(url.pathname) ||
url.searchParams.getAll('root').length !== 1 || url.searchParams.get('root') !== String(image.root_index) ||
url.searchParams.getAll('root_scope').length > 1 || (scope !== null && url.searchParams.get('root_scope') !== scope)) return null;
return { url: value };