Files
cinny/scripts/makeDecorationThumbs.py
T
Lotus CIandClaude Opus 5.5 78b1a2ff6f
CI / Build & Quality Checks (push) Successful in 3m34s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 11s
CI / Trigger Desktop Build (push) Successful in 15s
CI / Playwright smoke (e2e) (push) Successful in 10m56s
feat(decorations): 530 new avatar decorations, thumbnail-first picker
Adds 530 decorations in 19 new categories (631 total). Decorations that
contain Discord branding (the Clyde-visor helmets) are excluded.

The decorations are ~1 MB animated PNGs, so a picker that rendered every
one would pull ~590 MB while scrolling. The picker now:
- shows static 144px WebP thumbnails (~9.5 KB each, `thumbs/` on the CDN)
  and loads the animated file only on hover, focus or selection, with a
  fallback to the full file if a thumbnail is missing;
- mounts one category at a time behind tabs, plus a name search across
  all categories.

scripts/makeDecorationThumbs.py builds the thumbnails from the busiest
frame of each animation (many start on an empty frame).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-25 23:50:12 -04:00

49 lines
1.6 KiB
Python

#!/usr/bin/env python3
"""Build static picker thumbnails for avatar decorations.
The decorations are animated APNGs (~1 MB each). The settings picker shows
static WebP thumbnails instead and only loads the animated file on hover,
focus or selection, so browsing the catalog costs a few MB, not hundreds.
Usage: python3 scripts/makeDecorationThumbs.py <dir-of-slug.png> <out-dir>
Then upload <out-dir>/*.webp to `${DECORATION_CDN}/thumbs/`.
A missing thumbnail is harmless: the picker falls back to the full PNG.
Requires Pillow (pip install pillow).
"""
import sys
from pathlib import Path
from PIL import Image, ImageSequence
SIZE = 144 # 2x the 72px picker cell
def best_frame(im: Image.Image) -> Image.Image:
# Many animations start (or loop through) an empty frame, so pick the frame
# with the most visible pixels rather than frame 0.
best, best_score = None, -1
frames = list(ImageSequence.Iterator(im))
step = max(1, len(frames) // 24)
for frame in frames[::step]:
rgba = frame.convert('RGBA')
score = sum(rgba.getchannel('A').histogram()[33:])
if score > best_score:
best, best_score = rgba, score
return best
def main(src: str, out: str) -> None:
out_dir = Path(out)
out_dir.mkdir(parents=True, exist_ok=True)
for png in sorted(Path(src).glob('*.png')):
with Image.open(png) as im:
thumb = best_frame(im).resize((SIZE, SIZE), Image.LANCZOS)
thumb.save(out_dir / f'{png.stem}.webp', 'WEBP', quality=82, method=6)
if __name__ == '__main__':
if len(sys.argv) != 3:
sys.exit(__doc__)
main(sys.argv[1], sys.argv[2])