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:
vh
2026-09-23 10:47:34 -07:00
parent 447a9b67e9
commit d5e23c7d5f
8 changed files with 328 additions and 8 deletions
+25
View File
@@ -356,3 +356,28 @@ def test_a_huge_caption_sidecar_is_not_read_whole(tmp_path):
cap = {it.rel: it.caption for it in booth_items(b)}["a.png"]
assert cap is not None and len(cap) <= CAPTION_MAX
def test_a_dot_directory_hides_its_whole_subtree(tmp_path):
"""CLAUDE.md invariant 2 claims a dotfile costs nothing in item counts,
galleries or zips. That was only true at the TOP LEVEL: both `booth_items`
and `zip_booth` tested the FILE's name, so `.thumbs/a.png` has `p.name ==
"a.png"` and sailed through as a gallery item and a zip entry.
Pre-existing, found while adding a `.thumbs/` cache. Any path component
starting with a dot is the Booth's own namespace.
Defeating change: back to `p.name.startswith(".")`."""
import io
import zipfile
from booth.app import zip_booth
b = tmp_path / "b"
(b / ".thumbs").mkdir(parents=True)
(b / "real.png").write_bytes(b"\x89PNG")
(b / ".thumbs" / "real.png").write_bytes(b"\x89PNGthumb")
(b / ".marks.json").write_text("{}")
assert [i.rel for i in booth_items(b)] == ["real.png"]
assert zipfile.ZipFile(io.BytesIO(zip_booth(b))).namelist() == ["real.png"]