diff --git a/booth/thumbs.py b/booth/thumbs.py index 043cfe7..c37a426 100644 --- a/booth/thumbs.py +++ b/booth/thumbs.py @@ -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 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 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 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 # 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: """Where `rel`'s thumbnail lives. Mirrors the tree so two files with the - same basename in different folders cannot collide.""" - return booth / THUMB_DIR / (rel + ".webp") + same basename in different folders cannot collide. + + 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: @@ -53,8 +75,9 @@ def wants_thumb(rel: str) -> bool: def ensure_thumb(booth: Path, rel: str) -> Path | None: """The cached thumbnail for `rel`, generating it if needed. None when there - should not be one — Pillow absent, unsupported type, source already small - enough, or anything at all went wrong. + should not be one — Pillow absent, unsupported type, source already tile-sized + 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 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: # `open` reads the header only, so this is cheap enough to decide on. - if max(im.size) <= THUMB_MAX: - return None # already tile-sized; serving the original is right - im.thumbnail((THUMB_MAX, THUMB_MAX)) + fits = im.width <= THUMB_WIDTH and im.height <= THUMB_HEIGHT_MAX + if fits and (s_stat.st_size <= THUMB_LIGHT_BYTES or getattr(im, "is_animated", False)): + 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"): im = im.convert("RGBA" if "A" in im.getbands() else "RGB") # ⚠ CREATING THE CACHE DIR TOUCHES THE BOOTH DIRECTORY'S OWN diff --git a/persistent-memory.md b/persistent-memory.md index 518732e..64cff79 100644 --- a/persistent-memory.md +++ b/persistent-memory.md @@ -43,9 +43,18 @@ _As of 2026-09-23:_ /b//blurbooth`, and **`booth blur ` with NO files fogs the whole booth**. COMPOSES with `.blurred`, never overrides. All 17 handles can self-blur at post time. -- ✅ **THUMBNAILS ARE LIVE.** 77.5 MB → 0.78 MB on the biggest gallery; the Desk - ~100 MB → 1.12 MB. Four surfaces (tile, Desk strip, flag tray, filmstrip); the - review stage keeps the original. +- ✅ **THUMBNAILS ARE LIVE, AND SIZED FOR THE TILE'S WIDTH** (operator, + 2026-09-23: "blurry until selected"). The first cut capped the LONGEST side at + 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` - ✅ **CREATION + UPDATE DATES ARE ON THE RECORD** for all 30 booths (`created_at` via `statx`, `landed_at` already existed). design-dev renders diff --git a/tests/mutations/thumbs.toml b/tests/mutations/thumbs.toml new file mode 100644 index 0000000..761332f --- /dev/null +++ b/tests/mutations/thumbs.toml @@ -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")''' diff --git a/tests/test_thumbs.py b/tests/test_thumbs.py index 22c7660..5a4b712 100644 --- a/tests/test_thumbs.py +++ b/tests/test_thumbs.py @@ -18,7 +18,15 @@ sys.path.insert(0, str(pathlib.Path(__file__).parent.parent)) from booth.app import create_app # 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") @@ -36,15 +44,16 @@ def test_a_big_image_gets_a_much_smaller_thumbnail(tmp_path): src = _img(b / "big.png", 1024, 1024) out = ensure_thumb(b, "big.png") 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, ( 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): - """Serving the original is correct when it is already tile-sized. A cache - entry that saves nothing is pure cost. + """Serving the original is correct when it is already tile-sized AND already + light. A cache entry that saves nothing is pure cost. Defeating change: generating unconditionally.""" b = tmp_path / "g" @@ -177,3 +186,86 @@ def test_the_desk_preview_strip_uses_thumbnails(tmp_path): 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"' 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) diff --git a/tests/test_thumbs_browser.py b/tests/test_thumbs_browser.py new file mode 100644 index 0000000..261e218 --- /dev/null +++ b/tests/test_thumbs_browser.py @@ -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})")