perf(thumbs): the gallery shipped 77 MB to render 250px tiles
The operator found this in about a minute of using the live Desk: "images load
at full resolution instead of calculated thumbnails, which means they load VERY
slowly and are tiny."
MEASURED on the live set:
sindra-corpus-v1 66 images 77.5 MB 1024x1024 each
sindra-sfw-pool 59 images 71.7 MB
sindra 30 images 61.6 MB 2.1 MB average
sindra-bakeoff 40 images 57.2 MB
A tile renders around 250px, so the grid shipped roughly 16x the pixels that
reach the screen.
⚠ OUR PARKING RATIONALE WAS WRONG IN AN INSTRUCTIVE WAY. ROADMAP parked
progressive loading on "the largest gallery is 66 images; at that size a lazy
grid is almost certainly fine", and the parking-lot row said "270 <img
loading=lazy> may be fine". Both count IMAGES. Neither weighs BYTES. We measured
the dimension that was easy to measure rather than the one that determines the
experience, and 66 really is a fine count sitting on a terrible payload.
booth/thumbs.py caches WebP at 512px longest side inside the booth at
`.thumbs/<rel>.webp` — inside on purpose, so a cache can never outlive what it
describes. Pillow is an optional import: absent, every tile falls back to the
original, so the page is heavier and never broken. Generation is lazy, atomic
(temp + os.replace), rebuilt when the source is newer, and NEVER RAISES.
?thumb=1 rides the EXISTING file route rather than growing a new one, because
that route's traversal guard is already correct and a second route is a second
place to get it wrong.
ALSO FIXES A PRE-EXISTING LEAK THE CACHE WOULD HAVE WALKED INTO. booth_items and
zip_booth both tested `p.name.startswith(".")` — the FILE's name — so
`.thumbs/a.png` (name `a.png`) would have rendered as a gallery item and shipped
inside every zip. CLAUDE.md invariant 2 promises a dotfile costs nothing in item
counts, galleries or zips; that was true only at the top level. Both now skip
every dot-prefixed path COMPONENT.
AND THE FILMSTRIP, which is the same defect in a worse place: it shows EVERY
ring item at a few dozen pixels, so full-resolution frames there cost more than
the grid did. The stage is untouched and stays full size, because that is the
full-size review.
Item.thumb is derived in the resolver, not by a template reasoning about `kind`
(INV-1). build_gallery had to carry it too — a missing key there rendered as a
SILENT fallback to the full image, which is exactly where a new Item field gets
dropped with nothing failing.
754 green.
This commit is contained in:
+24
-4
@@ -144,6 +144,7 @@ def set_blurred(booth: Path, rel: str, on: bool) -> set[str]:
|
||||
# The link-board logic lives in booth/links.py (stdlib only) so the `booth` CLI
|
||||
# can use it without pulling FastAPI in. Re-exported here because call sites and
|
||||
# tests already reference these names through app.
|
||||
from booth.thumbs import ensure_thumb
|
||||
from booth.asks import ( # noqa: E402
|
||||
ANSWER_SUFFIX,
|
||||
ASK_SUFFIX,
|
||||
@@ -706,6 +707,10 @@ def build_gallery(child: Path) -> list[dict]:
|
||||
"section": it.section,
|
||||
# U7. Derived in the resolver (INV-1); this only carries it.
|
||||
"group": it.group,
|
||||
# Same: the tile's image source. Undefined here rendered as a
|
||||
# SILENT fallback to the full image — the adapter is exactly
|
||||
# where a new Item field gets dropped without anything failing.
|
||||
"thumb": it.thumb,
|
||||
# R2 C1. Same rule: the resolver numbers, this carries.
|
||||
"ordinal": it.ordinal,
|
||||
"caption": it.caption,
|
||||
@@ -727,8 +732,12 @@ def zip_booth(booth: Path) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for p in sorted(booth.rglob("*")):
|
||||
if p.is_file() and not p.name.startswith("."):
|
||||
zf.write(p, p.relative_to(booth).as_posix())
|
||||
# Same rule as booth_items: the Booth's dot-namespace is every
|
||||
# component, not just the leaf. A `.thumbs/` cache would otherwise
|
||||
# ship inside every download.
|
||||
rel = p.relative_to(booth)
|
||||
if p.is_file() and not any(part.startswith(".") for part in rel.parts):
|
||||
zf.write(p, rel.as_posix())
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
@@ -1787,7 +1796,10 @@ def create_app(
|
||||
flagged_rels = flagged_targets(marks)
|
||||
# recorded above, before this read: the current item counts as seen
|
||||
seen = read_seen(booth) & set(ring)
|
||||
film = [{"name": r, "url": by_rel[r].url, "ordinal": by_rel[r].ordinal,
|
||||
# `thumb` rides along for the filmstrip and the tray. NOT for the
|
||||
# stage, which is the full-size review and must stay full size.
|
||||
film = [{"name": r, "url": by_rel[r].url, "thumb": by_rel[r].thumb,
|
||||
"ordinal": by_rel[r].ordinal,
|
||||
"kind": by_rel[r].kind, "blurred": by_rel[r].blurred,
|
||||
"flagged": r in flagged_rels, "seen": r in seen,
|
||||
"current": r == f} for r in ring]
|
||||
@@ -1839,7 +1851,7 @@ def create_app(
|
||||
)
|
||||
|
||||
@app.get("/b/{name}/{filepath:path}")
|
||||
def booth_file(name: str, filepath: str, dl: int = 0):
|
||||
def booth_file(name: str, filepath: str, dl: int = 0, thumb: int = 0):
|
||||
booth = resolve_booth(name)
|
||||
try:
|
||||
target = (booth / filepath).resolve()
|
||||
@@ -1852,6 +1864,14 @@ def create_app(
|
||||
# in-page with no easy "save".
|
||||
if dl:
|
||||
return FileResponse(str(target), filename=target.name)
|
||||
# ?thumb=1 rides THIS route on purpose: the traversal guard above is
|
||||
# already correct and a second route would be a second place to get it
|
||||
# wrong. A cache miss, a damaged image or no Pillow all fall through to
|
||||
# the original, so the tile is never broken — only heavier.
|
||||
if thumb:
|
||||
cached = ensure_thumb(booth, target.relative_to(booth).as_posix())
|
||||
if cached is not None:
|
||||
return FileResponse(str(cached), media_type="image/webp")
|
||||
return FileResponse(str(target))
|
||||
|
||||
@app.post("/upload")
|
||||
|
||||
Reference in New Issue
Block a user