fix(thumbnails): address the final review of the thumbnail format work
- ffmpeg writes JPEG XL with -update 1, so a % in a file name is never an image-sequence pattern, and reads videos as file:<path>. The clear and a startup sweep remove temp files that interrupted writes left behind. - Convert outdated converts each group of same-stem files once, from the first source in the R2 order. An old preview that is already gone counts as removed, and each file's bytes count once. A current preview is only kept if it decodes. - An encoder that fails makes the write detect encoders again once. Another encoder is used if one is found; with none, the file is written as WebP and the availability Appearance shows is refreshed. - Changing the thumbnail format, quality or size while Convert outdated runs returns 409 busy. If the format still changes underneath, the job stops without removing anything. - Legacy preview spellings are matched case-insensitively, so X_thumb.JPG counts as outdated. - History's cover uses the same lookup as Images. It serves .jxl only when Accept lists image/jxl, and otherwise the original or the placeholder. - A video whose thumbnail the browser cannot show keeps its own element (Images) or its label (Library).
This commit is contained in:
+27
-10
@@ -2301,21 +2301,28 @@ Thumbnail format and settings (#317, `py/api/thumbnail_settings.py`,
|
||||
- Names: `thumbnails/<stem>_thumb.webp|.jxl|.jpg` for images and videos alike. Every
|
||||
lookup (listings, duplicates, enrichment, the autotag scan, the history route) takes
|
||||
the first existing preview the file owns, the configured format first: an older
|
||||
release's `_thumb.png` (or `.jpeg`, `.gif`, the source's own spelling) keeps showing
|
||||
until it is converted. Two files with one stem in a folder share one preview name
|
||||
(images first). A request for an older name answers the current preview. Previews
|
||||
release's `_thumb.png` (or `.jpeg`, `.gif`, the source's own spelling or the
|
||||
upper-case one) keeps showing, and counts as outdated, until it is converted. History's
|
||||
cover uses the same lookup as the Images listing. Two files with one stem in a folder
|
||||
share one preview name, made from the first source in `.png`, `.jpg`, `.jpeg`,
|
||||
`.webp`, `.gif`, then videos. A request for an older name answers the current preview. Previews
|
||||
are served with an explicit `image/webp` / `image/jxl` type (the platform's mimetypes
|
||||
table may not know them) and the same cache headers; `.jxl` is served from
|
||||
`thumbnails/` only.
|
||||
- Every preview is written by `write_thumbnail`: the first frame (animated sources give
|
||||
a still), RGB, fitted inside the edge, encoded into a hidden temp sibling that is
|
||||
closed, verified to decode (a JPEG XL file Pillow cannot read is checked by its
|
||||
signature) and renamed over the preview; a failure keeps the old file.
|
||||
signature) and renamed over the preview; a failure keeps the old file. ffmpeg writes
|
||||
with `-update 1`, so a `%` in a name is never a sequence pattern, and reads a video as
|
||||
`file:<path>`. Temp files a crash left (`.<stem>.part-<pid>-<thread><ext>`) are
|
||||
removed by the clear and, when older than two minutes, by a sweep at startup.
|
||||
- JPEG XL needs no new dependency: Pillow (natively or with `pillow_jxl`, imported when
|
||||
installed), else ffmpeg with `libjxl`, else `cjxl`, detected once. Quality maps to
|
||||
libjxl's distance (`0.1 + (100 − q) × 0.09` from 30 up: q80 → 1.9, q85 → 1.45; never
|
||||
lossless). Without an encoder, saving `jxl` is a 400 `jxl-unavailable` with the
|
||||
reason; if the encoder disappears later, previews are written as WebP (logged once)
|
||||
reason. An encoder that fails makes that write detect again once: another encoder
|
||||
found is used, none at all writes the file as WebP (logged once) and refreshes the
|
||||
availability Appearance reads; if JPEG XL has no encoder, previews are written as WebP
|
||||
and count as the format written. Encoder processes (and ffmpeg's frames) are tracked,
|
||||
bounded by a 30 s timeout and killed at the app's cleanup, after which none starts.
|
||||
- A preview in another format than the one written now is *outdated*.
|
||||
@@ -2324,10 +2331,16 @@ Thumbnail format and settings (#317, `py/api/thumbnail_settings.py`,
|
||||
(`POST /jobs/thumbnails {mode: "convert"}`, "Convert outdated thumbnails") runs under
|
||||
the thumbnail job's kind, guarded admission, worker threads and cancel: for each
|
||||
outdated file it writes the new preview from the source (a video with no decodable
|
||||
frame fails instead of getting the placeholder), then removes that file's old-format
|
||||
previews (regular files in its `thumbnails/` folder that the source owns, the source
|
||||
still present). A failure keeps the old preview; orphans (source gone) and links are
|
||||
never touched. Its final line is "N converted · F failed · <before> → <after>"
|
||||
frame fails instead of getting the placeholder; a current preview it keeps must
|
||||
decode), then removes that file's old-format previews (regular files in its
|
||||
`thumbnails/` folder that the source owns, the source still present; one already gone
|
||||
counts as removed). Files sharing a preview name are converted once, as a group, from
|
||||
the first source above, and each file's bytes count once. A failure keeps the old
|
||||
preview; orphans (source gone) and links are never touched. While it runs, a change of
|
||||
the thumbnail format, quality or size limit is a 409 `busy` naming it, and should the
|
||||
format still change underneath, the job stops at the next file without removing
|
||||
anything ("Stopped: the thumbnail format changed …"). Its final line is "N converted · F
|
||||
failed · <before> → <after>"
|
||||
(`tests/test_thumbnail_convert.py`, "Convert outdated replaces older-format previews"
|
||||
in `workspace-system-sources.spec.mjs`). Nothing converts by itself: Appearance's
|
||||
format change only makes previews outdated.
|
||||
@@ -2340,7 +2353,11 @@ Thumbnail format and settings (#317, `py/api/thumbnail_settings.py`,
|
||||
- The workspace decodes a 1×1 embedded JPEG XL once (`thumbnail-support.mjs`); where it
|
||||
fails, cards and the inspector cover load the original for a `.jxl` preview, and any
|
||||
preview `<img>` that fails to load falls back to the original (the legacy gallery
|
||||
already does). Appearance says when this browser cannot show JPEG XL thumbnails.
|
||||
already does). A video whose preview it cannot show keeps its own element (Images) or
|
||||
its "Video preview" label (Library), never a broken image. History's cover URL names
|
||||
no format, so the server serves a `.jxl` preview only when the request's `Accept`
|
||||
lists `image/jxl`; otherwise the file's preview in another format, else the original
|
||||
image or a video's placeholder. Appearance says when this browser cannot show JPEG XL thumbnails.
|
||||
|
||||
Overview (`system-overview.mjs`) reads `GET /prompt_manager/system/overview` once per
|
||||
visit through `latestOnly()` on the section scope; a late answer after the section left
|
||||
|
||||
@@ -87,6 +87,15 @@ try:
|
||||
routes = config.routes
|
||||
api = PromptManagerAPI()
|
||||
api.add_routes(routes)
|
||||
# Temp files of preview writes a crash interrupted (#317), off the
|
||||
# import path: the walk may cover a large thumbnails folder.
|
||||
import threading
|
||||
|
||||
threading.Thread(
|
||||
target=api.sweep_thumbnail_temps,
|
||||
name="PromptManagerPreviewTempSweep",
|
||||
daemon=True,
|
||||
).start()
|
||||
host_app = getattr(config.server_instance, "app", None)
|
||||
if host_app is not None and hasattr(host_app, "on_cleanup"):
|
||||
host_app.on_cleanup.append(api.cleanup_history_reads)
|
||||
|
||||
@@ -20,6 +20,7 @@ from .history_current import (
|
||||
_OpenedCurrentMedia,
|
||||
_current_media_response,
|
||||
_prepare_current_media,
|
||||
accepts_jxl,
|
||||
_reauthorize_current_target,
|
||||
candidate_locator,
|
||||
resolve_current_candidate,
|
||||
@@ -794,6 +795,7 @@ class HistoryRoutesMixin:
|
||||
media_options = {
|
||||
"ensure_preview": lambda *preview: needed.append(preview),
|
||||
"placeholder": self._video_placeholder_bytes(),
|
||||
"accepts_jxl": accepts_jxl(request),
|
||||
}
|
||||
prepared = await execute()
|
||||
if needed:
|
||||
|
||||
+77
-22
@@ -17,7 +17,7 @@ from aiohttp import web
|
||||
|
||||
from . import thumbnail_settings
|
||||
from .images import _canonical, _public_path, SERVABLE_EXTENSIONS, VIDEO_EXTENSIONS
|
||||
from .images import media_thumbnail_rel
|
||||
from .images import existing_media_thumbnail, media_thumbnail_rel
|
||||
|
||||
try:
|
||||
from ...utils.execution_outputs import (
|
||||
@@ -241,14 +241,19 @@ def resolve_current_candidate(
|
||||
|
||||
|
||||
def _open_current_target(
|
||||
target: Path, namespace: Path, scope: CurrentOutputScope
|
||||
target: Path, namespace: Path, scope: CurrentOutputScope, preview: bool = False
|
||||
) -> BinaryIO:
|
||||
"""Open only the canonical target and verify authority against the actual handle."""
|
||||
"""Open only the canonical target and verify authority against the actual handle.
|
||||
|
||||
A *preview* (a file under ``thumbnails/``) may also be JPEG XL (#317)."""
|
||||
if _canonical(target) != target:
|
||||
raise CurrentLocationUnavailable("not_authorized", stale=True)
|
||||
if not _authorized(target, namespace, scope):
|
||||
raise CurrentLocationUnavailable("not_authorized")
|
||||
if target.suffix.lower() not in SERVABLE_EXTENSIONS:
|
||||
suffix = target.suffix.lower()
|
||||
if suffix not in SERVABLE_EXTENSIONS and not (
|
||||
preview and suffix in thumbnail_settings.THUMBNAIL_EXTENSIONS
|
||||
):
|
||||
raise CurrentLocationUnavailable("unsupported_media")
|
||||
flags = (
|
||||
os.O_RDONLY
|
||||
@@ -321,6 +326,61 @@ def _preview_writable(root: Path, preview: Path) -> bool:
|
||||
).is_relative_to(_canonical(folder))
|
||||
|
||||
|
||||
def _is_jxl(path) -> bool:
|
||||
return Path(path).suffix.lower() == ".jxl"
|
||||
|
||||
|
||||
def _preview_without_jxl(root: Path, relative: Path) -> Path | None:
|
||||
"""The first existing preview *relative* owns that is not JPEG XL."""
|
||||
return next(
|
||||
(
|
||||
path
|
||||
for path in thumbnail_settings.owned_thumbnails(root, relative)
|
||||
if not _is_jxl(path)
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def accepts_jxl(request) -> bool:
|
||||
"""Whether the request's ``Accept`` lists ``image/jxl``."""
|
||||
accept = request.headers.get("Accept", "")
|
||||
return any(
|
||||
part.split(";", 1)[0].strip().lower() == "image/jxl"
|
||||
for part in accept.split(",")
|
||||
)
|
||||
|
||||
|
||||
def _placeholder_media(placeholder: bytes) -> "_OpenedCurrentMedia":
|
||||
return _OpenedCurrentMedia(io.BytesIO(placeholder), len(placeholder), "image/jpeg")
|
||||
|
||||
|
||||
def _cover_target(resolved, ensure_preview, placeholder, accepts_jxl):
|
||||
"""What the cover opens: a preview path, the placeholder media, or None
|
||||
for the original (see ``_prepare_current_media``)."""
|
||||
target = resolved.canonical_target
|
||||
relative = target.relative_to(resolved.gallery_root)
|
||||
is_video = target.suffix.lower() in VIDEO_EXTENSIONS
|
||||
# #317: any preview the file owns (the current format first); a missing
|
||||
# one is made under the current format's name.
|
||||
preview = existing_media_thumbnail(resolved.gallery_root, relative)
|
||||
if preview is not None and not accepts_jxl and _is_jxl(preview):
|
||||
preview = _preview_without_jxl(resolved.gallery_root, relative)
|
||||
if preview is None:
|
||||
if is_video and placeholder is not None:
|
||||
return _placeholder_media(placeholder)
|
||||
return None
|
||||
return _canonical(preview)
|
||||
if preview is not None:
|
||||
return _canonical(preview)
|
||||
preview = resolved.gallery_root / media_thumbnail_rel(relative)
|
||||
if ensure_preview is not None and _preview_writable(resolved.gallery_root, preview):
|
||||
ensure_preview(target, preview, is_video)
|
||||
if is_video and placeholder is not None:
|
||||
return _placeholder_media(placeholder)
|
||||
return None
|
||||
|
||||
|
||||
def _prepare_current_media(
|
||||
resolved: ResolvedCurrentCandidate,
|
||||
scope: CurrentOutputScope,
|
||||
@@ -328,6 +388,7 @@ def _prepare_current_media(
|
||||
thumbnail: bool,
|
||||
ensure_preview=None,
|
||||
placeholder: bytes | None = None,
|
||||
accepts_jxl: bool = True,
|
||||
) -> _OpenedCurrentMedia:
|
||||
"""Open the current file, or its preview for ``thumbnail``.
|
||||
|
||||
@@ -335,27 +396,21 @@ def _prepare_current_media(
|
||||
``ensure_preview(src, dst, is_video)`` (the route makes it and reads
|
||||
again, #249). Without one an image's original is opened and a video is
|
||||
the ``placeholder`` JPEG, never the video inside an ``<img>``.
|
||||
|
||||
The cover's URL names no format, so a JPEG XL preview goes only to a
|
||||
browser whose ``Accept`` lists ``image/jxl`` (*accepts_jxl*, review M3).
|
||||
Another one gets the file's preview in another format when there is one,
|
||||
else the original image or a video's placeholder: never a broken image.
|
||||
"""
|
||||
target = resolved.canonical_target
|
||||
is_preview = False
|
||||
if thumbnail:
|
||||
relative = target.relative_to(resolved.gallery_root)
|
||||
is_video = target.suffix.lower() in VIDEO_EXTENSIONS
|
||||
# #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
|
||||
):
|
||||
ensure_preview(target, preview, is_video)
|
||||
if is_video and placeholder is not None:
|
||||
return _OpenedCurrentMedia(
|
||||
io.BytesIO(placeholder), len(placeholder), "image/jpeg"
|
||||
)
|
||||
else:
|
||||
target = _canonical(preview)
|
||||
handle = _open_current_target(target, resolved.namespace, scope)
|
||||
chosen = _cover_target(resolved, ensure_preview, placeholder, accepts_jxl)
|
||||
if isinstance(chosen, _OpenedCurrentMedia):
|
||||
return chosen
|
||||
if chosen is not None:
|
||||
target, is_preview = chosen, True
|
||||
handle = _open_current_target(target, resolved.namespace, scope, is_preview)
|
||||
try:
|
||||
return _OpenedCurrentMedia(
|
||||
handle,
|
||||
|
||||
+39
-7
@@ -29,6 +29,7 @@ from .thumbnail_encode import (
|
||||
PreviewWriteHeld,
|
||||
replace_unless_held,
|
||||
run_encoder,
|
||||
sweep_stray_temps,
|
||||
temp_sibling,
|
||||
thumbnail_frame,
|
||||
writer_settings,
|
||||
@@ -453,7 +454,7 @@ def _preview_state(src, folder, stem, settings, folders):
|
||||
names[key]
|
||||
for key in (
|
||||
os.path.normcase(stem + extension)
|
||||
for extension in thumbnail_settings.candidate_extensions(
|
||||
for extension in thumbnail_settings.owned_extensions(
|
||||
src.suffix, settings.extension
|
||||
)
|
||||
if extension != settings.extension
|
||||
@@ -798,6 +799,15 @@ def media_thumbnail_rel(rel_path, settings=None):
|
||||
return thumbnail_settings.thumbnail_rel(rel_path, settings.extension)
|
||||
|
||||
|
||||
def existing_media_thumbnail(output_path, rel_path):
|
||||
"""The existing preview *rel_path* owns that a listing or cover shows
|
||||
(the format written now first, then older ones), or None. The Images
|
||||
listing and History's cover use this one lookup (review M5)."""
|
||||
return thumbnail_settings.existing_thumbnail(
|
||||
output_path, rel_path, writer_settings().extension
|
||||
)
|
||||
|
||||
|
||||
def media_thumbnail(output_path, rel_path, root_index, *, root_scope=None):
|
||||
"""``(thumbnail_url, ready)`` for *rel_path* under *output_path* (a few
|
||||
blocking stats). The Images listing, the Needs review queue cards and the
|
||||
@@ -815,17 +825,14 @@ def media_thumbnail(output_path, rel_path, root_index, *, root_scope=None):
|
||||
rel_path = Path(rel_path)
|
||||
if "thumbnails" in rel_path.parts[:-1]:
|
||||
return None, False
|
||||
settings = writer_settings()
|
||||
existing = thumbnail_settings.existing_thumbnail(
|
||||
output_path, rel_path, settings.extension
|
||||
)
|
||||
existing = existing_media_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)
|
||||
ready = thumbnail_url is not None
|
||||
if thumbnail_url is None:
|
||||
thumb_rel = media_thumbnail_rel(rel_path, settings)
|
||||
thumb_rel = media_thumbnail_rel(rel_path)
|
||||
quoted = urllib.parse.quote(thumb_rel, safe="/")
|
||||
thumbnail_url = f"/prompt_manager/images/serve/{quoted}?root={root_index}"
|
||||
else:
|
||||
@@ -2329,7 +2336,8 @@ class ImageRoutesMixin:
|
||||
"-v",
|
||||
"error",
|
||||
"-i",
|
||||
str(video_path),
|
||||
# Read as a file, never as a protocol URL (review M6).
|
||||
f"file:{video_path}",
|
||||
"-ss",
|
||||
"00:00:01",
|
||||
"-vframes",
|
||||
@@ -2426,6 +2434,25 @@ class ImageRoutesMixin:
|
||||
{"success": False, "error": _safe_error(exc)}, status=500
|
||||
)
|
||||
|
||||
def sweep_thumbnail_temps(self):
|
||||
"""Remove the old temp files crashed preview writes left in every
|
||||
root's ``thumbnails/`` folder (blocking; run at startup on its own
|
||||
thread). Returns how many went."""
|
||||
removed = 0
|
||||
try:
|
||||
output_dirs = self._get_all_output_dirs()
|
||||
except Exception as exc:
|
||||
self.logger.debug(f"No roots to sweep for preview temp files: {exc}")
|
||||
return 0
|
||||
for output_path in output_dirs:
|
||||
folder = Path(output_path) / "thumbnails"
|
||||
if folder.is_symlink() or not folder.is_dir():
|
||||
continue
|
||||
removed += sweep_stray_temps(folder)[0]
|
||||
if removed:
|
||||
self.logger.info(f"Removed {removed} interrupted preview temp files")
|
||||
return removed
|
||||
|
||||
def _clear_gallery_thumbnails_sync(
|
||||
self, output_dirs: list[Path]
|
||||
) -> tuple[int, int]:
|
||||
@@ -2453,6 +2480,11 @@ class ImageRoutesMixin:
|
||||
cleared_count = 0
|
||||
cleared_size = 0
|
||||
for thumbnails_dir in directories:
|
||||
# Temp files a crashed write left (review I1): the clear holds
|
||||
# every preview write, so none is in use.
|
||||
temps, temp_bytes = sweep_stray_temps(thumbnails_dir, min_age=0)
|
||||
cleared_count += temps
|
||||
cleared_size += temp_bytes
|
||||
for root, _dirs, files in os.walk(thumbnails_dir):
|
||||
for name in files:
|
||||
path = Path(root) / name
|
||||
|
||||
@@ -27,7 +27,7 @@ from . import thumbnail_encode
|
||||
from . import thumbnail_settings
|
||||
from .admin import _current_image_monitor
|
||||
from .job_routes import busy_response
|
||||
from .jobs import JobBusy
|
||||
from .jobs import THUMBNAILS_JOB_KIND, JobBusy
|
||||
|
||||
try:
|
||||
from ...utils.validators import validate_result_timeout, validate_worker_threads
|
||||
@@ -47,6 +47,8 @@ _SETTINGS_FILE_KEYS = {
|
||||
"THUMBNAIL_QUALITY": ("thumbnails", "quality"),
|
||||
"THUMBNAIL_EDGE": ("thumbnails", "edge"),
|
||||
}
|
||||
# The thumbnail settings a running Convert outdated job fixes (#317).
|
||||
_THUMBNAIL_SETTINGS = ("THUMBNAIL_FORMAT", "THUMBNAIL_QUALITY", "THUMBNAIL_EDGE")
|
||||
# Attributes that live on GalleryConfig; the others on PromptManagerConfig.
|
||||
_GALLERY_SETTINGS = frozenset({"MONITORING_ENABLED"})
|
||||
SOURCE_CHANGE_JOB_KIND = "source_change"
|
||||
@@ -321,6 +323,10 @@ class SettingsRoutesMixin:
|
||||
status=400,
|
||||
)
|
||||
|
||||
refused = self._thumbnail_change_refused(updates)
|
||||
if refused is not None:
|
||||
return refused
|
||||
|
||||
# Off the loop: an accepted root is resolved (filesystem access).
|
||||
new_roots, error = await self._run_in_executor(
|
||||
self._parse_gallery_roots, data
|
||||
@@ -380,6 +386,33 @@ class SettingsRoutesMixin:
|
||||
status=500,
|
||||
)
|
||||
|
||||
def _thumbnail_change_refused(self, updates):
|
||||
"""409 ``busy`` for a thumbnail format, quality or size change while
|
||||
Convert outdated runs (review I4): the job converts to the settings
|
||||
it started with, and a change under it would make it remove previews
|
||||
in the newly chosen format. Unchanged values pass."""
|
||||
from ..config import PromptManagerConfig
|
||||
|
||||
changed = any(
|
||||
key in updates and updates[key] != getattr(PromptManagerConfig, key)
|
||||
for key in _THUMBNAIL_SETTINGS
|
||||
)
|
||||
job = self._jobs.active(THUMBNAILS_JOB_KIND) if changed else None
|
||||
if job is None or job.scope.get("mode") != "convert":
|
||||
return None
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"code": "busy",
|
||||
"error": (
|
||||
f"{job.title} is running. Change the thumbnail settings "
|
||||
"when it finishes, or cancel it first."
|
||||
),
|
||||
"running": {"id": job.id, "title": job.title},
|
||||
},
|
||||
status=409,
|
||||
)
|
||||
|
||||
def _publish_settings(self, updates, new_roots):
|
||||
"""Install committed settings at runtime (inside the config commit).
|
||||
|
||||
|
||||
@@ -97,9 +97,7 @@ except ImportError: # tests import the package top-level
|
||||
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)
|
||||
)
|
||||
_SOURCE_ORDER = thumbnail_settings.IMAGE_SOURCE_ORDER + 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.
|
||||
|
||||
+138
-34
@@ -16,14 +16,26 @@ what was converted):
|
||||
1. the new preview is written from the SOURCE media in the configured
|
||||
format (``_generate_one``: atomic, verified to decode; a video whose frame
|
||||
cannot be decoded fails rather than getting the placeholder), unless a
|
||||
current preview at least as new as the source is already there;
|
||||
current preview at least as new as the source is already there and
|
||||
decodes;
|
||||
2. only then are that file's old-format previews removed: regular files (no
|
||||
links) in its ``thumbnails/`` folder, owned by that source
|
||||
(``thumbnail_settings.owns``), with the source still present.
|
||||
(``thumbnail_settings.owns``), with the source still present. One already
|
||||
gone counts as removed.
|
||||
|
||||
Files sharing a stem in one folder (``x.png`` and ``x.mp4``) share one
|
||||
preview (ruling R2), so they are converted once, as a group: the preview is
|
||||
made from the first source in ``thumbnail_settings.source_rank`` order
|
||||
(``.png``, ``.jpg``, ``.jpeg``, ``.webp``, ``.gif``, then videos), the old
|
||||
previews of every source in the group are removed, and each removed or
|
||||
written file's bytes count once.
|
||||
|
||||
A failure keeps the old preview. Previews whose source is gone (orphans) are
|
||||
never planned, so never touched. The final status line reads
|
||||
"N converted · F failed · <bytes before> → <bytes after>".
|
||||
never planned, so never touched. The format the job converts to is fixed
|
||||
when it starts; Appearance refuses a thumbnail settings change while it runs
|
||||
(409), and should the format still change underneath, the job stops at the
|
||||
next file without removing anything in another format. The final status line
|
||||
reads "N converted · F failed · <bytes before> → <bytes after>".
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -33,6 +45,7 @@ import time
|
||||
from pathlib import Path
|
||||
|
||||
from . import thumbnail_settings
|
||||
from .thumbnail_encode import ThumbnailVerifyError, verify_thumbnail, writer_settings
|
||||
from .admin import _plural, _scan_location, _scan_roots, _scan_status_line
|
||||
from .images import _thumbnail_is_fresh, _worker_threads, _thumbnail_batch_size
|
||||
from .prompts import safe_error_message
|
||||
@@ -47,6 +60,10 @@ NOTHING_OUTDATED = "No outdated previews to convert"
|
||||
CANCELLED_BEFORE_CONVERTING = "Cancelled before any preview was converted"
|
||||
SOURCE_GONE = "The file is gone; its old preview was kept"
|
||||
OLD_NOT_REMOVED = "Converted; the old preview could not be removed"
|
||||
FORMAT_CHANGED = (
|
||||
"Stopped: the thumbnail format changed while converting · "
|
||||
"{converted} converted. Run Convert outdated again."
|
||||
)
|
||||
_UNITS = ("B", "KB", "MB", "GB", "TB")
|
||||
|
||||
|
||||
@@ -76,6 +93,28 @@ def _old_previews(src, dst, settings):
|
||||
return old
|
||||
|
||||
|
||||
def _group_targets(targets):
|
||||
"""``[[(src, dst, is_video)]]``: targets sharing a preview name (one
|
||||
folder, one stem) in one group, its first source first (R2)."""
|
||||
groups = {}
|
||||
for target in targets:
|
||||
dst = Path(target[1])
|
||||
key = os.path.normcase(str(dst.with_suffix("")))
|
||||
groups.setdefault(key, []).append(target)
|
||||
ordered = []
|
||||
for group in groups.values():
|
||||
group.sort(
|
||||
key=lambda t: (thumbnail_settings.source_rank(Path(t[0]).suffix), str(t[0]))
|
||||
)
|
||||
ordered.append(group)
|
||||
return ordered
|
||||
|
||||
|
||||
def _format_changed(settings):
|
||||
"""Whether previews are written in another format than *settings* now."""
|
||||
return writer_settings().extension != settings.extension
|
||||
|
||||
|
||||
def _finished_line(totals, remaining):
|
||||
line = (
|
||||
f"{totals['converted']:,} converted · {totals['failed']:,} failed · "
|
||||
@@ -128,6 +167,13 @@ class ThumbnailConvertMixin:
|
||||
return "finished"
|
||||
totals = await self._convert_targets(job, options, targets, output_dirs)
|
||||
self.invalidate_gallery_cache()
|
||||
if totals["stopped"]:
|
||||
job.update(status_line=FORMAT_CHANGED.format(converted=totals["converted"]))
|
||||
job.log_line(
|
||||
"Conversion stopped · the thumbnail format changed · "
|
||||
f"{_plural(totals['converted'], 'preview')} converted"
|
||||
)
|
||||
return "cancelled"
|
||||
if job.cancelled:
|
||||
return self._end_conversion(
|
||||
job,
|
||||
@@ -149,27 +195,33 @@ class ThumbnailConvertMixin:
|
||||
return "cancelled"
|
||||
|
||||
async def _convert_targets(self, job, options, targets, output_dirs):
|
||||
"""Convert *targets* in worker batches, recording each file."""
|
||||
"""Convert *targets* in worker batches of preview groups, recording
|
||||
each file."""
|
||||
roots = _scan_roots(output_dirs)
|
||||
total = len(targets)
|
||||
groups = _group_targets(targets)
|
||||
job.update(total=total, status_line=_scan_status_line(0, total, 0, 0))
|
||||
totals = {"converted": 0, "failed": 0, "before": 0, "after": 0}
|
||||
totals["stopped"] = False
|
||||
start = time.monotonic()
|
||||
batch_size = _thumbnail_batch_size(_worker_threads())
|
||||
index = batches = 0
|
||||
for batch_start in range(0, total, batch_size):
|
||||
if job.cancelled:
|
||||
for batch_start in range(0, len(groups), batch_size):
|
||||
if job.cancelled or totals["stopped"]:
|
||||
break
|
||||
batch = targets[batch_start : batch_start + batch_size]
|
||||
batch = groups[batch_start : batch_start + batch_size]
|
||||
results = await self._run_in_executor(
|
||||
self._convert_batch, batch, options.settings, lambda: job.cancelled
|
||||
)
|
||||
for (src, _dst, _video), result in zip(batch, results):
|
||||
if result["action"] == "cancelled":
|
||||
continue
|
||||
index += 1
|
||||
self._record_conversion(job, src, result, roots, totals)
|
||||
job.update(processed=index, progress=index * 100 // total)
|
||||
for group, group_results in zip(batch, results):
|
||||
for (src, _dst, _video), result in zip(group, group_results):
|
||||
if result["action"] == "stopped":
|
||||
totals["stopped"] = True
|
||||
if result["action"] in ("cancelled", "stopped"):
|
||||
continue
|
||||
index += 1
|
||||
self._record_conversion(job, src, result, roots, totals)
|
||||
job.update(processed=index, progress=index * 100 // total)
|
||||
batches += 1
|
||||
job.update(
|
||||
status_line=_scan_status_line(
|
||||
@@ -195,27 +247,44 @@ class ThumbnailConvertMixin:
|
||||
detail = result.get("error") or "failed"
|
||||
job.record("errors", {**row, "detail": detail}, f"! {where} · {detail}")
|
||||
|
||||
def _convert_batch(self, targets, settings, stop):
|
||||
"""Convert ``targets`` on the configured worker threads (blocking)."""
|
||||
convert = functools.partial(self._convert_target, settings=settings, stop=stop)
|
||||
return map_parallel(convert, targets, _worker_threads())
|
||||
def _convert_batch(self, groups, settings, stop):
|
||||
"""Convert preview ``groups`` on the configured worker threads
|
||||
(blocking); one result list per group."""
|
||||
convert = functools.partial(self._convert_group, settings=settings, stop=stop)
|
||||
return map_parallel(convert, groups, _worker_threads())
|
||||
|
||||
def _convert_target(self, target, settings, stop):
|
||||
src, dst, is_video = target
|
||||
return self._convert_one(src, dst, is_video, settings, stop=stop)
|
||||
def _convert_group(self, group, settings, stop):
|
||||
"""Convert the preview a group of same-stem files shares, once.
|
||||
|
||||
def _convert_one(self, src, dst, is_video, settings, stop=None):
|
||||
The first source writes it; the others are recorded with the same
|
||||
outcome and no bytes of their own."""
|
||||
src, dst, is_video = group[0]
|
||||
siblings = [Path(target[0]) for target in group[1:]]
|
||||
result = self._convert_one(
|
||||
src, dst, is_video, settings, stop=stop, siblings=siblings
|
||||
)
|
||||
if result["action"] != "converted":
|
||||
return [result] * len(group)
|
||||
shared = {**result, "before": 0, "after": 0}
|
||||
shared["detail"] = f"{result['detail']} (shares {Path(src).name}'s preview)"
|
||||
return [result] + [shared] * len(siblings)
|
||||
|
||||
def _convert_one(self, src, dst, is_video, settings, stop=None, siblings=()):
|
||||
"""Convert one file's preview (blocking; never raises).
|
||||
|
||||
``converted`` with the bytes before and after, ``error`` (the old
|
||||
preview kept) or ``cancelled`` (a file boundary).
|
||||
``converted`` with the bytes removed and written, ``error`` (the old
|
||||
preview kept), ``cancelled`` (a file boundary) or ``stopped`` (the
|
||||
format changed underneath, review I4). *siblings* share the preview
|
||||
(R2): their old previews go too.
|
||||
"""
|
||||
if stop is not None and stop():
|
||||
return {"action": "cancelled"}
|
||||
if _format_changed(settings):
|
||||
return {"action": "stopped"}
|
||||
dst = Path(dst)
|
||||
try:
|
||||
old = _old_previews(src, dst, settings)
|
||||
if not _thumbnail_is_fresh(src, dst):
|
||||
old = self._old_group_previews([Path(src), *siblings], dst, settings)
|
||||
if not self._current_preview_usable(src, dst, settings):
|
||||
made = self._generate_one(
|
||||
src, dst, settings, is_video, rebuild=True, placeholder=False
|
||||
)
|
||||
@@ -226,22 +295,57 @@ class ThumbnailConvertMixin:
|
||||
return {"action": "error", "error": "The new preview was not written"}
|
||||
if not os.path.isfile(src):
|
||||
return {"action": "error", "error": SOURCE_GONE}
|
||||
kept = []
|
||||
for path, _size in old:
|
||||
try:
|
||||
os.remove(path)
|
||||
except OSError as exc:
|
||||
self.logger.warning(f"Could not remove old preview {path}: {exc}")
|
||||
kept.append(path)
|
||||
if _format_changed(settings):
|
||||
return {"action": "stopped"}
|
||||
removed, kept = self._remove_old_previews(old)
|
||||
if kept:
|
||||
return {"action": "error", "error": OLD_NOT_REMOVED}
|
||||
label = thumbnail_settings.FORMAT_LABELS[settings.format]
|
||||
return {
|
||||
"action": "converted",
|
||||
"before": sum(size for _path, size in old),
|
||||
"before": removed,
|
||||
"after": dst.stat().st_size,
|
||||
"detail": f"Converted to {label}",
|
||||
}
|
||||
except Exception as exc:
|
||||
self.logger.warning(f"Conversion failed for {src.name}", exc_info=True)
|
||||
return {"action": "error", "error": safe_error_message(exc)}
|
||||
|
||||
@staticmethod
|
||||
def _old_group_previews(sources, dst, settings):
|
||||
"""``[(path, size)]`` of every source's old previews, each path once."""
|
||||
seen, old = set(), []
|
||||
for source in sources:
|
||||
for path, size in _old_previews(source, dst, settings):
|
||||
key = os.path.normcase(str(path))
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
old.append((path, size))
|
||||
return old
|
||||
|
||||
@staticmethod
|
||||
def _current_preview_usable(src, dst, settings):
|
||||
"""A current preview at least as new as *src* that decodes (M4)."""
|
||||
if not _thumbnail_is_fresh(src, dst):
|
||||
return False
|
||||
try:
|
||||
verify_thumbnail(dst, settings.format)
|
||||
except ThumbnailVerifyError:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _remove_old_previews(self, old):
|
||||
"""``(bytes removed, [kept paths])``; a file already gone counts as
|
||||
removed, with no bytes (another run or a sibling removed it)."""
|
||||
removed, kept = 0, []
|
||||
for path, size in old:
|
||||
try:
|
||||
os.remove(path)
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
except OSError as exc:
|
||||
self.logger.warning(f"Could not remove old preview {path}: {exc}")
|
||||
kept.append(path)
|
||||
continue
|
||||
removed += size
|
||||
return removed, kept
|
||||
|
||||
@@ -20,8 +20,10 @@ Quality maps to a JPEG XL distance with libjxl's own
|
||||
(q80 → d1.9, q85 → d1.45, q90 → d1.0), the quadratic below 30, and q100 →
|
||||
d0.1 (a preview is never lossless). Pillow's plugin applies the same mapping
|
||||
inside libjxl. Detection is cached (``detect_jxl_encoder(refresh=True)``
|
||||
probes again). If JPEG XL is configured but no encoder is left at write
|
||||
time, that file is written as WebP and the fallback is logged once.
|
||||
probes again). An encoder that fails (its binary gone, or a non-zero exit)
|
||||
makes one write detect again: another encoder found is used for that file,
|
||||
none at all writes it as WebP (logged once) and refreshes what Appearance
|
||||
reads (``encoder_status``). The same encoder found again raises.
|
||||
|
||||
Encoder processes (and ffmpeg's video frames, images.py) go through
|
||||
``run_encoder``: tracked, bounded by a timeout, and killed by
|
||||
@@ -31,9 +33,11 @@ Encoder processes (and ffmpeg's video frames, images.py) go through
|
||||
import importlib
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess # nosec B404 - fixed argv lists, never a shell
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
|
||||
@@ -100,6 +104,46 @@ def replace_unless_held(tmp, dst, guard=None):
|
||||
os.replace(tmp, dst)
|
||||
|
||||
|
||||
# ``temp_sibling``'s names: ``.<stem>.<tag>-<pid>-<thread id><ext>``.
|
||||
_TEMP_NAME = re.compile(r"^\..+\.(?:part|src)-(\d+)-\d+\.[A-Za-z0-9]+$")
|
||||
# A temp at least this old (seconds) outlived its write: no encode runs longer.
|
||||
STRAY_TEMP_AGE = 4 * ENCODE_TIMEOUT
|
||||
|
||||
|
||||
def sweep_stray_temps(folder, min_age=None):
|
||||
"""Remove the temp files a crashed write left under *folder* (a preview
|
||||
folder, walked without following links); returns ``(count, bytes)``.
|
||||
|
||||
Only ``temp_sibling`` names are touched, never a link. With *min_age*
|
||||
(seconds, the startup sweep) a temp younger than that, or one this
|
||||
process may still be writing, is left; ``min_age=0`` (the clear, which
|
||||
holds every preview write) removes them all.
|
||||
"""
|
||||
min_age = STRAY_TEMP_AGE if min_age is None else min_age
|
||||
now = time.time()
|
||||
own = str(os.getpid())
|
||||
count = size = 0
|
||||
for root, _dirs, files in os.walk(folder):
|
||||
for name in files:
|
||||
match = _TEMP_NAME.match(name)
|
||||
if match is None:
|
||||
continue
|
||||
path = Path(root) / name
|
||||
try:
|
||||
stat = path.lstat()
|
||||
if path.is_symlink() or not path.is_file():
|
||||
continue
|
||||
if min_age and (match.group(1) == own or now - stat.st_mtime < min_age):
|
||||
continue
|
||||
path.unlink()
|
||||
except OSError as exc:
|
||||
logger.debug(f"Could not remove stray temp {name}: {exc}")
|
||||
continue
|
||||
count += 1
|
||||
size += stat.st_size
|
||||
return count, size
|
||||
|
||||
|
||||
def _unlink_quietly(path):
|
||||
try:
|
||||
Path(path).unlink()
|
||||
@@ -318,8 +362,29 @@ def _check(done, tmp, name):
|
||||
raise EncoderFailed(f"{name} could not encode the preview: {detail.strip()}")
|
||||
|
||||
|
||||
class _EncoderGone(RuntimeError):
|
||||
"""No JPEG XL encoder is left: this file is written as WebP."""
|
||||
|
||||
|
||||
def _write_jxl(img, tmp, settings, track):
|
||||
"""Encode with the detected encoder; one that fails makes the write
|
||||
detect again once (another one is used, none raises _EncoderGone)."""
|
||||
encoder = detect_jxl_encoder()
|
||||
try:
|
||||
_encode_jxl(encoder, img, tmp, settings, track)
|
||||
return
|
||||
except (OSError, EncoderFailed) as exc:
|
||||
again = detect_jxl_encoder(refresh=True)
|
||||
if again is None:
|
||||
raise _EncoderGone(str(exc)) from exc
|
||||
if again == encoder:
|
||||
raise
|
||||
logger.info(f"JPEG XL encoder {encoder} failed; using {again}")
|
||||
_unlink_quietly(tmp)
|
||||
_encode_jxl(again, img, tmp, settings, track)
|
||||
|
||||
|
||||
def _encode_jxl(encoder, img, tmp, settings, track):
|
||||
distance = f"{jxl_distance(settings.quality):.2f}"
|
||||
if encoder == "pillow":
|
||||
img.save(
|
||||
@@ -334,7 +399,8 @@ def _write_jxl(img, tmp, settings, track):
|
||||
cmd = [
|
||||
"ffmpeg", "-v", "error", "-f", "png_pipe", "-i", "-",
|
||||
"-frames:v", "1", "-c:v", "libjxl", "-distance", distance,
|
||||
"-effort", str(JXL_EFFORT), "-f", "image2", "-y", str(tmp),
|
||||
"-effort", str(JXL_EFFORT), "-f", "image2", "-update", "1",
|
||||
"-y", str(tmp),
|
||||
] # fmt: skip
|
||||
_check(run_encoder(cmd, input=_png_bytes(img), track=track), tmp, "ffmpeg")
|
||||
return
|
||||
@@ -407,6 +473,15 @@ def write_thumbnail(frame, dst, settings, *, guard=None, track=None):
|
||||
if fmt == "jxl" and detect_jxl_encoder() is None:
|
||||
_jxl_fallback()
|
||||
fmt, dst = "webp", dst.with_suffix(".webp")
|
||||
try:
|
||||
return _write_verified(frame, dst, fmt, settings, guard, track)
|
||||
except _EncoderGone:
|
||||
_jxl_fallback()
|
||||
dst = dst.with_suffix(".webp")
|
||||
return _write_verified(frame, dst, "webp", settings, guard, track)
|
||||
|
||||
|
||||
def _write_verified(frame, dst, fmt, settings, guard, track):
|
||||
tmp = temp_sibling(dst)
|
||||
try:
|
||||
_WRITERS[fmt](frame, tmp, settings, track)
|
||||
|
||||
@@ -124,6 +124,19 @@ def thumbnail_rel(rel_path, extension=None):
|
||||
return thumbnail_stem_rel(rel_path) + (extension or current().extension)
|
||||
|
||||
|
||||
# Ruling R2: files sharing a stem share one preview, made from the first
|
||||
# source in this order: these images, then videos (by extension name).
|
||||
IMAGE_SOURCE_ORDER = (".png", ".jpg", ".jpeg", ".webp", ".gif")
|
||||
|
||||
|
||||
def source_rank(source_extension):
|
||||
"""Sort key of a source extension in R2's order (images first)."""
|
||||
extension = source_extension.lower()
|
||||
if extension in IMAGE_SOURCE_ORDER:
|
||||
return (0, IMAGE_SOURCE_ORDER.index(extension), extension)
|
||||
return (1, 0, extension)
|
||||
|
||||
|
||||
def owns(thumb_extension, source_extension):
|
||||
"""Whether a ``_thumb<thumb_extension>`` preview can belong to a source
|
||||
with *source_extension* (ruling R4)."""
|
||||
@@ -154,6 +167,18 @@ def candidate_extensions(source_extension, preferred=None):
|
||||
return tuple(seen)
|
||||
|
||||
|
||||
def owned_extensions(source_extension, preferred=None):
|
||||
"""``candidate_extensions`` plus the other spellings an older release
|
||||
may have given a legacy preview (review M1): the source's own
|
||||
(``X_thumb.JPG`` for ``X.JPG``) and the upper-case legacy one."""
|
||||
extensions = list(candidate_extensions(source_extension, preferred))
|
||||
legacy = _legacy_extension(source_extension)
|
||||
for spelling in (source_extension, legacy.upper()):
|
||||
if spelling not in extensions and owns(spelling, source_extension):
|
||||
extensions.append(spelling)
|
||||
return tuple(extensions)
|
||||
|
||||
|
||||
def _is_preview_file(path):
|
||||
try:
|
||||
return not path.is_symlink() and path.is_file()
|
||||
@@ -165,14 +190,8 @@ def owned_in_folder(folder, stem, source_extension, preferred=None):
|
||||
"""Yield the existing previews ``<folder>/<stem><ext>`` a source with
|
||||
*source_extension* owns, in lookup order (regular files, no links; one
|
||||
file found under two spellings on a case-insensitive disk once)."""
|
||||
extensions = list(candidate_extensions(source_extension, preferred))
|
||||
if source_extension != source_extension.lower() and owns(
|
||||
source_extension, source_extension
|
||||
):
|
||||
# An older release kept the source's own spelling (``b_thumb.JPG``).
|
||||
extensions.append(source_extension)
|
||||
seen = set()
|
||||
for extension in extensions:
|
||||
for extension in owned_extensions(source_extension, preferred):
|
||||
path = Path(folder) / (stem + extension)
|
||||
if not _is_preview_file(path):
|
||||
continue
|
||||
|
||||
@@ -56,3 +56,16 @@ test('a video whose preview fails still swaps back to its own element', () => wi
|
||||
assert.equal(holder.children[0].tagName, 'video');
|
||||
assert.equal(holder.children[0].src, 'http://host/serve/c.mp4?root=0');
|
||||
}));
|
||||
|
||||
test('a video with a JPEG XL thumbnail keeps its own element where JPEG XL cannot be shown (review M2)', async () => {
|
||||
support.resetJxlSupport();
|
||||
await support.detectJxlSupport({ createImage: () => ({ set src(_) { queueMicrotask(() => this.onerror()); } }) });
|
||||
const dom = mountedDom();
|
||||
dom.win.ApiClient = { resolveUrl: url => `http://host${url}` };
|
||||
try {
|
||||
const node = cardMedia(image('/serve/thumbnails/c_thumb.jxl?root=0',
|
||||
{ path: 'c.mp4', filename: 'c.mp4', url: '/serve/c.mp4?root=0', is_video: true }));
|
||||
assert.equal(node.tagName, 'video');
|
||||
assert.equal(node.src, 'http://host/serve/c.mp4?root=0');
|
||||
} finally { support.resetJxlSupport(); dom.restore(); }
|
||||
});
|
||||
|
||||
@@ -72,3 +72,30 @@ test('a thumbnail that fails to load falls back to the original once', () => {
|
||||
listen[0][2]();
|
||||
assert.equal(scoped.src, '/serve/b.png');
|
||||
});
|
||||
|
||||
test('a video card shows its thumbnail only when this browser can show it (review M2)', () => {
|
||||
const video = { is_video: true, url: '/serve/c.mp4?root=0', thumbnail_url: '/serve/thumbnails/c_thumb.jxl?root=0' };
|
||||
assert.equal(support.showsThumbnail(video, false), false, 'JPEG XL unsupported: the video keeps its own element');
|
||||
assert.equal(support.showsThumbnail(video, true), true);
|
||||
assert.equal(support.showsThumbnail(video, undefined), true, 'unknown: try it, the error swaps');
|
||||
assert.equal(support.showsThumbnail({ ...video, thumbnail_url: '/serve/thumbnails/c_thumb.webp' }, false), true);
|
||||
assert.equal(support.showsThumbnail({ ...video, thumbnail_url: null }, true), false);
|
||||
assert.equal(support.showsThumbnail({ url: '/serve/a.png', thumbnail_url: '/serve/thumbnails/a_thumb.jxl' }, false), true,
|
||||
'an image always shows something: its original');
|
||||
});
|
||||
|
||||
test('a video thumbnail that fails to load is replaced once, never left broken', () => {
|
||||
const listeners = {};
|
||||
let replaced = null;
|
||||
const node = { addEventListener(type, handler, options) { listeners[type] = { handler, options }; },
|
||||
replaceWith(next) { replaced = next; } };
|
||||
support.replaceOnError(node, () => 'label');
|
||||
assert.equal(listeners.error.options.once, true);
|
||||
listeners.error.handler();
|
||||
assert.equal(replaced, 'label');
|
||||
const listen = [];
|
||||
support.replaceOnError(node, () => 'other', (...args) => listen.push(args));
|
||||
assert.equal(listen[0][1], 'error');
|
||||
listen[0][2]();
|
||||
assert.equal(replaced, 'other');
|
||||
});
|
||||
|
||||
@@ -1424,6 +1424,71 @@ class TestThumbnailSettings(AdminAPITestCase):
|
||||
self.assertEqual(self._read_config()["thumbnails"]["format"], "jxl")
|
||||
self.assertEqual(PromptManagerConfig.THUMBNAIL_FORMAT, "jxl")
|
||||
|
||||
async def _running_job(self, mode):
|
||||
from py.api.jobs import THUMBNAILS_JOB_KIND
|
||||
from py.api.thumbnail_convert import CONVERT_JOB_TITLE
|
||||
|
||||
gate = asyncio.Event()
|
||||
|
||||
async def runner(job):
|
||||
await gate.wait()
|
||||
job.finish("finished")
|
||||
|
||||
job = self.api._jobs.start(
|
||||
THUMBNAILS_JOB_KIND,
|
||||
CONVERT_JOB_TITLE if mode == "convert" else "Thumbnail generation",
|
||||
admission="guarded",
|
||||
scope={"mode": mode},
|
||||
runner=runner,
|
||||
)
|
||||
|
||||
async def release():
|
||||
gate.set()
|
||||
await asyncio.wait_for(job.task, 5)
|
||||
|
||||
self.addAsyncCleanup(release)
|
||||
return job
|
||||
|
||||
async def test_thumbnail_changes_wait_for_a_running_conversion(self):
|
||||
# Review I4: a convert job's format is fixed; changing the format,
|
||||
# quality or size limit under it is refused with the job named.
|
||||
PromptManagerConfig.update_config(
|
||||
{"thumbnails": {"format": "webp", "quality": 80, "edge": 512}}
|
||||
)
|
||||
job = await self._running_job("convert")
|
||||
for change in (
|
||||
{"thumbnail_format": "jpeg"},
|
||||
{"thumbnail_quality": 70},
|
||||
{"thumbnail_edge": 300},
|
||||
):
|
||||
resp = await self.client.request("POST", self.URL, json=change)
|
||||
body = await resp.json()
|
||||
self.assertEqual(resp.status, 409, change)
|
||||
self.assertEqual(body["code"], "busy")
|
||||
self.assertEqual(body["running"]["id"], job.id)
|
||||
self.assertIn("Convert outdated thumbnails", body["error"])
|
||||
self.assertIn("thumbnail", body["error"].lower())
|
||||
self.assertEqual(
|
||||
(
|
||||
PromptManagerConfig.THUMBNAIL_FORMAT,
|
||||
PromptManagerConfig.THUMBNAIL_QUALITY,
|
||||
PromptManagerConfig.THUMBNAIL_EDGE,
|
||||
),
|
||||
("webp", 80, 512),
|
||||
)
|
||||
# The same values, or other settings, still save.
|
||||
resp = await self.client.request(
|
||||
"POST", self.URL, json={"thumbnail_format": "webp", "thumbnail_quality": 80}
|
||||
)
|
||||
self.assertEqual(resp.status, 200)
|
||||
|
||||
async def test_a_missing_previews_job_does_not_block_thumbnail_changes(self):
|
||||
await self._running_job("missing")
|
||||
resp = await self.client.request(
|
||||
"POST", self.URL, json={"thumbnail_format": "jpeg"}
|
||||
)
|
||||
self.assertEqual(resp.status, 200)
|
||||
|
||||
|
||||
class TestDiagnostics(AdminAPITestCase):
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from unittest import skipUnless
|
||||
from unittest.mock import patch
|
||||
|
||||
from aiohttp import web
|
||||
from PIL import Image
|
||||
from aiohttp.test_utils import make_mocked_request
|
||||
|
||||
from tests.test_api_images import ImageAPITestCase, make_png
|
||||
@@ -1174,6 +1175,75 @@ class HistoryOutputAPITests(ImageAPITestCase):
|
||||
await asyncio.wait_for(job.task, 5)
|
||||
self.assertFalse((self.output_dir / "thumbnails").exists())
|
||||
|
||||
async def test_the_cover_is_the_preview_the_images_listing_shows(self):
|
||||
# Review M5: JPEG XL configured with no encoder left writes WebP; the
|
||||
# history cover prefers that WebP, like the Images listing.
|
||||
from py.api import thumbnail_encode
|
||||
from py.config import PromptManagerConfig
|
||||
|
||||
make_png(self.output_dir / "image.png")
|
||||
folder = self.output_dir / "thumbnails"
|
||||
folder.mkdir()
|
||||
(folder / "image_thumb.jxl").write_bytes(b"\xff\x0a stale jxl")
|
||||
webp = folder / "image_thumb.webp"
|
||||
Image.new("RGB", (16, 16)).save(webp, "WEBP")
|
||||
start = self.capture([self.ref()])
|
||||
descriptor = await self.media(start)
|
||||
with (
|
||||
patch.object(PromptManagerConfig, "THUMBNAIL_FORMAT", "jxl"),
|
||||
patch.object(thumbnail_encode, "detect_jxl_encoder", return_value=None),
|
||||
):
|
||||
response = await self.current(descriptor["thumbnail_url"])
|
||||
body = await response.read()
|
||||
self.assertEqual(response.headers["Content-Type"], "image/webp")
|
||||
self.assertEqual(body, webp.read_bytes())
|
||||
|
||||
async def _jxl_cover(self, filename, accept):
|
||||
from py.api import thumbnail_encode
|
||||
from py.config import PromptManagerConfig
|
||||
|
||||
folder = self.output_dir / "thumbnails"
|
||||
folder.mkdir(exist_ok=True)
|
||||
stem = filename.rsplit(".", 1)[0]
|
||||
jxl = folder / f"{stem}_thumb.jxl"
|
||||
jxl.write_bytes(b"\xff\x0a jxl preview")
|
||||
start = self.capture([self.ref(filename=filename)])
|
||||
descriptor = await self.media(start)
|
||||
headers = {"Accept": accept} if accept else {}
|
||||
with (
|
||||
patch.object(PromptManagerConfig, "THUMBNAIL_FORMAT", "jxl"),
|
||||
patch.object(thumbnail_encode, "detect_jxl_encoder", return_value="cjxl"),
|
||||
):
|
||||
response = await self.current(descriptor["thumbnail_url"], headers=headers)
|
||||
body = await response.read()
|
||||
return response, body, jxl
|
||||
|
||||
async def test_a_jxl_cover_is_served_only_to_a_browser_that_accepts_it(self):
|
||||
# Review M3: History's cover URL names no format, so the server picks:
|
||||
# a browser that does not list image/jxl gets the original image.
|
||||
original = make_png(self.output_dir / "image.png")
|
||||
response, body, jxl = await self._jxl_cover(
|
||||
"image.png", "image/avif,image/webp,image/*,*/*;q=0.8"
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual(body, original.read_bytes())
|
||||
self.assertEqual(response.headers["Content-Type"], "image/png")
|
||||
self.assertTrue(jxl.exists())
|
||||
|
||||
async def test_a_browser_that_lists_image_jxl_gets_the_jxl_cover(self):
|
||||
make_png(self.output_dir / "image.png")
|
||||
response, body, jxl = await self._jxl_cover(
|
||||
"image.png", "image/webp,image/avif,image/jxl,image/*;q=0.8"
|
||||
)
|
||||
self.assertEqual(response.headers["Content-Type"], "image/jxl")
|
||||
self.assertEqual(body, jxl.read_bytes())
|
||||
|
||||
async def test_a_video_jxl_cover_is_the_placeholder_without_jxl_support(self):
|
||||
(self.output_dir / "clip.webm").write_bytes(b"AV1 stream no decoder here")
|
||||
response, body, _jxl = await self._jxl_cover("clip.webm", "image/*,*/*")
|
||||
self.assertEqual(response.headers["Content-Type"], "image/jpeg")
|
||||
self.assertTrue(body.startswith(b"\xff\xd8"))
|
||||
|
||||
async def test_a_preview_that_cannot_be_made_serves_the_original(self):
|
||||
broken = self.output_dir / "image.png"
|
||||
broken.write_bytes(b"\x89PNG\r\n\x1a\n not an image")
|
||||
|
||||
@@ -268,6 +268,99 @@ class TestConvertOutdated(ConvertCase):
|
||||
self.assertEqual(job.counters["errors"], 1)
|
||||
self.assertTrue(old.exists(), "a source gone mid-run makes an orphan")
|
||||
|
||||
async def test_same_stem_sources_convert_once_and_count_bytes_once(self):
|
||||
# Review I2 / R2: x.png and x.jpg share x_thumb.webp. The pair is
|
||||
# converted once, from the image first in the source order (.png),
|
||||
# and every removed file's bytes count once.
|
||||
original = PromptManagerConfig.WORKER_THREADS
|
||||
PromptManagerConfig.WORKER_THREADS = 2
|
||||
self.addCleanup(setattr, PromptManagerConfig, "WORKER_THREADS", original)
|
||||
from py.api.thumbnail_convert import readable_bytes
|
||||
|
||||
png = self.output_dir / "x.png"
|
||||
Image.new("RGB", (300, 300), (220, 20, 20)).save(png)
|
||||
jpg = self.output_dir / "x.jpg"
|
||||
Image.new("RGB", (300, 300), (20, 20, 220)).save(jpg)
|
||||
old_png = big_png(self._thumb_path("x", ".png"), size=(200, 200))
|
||||
old_jpg = self._thumb_path("x", ".jpg")
|
||||
Image.effect_noise((200, 200), 40).convert("RGB").save(old_jpg, "JPEG")
|
||||
later = max(png.stat().st_mtime, jpg.stat().st_mtime) + 5
|
||||
for path in (old_png, old_jpg):
|
||||
os.utime(path, (later, later))
|
||||
before = old_png.stat().st_size + old_jpg.stat().st_size
|
||||
|
||||
_, job = await self._convert()
|
||||
|
||||
self.assertEqual(job.state, "finished")
|
||||
self.assertEqual(
|
||||
(job.counters["generated"], job.counters["errors"]), (2, 0), job.log
|
||||
)
|
||||
new = self._thumb_path("x")
|
||||
self.assertFalse(old_png.exists() or old_jpg.exists())
|
||||
with Image.open(new) as img:
|
||||
red, _green, blue = img.convert("RGB").getpixel((5, 5))
|
||||
self.assertGreater(red, blue, "the image first in the source order wins")
|
||||
self.assertEqual(
|
||||
job.status_line,
|
||||
f"2 converted · 0 failed · {readable_bytes(before)} → "
|
||||
f"{readable_bytes(new.stat().st_size)}",
|
||||
)
|
||||
|
||||
async def test_an_old_preview_already_gone_counts_as_removed(self):
|
||||
old = self._legacy("g")
|
||||
real = self.api._generate_one
|
||||
|
||||
def remove_first(src, *args, **kwargs):
|
||||
os.remove(old)
|
||||
return real(src, *args, **kwargs)
|
||||
|
||||
self.api._generate_one = remove_first
|
||||
_, job = await self._convert()
|
||||
self.assertEqual((job.counters["generated"], job.counters["errors"]), (1, 0))
|
||||
self.assertRegex(job.status_line, r"^1 converted · 0 failed · 0 B → ")
|
||||
|
||||
async def test_a_current_preview_that_does_not_decode_is_written_again(self):
|
||||
# Review M4: a kept current preview is decoded before old ones go.
|
||||
old = self._legacy("m")
|
||||
current = self._thumb_path("m")
|
||||
current.write_bytes(b"RIFF not a webp")
|
||||
later = old.stat().st_mtime + 5
|
||||
os.utime(current, (later, later))
|
||||
_, job = await self._convert()
|
||||
self.assertEqual((job.counters["generated"], job.counters["errors"]), (1, 0))
|
||||
with Image.open(current) as img:
|
||||
self.assertEqual(img.format, "WEBP")
|
||||
self.assertFalse(old.exists())
|
||||
|
||||
async def test_a_format_change_underneath_stops_the_job(self):
|
||||
# Review I4 (defense in depth): the job never removes previews in a
|
||||
# format chosen after it started.
|
||||
original = PromptManagerConfig.WORKER_THREADS
|
||||
PromptManagerConfig.WORKER_THREADS = 1
|
||||
self.addCleanup(setattr, PromptManagerConfig, "WORKER_THREADS", original)
|
||||
olds = [self._legacy(f"f{i}") for i in range(4)]
|
||||
real = self.api._convert_one
|
||||
calls = []
|
||||
|
||||
def switching(*args, **kwargs):
|
||||
result = real(*args, **kwargs)
|
||||
calls.append(result)
|
||||
if len(calls) == 1:
|
||||
PromptManagerConfig.update_config(
|
||||
{"thumbnails": {"format": "jpeg", "quality": 80, "edge": 512}}
|
||||
)
|
||||
jpeg = self._thumb_path("f3", ".jpg")
|
||||
Image.new("RGB", (16, 16)).save(jpeg, "JPEG")
|
||||
return result
|
||||
|
||||
self.api._convert_one = switching
|
||||
_, job = await self._convert()
|
||||
self.assertEqual(job.state, "cancelled")
|
||||
self.assertIn("format changed", job.status_line)
|
||||
self.assertEqual(job.counters["generated"], 1)
|
||||
self.assertTrue(self._thumb_path("f3", ".jpg").exists(), "the new format stays")
|
||||
self.assertEqual(sum(1 for p in olds if p.exists()), 3)
|
||||
|
||||
def test_byte_sizes_are_readable(self):
|
||||
from py.api.thumbnail_convert import readable_bytes
|
||||
|
||||
|
||||
@@ -277,6 +277,161 @@ class TestWrite(TempCase):
|
||||
te.verify_thumbnail(path, "jxl")
|
||||
|
||||
|
||||
class TestFfmpegNames(TempCase):
|
||||
"""Review I1: ffmpeg's image2 muxer reads ``%d`` in an output name as a
|
||||
sequence pattern; ``-update 1`` keeps the temp name literal."""
|
||||
|
||||
def test_the_ffmpeg_command_writes_one_literal_file(self):
|
||||
seen = []
|
||||
|
||||
def fake(cmd, **kwargs):
|
||||
seen.append(cmd)
|
||||
Path(cmd[-1]).write_bytes(JXL_CODESTREAM + b"\x00" * 8)
|
||||
return subprocess.CompletedProcess(cmd, 0, b"", b"")
|
||||
|
||||
with (
|
||||
patch.object(te, "detect_jxl_encoder", return_value="ffmpeg"),
|
||||
patch.object(te, "run_encoder", side_effect=fake),
|
||||
patch.object(te, "_pillow_decodes_jxl", return_value=False),
|
||||
):
|
||||
te.write_thumbnail(
|
||||
Image.new("RGB", (8, 8)),
|
||||
self.tmp / "a_thumb.jxl",
|
||||
ThumbnailSettings("jxl", 80, 64),
|
||||
)
|
||||
cmd = seen[0]
|
||||
self.assertEqual(cmd[cmd.index("-update") + 1], "1")
|
||||
self.assertLess(cmd.index("-update"), len(cmd) - 1)
|
||||
|
||||
@unittest.skipUnless(_ffmpeg_has_libjxl(), "ffmpeg without libjxl")
|
||||
def test_a_percent_in_the_name_is_written_under_that_name(self):
|
||||
dst = self.tmp / "50%done_thumb.jxl"
|
||||
with patch.object(te, "detect_jxl_encoder", return_value="ffmpeg"):
|
||||
written = te.write_thumbnail(
|
||||
Image.new("RGB", (32, 32), (10, 200, 10)),
|
||||
dst,
|
||||
ThumbnailSettings("jxl", 80, 64),
|
||||
)
|
||||
self.assertEqual(written, dst)
|
||||
self.assertTrue(dst.is_file())
|
||||
self.assertEqual(self._leftovers(), [], "no stray sequence-named temp")
|
||||
|
||||
|
||||
class TestStrayTemps(TempCase):
|
||||
"""Review I1: a temp left by a crash (or the old ``%`` bug) is swept."""
|
||||
|
||||
def _touch(self, name, age=3600):
|
||||
path = self.tmp / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(b"x" * 10)
|
||||
when = path.stat().st_mtime - age
|
||||
os.utime(path, (when, when))
|
||||
return path
|
||||
|
||||
def test_only_old_temp_siblings_are_removed(self):
|
||||
strays = [
|
||||
self._touch(".a_thumb.part-12-34.webp"),
|
||||
self._touch(".501one.part-1-2.jxl"),
|
||||
self._touch("sub/.b_thumb.src-5-6.png"),
|
||||
]
|
||||
kept = [
|
||||
self._touch("a_thumb.webp"),
|
||||
self._touch(".hidden"),
|
||||
self._touch("notes.part-1-2.webp"),
|
||||
self._touch(".c_thumb.part-x-2.webp"),
|
||||
self._touch(".young_thumb.part-7-8.webp", age=0),
|
||||
]
|
||||
own = self._touch(f".mine_thumb.part-{os.getpid()}-9.webp")
|
||||
count, size = te.sweep_stray_temps(self.tmp, min_age=60)
|
||||
self.assertEqual((count, size), (3, 30))
|
||||
for path in strays:
|
||||
self.assertFalse(path.exists(), path.name)
|
||||
for path in kept + [own]:
|
||||
self.assertTrue(path.exists(), path.name)
|
||||
|
||||
def test_without_an_age_every_temp_goes_but_links_stay(self):
|
||||
stray = self._touch(".a_thumb.part-1-2.webp", age=0)
|
||||
own = self._touch(f".mine_thumb.part-{os.getpid()}-9.webp", age=0)
|
||||
target = self._touch("target.bin")
|
||||
link = self.tmp / ".l_thumb.part-1-2.webp"
|
||||
try:
|
||||
link.symlink_to(target)
|
||||
except (OSError, NotImplementedError):
|
||||
link = None
|
||||
self.assertEqual(te.sweep_stray_temps(self.tmp, min_age=0)[0], 2)
|
||||
self.assertFalse(stray.exists() or own.exists())
|
||||
if link is not None:
|
||||
self.assertTrue(link.is_symlink())
|
||||
|
||||
def test_a_missing_folder_sweeps_nothing(self):
|
||||
self.assertEqual(te.sweep_stray_temps(self.tmp / "none"), (0, 0))
|
||||
|
||||
|
||||
class TestEncoderVanishes(TempCase):
|
||||
"""Review I3: an encoder that disappears after detection is detected
|
||||
again once; with none left, that file is written as WebP and the
|
||||
availability Appearance reads is refreshed."""
|
||||
|
||||
def test_a_missing_binary_falls_back_to_webp_and_refreshes(self):
|
||||
found = iter(["cjxl", None])
|
||||
with (
|
||||
patch.object(te, "_detect", side_effect=lambda: next(found)),
|
||||
patch.object(te, "run_encoder", side_effect=FileNotFoundError("cjxl")),
|
||||
self.assertLogs("prompt_manager.thumbnails", logging.WARNING),
|
||||
):
|
||||
self.assertTrue(te.encoder_status()["jxl"]["available"])
|
||||
written = te.write_thumbnail(
|
||||
Image.new("RGB", (16, 16)),
|
||||
self.tmp / "v_thumb.jxl",
|
||||
ThumbnailSettings("jxl", 80, 64),
|
||||
)
|
||||
status = te.encoder_status()["jxl"]
|
||||
self.assertEqual(written.name, "v_thumb.webp")
|
||||
with Image.open(written) as img:
|
||||
self.assertEqual(img.format, "WEBP")
|
||||
self.assertEqual((status["available"], status["encoder"]), (False, None))
|
||||
self.assertEqual(self._leftovers(), [])
|
||||
|
||||
def test_another_encoder_found_again_is_used(self):
|
||||
found = iter(["cjxl", "ffmpeg"])
|
||||
used = []
|
||||
|
||||
def fake(cmd, **kwargs):
|
||||
used.append(cmd[0])
|
||||
if cmd[0] == "cjxl":
|
||||
raise FileNotFoundError("cjxl")
|
||||
Path(cmd[-1]).write_bytes(JXL_CODESTREAM + b"\x00" * 8)
|
||||
return subprocess.CompletedProcess(cmd, 0, b"", b"")
|
||||
|
||||
with (
|
||||
patch.object(te, "_detect", side_effect=lambda: next(found)),
|
||||
patch.object(te, "run_encoder", side_effect=fake),
|
||||
patch.object(te, "_pillow_decodes_jxl", return_value=False),
|
||||
):
|
||||
written = te.write_thumbnail(
|
||||
Image.new("RGB", (16, 16)),
|
||||
self.tmp / "w_thumb.jxl",
|
||||
ThumbnailSettings("jxl", 80, 64),
|
||||
)
|
||||
self.assertEqual(written.name, "w_thumb.jxl")
|
||||
self.assertEqual(used, ["cjxl", "ffmpeg"])
|
||||
self.assertEqual(self._leftovers(), [])
|
||||
|
||||
def test_the_same_encoder_failing_again_raises(self):
|
||||
failed = subprocess.CompletedProcess([], 1, b"", b"bad input")
|
||||
with (
|
||||
patch.object(te, "_detect", return_value="cjxl") as detect,
|
||||
patch.object(te, "run_encoder", return_value=failed),
|
||||
):
|
||||
with self.assertRaises(te.EncoderFailed):
|
||||
te.write_thumbnail(
|
||||
Image.new("RGB", (16, 16)),
|
||||
self.tmp / "x_thumb.jxl",
|
||||
ThumbnailSettings("jxl", 80, 64),
|
||||
)
|
||||
self.assertEqual(detect.call_count, 2, "detected again once")
|
||||
|
||||
|
||||
class TestEncoderProcesses(TempCase):
|
||||
SLEEPER = [sys.executable, "-c", "import time; time.sleep(60)"]
|
||||
|
||||
|
||||
@@ -336,6 +336,50 @@ class TestPlanStatusAndJobs(FormatCase):
|
||||
self.assertTrue((self.output_dir / "k.png").exists())
|
||||
|
||||
|
||||
class TestReviewFixes(FormatCase):
|
||||
async def test_a_legacy_preview_in_another_case_is_outdated(self):
|
||||
# Review M1: X.JPG's older X_thumb.JPG, and y.png's y_thumb.PNG.
|
||||
upper = self.output_dir / "X.JPG"
|
||||
Image.new("RGB", (64, 64)).save(upper, "JPEG")
|
||||
make_png(self.output_dir / "y.png")
|
||||
for name in ("X_thumb.JPG", "y_thumb.PNG"):
|
||||
legacy = self.output_dir / "thumbnails" / name
|
||||
legacy.parent.mkdir(parents=True, exist_ok=True)
|
||||
Image.new("RGB", (32, 32)).save(legacy, "PNG" if "PNG" in name else "JPEG")
|
||||
body = await (await self.client.get(STATUS)).json()
|
||||
self.assertEqual((body["outdated"], body["pending"]), (2, 0))
|
||||
resp = await self.client.post(JOB, json={"mode": "convert"})
|
||||
job = await self._finished((await resp.json())["job"]["id"])
|
||||
self.assertEqual(job.counters["generated"], 2, job.log)
|
||||
names = sorted(os.listdir(self.output_dir / "thumbnails"))
|
||||
self.assertEqual(names, ["X_thumb.webp", "y_thumb.webp"])
|
||||
|
||||
async def test_clear_also_removes_stray_temp_files(self):
|
||||
make_png(self.output_dir / "k.png")
|
||||
make_png(self._thumb("k"))
|
||||
folder = self.output_dir / "thumbnails"
|
||||
for name in (".k_thumb.part-1-2.webp", ".501one.part-3-4.jxl"):
|
||||
(folder / name).write_bytes(b"partial")
|
||||
keep = folder / "notes.txt"
|
||||
keep.write_bytes(b"x")
|
||||
resp = await self.client.post("/prompt_manager/images/clear-thumbnails")
|
||||
self.assertEqual((await resp.json())["cleared_files"], 3)
|
||||
self.assertEqual(os.listdir(folder), ["notes.txt"])
|
||||
|
||||
def test_the_startup_sweep_removes_only_old_temp_files(self):
|
||||
folder = self.output_dir / "thumbnails" / "sub"
|
||||
folder.mkdir(parents=True)
|
||||
old = folder / ".a_thumb.part-1-2.webp"
|
||||
young = folder / ".b_thumb.part-1-2.webp"
|
||||
for path in (old, young):
|
||||
path.write_bytes(b"partial")
|
||||
when = old.stat().st_mtime - 3600
|
||||
os.utime(old, (when, when))
|
||||
self.assertEqual(self.api.sweep_thumbnail_temps(), 1)
|
||||
self.assertFalse(old.exists())
|
||||
self.assertTrue(young.exists())
|
||||
|
||||
|
||||
class TestRemoval(FormatCase):
|
||||
def test_removing_a_source_removes_its_previews_in_every_format(self):
|
||||
make_png(self.output_dir / "r.png")
|
||||
|
||||
@@ -617,6 +617,8 @@ class TestThumbnailJob(ThumbnailJobCase):
|
||||
"scale='min(iw,320)':'min(ih,320)':force_original_aspect_ratio=decrease",
|
||||
)
|
||||
self.assertEqual(cmd[-5:], ["-f", "image2pipe", "-vcodec", "png", "-"])
|
||||
# Review M6: the input is always read as a file, never a protocol URL.
|
||||
self.assertTrue(cmd[cmd.index("-i") + 1].startswith("file:"))
|
||||
with Image.open(self._thumb_path("clip")) as thumb:
|
||||
self.assertEqual((thumb.format, thumb.size), ("WEBP", (320, 180)))
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { cardTitle, cardSubtitle, imageKey, mediaRatio } from './images-state.mj
|
||||
import { createWorkspaceIcon } from './icons.mjs';
|
||||
import { createTagButton } from './image-tagging.mjs';
|
||||
import { paintStar } from './images-favorites.mjs';
|
||||
import { fallBackToOriginal, thumbnailSource } from './thumbnail-support.mjs';
|
||||
import { fallBackToOriginal, replaceOnError, showsThumbnail, thumbnailSource } from './thumbnail-support.mjs';
|
||||
|
||||
function el(tag, text, className) {
|
||||
const node = document.createElement(tag);
|
||||
@@ -33,11 +33,11 @@ function videoMedia(image) {
|
||||
*/
|
||||
export function cardMedia(image, lazy = false) {
|
||||
const { resolveUrl } = window.ApiClient;
|
||||
if (image.is_video && !image.thumbnail_url) return videoMedia(image);
|
||||
if (!showsThumbnail(image)) return videoMedia(image);
|
||||
const node = el('img');
|
||||
node.src = resolveUrl(thumbnailSource(image));
|
||||
// replaceWith does nothing for a card no longer in the page.
|
||||
if (image.is_video) node.addEventListener('error', () => node.replaceWith(videoMedia(image)), { once: true });
|
||||
if (image.is_video) replaceOnError(node, () => videoMedia(image));
|
||||
else fallBackToOriginal(node, resolveUrl(image.url));
|
||||
// The concept card image: alt is the card title, with the listing's intrinsic size when known.
|
||||
node.alt = cardTitle(image); node.decoding = 'async'; if (lazy) node.loading = 'lazy';
|
||||
|
||||
@@ -15,7 +15,7 @@ import {pageHeading} from './heading.mjs';
|
||||
import {mountToolbar} from './toolbar.mjs';
|
||||
import {libraryFilters,libraryViewState} from './saved-views.mjs';
|
||||
import {openRefineDialog,openContentDialog} from './workspace-dialogs.mjs';
|
||||
import {fallBackToOriginal,thumbnailSource} from './thumbnail-support.mjs';
|
||||
import {fallBackToOriginal,replaceOnError,showsThumbnail,thumbnailSource} from './thumbnail-support.mjs';
|
||||
|
||||
let shelfVisit=null;
|
||||
// Every confirmed receipt advances this; a shelf mounted before it never restores its cached stream.
|
||||
@@ -214,10 +214,12 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
|
||||
const art=el('div',undefined,'card-art');art.setAttribute('aria-hidden','true');
|
||||
const previews=item.previews || [];
|
||||
for(const image of previews.slice(0,2)) {
|
||||
if(image.is_video && !image.thumbnail_url) art.append(el('span','Video preview','small'));
|
||||
// #317: a video with no preview, or one this browser cannot show (JPEG XL), keeps its label.
|
||||
if(!showsThumbnail(image)) art.append(el('span','Video preview','small'));
|
||||
else {
|
||||
// #317: a JPEG XL preview this browser cannot show, or one that fails, loads the original.
|
||||
const img=el('img');img.src=window.ApiClient.resolveUrl(thumbnailSource(image));if(!image.is_video) fallBackToOriginal(img,window.ApiClient.resolveUrl(image.url));img.alt='';img.loading='lazy';img.decoding='async';img.width=image.width || 800;img.height=image.height || 600;art.append(img);
|
||||
// #317: an image's JPEG XL preview this browser cannot show, or one that fails, loads the original;
|
||||
// a video's preview that fails becomes its label, never a broken image.
|
||||
const img=el('img');img.src=window.ApiClient.resolveUrl(thumbnailSource(image));if(image.is_video) replaceOnError(img,()=>el('span','Video preview','small'));else fallBackToOriginal(img,window.ApiClient.resolveUrl(image.url));img.alt='';img.loading='lazy';img.decoding='async';img.width=image.width || 800;img.height=image.height || 600;art.append(img);
|
||||
}
|
||||
}
|
||||
if(!previews.length) art.append(el('span','No available image previews','small'));
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
* original when the thumbnail is JPEG XL and this browser cannot decode it. While the answer is unknown the
|
||||
* thumbnail is tried; `fallBackToOriginal` then catches a failure.
|
||||
* - `fallBackToOriginal(img, url)`: a thumbnail `<img>` that fails to load shows the original instead (once).
|
||||
* - `showsThumbnail(image)`: false for a video whose thumbnail is missing or one this browser cannot show (its URL
|
||||
* would be the video itself): the card keeps the video's own element or label, never a broken image.
|
||||
* - `replaceOnError(img, next)`: a video thumbnail `<img>` that fails to load is replaced by `next()` (once).
|
||||
*/
|
||||
|
||||
// A 1×1 JPEG XL image (cjxl -d 1), decoded once to learn whether this browser shows JPEG XL.
|
||||
@@ -60,6 +63,20 @@ export function thumbnailSource(image, supported = answer) {
|
||||
return thumbnail;
|
||||
}
|
||||
|
||||
/** Whether a card shows `image`'s thumbnail (always for an image: its original at worst). */
|
||||
export function showsThumbnail(image, supported = answer) {
|
||||
if (!image?.is_video) return true;
|
||||
const source = thumbnailSource(image, supported);
|
||||
return Boolean(source) && source !== image.url;
|
||||
}
|
||||
|
||||
/** A video thumbnail `<img>` that fails to load is replaced by `next()`, once (`listen` as in fallBackToOriginal). */
|
||||
export function replaceOnError(node, next, listen = null) {
|
||||
const swap = () => node.replaceWith(next());
|
||||
if (listen) listen(node, 'error', swap, { once: true });
|
||||
else node.addEventListener('error', swap, { once: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* A thumbnail `<img>` that fails to load shows `original` instead, once. `listen(target, type, handler, options)` is the
|
||||
* caller's scoped listener (a scope's `listen`); without one a plain once-listener is used.
|
||||
|
||||
Reference in New Issue
Block a user