Files
booth/booth/thumbs.py
T
vh 72d6c61629 fix(as-S5c): whose key it is, the doc bar, tile sizes, the rail's shadow, reveal names
The last of the anti-slop interaction work (guidelines G6, G7, G14, G15,
G17), plus booth-dev's note from S5b's gate. Every S5b promise holds: no
re-POST, serialized saves, a batch never reloads, focus survives a swap.
- Keys (G6): one rule in base.html's <head>, BoothKeys.theirs(e), called
  first by the grid, the review and compare. A field or a player owns every
  key but Escape (Esc still goes back from a focused player); a control
  owns Space; a focused 1:1 stage that can pan owns the
  arrows and Space (Chromium puts it in the Tab order); Ctrl/Meta/Alt are
  the browser's. The field check lives on as BoothKeys.isEditable. Before:
  an arrow on a focused video left the review, and Enter on any control
  also opened the grid cursor's tile.
- The grid cursor is real focus: the tile it moves to gets tabindex=-1
  (script-set, one tile at a time) and focus, without a scroll; the cursor
  is an item (its data-item), and a doc closed with its ✕ is skipped; focus that
  lands on a tile (S5b's fallback) makes it the cursor; Enter opens the
  review only from the body, the grid or the tile, by its view?f= link;
  n opens a closed doc's fold; Escape clears the cursor
  and releases the tile's focus. The reticle is its focus mark (no second
  ring).
- The doc bar (G7): the controls leave the <summary>. div.doc-bar holds
  details.doc-fold (its summary is the label only) and div.doc-tools beside
  it; the body and notes follow in div.doc-inline, hidden with a closed
  fold by :has(), scripts on or off. A closed doc keeps its tools. Renders
  pixel-identical to today at 1280 and 390, light and dark.
- Tile sizes (G14): a gallery tile's <img> carries width/height, the
  picture as the browser draws it (EXIF 5-8 swap), read from the header
  only (no decode; PNG getexif is skipped unless the header carried it),
  opened O_NOFOLLOW|O_NONBLOCK, cached by the file's identity (ctime
  included, so cp -p over a file is seen), in a separate
  step (items.image_dims over thumbs.drawn_size) so the Desk never pays it.
  Measured before: a link to tile 30 of 40 landed 44px low (3/3); after, on
  its mark. content-visibility:auto, which the report proposed too, is NOT
  added: a swapped-in tile has no remembered size, and a flag far down moved
  the page 2929px (3/3; 0px without it).
- The rail (G15): html:has(.rail){scroll-padding-top} replaces .item's
  scroll-margin-top (the two add), so a control reached by Tab stops below
  the sticky rail too. Measured before: a Tab-focused flag button at 19.6px,
  under the rail's bottom at 47.6px. The scripts-off fallbacks are the old
  rules' numbers (132px, 217px at <=480), now pinned by a test. The height
  script follows the live rail after every in-place save (it watched the
  replaced node, and read 0px after one flag), and the rail's own controls
  cancel the padding (a Tab between stuck group links scrolled 357px).
- Reveal names (G17): no aria-label on any reveal control; the name is the
  words on it, the glyph in an aria-hidden span, the item's name as
  .sr-only text ("reveal a.png" / "hide a.png"). Reveal all drops
  aria-pressed (its words already say the state; r2b rules them) and its
  "on" look reads the .reveal-all class on <html>. No pixel changes.
- booth-dev's note: a refused batch's forms enter `unsent` with the
  refusal's words, and a later save says every standing failure's words
  (each once, in order) instead of "Saved.", and every warning says the
  other standing failures first, so no failure buries another. Test first:
  test_a_batch_refusal_outlives_an_unrelated_save.
- Rows re-anchored to the same failure: r2b "Space on a focused review
  button", r3 "C3 a held modifier" and both "C3 Space on a focused ..."
  (now in BoothKeys), r2c "the stage reveal shows with scripts off", and
  this contract's S3 doc-bar row and five S5b status-line rows.

Folded from the heid contract review (BEINKA, panel 4/4, thread
01M3NZJNX8D3BEYD48M9K3MV3Q): 24 flags, all prose the tests left open; the
contract states the tile/focus/cursor seam with S5b, the helper's union and
scope, the size's source and every path to none, Reveal all's name, the
refusal sentence's lifetime, and the fallback arithmetic (one test added).

Folded from the heid bug-hunt (HRÖSKVA, panel 4/4, thread
01M3P0ZPRSASFSE5K3PR4NTQP6): R1 closed docs and the cursor as an item, R2
the rail's height after a save, R3 no warning buries another, R5 the view?f=
link, R7 ctime in the size cache, R8 Escape from a player, R9 the rail's own
controls, R10 n on a closed doc. Refuted with reasons: R4 (unreachable: refused
picks re-send together), R6 (Chrome takes the same header's size with or
without the attributes; measured), R11 (by design).

From this slice's own falsifier runs: a "one row wide" row that mutated a
flex basis a non-wrapping bar just shrinks (re-aimed at the bar's flex), and
a Reveal-all "on look" read under the clicking pointer, where :hover draws
the same border (the pointer now leaves first; 3/3 proved).

Contract: as_antislop S5c.
Falsifiers: antislop.toml S5c section.
2026-09-29 01:09:15 -07:00

274 lines
13 KiB
Python

"""Derived thumbnails, cached inside the booth.
MEASURED, not assumed. ROADMAP parked progressive loading on "the largest
gallery is 66 images; at that size a lazy grid is almost certainly fine" — which
counted IMAGES and never weighed BYTES. The live set on 2026-09-23:
sindra-corpus-v1 66 images 77.5 MB 1024x1024 each
sindra-sfw-pool 59 images 71.7 MB
sindra 30 images 61.6 MB 2.1 MB average
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.
The cache lives at `<booth>/.thumbs/<rel>.<rule>.webp` (see `thumb_path`),
inside the booth on purpose, so it is swept with the booth and never outlives
what it describes. And because it is inside the booth, where any fleet session
can write, every entry on the way to it may be planted: the cache directories,
the cache file and the temp file are each checked or created so that a link,
a directory or a planted file cannot redirect a write or pose as a thumbnail. Both
`booth_items` and `zip_booth` skip every dot-prefixed path COMPONENT, which they
did not do until this module needed them to.
"""
from __future__ import annotations
import functools
import os
import stat
import tempfile
from pathlib import Path
try: # optional: absence degrades to full-size images, never to a broken page
from PIL import Image as _Image
from PIL import ImageOps as _ImageOps
except ImportError: # pragma: no cover
_Image = None
_ImageOps = None
THUMB_DIR = ".thumbs"
# 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
# A header is free to read and claims any size it likes; `thumbnail()` then
# decodes it, on every request, because a failure is not cached. Past this the
# original is served and nothing is decoded. 64 MP is 8K x 8K, far past any
# image a booth has held.
THUMB_MAX_PIXELS = 64_000_000
# Bump when the ENCODING changes in a way the numbers above do not show (a mode
# conversion, an orientation rule), so every cached thumbnail is rebuilt.
THUMB_VERSION = 2
# What Pillow can open from a plain install. SVG is vector (Pillow cannot read
# it, and it is already small); AVIF needs a plugin we do not require.
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.
THE WHOLE RULE IS IN THE NAME: width, height cap, quality and an encoding
version. The freshness check below only notices a changed source, so a
thumbnail cut to an older rule would otherwise be served forever. A change
to any of them is a cache miss, and the old files are orphans swept with
their booth."""
rule = f"{THUMB_WIDTH}x{THUMB_HEIGHT_MAX}q{THUMB_QUALITY}v{THUMB_VERSION}"
return booth / THUMB_DIR / f"{rel}.{rule}.webp"
def wants_thumb(rel: str) -> bool:
"""Whether a rel is a candidate at all — extension only, no file read.
Called from the resolver for every item on every index load, so it must not
touch the disk."""
return Path(rel).suffix.lower() in THUMBABLE
def _fresh(out: Path, s_stat: os.stat_result) -> bool:
"""A cache hit: a REGULAR file (lstat, so a link or a directory planted at
the name never counts) carrying its source's EXACT mtime. Exact, not
"at least as new": a source replaced by `cp -p` or an archive extract keeps
an OLDER stamp, and `>=` served the old thumbnail forever."""
try:
o = os.lstat(out)
except OSError:
return False
return stat.S_ISREG(o.st_mode) and o.st_mtime_ns == s_stat.st_mtime_ns
def _cache_dir(booth: Path, parent: Path) -> bool:
"""Make `parent` (a directory under `booth`) exist as REAL directories,
component by component, never following a link. False when something that
is not a directory is in the way: a `.thumbs` planted as a link would
otherwise put the cache outside the booth, beyond the sweep.
⚠ CREATING `.thumbs` TOUCHES THE BOOTH DIRECTORY'S OWN MTIME, and
`_newest_mtime` seeds from exactly that — so merely LOOKING at a booth aged
it, and once the Desk pulls a thumbnail per booth, one index load would push
every expiry out and the TTL would never fire again. Excluding the cache's
CONTENTS is not enough; the directory entry is the leak. So the booth's
mtime is put back after `.thumbs` is made. That cannot hide real activity:
any file an agent adds is counted by its OWN mtime in the same walk, and the
directory stamp is only the seed."""
cur = booth
for part in parent.relative_to(booth).parts:
cur = cur / part
try:
if not stat.S_ISDIR(os.lstat(cur).st_mode):
return False
continue
except FileNotFoundError:
pass
restore = booth.stat() if cur.parent == booth else None
try:
os.mkdir(cur)
except FileExistsError:
pass
if restore is not None:
try:
os.utime(booth, ns=(restore.st_atime_ns, restore.st_mtime_ns))
except OSError:
pass
if not stat.S_ISDIR(os.lstat(cur).st_mode):
return False
return True
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
tile-sized and light, an animation that already fits (a thumbnail is one
frame; an animation too big to fit IS flattened), over the pixel budget,
something planted in the cache's way, 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
other read on this path already takes.
"""
if _Image is None or not wants_thumb(rel):
return None
src = booth / rel
out = thumb_path(booth, rel)
try:
s_stat = src.stat()
if _fresh(out, s_stat):
return out
with _Image.open(src) as im:
# `open` reads the header only, so this is cheap enough to decide on.
w, h = im.size
if w * h > THUMB_MAX_PIXELS:
return None
# The size the picture is SEEN at: a camera stores a portrait
# sideways and says so in EXIF, and the browser honours it on the
# original. Pillow does not, so sizing the raw pixels tiled a
# portrait as a landscape (groa, seat-verified).
orientation = im.getexif().get(0x0112, 1)
if orientation in (5, 6, 7, 8):
w, h = h, w
fits = w <= THUMB_WIDTH and h <= 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
if orientation != 1:
im = _ImageOps.exif_transpose(im)
im.thumbnail((THUMB_WIDTH, THUMB_HEIGHT_MAX))
if im.mode not in ("RGB", "RGBA"):
# A palette PNG carries transparency in `info`, not as a band:
# `getbands()` alone baked it opaque (3/4 arms, seat-executed).
alpha = "A" in im.getbands() or "transparency" in im.info
im = im.convert("RGBA" if alpha else "RGB")
if not _cache_dir(booth, out.parent):
return None
# Atomic, like every other sidecar this service writes, through a
# temp file created O_EXCL under an unpredictable name: the old
# `<out>.<pid>.tmp` could be planted as a link, and the encoder
# wrote THROUGH it (seat P5: 600 B -> 316,400 B).
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)
os.utime(tmp, ns=(s_stat.st_atime_ns, s_stat.st_mtime_ns))
os.replace(tmp, out)
finally:
try:
os.unlink(tmp)
except OSError:
pass
return out if _fresh(out, s_stat) else None
except Exception: # noqa: BLE001 — a bad image costs its own tile, never the page
return None
# as S5c (G14): a gallery tile's <img> carries the picture's size, so a lazy
# tile reserves its box before it loads and a link to a tile far down lands
# where it points. Measured before: 40 portraits, `#item-30.png`, the tile's top
# 44px below its mark in 3 runs of 3; with sizes, on it.
#
# HEADER ONLY. `Image.open` reads the header and decodes nothing. The EXIF
# orientation is read only when the header already carried it: Pillow's PNG
# `getexif()` otherwise DECODES the whole picture looking for a late eXIf chunk,
# which on a 270-tile gallery is 270 decodes per render.
#
# CACHED by the file's identity, so a render reads each header once while it
# is unchanged. The size of an entry is two ints; the bound keeps a long-lived
# service from growing without limit as booths come and go.
SIZE_CACHE = 4096
def drawn_size(path: Path) -> tuple[int, int] | None:
"""(width, height) of the picture at `path` as a browser DRAWS it: EXIF
orientations 5-8 swap the two, as `ensure_thumb` does. None for anything
whose header cannot be read safely.
NEVER RAISES. The size only shapes a tile's box before its picture loads,
and the loaded picture's own ratio wins then (`aspect-ratio: auto w / h`),
so a missing size is today's markup and a wrong one costs a jump, never a
distorted picture. Any fleet session can write into a booth, so a link or
a FIFO may be planted where a picture was: the file is opened
`O_NOFOLLOW` (a link is refused) and `O_NONBLOCK` (a FIFO cannot hang the
render; it reads as empty, which is not an image)."""
if _Image is None:
return None
try:
st = os.lstat(path) # the identity, never through a link
except OSError:
return None
return _read_size(str(path), st.st_dev, st.st_ino, st.st_size, st.st_mtime_ns, st.st_ctime_ns)
@functools.lru_cache(maxsize=SIZE_CACHE)
def _read_size(path: str, dev: int, ino: int, size: int, mtime_ns: int,
ctime_ns: int) -> tuple[int, int] | None:
"""The read behind `drawn_size`. Every argument after `path` is the cache
key's identity only: a file replaced in place is a new entry. The change
time is in it because `cp -p` over a file keeps its inode and restores its
mtime, and a same-length replacement kept the old size too (heid bug-hunt
R7); no write can restore a ctime."""
try:
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
except OSError:
return None
try:
with os.fdopen(fd, "rb") as fh:
fd = -1
with _Image.open(fh) as im:
w, h = im.size
# only when the header already carried it: see the note above
orientation = (im.getexif().get(0x0112, 1) if "exif" in im.info else 1)
if w <= 0 or h <= 0:
return None
return (h, w) if orientation in (5, 6, 7, 8) else (w, h)
except Exception: # noqa: BLE001 — a bad header costs its own size, never the page
return None
finally:
if fd >= 0:
os.close(fd)