Character portrait
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
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user