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 @@