From dec74a46d7819282b4882e802e2865c0d964b5e8 Mon Sep 17 00:00:00 2001 From: Frank Schwenk Date: Sat, 1 Aug 2026 13:04:33 +0200 Subject: [PATCH] feat: serve responsive WebP srcsets for faster mobile browsing On-demand ?w= resize with a disk cache under webcache/; downloads stay full-resolution. Co-authored-by: Cursor --- .env.example | 3 +- app/config.py | 2 + app/images.py | 89 ++++++++++++++++++++++++++++++++++++++++ app/main.py | 25 +++++++++-- app/templates/base.html | 3 +- app/templates/index.html | 10 ++++- app/templates/job.html | 40 ++++++++++++++++-- tests/conftest.py | 4 +- tests/test_main.py | 53 +++++++++++++++++++++++- 9 files changed, 217 insertions(+), 12 deletions(-) create mode 100644 app/images.py diff --git a/.env.example b/.env.example index bde007a..40b8d40 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,8 @@ # --- Host data path (Syncthing share root) ------------------------------ # Prod default (compose.yml): /home/frank/sync.schwenk.online/data/livef12 -# Layout under that path: incoming/ variants/ intermediates/ meta/ +# Layout under that path: incoming/ variants/ intermediates/ meta/ webcache/ +# (webcache = resized WebP for srcset; safe to wipe / add to phone .stignore) # Local override when the absolute path does not exist: # DATA_HOST_DIR=./data # DATA_HOST_DIR=/home/frank/sync.schwenk.online/data/livef12 diff --git a/app/config.py b/app/config.py index 1a0a0ad..c816677 100644 --- a/app/config.py +++ b/app/config.py @@ -34,6 +34,8 @@ INTERMEDIATES_DIR = DATA_DIR / "intermediates" # Web bookkeeping (status + manifest). Not needed on the phone — ignore via # Syncthing .stignore if desired. META_DIR = DATA_DIR / "meta" +# Resized WebP cache for srcset (web container only; safe to wipe / .stignore). +WEB_CACHE_DIR = DATA_DIR / "webcache" ASSETS_DIR = DATA_DIR / "assets" PROCESSED_FILE = DATA_DIR / "processed.json" diff --git a/app/images.py b/app/images.py new file mode 100644 index 0000000..ca9ba4f --- /dev/null +++ b/app/images.py @@ -0,0 +1,89 @@ +"""On-demand web image resizing with disk cache (srcset helpers).""" + +from __future__ import annotations + +import logging +import tempfile +from pathlib import Path +from urllib.parse import quote + +from PIL import Image + +from . import config + +logger = logging.getLogger("livef12.images") + +# Allowed ?w= values for /jobs/.../files/... — keep in sync with templates. +SRCSET_WIDTHS: tuple[int, ...] = (320, 640, 960, 1280) + +# Default sizes for the 1/2/4-column job + variant grids. +GRID_SIZES = "(min-width: 960px) 25vw, (min-width: 640px) 50vw, 100vw" +# Original/rembg pair is always two columns. +PAIR_SIZES = "(min-width: 640px) 50vw, 50vw" + + +def file_url(job_id: str, filename: str, width: int | None = None) -> str: + base = f"/jobs/{quote(job_id, safe='')}/files/{quote(filename, safe='')}" + if width is None: + return base + return f"{base}?w={width}" + + +def srcset_for(job_id: str, filename: str) -> str: + return ", ".join(f"{file_url(job_id, filename, w)} {w}w" for w in SRCSET_WIDTHS) + + +def img_attrs(job_id: str, filename: str, *, sizes: str = GRID_SIZES) -> dict[str, str]: + """Attrs for responsive : src, srcset, sizes, full_src (lightbox).""" + return { + "src": file_url(job_id, filename, 640), + "srcset": srcset_for(job_id, filename), + "sizes": sizes, + # Lightbox: large webp, not the multi-MB master PNG/JPEG. + "full_src": file_url(job_id, filename, SRCSET_WIDTHS[-1]), + } + + +def get_or_create_resized(source: Path, width: int) -> Path: + """Return a cached WebP whose longest edge is at most `width` (no upscale).""" + if width not in SRCSET_WIDTHS: + raise ValueError(f"unsupported width: {width}") + if not source.is_file(): + raise FileNotFoundError(source) + + config.WEB_CACHE_DIR.mkdir(parents=True, exist_ok=True) + cache_path = config.WEB_CACHE_DIR / f"{source.name}.w{width}.webp" + try: + if cache_path.is_file() and cache_path.stat().st_mtime >= source.stat().st_mtime: + return cache_path + except OSError: + pass + + _write_resized(source, cache_path, width) + return cache_path + + +def _write_resized(source: Path, dest: Path, width: int) -> None: + with Image.open(source) as img: + img.load() + if img.mode == "P": + img = img.convert("RGBA" if "transparency" in img.info else "RGB") + elif img.mode not in ("RGB", "RGBA"): + img = img.convert("RGB") + + if img.width > width: + new_h = max(1, round(img.height * (width / img.width))) + img = img.resize((width, new_h), Image.Resampling.LANCZOS) + + dest.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile( + dir=dest.parent, suffix=".webp", delete=False + ) as tmp: + tmp_path = Path(tmp.name) + try: + img.save(tmp_path, format="WEBP", quality=78, method=4) + tmp_path.replace(dest) + except Exception: + tmp_path.unlink(missing_ok=True) + raise + logger.debug("cached %s -> %s (%dx%d)", source.name, dest.name, img.width, img.height) diff --git a/app/main.py b/app/main.py index aa5d9b4..f35e356 100644 --- a/app/main.py +++ b/app/main.py @@ -13,7 +13,7 @@ from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates -from . import config, pipeline, remix +from . import config, images, pipeline, remix from .logging_config import configure_logging configure_logging() @@ -23,6 +23,9 @@ app = FastAPI(title=config.SITE_TITLE) BASE_DIR = Path(__file__).resolve().parent templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) +templates.env.globals["img_attrs"] = images.img_attrs +templates.env.globals["PAIR_SIZES"] = images.PAIR_SIZES +templates.env.globals["GRID_SIZES"] = images.GRID_SIZES app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static") # Job id = sanitized source stem (may include dots). @@ -120,9 +123,25 @@ def job_detail(request: Request, job_id: str) -> HTMLResponse: @app.get("/jobs/{job_id}/files/{filename:path}") -def job_file(job_id: str, filename: str) -> FileResponse: +def job_file( + job_id: str, + filename: str, + w: int | None = Query(None, description="Longest-edge width for srcset WebP"), +) -> FileResponse: path = _safe_job_file(job_id, filename) - return FileResponse(path) + if w is None: + return FileResponse(path) + if w not in images.SRCSET_WIDTHS: + raise HTTPException( + status_code=400, + detail=f"Ungueltige Breite (erlaubt: {', '.join(map(str, images.SRCSET_WIDTHS))})", + ) + try: + cached = images.get_or_create_resized(path, w) + except OSError as exc: + logger.warning("resize failed for %s w=%s: %s", path.name, w, exc) + raise HTTPException(status_code=500, detail="Bild konnte nicht skaliert werden") from exc + return FileResponse(cached, media_type="image/webp", filename=cached.name) @app.get("/jobs/{job_id}/remix", response_class=HTMLResponse) diff --git a/app/templates/base.html b/app/templates/base.html index 0154cab..b1864c3 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -44,7 +44,8 @@ if (!target.classList.contains("lightboxable")) return; e.preventDefault(); e.stopPropagation(); - openLightbox(target.currentSrc || target.src, target.alt); + var full = target.getAttribute("data-full-src"); + openLightbox(full || target.currentSrc || target.src, target.alt); }); box.addEventListener("click", function (e) { diff --git a/app/templates/index.html b/app/templates/index.html index 2b791a5..5bd9d50 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -9,7 +9,15 @@
  • {% if job.thumbnail %} - Variante von Job {{ job.job_id }} + {% set img = img_attrs(job.job_id, job.thumbnail) %} + Variante von Job {{ job.job_id }} {% else %} {{ job.status }} {% endif %} diff --git a/app/templates/job.html b/app/templates/job.html index 5bcb780..4fa8aa7 100644 --- a/app/templates/job.html +++ b/app/templates/job.html @@ -13,8 +13,16 @@

    Original & Rembg

    {% if original_name %} + {% set img = img_attrs(job_id, original_name, sizes=PAIR_SIZES) %}
    - Original + Original
    Original · Download @@ -22,8 +30,16 @@
    {% endif %} {% if has_rembg and rembg_name %} + {% set img = img_attrs(job_id, rembg_name, sizes=PAIR_SIZES) %}
    - Freigestellt (rembg) + Freigestellt (rembg)
    Rembg · Download @@ -45,8 +61,16 @@ {% else %}
      {% for v in manifest.variants %} + {% set img = img_attrs(job_id, v.file) %}
    • - Variante {{ v.id }} + Variante {{ v.id }}
      {{ v.id }} {{ v.source }}
      @@ -72,8 +96,16 @@

      Zwischenschritte

        {% for name in intermediates %} + {% set img = img_attrs(job_id, name) %}
      • - {{ name }} + {{ name }} {{ name }}
      • {% endfor %} diff --git a/tests/conftest.py b/tests/conftest.py index 5ccea05..96b5fc3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,7 +14,8 @@ def data_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: intermediates = tmp_path / "intermediates" meta = tmp_path / "meta" inbox = tmp_path / "incoming" - for directory in (variants, intermediates, meta, inbox): + webcache = tmp_path / "webcache" + for directory in (variants, intermediates, meta, inbox, webcache): directory.mkdir() monkeypatch.setattr("app.config.DATA_DIR", tmp_path) @@ -22,5 +23,6 @@ def data_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: monkeypatch.setattr("app.config.INTERMEDIATES_DIR", intermediates) monkeypatch.setattr("app.config.META_DIR", meta) monkeypatch.setattr("app.config.INBOX_DIR", inbox) + monkeypatch.setattr("app.config.WEB_CACHE_DIR", webcache) monkeypatch.setattr("app.config.PROCESSED_FILE", tmp_path / "processed.json") return tmp_path diff --git a/tests/test_main.py b/tests/test_main.py index e603127..c583173 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -5,10 +5,11 @@ from __future__ import annotations from pathlib import Path import pytest -from app import config, pipeline +from app import config, images, pipeline from app.main import _safe_job_file, _validate_job_id, app from fastapi import HTTPException from fastapi.testclient import TestClient +from PIL import Image @pytest.fixture @@ -16,6 +17,11 @@ def client(data_dir: Path) -> TestClient: return TestClient(app) +def _write_test_png(path: Path, size: tuple[int, int] = (1200, 800)) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + Image.new("RGB", size, color=(40, 80, 120)).save(path, format="PNG") + + def test_validate_job_id_sanitizes_input() -> None: assert _validate_job_id("foo@bar") == "foo_bar" assert _validate_job_id("photo01") == "photo01" @@ -50,3 +56,48 @@ def test_index_empty(client: TestClient) -> None: def test_job_not_found(client: TestClient) -> None: response = client.get("/jobs/does-not-exist") assert response.status_code == 404 + + +def test_job_file_srcset_resize(client: TestClient, data_dir: Path) -> None: + stem = "photo01" + paths = pipeline.job_paths(stem) + png = paths.variants / f"{stem}_v1.png" + _write_test_png(png) + pipeline.write_status(paths, "done", created_at="2026-01-01T00:00:00") + pipeline.write_manifest( + paths, + { + "variants": [{"id": "v1", "file": png.name}], + "created_at": "2026-01-01T00:00:00", + }, + ) + + full = client.get(f"/jobs/{stem}/files/{png.name}") + assert full.status_code == 200 + assert full.headers["content-type"].startswith("image/") + + bad = client.get(f"/jobs/{stem}/files/{png.name}?w=999") + assert bad.status_code == 400 + + resized = client.get(f"/jobs/{stem}/files/{png.name}?w=320") + assert resized.status_code == 200 + assert resized.headers["content-type"] == "image/webp" + assert len(resized.content) < len(full.content) + + cached = config.WEB_CACHE_DIR / f"{png.name}.w320.webp" + assert cached.is_file() + with Image.open(cached) as img: + assert img.width == 320 + + index = client.get("/") + assert index.status_code == 200 + assert f"/jobs/{stem}/files/{png.name}?w=640" in index.text + assert "srcset=" in index.text + assert "data-full-src=" in index.text + + +def test_img_attrs_builds_srcset() -> None: + attrs = images.img_attrs("job1", "job1_v1.png") + assert attrs["src"].endswith("?w=640") + assert "320w" in attrs["srcset"] + assert attrs["full_src"].endswith("?w=1280")