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
+4
View File
@@ -86,6 +86,10 @@ Each wizard page has a **Reset this page** button that clears only that page's d
The Summary page and the sheet tabs in the lobbies show the character as **A4 pages**: page 1 has name, attributes, derived stats and all skills (proficiency dots, attribute, points, total), page 2 has talents, mutations and implants, equipment, background and a ruled notes area. On the Summary page, **Print / PDF (A4)** opens just the pages in a clean window; choose "Save as PDF" in the print dialog (margins none, background graphics on if your browser asks). Hover or tap a value to see how it is made up (not printed). **Light / Dark** (in the lobby it sits above the sheet) switches the colours of the sheet on screen (remembered in the browser; printed pages are always light). The Word and PDF downloads on the Summary page are separate exports with their own layout.
### Character portrait
On the **Concept** page *Add picture* takes a PNG, JPEG or WebP: move and zoom it inside the square window, *Use this* stores a small 256 px picture inside the character (so it is saved, shared, exported as JSON and loaded with it). The server checks and re-encodes it. The picture appears in the header of the character sheet (also when printing), as icon in the player bar of the lobby and as the token of the character on the map (in the player's colour ring). Only members of the lobby get the picture through `/api/lobbies/{id}/portrait/{username}`. The PDF / Word export from the server does not contain it yet.
### Rules editor
Admins get an **Editor** tab to add, change and delete **items, item categories, skills, skill groups and professions** with forms. Admins are the usernames in `AW_ADMINS` (comma separated, set in `.env`); without that setting only the first account ever created is admin.
+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
+2
View File
@@ -1,3 +1,4 @@
import PortraitEditor from './Portrait.jsx'
import { useEffect, useMemo, useRef, useState } from 'react'
import { applyPreset, applyProfession, compute, effectiveRules, emptyCharacter, kitItems, normalize, resetStep, setProfessionPicks } from './calc.js'
import SheetPanel from './SheetPanel.jsx'
@@ -167,6 +168,7 @@ export default function Creator({ rules: baseRules, user, loadRequest, clearLoad
})()}
<p className="small">Create or edit presets on the <a href="#presets">Presets</a> page.</p>
</div>
<div className="card portraitbox"><PortraitEditor value={ch.portrait} onChange={(p) => set({ portrait: p })} /></div>
<div className="grid2">
<label>Character name<input value={ch.name} onChange={(e) => set({ name: e.target.value })} /></label>
<label>Player<input value={ch.player} onChange={(e) => set({ player: e.target.value })} /></label>
+2 -2
View File
@@ -432,7 +432,7 @@ function CharBar({ lobby, sel, onSel }) {
return (
<div key={m.username} role="listitem" className={`pcard${sel === m.username ? ' sel' : ''}${can ? ' can' : ''}`} style={{ '--pc': m.color || 'var(--line)' }}
onClick={() => can && onSel(m.username)} tabIndex={can ? 0 : undefined} onKeyDown={(e) => can && e.key === 'Enter' && onSel(m.username)}>
<span className="pavatar">{(c?.name || m.username).slice(0, 1).toUpperCase()}</span>
<span className="pavatar">{c?.portrait ? <img src={`/api/lobbies/${lobby.id}/portrait/${encodeURIComponent(m.username)}?v=${c.portrait}`} alt="" /> : (c?.name || m.username).slice(0, 1).toUpperCase()}</span>
<span className="pinfo">
<b>{c ? c.name : 'No character'}</b>
<span className="muted small">{m.username}{m.me ? ' (you)' : ''}{c?.class ? ` · ${c.class}` : ''}</span>
@@ -762,7 +762,7 @@ function LobbyView({ id, rules, user }) {
const selTok = lobby.is_dm ? mapState.tokens.find((t) => t.id === mapSel && t.kind === 'npc') : null
const walls = full && showWalls ? mapState.walls || [] : []
return (
<MapView map={mapState.map} image={mapImg} tokens={mapState.tokens} grid={showGrid}
<MapView map={mapState.map} image={mapImg} portraitUrl={(t) => t.portrait ? `/api/lobbies/${id}/portrait/${encodeURIComponent(t.username)}?v=${t.portrait}` : null} tokens={mapState.tokens} grid={showGrid}
onMove={moveToken} selected={mapSel} onSelect={setMapSel}
overlay={walls.length ? <WallLayer map={mapState.map} walls={walls} onDoor={(w) => act(`/api/lobbies/${id}/doors/${w.id}`, 'PUT', { open: !w.open }, () => loadMap())} /> : null}>
{mapState.vision?.empty && <div className="mapempty">{lobby.is_dm ? 'This player is not on the map yet. Switch to the full view and drag their token onto it.' : 'Waiting for the DM to put your token on the map.'}</div>}
+2 -2
View File
@@ -6,7 +6,7 @@ const clamp = (v, a, b) => Math.min(b, Math.max(a, v))
// Map with pan (drag the background), zoom (wheel / pinch) and draggable tokens. Coordinates are image pixels.
// Everybody pans and zooms for themselves; token moves go out through onMove(id, x, y) which returns a promise.
export function MapView({ map, image, tokens, onMove, selected, onSelect, grid: showGrid, tool, onClick, onHover, overlay, children }) {
export function MapView({ map, image, portraitUrl, tokens, onMove, selected, onSelect, grid: showGrid, tool, onClick, onHover, overlay, children }) {
const box = useRef(null)
const [view, setView] = useState({ tx: 0, ty: 0, s: 1 })
const [drag, setDrag] = useState(null)
@@ -125,7 +125,7 @@ export function MapView({ map, image, tokens, onMove, selected, onSelect, grid:
<div key={t.id} className={`maptoken ${t.kind} ${t.hidden ? 'hid' : ''} ${selected === t.id ? 'sel' : ''} ${t.can_move ? 'mov' : ''}`}
style={{ left: x - d(t) / 2, top: y - d(t) / 2, width: d(t), height: d(t), '--tc': t.color, fontSize: Math.max(8, d(t) * 0.45) }}
onPointerDown={(e) => startToken(e, t)} title={t.name + (t.hidden ? ' (hidden from players)' : '')}>
<span>{(t.name || '?').trim().slice(0, 2)}</span>
{portraitUrl?.(t) ? <img className="tokpic" src={portraitUrl(t)} alt="" draggable={false} /> : <span>{(t.name || '?').trim().slice(0, 2)}</span>}
<em style={{ fontSize: Math.max(9, map.grid * 0.28) }}>{t.name}</em>
</div>
)
+1
View File
@@ -110,6 +110,7 @@ export default function PaperSheet({ data, baseRules, tools, bare, onRoll }) {
<section className="paper">
<header className="phead">
<div className="pname">{name}</div>
{ch.portrait && <img className="pportrait" src={ch.portrait} alt="" />}
<div className="pbrand">Arcane Wasteland<span>Character sheet</span></div>
</header>
<div className="pfields">
+76
View File
@@ -0,0 +1,76 @@
import { useRef, useState } from 'react'
const BOX = 200 // size of the crop window on screen
const OUT = 256 // size of the stored picture
// Picture of a character: choose a file, move and zoom it inside a square window, apply. Stored as a small data URL.
export default function PortraitEditor({ value, onChange }) {
const [pic, setPic] = useState(null) // { img, w, h }
const [zoom, setZoom] = useState(1)
const [off, setOff] = useState({ x: 0, y: 0 })
const [err, setErr] = useState('')
const drag = useRef(null)
const pick = (e) => {
const f = e.target.files?.[0]
e.target.value = ''
if (!f) return
setErr('')
if (!/^image\/(png|jpeg|webp)$/.test(f.type)) return setErr('Please choose a PNG, JPEG or WebP image.')
if (f.size > 12_000_000) return setErr('The image is too large (at most 12 MB).')
const url = URL.createObjectURL(f)
const img = new Image()
img.onload = () => { setPic({ img, w: img.naturalWidth, h: img.naturalHeight, url }); setZoom(1); setOff({ x: 0, y: 0 }) }
img.onerror = () => { URL.revokeObjectURL(url); setErr('This image could not be read.') }
img.src = url
}
const scale = pic ? (BOX / Math.min(pic.w, pic.h)) * zoom : 1
const clampOff = (o, z = zoom) => {
if (!pic) return o
const s = (BOX / Math.min(pic.w, pic.h)) * z
const mx = Math.max(0, (pic.w * s - BOX) / 2), my = Math.max(0, (pic.h * s - BOX) / 2)
return { x: Math.min(mx, Math.max(-mx, o.x)), y: Math.min(my, Math.max(-my, o.y)) }
}
const down = (e) => { e.currentTarget.setPointerCapture(e.pointerId); drag.current = { x: e.clientX, y: e.clientY, off } }
const move = (e) => { if (drag.current) setOff(clampOff({ x: drag.current.off.x + e.clientX - drag.current.x, y: drag.current.off.y + e.clientY - drag.current.y })) }
const up = () => { drag.current = null }
const close = () => { if (pic) URL.revokeObjectURL(pic.url); setPic(null) }
const apply = () => {
const size = BOX / scale // visible part of the original, in its pixels
const cx = pic.w / 2 - off.x / scale, cy = pic.h / 2 - off.y / scale
const cv = document.createElement('canvas')
cv.width = cv.height = OUT
cv.getContext('2d').drawImage(pic.img, cx - size / 2, cy - size / 2, size, size, 0, 0, OUT, OUT)
const data = cv.toDataURL('image/webp', 0.85)
onChange(data.startsWith('data:image/webp') ? data : cv.toDataURL('image/jpeg', 0.88))
close()
}
return (
<div className="portraiteditor">
{!pic && (
<div className="portraitcur">
{value ? <img src={value} alt="Character portrait" /> : <span className="portraitnone muted small">No picture</span>}
<div className="portraitbtns">
<label className="uploadbtn small">{value ? 'Change picture' : 'Add picture'}<input type="file" accept="image/png,image/jpeg,image/webp" hidden onChange={pick} /></label>
{value && <button className="ghost" onClick={() => onChange('')}>Remove</button>}
<span className="small muted">Shown on the sheet, in lobbies and on the map.</span>
</div>
</div>
)}
{pic && (
<div className="portraitcrop">
<div className="cropwin" style={{ width: BOX, height: BOX }} onPointerDown={down} onPointerMove={move} onPointerUp={up} onPointerCancel={up}>
<img src={pic.url} alt="" draggable={false} style={{ width: pic.w * scale, height: pic.h * scale, left: (BOX - pic.w * scale) / 2 + off.x, top: (BOX - pic.h * scale) / 2 + off.y }} />
</div>
<div className="cropctl">
<label className="small">Zoom <input type="range" min="1" max="4" step="0.02" value={zoom} onChange={(e) => { const z = +e.target.value; setZoom(z); setOff((o) => clampOff(o, z)) }} /></label>
<p className="small muted">Drag the picture to choose the section.</p>
<div className="row"><button className="on" onClick={apply}>Use this</button><button className="ghost" onClick={close}>Cancel</button></div>
</div>
</div>
)}
{err && <p className="warn small">{err}</p>}
</div>
)
}
+2 -2
View File
@@ -6,7 +6,7 @@ export function emptyCharacter(rules) {
const attributes = {}
rules.attributes.forEach((a) => (attributes[a.id] = rules.creation.attr_start))
return {
name: '', player: '', concept: '', background: '', class_id: '', subclass_id: '',
name: '', player: '', concept: '', background: '', portrait: '', class_id: '', subclass_id: '',
attributes,
profession: '', profession_bonus_attr: '', profession_picks: [],
preset: rules.default_preset || 'standard', creation: {}, // creation = overrides of rules.creation for this character
@@ -182,7 +182,7 @@ export function setProfessionPicks(ch, rules, picks) {
export function resetStep(ch, rules, step) {
const e = emptyCharacter(rules)
switch (step) {
case 0: return { ...ch, name: '', player: '', concept: '', background: '', class_id: '', subclass_id: '' }
case 0: return { ...ch, name: '', player: '', concept: '', background: '', portrait: '', class_id: '', subclass_id: '' }
case 1: return { ...ch, attributes: e.attributes }
case 2: return pruneLocked({ ...ch, proficiencies: stripProfessionProfs(ch, rules), profession: '', profession_bonus_attr: '', profession_picks: [] }, rules)
case 3: return { ...ch, skill_points: {}, unlocked: [], proficiency_picks: {} }
+14
View File
@@ -466,3 +466,17 @@ label.chk input[type=checkbox] { width: auto; margin: 0; }
.soundrow { display: grid; gap: .25rem; padding: .4rem; border: 1px solid var(--line); border-radius: 8px; }
.soundrow .row { justify-content: space-between; align-items: center; flex-wrap: wrap; }
.uploadbtn.small { padding: .15rem .6rem; font-size: .85rem; }
/* ---- portraits */
.portraitbox { display: block; }
.portraitcur { display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; }
.portraitcur img, .portraitnone { width: 6.5rem; height: 6.5rem; border-radius: 10px; object-fit: cover; border: 1px solid var(--line); background: var(--bg); display: grid; place-items: center; }
.portraitbtns { display: grid; gap: .4rem; justify-items: start; }
.portraitcrop { display: flex; gap: 1rem; flex-wrap: wrap; align-items: flex-start; }
.cropwin { position: relative; overflow: hidden; border-radius: 10px; border: 2px solid var(--noxium, #7aa63c); background: #000; cursor: move; touch-action: none; flex: none; }
.cropwin img { position: absolute; max-width: none; user-select: none; }
.cropctl { display: grid; gap: .5rem; }
.pportrait { width: 21mm; height: 21mm; object-fit: cover; border: 1.5px solid var(--pg); border-radius: 1.5mm; flex: none; margin-left: auto; }
.pavatar { overflow: hidden; border: 2px solid var(--pc); }
.pavatar img { width: 100%; height: 100%; object-fit: cover; display: block; }
.maptoken .tokpic { position: absolute; inset: 0; width: 100%; height: 100%; border-radius: 50%; object-fit: cover; pointer-events: none; }