merge(thumbs): thumbnails sized for the tile's width at 2x, and a cache that cannot be planted

Operator-approved 2026-09-23 ("fix it, one bigger thumbnail"), after his
report that sindra-nude-final looked "blurry until selected". c2b1454 sizes
thumbnails at 768 wide (the widest desktop tile, doubled for a 2x screen) and
caps them at 4096 tall. A browser test holds the number against the rendered
grid. c19d8c9 folds the heid bug-hunt (4/4 arms, five seat-executed probes):
cache hits must be regular files carrying the source's exact mtime, the cache
dirs never follow a link, the temp file is mkstemp, palette alpha and EXIF
orientation survive, and there is a 64 MP decode budget. 828 passed on the
branch; thumbs.toml 14/14.
This commit is contained in:
vh
2026-09-23 23:07:07 -07:00
5 changed files with 546 additions and 51 deletions
+142
View File
@@ -0,0 +1,142 @@
# 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}.{rule}.webp"'''
new = '''
return booth / THUMB_DIR / (rel + ".webp")'''
# ---- the heid bug-hunt on this change (4/4 arms), folded ------------------------
[[mutation]]
label = "a cache hit trusts the name and the mtime (a planted directory is served)"
file = "booth/thumbs.py"
test = "tests/test_thumbs.py::test_a_planted_directory_at_the_cache_path_is_not_served"
old = '''
return stat.S_ISREG(o.st_mode) and o.st_mtime_ns == s_stat.st_mtime_ns'''
new = '''
return o.st_mtime_ns >= s_stat.st_mtime_ns'''
[[mutation]]
label = "freshness is 'at least as new' (a cp -p'd older source pins the old thumbnail)"
file = "booth/thumbs.py"
test = "tests/test_thumbs.py::test_a_source_replaced_with_an_older_mtime_is_rebuilt"
old = '''
return stat.S_ISREG(o.st_mode) and o.st_mtime_ns == s_stat.st_mtime_ns'''
new = '''
return stat.S_ISREG(o.st_mode) and o.st_mtime_ns >= s_stat.st_mtime_ns'''
[[mutation]]
label = "the cache dirs are made by following links (a planted .thumbs link escapes the booth)"
file = "booth/thumbs.py"
test = "tests/test_thumbs.py::test_a_symlinked_cache_dir_is_never_written_through"
old = '''
if not _cache_dir(booth, out.parent):
return None'''
new = '''
out.parent.mkdir(parents=True, exist_ok=True)'''
[[mutation]]
label = "a predictable temp name the encoder writes through"
file = "booth/thumbs.py"
test = "tests/test_thumbs.py::test_a_planted_link_at_the_old_temp_name_cannot_redirect_the_write"
old = '''
fd, tmp = tempfile.mkstemp(prefix=".", suffix=".tmp", dir=out.parent)
try:
with os.fdopen(fd, "wb") as fh:
im.save(fh, "WEBP", quality=THUMB_QUALITY, method=4)'''
new = '''
tmp = str(out) + f".{os.getpid()}.tmp"
try:
im.save(tmp, "WEBP", quality=THUMB_QUALITY, method=4)'''
[[mutation]]
label = "RGBA chosen by getbands() alone (palette transparency baked opaque)"
file = "booth/thumbs.py"
test = "tests/test_thumbs.py::test_palette_transparency_survives_the_thumbnail"
old = '''
alpha = "A" in im.getbands() or "transparency" in im.info'''
new = '''
alpha = "A" in im.getbands()'''
[[mutation]]
label = "EXIF orientation ignored (a camera portrait tiled sideways)"
file = "booth/thumbs.py"
test = "tests/test_thumbs.py::test_a_camera_portrait_is_sized_and_saved_upright"
old = '''
orientation = im.getexif().get(0x0112, 1)'''
new = '''
orientation = 1'''
[[mutation]]
label = "no pixel budget: whatever the header claims is decoded"
file = "booth/thumbs.py"
test = "tests/test_thumbs.py::test_an_image_past_the_pixel_budget_is_never_decoded"
old = '''
if w * h > THUMB_MAX_PIXELS:
return None'''
new = '''
if False:
return None'''
+201 -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.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,191 @@ 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)
# ---- the heid bug-hunt on this change (4/4 arms), folded ------------------------
#
# The cache sits in a directory any fleet session can write into, so every entry
# on the way to it may be planted. The new size rules only governed cache MISSES;
# the hit path trusted a name and an mtime.
def test_a_planted_directory_at_the_cache_path_is_not_served(tmp_path):
"""4/4, seat-executed: a directory at the cache path, with a future mtime,
was returned AS the thumbnail. Defeating change: a cache hit that checks
only the mtime."""
import os
b = tmp_path / "g"
_noise(b / "p.png", 704, 1408)
out = thumb_path(b, "p.png")
out.mkdir(parents=True)
os.utime(out, (2e9, 2e9))
got = ensure_thumb(b, "p.png")
assert got is None or got.is_file()
def test_a_source_replaced_with_an_older_mtime_is_rebuilt(tmp_path):
"""kimi: `cp -p` or an archive extract keeps an OLDER mtime, and a cache
newer than its source was served forever. The cache now carries its
source's exact mtime, so any change is a miss. Defeating change: `>=`."""
import os
b = tmp_path / "g"
_noise(b / "p.png", 704, 1408)
first = ensure_thumb(b, "p.png")
assert PIL.open(first).size == (704, 1408)
_noise(b / "p.png", 1536, 768)
os.utime(b / "p.png", (1e9, 1e9)) # an older stamp than the cache
assert PIL.open(ensure_thumb(b, "p.png")).size == (THUMB_WIDTH, THUMB_WIDTH // 2)
def test_a_symlinked_cache_dir_is_never_written_through(tmp_path):
"""seat P4: `.thumbs` planted as a link to another directory put the cache
outside the booth, beyond the sweep. Defeating change: `mkdir(parents=True)`,
which follows an existing link."""
b = tmp_path / "g"
_noise(b / "p.png", 704, 1408)
elsewhere = tmp_path / "elsewhere"
elsewhere.mkdir()
(b / THUMB_DIR).symlink_to(elsewhere)
assert ensure_thumb(b, "p.png") is None
assert list(elsewhere.iterdir()) == []
def test_a_planted_link_at_the_old_temp_name_cannot_redirect_the_write(tmp_path):
"""groa, seat P5: the temp name was `<out>.<pid>.tmp`, predictable, so a
link planted there made the encoder truncate and overwrite its target
(600 B -> 316,400 B). Defeating change: any predictable temp name."""
import os
b = tmp_path / "g"
_noise(b / "p.png", 704, 1408)
victim = tmp_path / "victim.txt"
victim.write_text("untouched")
out = thumb_path(b, "p.png")
out.parent.mkdir(parents=True)
(out.parent / (out.name + f".{os.getpid()}.tmp")).symlink_to(victim)
ensure_thumb(b, "p.png")
assert victim.read_text() == "untouched"
def test_palette_transparency_survives_the_thumbnail(tmp_path):
"""3/4, seat-executed, and INTRODUCED by this change: the fits-but-heavy
branch newly re-encodes palette PNGs, and `getbands()` of mode P has no A
even with a tRNS chunk, so transparency became opaque. Defeating change:
choosing RGBA by `getbands()` alone."""
import os
b = tmp_path / "g"
b.mkdir()
im = PIL.frombytes("P", (400, 400), os.urandom(400 * 400))
im.putpalette(os.urandom(768))
im.save(b / "p.png", "PNG", transparency=0)
assert (b / "p.png").stat().st_size > THUMB_LIGHT_BYTES
t = PIL.open(ensure_thumb(b, "p.png"))
assert t.mode == "RGBA" and t.getchannel("A").getextrema()[0] == 0
def test_a_camera_portrait_is_sized_and_saved_upright(tmp_path):
"""groa, seat-verified: EXIF orientation was ignored, so a portrait shot
stored sideways was sized as a landscape and tiled sideways. Defeating
change: sizing the raw pixels without `exif_transpose`."""
import os
b = tmp_path / "g"
b.mkdir()
exif = PIL.Exif()
exif[0x0112] = 6 # rotate 90 CW to display
PIL.frombytes("RGB", (1200, 800), os.urandom(1200 * 800 * 3)).save(
b / "cam.jpg", "JPEG", exif=exif, quality=95)
assert PIL.open(ensure_thumb(b, "cam.jpg")).size == (THUMB_WIDTH, 1152)
def test_an_image_past_the_pixel_budget_is_never_decoded(tmp_path, monkeypatch):
"""2/4: the header is free to read and `thumbnail()` then decodes whatever it
claims, on every request, since a failure is not cached. Over the budget,
the original is served instead. Defeating change: no budget check."""
import booth.thumbs as thumbs
b = tmp_path / "g"
_noise(b / "p.png", 704, 1408)
monkeypatch.setattr(thumbs, "THUMB_MAX_PIXELS", 704 * 1408 - 1)
assert ensure_thumb(b, "p.png") is None
+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})")