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.
274 lines
13 KiB
Python
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)
|