From 776aa3982327a5f8023999d33970ad2ce484a824 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Sun, 4 Oct 2026 18:55:56 -0400 Subject: [PATCH 1/3] Cache a screen-sized copy of TIFF/HEIC images served by /images/ /images/ re-encoded every TIFF/HEIC to a full-resolution PNG on every request, with no validators, so each view and slideshow preload paid for the decode and a 30-70 MB encode again. It now serves a JPEG (WebP when the image has transparency) capped at 4096px on the long edge, cached in the album's thumbnail directory and served with a content ETag/304. - Named with the tile digest, so the reindex sweep and the per-image delete reclaim display copies with no second keep-set. - Per-file lock plus write-then-rename: concurrent requests convert once and never read a partial file. Abandoned temporaries are swept. - Rebuilt when the source is newer; a failed cache write still serves. - 16-bit scans are reduced to 8-bit after _png_safe's rescale. On a 6000x4000 LZW TIFF: 2.55 s / 53 MB before, 0.67 s / 3.9 MB cold, ~1 ms warm. Downloads keep using ?original=1. Fixes #414 Co-Authored-By: Claude Opus 5.5 --- photomap/backend/media_types.py | 2 +- photomap/backend/routers/search.py | 178 ++++++++++++++++++++++---- photomap/backend/thumbnail_cache.py | 50 +++++++- tests/backend/test_image_formats.py | 173 ++++++++++++++++++++++++- tests/backend/test_thumbnail_cache.py | 29 +++++ 5 files changed, 401 insertions(+), 31 deletions(-) diff --git a/photomap/backend/media_types.py b/photomap/backend/media_types.py index 7074b09d..19d66cb4 100644 --- a/photomap/backend/media_types.py +++ b/photomap/backend/media_types.py @@ -69,7 +69,7 @@ ) # Images only Safari (TIFF) or nothing (HEIC in Chrome/Firefox) renders -# natively, so ``/images/`` serves them converted to PNG instead of raw. +# natively, so ``/images/`` serves them as a cached JPEG/WebP copy instead of raw. BROWSER_CONVERTED_EXTENSIONS: frozenset[str] = frozenset({".tif", ".tiff", ".heif", ".heic"}) # Container -> MIME map, and the single source of truth for which containers diff --git a/photomap/backend/routers/search.py b/photomap/backend/routers/search.py index 7d1f84ba..5d6e4efe 100644 --- a/photomap/backend/routers/search.py +++ b/photomap/backend/routers/search.py @@ -12,6 +12,8 @@ import json import os import re +import tempfile +import threading import zipfile from io import BytesIO from logging import getLogger @@ -28,8 +30,8 @@ from ..embeddings import SUPPORTED_EXTENSIONS, MediaFilter from ..media_types import is_video, needs_browser_conversion, video_media_type from ..metadata_modules import SlideSummary, video_external_link_html -from ..thumbnail_cache import thumbnail_dir, tile_hash -from ..util import is_cuda_oom +from ..thumbnail_cache import display_copy_stem, thumbnail_dir, tile_hash +from ..util import BoundedLRU, is_cuda_oom from ..video_cache import VideoFrameCache from ..video_transcode import ( HLS_PLAYLIST_NAME, @@ -384,7 +386,7 @@ def _video_placeholder_response(size: int) -> Response: _THUMBNAIL_CACHE_HEADERS = {"Cache-Control": "no-cache"} -def _thumbnail_response(request: Request, path: Path) -> Response: +def _thumbnail_response(request: Request, path: Path, media_type: str = "image/png") -> Response: """A tile, or a 304 if the caller already has it. ``no-cache`` above means the browser revalidates every time, and @@ -409,13 +411,17 @@ def _thumbnail_response(request: Request, path: Path) -> Response: # the next request rebuilds it; 404 rather than a 500 so it reads as # a missing tile instead of a broken server. raise HTTPException(status_code=404, detail="Thumbnail is no longer available") from None + return _validated_response(request, payload, media_type) + +def _validated_response(request: Request, payload: bytes, media_type: str) -> Response: + """``payload`` with a content ETag, or a 304 if the caller already has it.""" etag = f'"{hashlib.md5(payload, usedforsecurity=False).hexdigest()}"' if request.headers.get("if-none-match") == etag: return Response(status_code=304, headers={**_THUMBNAIL_CACHE_HEADERS, "etag": etag}) return Response( content=payload, - media_type="image/png", + media_type=media_type, headers={**_THUMBNAIL_CACHE_HEADERS, "etag": etag}, ) @@ -581,12 +587,15 @@ async def serve_video_frame( # or a converted stream and FastAPI refuses to work with Union types # in response_model. @search_router.get("/images/{album_key}/{path:path}", tags=["Search"]) -async def serve_image(album_key: str, path: str, album_config: AlbumDep, original: bool = False): +async def serve_image( + request: Request, album_key: str, path: str, album_config: AlbumDep, original: bool = False +): """Serve images from different albums dynamically. - Formats browsers cannot render are converted to PNG unless ``original`` is - set, which the download button uses so a saved ``.tif`` is the user's file - rather than a flattened, first-page-only PNG under the same name. + Formats browsers cannot render are served as a cached, screen-sized JPEG + (WebP if the image has transparency) unless ``original`` is set, which the + download button uses so a saved ``.tif`` is the user's file rather than a + flattened, downscaled, first-page-only copy under the same name. """ image_path = config_manager.find_image_in_album(album_key, path) if not image_path: @@ -607,9 +616,14 @@ async def serve_image(album_key: str, path: str, album_config: AlbumDep, origina raise HTTPException(status_code=404, detail="File not found") if needs_browser_conversion(image_path) and not original: + stem = None + relative_path = config_manager.get_relative_path(str(image_path), album_key) + if album_config.index and relative_path is not None: + stem = display_copy_stem(thumbnail_dir(Path(album_config.index)), relative_path) # Decoding a large scanned TIFF takes long enough to stall every other # request if done on the loop, so it runs in a worker thread. - return await asyncio.to_thread(serve_image_with_conversion, image_path) + payload, media_type = await asyncio.to_thread(_display_copy, image_path, stem) + return _validated_response(request, payload, media_type) return FileResponse(image_path) @@ -660,8 +674,8 @@ async def serve_video( Returns a ``FileResponse`` specifically: Starlette implements HTTP Range on it, which is what makes the ``