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..b6c44fab 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,23 @@ 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 + try: + # Resolved, as the index stores it: get_relative_path resolves the + # roots, so an unresolved path under a symlinked root (or a mapped + # Windows drive) would fall back to the bare filename — keying + # every same-named image in the album onto one cached copy, and + # onto a digest the sweep and the delete path never produce. + resolved = image_path.resolve() + except (OSError, RuntimeError): + resolved = None + relative_path = resolved and config_manager.get_relative_path(str(resolved), album_key) + if album_config.index and relative_path: + stem = display_copy_stem(thumbnail_dir(Path(album_config.index)), relative_path, album_key) # 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 +683,8 @@ async def serve_video( Returns a ``FileResponse`` specifically: Starlette implements HTTP Range on it, which is what makes the ``