fix(thumbs): size thumbnails for the tile's width at 2x, not 512 on the long side

The operator on sindra-nude-final: "the images look blurry until they're
selected and blown up." The cap was 512px on the LONGEST side, which the
comment called "comfortably above any tile size", and it was, for a square. A
gallery tile is sized by its WIDTH, though, and a 704x1408 portrait got 256px
of width for a tile Chromium renders at 361 CSS px. That is 1.4x stretched at
1x density and 2.8x on a 2x screen. The review stage serves the original,
which is why it looked sharp once opened.

- THUMB_WIDTH = 768: the widest desktop tile (3 columns, 1440px and up,
  measured at 321-361 CSS px across viewports) doubled for a 2x screen.
  THUMB_HEIGHT_MAX = 4096 stops a long screenshot going through at full height.
- An original that fits the bounds is served as-is only when it is also light
  (<= 64 KB; 768-wide thumbnails average 39 KB over the 381 live images) or
  animated, since a thumbnail is one frame. Fitting a tile in pixels is not
  being cheap in bytes: these portraits are ~1.1 MB PNGs.
- The size rule is in the cache name (`<rel>.768w.webp`). The live 512-cap
  thumbnails are newer than their sources, so the mtime check alone would have
  served them forever. The old files are orphans, swept with their booth.
- tests/test_thumbs_browser.py holds THUMB_WIDTH against the rendered grid at
  1440, 1920 and 2560. The constant is a layout number, and a redesign that
  widens the tiles turns it red instead of soft.

Measured cost, all 381 live images: 4.8 MB -> 14.2 MB of thumbnails, still ~27x
under the 386 MB of originals. Known limit: the 2-column (<=472px) and 1-column
(<=650px) reflows are softer than 768 covers at 2x. tests/mutations/thumbs.toml
proves 7 falsifiers.
This commit is contained in:
vh
2026-09-23 22:23:03 -07:00
parent cce6a20abe
commit c2b1454358
5 changed files with 260 additions and 16 deletions
+33 -9
View File
@@ -8,7 +8,7 @@ counted IMAGES and never weighed BYTES. The live set on 2026-09-23:
sindra-sfw-pool 59 images 71.7 MB sindra-sfw-pool 59 images 71.7 MB
sindra 30 images 61.6 MB 2.1 MB average sindra 30 images 61.6 MB 2.1 MB average
A tile renders around 250px wide, so the gallery shipped roughly 16x the pixels A tile renders a few hundred px wide, so the gallery shipped roughly 16x the pixels
that reach the screen and 77 MB on one page load. 66 is a fine count sitting on that reach the screen and 77 MB on one page load. 66 is a fine count sitting on
a terrible payload; the operator found it in about a minute of using the Desk. a terrible payload; the operator found it in about a minute of using the Desk.
@@ -29,7 +29,24 @@ except ImportError: # pragma: no cover
_Image = None _Image = None
THUMB_DIR = ".thumbs" THUMB_DIR = ".thumbs"
THUMB_MAX = 512 # longest side, px — comfortably above any tile size # SIZED FOR THE TILE'S WIDTH, AT 2x DENSITY. A gallery tile is sized by its
# width (the image is `width:100%; height:auto`), and on the desktop grid (3
# columns, 1440px viewports and up) it measures 321-361 CSS px, so 768 covers
# the widest one on a 2x screen. This used to be 512 on the LONGEST side, which
# the comment called "comfortably above any tile size", and it was, for a square.
# A 704x1408 portrait got 256px of width for a 361px tile: 1.4x stretched at 1x,
# 2.8x on a 2x screen, and the operator saw it as "blurry until selected".
# Narrower windows reflow to 2 columns (up to 472px) or 1 (up to 650px) and are
# softer than this covers at 2x. tests/test_thumbs_browser.py holds this number
# against the rendered grid, so a wider tile turns it red instead of soft.
THUMB_WIDTH = 768
# Width alone would let a long screenshot through at full height.
THUMB_HEIGHT_MAX = 4096
# An original that already fits the bounds is served as-is only when it is also
# LIGHT: fitting a tile in pixels is not being cheap in bytes, and a 704x1408
# PNG is about a megabyte. Measured on the 381 live images, 2026-09-23: 768-wide
# thumbnails average 39 KB, so anything at or under 64 KB has nothing to save.
THUMB_LIGHT_BYTES = 64 * 1024
THUMB_QUALITY = 78 THUMB_QUALITY = 78
# What Pillow can open from a plain install. SVG is vector (Pillow cannot read # What Pillow can open from a plain install. SVG is vector (Pillow cannot read
@@ -39,8 +56,13 @@ THUMBABLE = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"}
def thumb_path(booth: Path, rel: str) -> Path: def thumb_path(booth: Path, rel: str) -> Path:
"""Where `rel`'s thumbnail lives. Mirrors the tree so two files with the """Where `rel`'s thumbnail lives. Mirrors the tree so two files with the
same basename in different folders cannot collide.""" same basename in different folders cannot collide.
return booth / THUMB_DIR / (rel + ".webp")
The SIZE RULE IS IN THE NAME. The mtime check below only notices a changed
source, so a thumbnail cut to an older rule, newer than its source, would be
served forever. Naming the width means a change to the rule is a cache miss,
and the old files are orphans swept with their booth."""
return booth / THUMB_DIR / f"{rel}.{THUMB_WIDTH}w.webp"
def wants_thumb(rel: str) -> bool: def wants_thumb(rel: str) -> bool:
@@ -53,8 +75,9 @@ def wants_thumb(rel: str) -> bool:
def ensure_thumb(booth: Path, rel: str) -> Path | None: def ensure_thumb(booth: Path, rel: str) -> Path | None:
"""The cached thumbnail for `rel`, generating it if needed. None when there """The cached thumbnail for `rel`, generating it if needed. None when there
should not be one — Pillow absent, unsupported type, source already small should not be one — Pillow absent, unsupported type, source already tile-sized
enough, or anything at all went wrong. and light (or animated, since a thumbnail is one frame), or anything at all
went wrong.
NEVER RAISES. A thumbnail is an optimisation; a booth page that will not NEVER RAISES. A thumbnail is an optimisation; a booth page that will not
load is worse than a page that loads slowly, which is the posture every load is worse than a page that loads slowly, which is the posture every
@@ -77,9 +100,10 @@ def ensure_thumb(booth: Path, rel: str) -> Path | None:
with _Image.open(src) as im: with _Image.open(src) as im:
# `open` reads the header only, so this is cheap enough to decide on. # `open` reads the header only, so this is cheap enough to decide on.
if max(im.size) <= THUMB_MAX: fits = im.width <= THUMB_WIDTH and im.height <= THUMB_HEIGHT_MAX
return None # already tile-sized; serving the original is right if fits and (s_stat.st_size <= THUMB_LIGHT_BYTES or getattr(im, "is_animated", False)):
im.thumbnail((THUMB_MAX, THUMB_MAX)) return None # already tile-sized and cheap (or moving): serve the original
im.thumbnail((THUMB_WIDTH, THUMB_HEIGHT_MAX))
if im.mode not in ("RGB", "RGBA"): if im.mode not in ("RGB", "RGBA"):
im = im.convert("RGBA" if "A" in im.getbands() else "RGB") im = im.convert("RGBA" if "A" in im.getbands() else "RGB")
# ⚠ CREATING THE CACHE DIR TOUCHES THE BOOTH DIRECTORY'S OWN # ⚠ CREATING THE CACHE DIR TOUCHES THE BOOTH DIRECTORY'S OWN
+12 -3
View File
@@ -43,9 +43,18 @@ _As of 2026-09-23:_
/b/<name>/blurbooth`, and **`booth blur <name>` with NO files fogs the whole /b/<name>/blurbooth`, and **`booth blur <name>` with NO files fogs the whole
booth**. COMPOSES with `.blurred`, never overrides. All 17 handles can booth**. COMPOSES with `.blurred`, never overrides. All 17 handles can
self-blur at post time. self-blur at post time.
- ✅ **THUMBNAILS ARE LIVE.** 77.5 MB → 0.78 MB on the biggest gallery; the Desk - ✅ **THUMBNAILS ARE LIVE, AND SIZED FOR THE TILE'S WIDTH** (operator,
~100 MB → 1.12 MB. Four surfaces (tile, Desk strip, flag tray, filmstrip); the 2026-09-23: "blurry until selected"). The first cut capped the LONGEST side at
review stage keeps the original. 512, so a 704x1408 portrait got 256px of width for a 361px tile, stretched
1.4x at 1x and 2.8x on a 2x screen. Now they are 768 wide (2x the widest
desktop tile) and capped at 4096 tall, and an original that fits but weighs
over 64 KB is still re-encoded. Measured on the 381 live images: all
thumbnails 4.8 → 14.2 MB, still ~27x under the originals. ⚠ **768 is a LAYOUT
number:** `tests/test_thumbs_browser.py` holds it against the rendered grid,
so if a redesign widens the tiles, that test goes red. The 2-column (≤472px)
and 1-column (≤650px) reflows are softer than 768 covers at 2x; 1024 would
cover 2 columns for 18.5 MB total. Four surfaces (tile, Desk strip, flag tray,
filmstrip); the review stage keeps the original.
→ `persistent-memory.d/2026-09-23-the-cache-that-aged-the-thing-it-cached.md` → `persistent-memory.d/2026-09-23-the-cache-that-aged-the-thing-it-cached.md`
- ✅ **CREATION + UPDATE DATES ARE ON THE RECORD** for all 30 booths - ✅ **CREATION + UPDATE DATES ARE ON THE RECORD** for all 30 booths
(`created_at` via `statx`, `landed_at` already existed). design-dev renders (`created_at` via `statx`, `landed_at` already existed). design-dev renders
+69
View File
@@ -0,0 +1,69 @@
# Thumbnails sized for the tile's WIDTH at 2x density, not 512 on the longest
# side. The operator on sindra-nude-final, 2026-09-23: "the images look blurry
# until they're selected and blown up". Every row is a change
# tests/test_thumbs.py or tests/test_thumbs_browser.py claims to forbid.
unit = "thumbnails sized for the tile"
[[mutation]]
label = "the old rule: bound the longest side at 512 (portraits get 256px of width)"
file = "booth/thumbs.py"
test = "tests/test_thumbs.py::test_a_portrait_keeps_its_full_width"
old = '''
im.thumbnail((THUMB_WIDTH, THUMB_HEIGHT_MAX))'''
new = '''
im.thumbnail((512, 512))'''
[[mutation]]
label = "the width bound is below what the desktop tile needs at 2x"
file = "booth/thumbs.py"
test = "tests/test_thumbs_browser.py::test_a_thumbnail_covers_its_tile_at_2x_density"
old = '''
THUMB_WIDTH = 768'''
new = '''
THUMB_WIDTH = 640'''
[[mutation]]
label = "no height bound: a long screenshot goes through at full height"
file = "booth/thumbs.py"
test = "tests/test_thumbs.py::test_an_extremely_tall_image_is_bounded_by_height_too"
old = '''
im.thumbnail((THUMB_WIDTH, THUMB_HEIGHT_MAX))'''
new = '''
im.thumbnail((THUMB_WIDTH, 10 ** 6))'''
[[mutation]]
label = "fitting in pixels is taken as light in bytes (the megabyte portrait is served whole)"
file = "booth/thumbs.py"
test = "tests/test_thumbs.py::test_a_tile_width_image_that_is_heavy_still_gets_a_thumbnail"
old = '''
if fits and (s_stat.st_size <= THUMB_LIGHT_BYTES or getattr(im, "is_animated", False)):'''
new = '''
if fits:'''
[[mutation]]
label = "an already small, light image gets a cache entry that saves nothing"
file = "booth/thumbs.py"
test = "tests/test_thumbs.py::test_an_already_small_image_gets_no_thumbnail"
old = '''
if fits and (s_stat.st_size <= THUMB_LIGHT_BYTES or getattr(im, "is_animated", False)):'''
new = '''
if fits and getattr(im, "is_animated", False):'''
[[mutation]]
label = "a heavy animated GIF that fits is flattened to one frame"
file = "booth/thumbs.py"
test = "tests/test_thumbs.py::test_an_animated_gif_that_fits_is_served_as_itself"
old = '''
if fits and (s_stat.st_size <= THUMB_LIGHT_BYTES or getattr(im, "is_animated", False)):'''
new = '''
if fits and s_stat.st_size <= THUMB_LIGHT_BYTES:'''
[[mutation]]
label = "an unversioned cache name: a thumbnail cut to the old rule is served forever"
file = "booth/thumbs.py"
test = "tests/test_thumbs.py::test_a_thumbnail_cut_to_the_old_rule_is_not_served"
old = '''
return booth / THUMB_DIR / f"{rel}.{THUMB_WIDTH}w.webp"'''
new = '''
return booth / THUMB_DIR / (rel + ".webp")'''
+96 -4
View File
@@ -18,7 +18,15 @@ sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
from booth.app import create_app # noqa: E402 from booth.app import create_app # noqa: E402
from booth.items import booth_items # noqa: E402 from booth.items import booth_items # noqa: E402
from booth.thumbs import THUMB_DIR, THUMB_MAX, ensure_thumb, wants_thumb # noqa: E402 from booth.thumbs import ( # noqa: E402
THUMB_DIR,
THUMB_HEIGHT_MAX,
THUMB_LIGHT_BYTES,
THUMB_WIDTH,
ensure_thumb,
thumb_path,
wants_thumb,
)
PIL = pytest.importorskip("PIL.Image", reason="Pillow is not installed") PIL = pytest.importorskip("PIL.Image", reason="Pillow is not installed")
@@ -36,15 +44,16 @@ def test_a_big_image_gets_a_much_smaller_thumbnail(tmp_path):
src = _img(b / "big.png", 1024, 1024) src = _img(b / "big.png", 1024, 1024)
out = ensure_thumb(b, "big.png") out = ensure_thumb(b, "big.png")
assert out is not None and out.is_file() assert out is not None and out.is_file()
assert max(PIL.open(out).size) <= THUMB_MAX w, h = PIL.open(out).size
assert w <= THUMB_WIDTH and h <= THUMB_HEIGHT_MAX
assert out.stat().st_size * 4 < src.stat().st_size, ( assert out.stat().st_size * 4 < src.stat().st_size, (
f"thumb {out.stat().st_size}B vs source {src.stat().st_size}B — not worth the cache" f"thumb {out.stat().st_size}B vs source {src.stat().st_size}B — not worth the cache"
) )
def test_an_already_small_image_gets_no_thumbnail(tmp_path): def test_an_already_small_image_gets_no_thumbnail(tmp_path):
"""Serving the original is correct when it is already tile-sized. A cache """Serving the original is correct when it is already tile-sized AND already
entry that saves nothing is pure cost. light. A cache entry that saves nothing is pure cost.
Defeating change: generating unconditionally.""" Defeating change: generating unconditionally."""
b = tmp_path / "g" b = tmp_path / "g"
@@ -177,3 +186,86 @@ def test_the_desk_preview_strip_uses_thumbnails(tmp_path):
html = c.get("/").text html = c.get("/").text
assert 'src="/b/one/a.png?thumb=1"' in html, "the Desk strip still pulls full images" assert 'src="/b/one/a.png?thumb=1"' in html, "the Desk strip still pulls full images"
assert 'src="/b/one/a.png"' not in html assert 'src="/b/one/a.png"' not in html
# ---- sized for the tile's WIDTH, at 2x density ---------------------------------
#
# The operator, on sindra-nude-final: "the images look blurry until they're
# selected and blown up". The cap was 512 on the LONGEST side, but a tile is sized
# by its WIDTH, so a 704x1408 portrait got a 256px-wide thumbnail stretched into
# a 361px tile: 1.4x at 1x density, 2.8x on a 2x screen.
def _noise(path: pathlib.Path, w: int, h: int):
"""A photographic-weight image: incompressible, so its bytes are realistic.
A flat colour compresses to almost nothing and would take the light path."""
import os
path.parent.mkdir(parents=True, exist_ok=True)
PIL.frombytes("RGB", (w, h), os.urandom(w * h * 3)).save(path, "PNG")
return path
def test_a_portrait_keeps_its_full_width(tmp_path):
"""Defeating change: bounding the longest side, which gave this image 256px
of width for a tile that shows 361."""
b = tmp_path / "g"
_noise(b / "p.png", 704, 1408)
out = ensure_thumb(b, "p.png")
assert out is not None
assert PIL.open(out).size == (704, 1408)
def test_a_wide_image_is_bounded_by_width(tmp_path):
b = tmp_path / "g"
_noise(b / "w.png", 2048, 1024)
out = ensure_thumb(b, "w.png")
assert PIL.open(out).size == (THUMB_WIDTH, THUMB_WIDTH // 2)
def test_a_tile_width_image_that_is_heavy_still_gets_a_thumbnail(tmp_path):
"""Fitting the tile in PIXELS is not being light in BYTES: a 704x1408 PNG is
about a megabyte, and serving it as its own thumbnail would undo the cache.
Defeating change: skipping every image that fits the bounds."""
b = tmp_path / "g"
src = _noise(b / "p.png", 704, 1408)
assert src.stat().st_size > THUMB_LIGHT_BYTES
out = ensure_thumb(b, "p.png")
assert out is not None and out.stat().st_size < src.stat().st_size
def test_an_extremely_tall_image_is_bounded_by_height_too(tmp_path):
"""Width alone would let a long screenshot through at full height."""
b = tmp_path / "g"
_noise(b / "t.png", 300, THUMB_HEIGHT_MAX * 2)
w, h = PIL.open(ensure_thumb(b, "t.png")).size
assert h <= THUMB_HEIGHT_MAX and w <= 150
def test_an_animated_gif_that_fits_is_served_as_itself(tmp_path):
"""A thumbnail is one frame. A heavy GIF that already fits the tile used to
be served whole (it was under the old cap) and must stay animated.
Defeating change: dropping the animation guard on the fits-but-heavy path."""
import os
b = tmp_path / "g"
b.mkdir()
frames = [PIL.frombytes("RGB", (300, 300), os.urandom(300 * 300 * 3)) for _ in range(3)]
frames[0].save(b / "a.gif", save_all=True, append_images=frames[1:])
assert (b / "a.gif").stat().st_size > THUMB_LIGHT_BYTES
assert ensure_thumb(b, "a.gif") is None
def test_a_thumbnail_cut_to_the_old_rule_is_not_served(tmp_path):
"""The live booths hold 512-cap thumbnails that are NEWER than their
sources, so the mtime check alone would serve them forever. The size rule
is in the cache name, so a thumbnail cut to another rule is simply not
found. Defeating change: an unversioned cache name."""
import os
b = tmp_path / "g"
_noise(b / "p.png", 704, 1408)
legacy = b / THUMB_DIR / "p.png.webp"
legacy.parent.mkdir(parents=True)
PIL.new("RGB", (256, 512)).save(legacy, "WEBP")
os.utime(legacy, None)
out = ensure_thumb(b, "p.png")
assert out == thumb_path(b, "p.png") and out != legacy
assert PIL.open(out).size == (704, 1408)
+50
View File
@@ -0,0 +1,50 @@
"""Thumbnails against the tile they are drawn into, in a real browser.
`THUMB_WIDTH` is derived from a LAYOUT number: the widest gallery tile on the
desktop grid, doubled for a 2x screen. A Python test cannot see a CSS width, so
without this file the constant and the grid could drift apart silently, which
is how the tiles went soft in the first place. If the grid widens its tiles,
this goes red and the constant is revisited, rather than the operator finding
it by eye.
Scoped to the desktop layout (3 columns, 1440px and up), which is where tiles
are measured at 321-361 CSS px. Narrower windows reflow to 2 columns (up to
472 px) or 1 (up to 650 px): at 2x density those are softer than this bound
covers, and that is a known limit, not a defect this file asserts against.
"""
from __future__ import annotations
import os
import pytest
from test_embed_browser import browser, live # noqa: F401 (fixtures)
from booth.thumbs import THUMB_WIDTH
PIL = pytest.importorskip("PIL.Image", reason="Pillow is not installed")
@pytest.mark.parametrize("viewport", [(1440, 900), (1920, 1080), (2560, 1440)])
def test_a_thumbnail_covers_its_tile_at_2x_density(browser, live, viewport): # noqa: F811
base, root = live
b = root / "g"
b.mkdir()
for n in ("a.png", "b.png", "c.png"):
PIL.frombytes("RGB", (1024, 1024), os.urandom(1024 * 1024 * 3)).save(b / n, "PNG")
pg = browser.new_page(viewport={"width": viewport[0], "height": viewport[1]})
try:
pg.goto(f"{base}/b/g/", wait_until="load")
pg.wait_for_function(
"Array.from(document.querySelectorAll('.gallery .item img'))"
".every(i => i.complete && i.naturalWidth)", timeout=15000)
tiles = pg.evaluate(
"Array.from(document.querySelectorAll('.gallery .item img'))"
".map(i => [i.currentSrc, i.clientWidth, i.naturalWidth])")
finally:
pg.close()
assert tiles, "no gallery tiles rendered"
for src, shown, natural in tiles:
assert "thumb=1" in src, f"{src} is not the thumbnail"
assert natural >= 2 * shown, (
f"{src}: a {shown}px tile needs {2 * shown}px at 2x, the thumbnail has {natural}px"
f" (THUMB_WIDTH={THUMB_WIDTH})")