Files
AWCharacterCreator/backend/app/portrait.py
T
Claude d4d7b4d22e 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
2026-10-23 19:33:28 +00:00

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