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.
272 lines
11 KiB
Python
272 lines
11 KiB
Python
"""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("<svg/>")
|
|
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)
|