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
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
49 lines
1.6 KiB
Python
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])
|