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:
Claude
2026-10-23 19:33:28 +00:00
parent 03ebc3a138
commit d4d7b4d22e
11 changed files with 181 additions and 9 deletions
+18 -2
View File
@@ -9,7 +9,7 @@ from pathlib import Path
import asyncio
from fastapi import APIRouter, Body, Depends, FastAPI, HTTPException, Request, Response, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse, JSONResponse
from fastapi.responses import FileResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles
from . import auth
@@ -17,6 +17,7 @@ from .derive import compute
from . import rules_edit
from . import maps as maps_mod
from . import sounds as sounds_mod
from . import portrait as portrait_mod
from .realtime import hub
from .export import to_docx, to_pdf
@@ -386,6 +387,7 @@ def my_characters(user: dict = Depends(current_user)):
@api.post("/characters")
def create_character(ch: dict = Body(...), user: dict = Depends(current_user)):
portrait_mod.clean(ch)
check_size(ch)
with db() as con:
if con.execute("SELECT COUNT(*) FROM characters WHERE owner_id=?", (user["id"],)).fetchone()[0] >= MAX_CHARACTERS:
@@ -437,6 +439,7 @@ def remove_share(cid: str, username: str, user: dict = Depends(current_user)):
@api.put("/characters/{cid}")
def update_character(cid: str, ch: dict = Body(...), user: dict = Depends(current_user)):
portrait_mod.clean(ch)
check_size(ch)
with db() as con:
_owned(con, "characters", cid, user)
@@ -533,7 +536,7 @@ def _card(data: str, rules: dict) -> dict:
prof = next((p for p in rules["professions"] if p["id"] == ch.get("profession")), None)
return {"name": ch.get("name") or "Nameless", "concept": str(ch.get("concept") or "")[:120],
"class": (cls or {}).get("name", ""), "subclass": (sub or {}).get("name", ""),
"profession": (prof or {}).get("name", "")}
"profession": (prof or {}).get("name", ""), "portrait": portrait_mod.tag(ch.get("portrait"))}
@api.get("/lobbies")
@@ -590,6 +593,19 @@ def join_lobby(request: Request, body: dict = Body(...), user: dict = Depends(cu
return {"id": lobby["id"]}
@api.get("/lobbies/{lid}/portrait/{username}")
def lobby_portrait(lid: str, username: str, user: dict = Depends(current_user)):
"""The portrait of a player's character in this lobby (icon on the map and in the player bar). Members only."""
with db() as con:
_lobby_for(con, lid, user)
row = con.execute("""SELECT c.data FROM lobby_members m JOIN users u ON u.id = m.user_id
JOIN characters c ON c.id = m.character_id WHERE m.lobby_id=? AND u.username=?""", (lid, username)).fetchone()
img = portrait_mod.image(json.loads(row["data"]).get("portrait")) if row else None
if not img:
raise HTTPException(404, "No portrait")
return Response(img, media_type="image/webp", headers={"Cache-Control": "private, max-age=86400"})
@api.get("/lobbies/{lid}")
def read_lobby(lid: str, user: dict = Depends(current_user)):
rules = load_rules()
+8 -1
View File
@@ -16,6 +16,7 @@ from fastapi.responses import Response
from PIL import Image, ImageOps
from . import vision
from .portrait import tag as portrait_tag
MAX_UPLOAD = 12_000_000 # bytes of the uploaded file
MAX_SIDE = 4096 # larger images are scaled down
@@ -350,7 +351,13 @@ def install(api, db, current_user, lobby_for, dm_only, hub, event, store_dir: Pa
name = (json.loads(r["data"]).get("name") if r["data"] else "") or r["username"]
except ValueError:
name = r["username"]
out.append({"id": r["id"], "kind": "player" if player else "npc", "username": r["username"], "name": name,
pt = ""
if player and r["data"]:
try:
pt = portrait_tag(json.loads(r["data"]).get("portrait"))
except ValueError:
pass
out.append({"id": r["id"], "kind": "player" if player else "npc", "username": r["username"], "name": name, "portrait": pt,
"x": r["x"], "y": r["y"], "size": r["size"], "hidden": bool(r["hidden"]),
"color": (r["mcolor"] or "#999999") if player else r["color"],
"can_move": ctx["is_dm"] or (r["user_id"] == user["id"] and not (ctx["los"] and r["x"] is None))})
+52
View File
@@ -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