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()