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 <cursoragent@cursor.com>
This commit is contained in:
+2
-1
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
# --- Host data path (Syncthing share root) ------------------------------
|
# --- Host data path (Syncthing share root) ------------------------------
|
||||||
# Prod default (compose.yml): /home/frank/sync.schwenk.online/data/livef12
|
# 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:
|
# Local override when the absolute path does not exist:
|
||||||
# DATA_HOST_DIR=./data
|
# DATA_HOST_DIR=./data
|
||||||
# DATA_HOST_DIR=/home/frank/sync.schwenk.online/data/livef12
|
# DATA_HOST_DIR=/home/frank/sync.schwenk.online/data/livef12
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ INTERMEDIATES_DIR = DATA_DIR / "intermediates"
|
|||||||
# Web bookkeeping (status + manifest). Not needed on the phone — ignore via
|
# Web bookkeeping (status + manifest). Not needed on the phone — ignore via
|
||||||
# Syncthing .stignore if desired.
|
# Syncthing .stignore if desired.
|
||||||
META_DIR = DATA_DIR / "meta"
|
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"
|
ASSETS_DIR = DATA_DIR / "assets"
|
||||||
PROCESSED_FILE = DATA_DIR / "processed.json"
|
PROCESSED_FILE = DATA_DIR / "processed.json"
|
||||||
|
|
||||||
|
|||||||
@@ -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 <img>: 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)
|
||||||
+21
-2
@@ -13,7 +13,7 @@ from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
|||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
from . import config, pipeline, remix
|
from . import config, images, pipeline, remix
|
||||||
from .logging_config import configure_logging
|
from .logging_config import configure_logging
|
||||||
|
|
||||||
configure_logging()
|
configure_logging()
|
||||||
@@ -23,6 +23,9 @@ app = FastAPI(title=config.SITE_TITLE)
|
|||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
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")
|
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
|
||||||
|
|
||||||
# Job id = sanitized source stem (may include dots).
|
# 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}")
|
@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)
|
path = _safe_job_file(job_id, filename)
|
||||||
|
if w is None:
|
||||||
return FileResponse(path)
|
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)
|
@app.get("/jobs/{job_id}/remix", response_class=HTMLResponse)
|
||||||
|
|||||||
@@ -44,7 +44,8 @@
|
|||||||
if (!target.classList.contains("lightboxable")) return;
|
if (!target.classList.contains("lightboxable")) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
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) {
|
box.addEventListener("click", function (e) {
|
||||||
|
|||||||
@@ -9,7 +9,15 @@
|
|||||||
<li class="job-card">
|
<li class="job-card">
|
||||||
<a href="/jobs/{{ job.job_id }}">
|
<a href="/jobs/{{ job.job_id }}">
|
||||||
{% if job.thumbnail %}
|
{% if job.thumbnail %}
|
||||||
<img class="thumb lightboxable" src="/jobs/{{ job.job_id }}/files/{{ job.thumbnail }}" alt="Variante von Job {{ job.job_id }}" loading="lazy">
|
{% set img = img_attrs(job.job_id, job.thumbnail) %}
|
||||||
|
<img class="thumb lightboxable"
|
||||||
|
src="{{ img.src }}"
|
||||||
|
srcset="{{ img.srcset }}"
|
||||||
|
sizes="{{ img.sizes }}"
|
||||||
|
data-full-src="{{ img.full_src }}"
|
||||||
|
alt="Variante von Job {{ job.job_id }}"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async">
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="thumb thumb-placeholder status-{{ job.status }}">{{ job.status }}</span>
|
<span class="thumb thumb-placeholder status-{{ job.status }}">{{ job.status }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
+36
-4
@@ -13,8 +13,16 @@
|
|||||||
<h2>Original & Rembg</h2>
|
<h2>Original & Rembg</h2>
|
||||||
<div class="pair-grid">
|
<div class="pair-grid">
|
||||||
{% if original_name %}
|
{% if original_name %}
|
||||||
|
{% set img = img_attrs(job_id, original_name, sizes=PAIR_SIZES) %}
|
||||||
<figure>
|
<figure>
|
||||||
<img class="lightboxable" src="/jobs/{{ job_id }}/files/{{ original_name }}" alt="Original" loading="lazy">
|
<img class="lightboxable"
|
||||||
|
src="{{ img.src }}"
|
||||||
|
srcset="{{ img.srcset }}"
|
||||||
|
sizes="{{ img.sizes }}"
|
||||||
|
data-full-src="{{ img.full_src }}"
|
||||||
|
alt="Original"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async">
|
||||||
<figcaption>
|
<figcaption>
|
||||||
Original ·
|
Original ·
|
||||||
<a href="/jobs/{{ job_id }}/files/{{ original_name }}" download>Download</a>
|
<a href="/jobs/{{ job_id }}/files/{{ original_name }}" download>Download</a>
|
||||||
@@ -22,8 +30,16 @@
|
|||||||
</figure>
|
</figure>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if has_rembg and rembg_name %}
|
{% if has_rembg and rembg_name %}
|
||||||
|
{% set img = img_attrs(job_id, rembg_name, sizes=PAIR_SIZES) %}
|
||||||
<figure>
|
<figure>
|
||||||
<img class="lightboxable" src="/jobs/{{ job_id }}/files/{{ rembg_name }}" alt="Freigestellt (rembg)" loading="lazy">
|
<img class="lightboxable"
|
||||||
|
src="{{ img.src }}"
|
||||||
|
srcset="{{ img.srcset }}"
|
||||||
|
sizes="{{ img.sizes }}"
|
||||||
|
data-full-src="{{ img.full_src }}"
|
||||||
|
alt="Freigestellt (rembg)"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async">
|
||||||
<figcaption>
|
<figcaption>
|
||||||
Rembg ·
|
Rembg ·
|
||||||
<a href="/jobs/{{ job_id }}/files/{{ rembg_name }}" download>Download</a>
|
<a href="/jobs/{{ job_id }}/files/{{ rembg_name }}" download>Download</a>
|
||||||
@@ -45,8 +61,16 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<ul class="variant-grid">
|
<ul class="variant-grid">
|
||||||
{% for v in manifest.variants %}
|
{% for v in manifest.variants %}
|
||||||
|
{% set img = img_attrs(job_id, v.file) %}
|
||||||
<li class="variant-card">
|
<li class="variant-card">
|
||||||
<img class="lightboxable" src="/jobs/{{ job_id }}/files/{{ v.file }}" alt="Variante {{ v.id }}" loading="lazy">
|
<img class="lightboxable"
|
||||||
|
src="{{ img.src }}"
|
||||||
|
srcset="{{ img.srcset }}"
|
||||||
|
sizes="{{ img.sizes }}"
|
||||||
|
data-full-src="{{ img.full_src }}"
|
||||||
|
alt="Variante {{ v.id }}"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async">
|
||||||
<div class="variant-meta">
|
<div class="variant-meta">
|
||||||
<strong>{{ v.id }}</strong> <span class="tag">{{ v.source }}</span>
|
<strong>{{ v.id }}</strong> <span class="tag">{{ v.source }}</span>
|
||||||
<dl>
|
<dl>
|
||||||
@@ -72,8 +96,16 @@
|
|||||||
<h2>Zwischenschritte</h2>
|
<h2>Zwischenschritte</h2>
|
||||||
<ul class="intermediate-grid">
|
<ul class="intermediate-grid">
|
||||||
{% for name in intermediates %}
|
{% for name in intermediates %}
|
||||||
|
{% set img = img_attrs(job_id, name) %}
|
||||||
<li>
|
<li>
|
||||||
<img class="lightboxable" src="/jobs/{{ job_id }}/files/{{ name }}" alt="{{ name }}" loading="lazy">
|
<img class="lightboxable"
|
||||||
|
src="{{ img.src }}"
|
||||||
|
srcset="{{ img.srcset }}"
|
||||||
|
sizes="{{ img.sizes }}"
|
||||||
|
data-full-src="{{ img.full_src }}"
|
||||||
|
alt="{{ name }}"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async">
|
||||||
<span>{{ name }}</span>
|
<span>{{ name }}</span>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
+3
-1
@@ -14,7 +14,8 @@ def data_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|||||||
intermediates = tmp_path / "intermediates"
|
intermediates = tmp_path / "intermediates"
|
||||||
meta = tmp_path / "meta"
|
meta = tmp_path / "meta"
|
||||||
inbox = tmp_path / "incoming"
|
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()
|
directory.mkdir()
|
||||||
|
|
||||||
monkeypatch.setattr("app.config.DATA_DIR", tmp_path)
|
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.INTERMEDIATES_DIR", intermediates)
|
||||||
monkeypatch.setattr("app.config.META_DIR", meta)
|
monkeypatch.setattr("app.config.META_DIR", meta)
|
||||||
monkeypatch.setattr("app.config.INBOX_DIR", inbox)
|
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")
|
monkeypatch.setattr("app.config.PROCESSED_FILE", tmp_path / "processed.json")
|
||||||
return tmp_path
|
return tmp_path
|
||||||
|
|||||||
+52
-1
@@ -5,10 +5,11 @@ from __future__ import annotations
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
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 app.main import _safe_job_file, _validate_job_id, app
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -16,6 +17,11 @@ def client(data_dir: Path) -> TestClient:
|
|||||||
return TestClient(app)
|
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:
|
def test_validate_job_id_sanitizes_input() -> None:
|
||||||
assert _validate_job_id("foo@bar") == "foo_bar"
|
assert _validate_job_id("foo@bar") == "foo_bar"
|
||||||
assert _validate_job_id("photo01") == "photo01"
|
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:
|
def test_job_not_found(client: TestClient) -> None:
|
||||||
response = client.get("/jobs/does-not-exist")
|
response = client.get("/jobs/does-not-exist")
|
||||||
assert response.status_code == 404
|
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")
|
||||||
|
|||||||
Reference in New Issue
Block a user