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):