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:
+18
-2
@@ -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
@@ -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))})
|
||||
|
||||
@@ -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