Crop-and-zoom picture editor on the Concept page, stored (re-encoded to a 256 px WebP data URL) inside the character; shown on the sheet header, in the lobby player bar and as token icon on the map. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HJhXuLD2Z6hv3Wqeq9UL9K
53 lines
2.0 KiB
Python
53 lines
2.0 KiB
Python
"""Character portraits: a small square picture stored inside the character as a data URL (so it travels with saving, sharing and
|
|
loading). The browser crops it to a square; the server checks and re-encodes it (256 px, WebP) so only real images get stored."""
|
|
import base64
|
|
import binascii
|
|
import hashlib
|
|
import io
|
|
|
|
from fastapi import HTTPException
|
|
from PIL import Image, ImageOps
|
|
|
|
SIZE = 256
|
|
MAX_RAW = 400_000 # bytes of the uploaded data before re-encoding
|
|
|
|
|
|
def clean(ch: dict) -> None:
|
|
"""Validates and re-encodes ch["portrait"] in place (an empty value removes it)."""
|
|
p = ch.get("portrait")
|
|
if not p:
|
|
ch["portrait"] = ""
|
|
return
|
|
if not isinstance(p, str) or not p.startswith("data:image/") or ";base64," not in p:
|
|
raise HTTPException(422, "The portrait must be an image")
|
|
try:
|
|
raw = base64.b64decode(p.split(";base64,", 1)[1], validate=True)
|
|
if len(raw) > MAX_RAW:
|
|
raise HTTPException(413, "The portrait is too large")
|
|
im = Image.open(io.BytesIO(raw))
|
|
if im.format not in ("PNG", "JPEG", "WEBP"):
|
|
raise ValueError
|
|
im.load()
|
|
except HTTPException:
|
|
raise
|
|
except (binascii.Error, ValueError, OSError, Image.DecompressionBombError):
|
|
raise HTTPException(422, "The portrait must be a PNG, JPEG or WebP image")
|
|
im = ImageOps.fit(ImageOps.exif_transpose(im).convert("RGB"), (SIZE, SIZE))
|
|
out = io.BytesIO()
|
|
im.save(out, "WEBP", quality=85)
|
|
ch["portrait"] = "data:image/webp;base64," + base64.b64encode(out.getvalue()).decode()
|
|
|
|
|
|
def tag(portrait) -> str:
|
|
"""Short fingerprint, used as cache key in URLs ("" if there is no portrait)."""
|
|
return hashlib.sha1(portrait.encode()).hexdigest()[:10] if portrait else ""
|
|
|
|
|
|
def image(portrait) -> bytes | None:
|
|
if not portrait or ";base64," not in portrait:
|
|
return None
|
|
try:
|
|
return base64.b64decode(portrait.split(";base64,", 1)[1])
|
|
except (binascii.Error, ValueError):
|
|
return None
|