"""Thumbnails — the fix for a gallery that shipped 77 MB to render 250px tiles.
The operator found this in about a minute of using the live Desk. ROADMAP had
parked it on "the largest gallery is 66 images", which counted IMAGES and never
weighed BYTES; 66 is a fine count sitting on a terrible payload.
"""
from __future__ import annotations
import io
import pathlib
import sys
import pytest
from fastapi.testclient import TestClient
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 ( # 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")
def _img(path: pathlib.Path, w: int, h: int, fmt="PNG"):
path.parent.mkdir(parents=True, exist_ok=True)
PIL.new("RGB", (w, h), (120, 30, 90)).save(path, fmt)
return path
def test_a_big_image_gets_a_much_smaller_thumbnail(tmp_path):
"""The whole point, asserted in BYTES rather than in existence — a thumbnail
that is not dramatically smaller has not fixed anything."""
b = tmp_path / "g"
src = _img(b / "big.png", 1024, 1024)
out = ensure_thumb(b, "big.png")
assert out is not None and out.is_file()
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 AND already
light. A cache entry that saves nothing is pure cost.
Defeating change: generating unconditionally."""
b = tmp_path / "g"
_img(b / "small.png", 200, 200)
assert ensure_thumb(b, "small.png") is None
def test_the_cache_lives_inside_the_booth_and_is_invisible(tmp_path):
"""`.thumbs/` is inside the booth so it is swept with it — a cache that
outlives what it describes is a leak. And it must not become gallery items
or zip entries: both skip every dot-prefixed path COMPONENT, which they did
not do until this module needed them to."""
import zipfile
from booth.app import zip_booth
b = tmp_path / "g"
_img(b / "big.png", 1024, 1024)
ensure_thumb(b, "big.png")
assert (b / THUMB_DIR).is_dir(), "the cache is not inside the booth"
assert [i.rel for i in booth_items(b)] == ["big.png"]
assert zipfile.ZipFile(io.BytesIO(zip_booth(b))).namelist() == ["big.png"]
def test_a_damaged_image_costs_its_own_tile_not_the_page(tmp_path):
"""NEVER RAISES. A thumbnail is an optimisation; a page that will not load
is worse than one that loads slowly.
Defeating change: letting the Pillow exception out."""
b = tmp_path / "g"
b.mkdir()
(b / "lies.png").write_bytes(b"\x89PNG\r\n\x1a\n" + b"not an image at all" * 20)
assert ensure_thumb(b, "lies.png") is None
def test_a_stale_thumbnail_is_rebuilt(tmp_path):
"""Editing a file in place must not leave the old thumbnail forever."""
import os
b = tmp_path / "g"
_img(b / "x.png", 1024, 1024)
first = ensure_thumb(b, "x.png")
before = first.stat().st_mtime_ns
_img(b / "x.png", 900, 900)
os.utime(b / "x.png", None)
again = ensure_thumb(b, "x.png")
assert again.stat().st_mtime_ns != before, "the stale thumbnail survived an edit"
def test_only_thumbable_types_are_candidates():
"""SVG is vector and Pillow cannot read it; a video is not an image."""
assert wants_thumb("a.png") and wants_thumb("A.JPG") and wants_thumb("a.webp")
assert not wants_thumb("a.svg") and not wants_thumb("a.webm") and not wants_thumb("a.txt")
def test_the_item_record_carries_the_thumb_url(tmp_path):
"""INV-1: the resolver decides whether an item has a thumbnail. No template
appends `?thumb=1` by reasoning about `kind` itself."""
b = tmp_path / "g"
_img(b / "big.png", 1024, 1024)
_img(b / "vec.svg", 10, 10) if False else (b / "vec.svg").write_text("")
by = {i.rel: i for i in booth_items(b)}
assert by["big.png"].thumb == "big.png?thumb=1"
assert by["vec.svg"].thumb is None
def test_the_route_serves_the_thumbnail_and_the_original(tmp_path):
"""?thumb=1 rides the EXISTING file route, so it inherits that route's
traversal guard rather than growing a second one."""
b = tmp_path / "g"
src = _img(b / "big.png", 1024, 1024)
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
full = c.get("/b/g/big.png")
thumb = c.get("/b/g/big.png?thumb=1")
assert full.status_code == thumb.status_code == 200
assert len(thumb.content) * 4 < len(full.content), "the route served the full image"
assert len(full.content) == src.stat().st_size
def test_the_gallery_tile_requests_the_thumbnail(tmp_path):
"""The operator's actual complaint: the grid pulled full-resolution files."""
b = tmp_path / "g"
for n in ("a.png", "b.png"):
_img(b / n, 1024, 1024)
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
html = c.get("/b/g/").text
assert 'src="a.png?thumb=1"' in html, "the tile still asks for the full image"
def test_a_thumb_request_for_a_traversal_path_is_still_refused(tmp_path):
"""The guard is the file route's, and it must not be weakened by the new
query parameter."""
b = tmp_path / "g"
_img(b / "big.png", 1024, 1024)
(tmp_path / "secret.txt").write_text("nope")
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
assert c.get("/b/g/../secret.txt?thumb=1").status_code in (404, 400)
def test_the_filmstrip_and_tray_use_thumbnails_but_the_stage_does_not(tmp_path):
"""The same 77 MB in a different place. The filmstrip shows EVERY ring item
at a few dozen pixels, so full-resolution frames there are worse than the
grid was — while the stage is the full-size review and must stay full size.
Defeating change: `x.url` in the filmstrip, or `it.thumb` on the stage."""
b = tmp_path / "g"
for n in ("a.png", "b.png", "c.png"):
_img(b / n, 1024, 1024)
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
html = c.get("/b/g/view?f=a.png").text
assert 'id="vimg" src="a.png"' in html, "the stage must serve the full image"
assert 'src="b.png?thumb=1"' in html, "the filmstrip still pulls full images"
assert 'src="b.png"' not in html.replace('src="b.png?thumb=1"', ""), \
"a full-size frame survived in the strip"
def test_the_desk_preview_strip_uses_thumbnails(tmp_path):
"""The heaviest surface in the service, on the page he opens FIRST: four
small images per booth, across every booth. design-dev measured 28
originals / 24.1 MB on a 12-booth copy; live has 28 booths.
Defeating change: `it.url` in the preview tuple."""
for name in ("one", "two"):
b = tmp_path / name
for n in ("a.png", "b.png"):
_img(b / n, 1024, 1024)
c = TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False))
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 `..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