Files
discordbot/blue_archive/gacha_renderer.py
T
2026-10-21 22:59:39 +03:00

395 lines
15 KiB
Python

#this renders the gacha recruitment result screen using pillow, mimics the in-game look##
"""Pillow-based compositor: renders an in-game-style gacha recruitment result screen."""
######################################################################
import asyncio
import functools
import io
import logging
import math
import os
import time
import uuid
from pathlib import Path
from typing import Optional
import aiohttp
from PIL import Image, ImageDraw, ImageFont
from .data import db
from .constants import (
CANVAS_W, CANVAS_H,
CARD_W, CARD_H, CARD_RADIUS,
GAP_X, GAP_Y, COLS, ROWS,
GRID_W, GRID_H, GRID_X, GRID_Y,
HEADER_Y, FOOTER_Y,
STAR_BANNER_H, NAME_STRIP_H, PORTRAIT_TOP, PORTRAIT_H,
RARITY_COLOR, RARITY_BG,
BUST_HEIGHT_FRAC, BUST_HEAD_FRAC,
BG_PATH, SPARK_TARGET,
PORTRAIT_CACHE_DIR, WIKI_RETRY_HOURS,
FONT_PATHS,
)
log = logging.getLogger(__name__)
# ── Font helpers ───────────────────────────────────────────────────────────
@functools.lru_cache(maxsize=16)
def _get_font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont:
"""Try to load a nice font; fall back to default. Cached per (size, bold)."""
for fp in FONT_PATHS:
if Path(fp).exists():
try:
return ImageFont.truetype(fp, size)
except Exception:
continue
return ImageFont.load_default()
HEADER_FONT = _get_font(52, bold=True)
FOOTER_FONT = _get_font(18, bold=False)
# ── Image fetching ─────────────────────────────────────────────────────────
def _bust_crop(img: Image.Image) -> Image.Image:
"""Crop a full-body sprite to head-and-shoulders so it fills the card.
Anchored on the alpha box top (Kid variants sit lower in the frame) and on
the alpha-weighted centre of the top slice, so thin props like rifles and
parasols barely pull the crop off the face.
"""
alpha = img.getchannel("A")
box = alpha.getbbox()
if not box:
return img
left, top, right, bottom = box
fig_h = bottom - top
crop_h = min(img.height - top, max(1, int(fig_h * BUST_HEIGHT_FRAC)))
head = alpha.crop((left, top, right, top + max(1, int(fig_h * BUST_HEAD_FRAC))))
cols = head.resize((head.width, 1), Image.BOX).tobytes()
weight = sum(cols)
offset = sum(x * c for x, c in enumerate(cols)) // weight if weight else head.width // 2
cx = left + offset
crop_w = min(img.width, max(1, round(crop_h * (CARD_W - 12) / PORTRAIT_H)))
x0 = max(0, min(img.width - crop_w, cx - crop_w // 2))
return img.crop((x0, top, x0 + crop_w, top + crop_h))
def _cached_portrait(student: dict) -> Optional[Image.Image]:
"""Load a cached portrait, or None when one should be (re)fetched."""
try:
wiki = PORTRAIT_CACHE_DIR / f"{student['Id']}.wiki.png"
if wiki.exists():
return Image.open(wiki).convert("RGBA")
alt = PORTRAIT_CACHE_DIR / f"{student['Id']}.alt.png"
if alt.exists() and time.time() - alt.stat().st_mtime < WIKI_RETRY_HOURS * 3600:
return Image.open(alt).convert("RGBA")
except Exception: # unreadable file would otherwise blank the card forever
log.warning("bad cached portrait for %s, refetching", student["Name"])
return None
def _cache_portrait(student: dict, img: Image.Image, from_wiki: bool) -> None:
"""Keep fetched art on disk so an upstream outage can't change the cards."""
try:
PORTRAIT_CACHE_DIR.mkdir(parents=True, exist_ok=True)
kind = "wiki" if from_wiki else "alt"
path = PORTRAIT_CACHE_DIR / f"{student['Id']}.{kind}.png"
tmp = path.parent / f"{path.name}.{uuid.uuid4().hex[:8]}.tmp"
img.save(tmp, format="PNG") # the .tmp name hides the format from Pillow
os.replace(tmp, path) # a restart mid-save must not leave half a card
if from_wiki: # wiki art wins from here on, drop the stand-in
(PORTRAIT_CACHE_DIR / f"{student['Id']}.alt.png").unlink(missing_ok=True)
except Exception:
log.warning("could not cache portrait for %s", student["Name"], exc_info=True)
async def _fetch_portrait(session: aiohttp.ClientSession, student: dict) -> Optional[Image.Image]:
"""A student's portrait: disk cache, else wiki art, collection art, sprite."""
cached = await asyncio.to_thread(_cached_portrait, student)
if cached is not None:
return cached
sources = (
(db.cdn_portrait(student), "wiki"),
(db.cdn_collection(student), "collection"),
(db.cdn_sprite(student), "sprite"),
)
for url, kind in sources:
try:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp:
if resp.status != 200:
continue
data = await resp.read()
img = Image.open(io.BytesIO(data)).convert("RGBA")
if kind == "sprite":
img = _bust_crop(img)
elif kind == "wiki":
# Wiki art is a face shot; only trim it if it is very tall.
p_w, p_h = img.size
crop_h = int(p_w * 1.20)
if crop_h < p_h:
img = img.crop((0, 0, p_w, crop_h))
await asyncio.to_thread(_cache_portrait, student, img, kind == "wiki")
return img
except Exception:
log.debug("portrait fetch failed via %s", url)
log.warning("no portrait for %s", student["Name"])
return None
# ── Drawing helpers ────────────────────────────────────────────────────────
def _star_polygon(cx: int, cy: int, outer_r: int, inner_r: int) -> list[tuple[int, int]]:
"""Generate vertex list for a 5-pointed star centered at (cx, cy).
Uses 10 alternating outer/inner radius points, rotated by pi/5 each step.
"""
points = []
for i in range(10):
angle = math.pi / 2 + i * math.pi / 5 # start from top
r = outer_r if i % 2 == 0 else inner_r
x = cx + r * math.cos(angle)
y = cy - r * math.sin(angle)
points.append((int(x), int(y)))
return points
def _draw_star_icons(
draw: ImageDraw.Draw,
cx: int,
cy: int,
count: int,
color: tuple,
size: int = 7,
) -> None:
"""Draw <count> gold 5-pointed star icons horizontally centered at (cx, cy)."""
outer = size
inner = int(size * 0.38)
spacing = int(size * 2.4)
total_w = (count - 1) * spacing
start_x = cx - total_w // 2
for i in range(count):
pts = _star_polygon(start_x + i * spacing, cy, outer, inner)
# Shadow
draw.polygon([(p[0] + 1, p[1] + 1) for p in pts], fill=(0, 0, 0, 100))
draw.polygon(pts, fill=color)
# ── Sync render helpers (run in thread) ────────────────────────────────────
def _load_background() -> Image.Image:
"""Load or create the render canvas background."""
if BG_PATH.exists():
return Image.open(BG_PATH).convert("RGBA")
return Image.new("RGBA", (CANVAS_W, CANVAS_H), (180, 226, 245))
def _encode_png(canvas: Image.Image) -> io.BytesIO:
"""Encode a Pillow image to optimized PNG bytes."""
output = io.BytesIO()
canvas.convert("RGB").save(output, format="PNG", optimize=True)
output.seek(0)
return output
# ── Main render ────────────────────────────────────────────────────────────
async def render_pull(
pulls: list[dict],
banner_name: str = "Regular Recruitment",
spark_count: int = 0,
) -> io.BytesIO:
"""Render a 10-pull (or single-pull) result as a PNG image.
Args:
pulls: List of student dicts from StudentDB (must have Id, Name, StarGrade).
banner_name: Display name for the banner header.
spark_count: Current spark/pity count to show in the footer.
Returns:
BytesIO containing the rendered PNG image.
"""
# Load background (file I/O off the event loop)
canvas = await asyncio.to_thread(_load_background)
draw = ImageDraw.Draw(canvas)
# ── Header ──────────────────────────────────────────────────────────
_draw_header(draw, banner_name)
# ── Footer / spark ──────────────────────────────────────────────────
_draw_footer(draw, spark_count)
# ── Fetch all portraits ─────────────────────────────────────────────
timeout = aiohttp.ClientTimeout(total=30, connect=5)
async with aiohttp.ClientSession(timeout=timeout) as session:
portrait_tasks = [_fetch_portrait(session, p) for p in pulls]
portraits = await asyncio.gather(*portrait_tasks, return_exceptions=True)
# Unwrap exceptions: failed fetches become None
for i, result in enumerate(portraits):
if isinstance(result, Exception):
log.warning("Portrait %d (%s) failed: %s", pulls[i]["Id"], pulls[i]["Name"], result)
portraits[i] = None
# ── Draw cards in grid ──────────────────────────────────────────────
for idx, (student, portrait) in enumerate(zip(pulls, portraits)):
col = idx % COLS
row = idx // COLS
cx = GRID_X + col * (CARD_W + GAP_X)
cy = GRID_Y + row * (CARD_H + GAP_Y)
_draw_card(canvas, draw, cx, cy, student, portrait)
# ── Encode (CPU-bound — off the event loop) ─────────────────────────
return await asyncio.to_thread(_encode_png, canvas)
def _draw_header(draw: ImageDraw.Draw, banner_name: str) -> None:
"""Draw the 'Recruitment Results' header at the top."""
title = "Recruitment Results"
tw = draw.textlength(title, font=HEADER_FONT)
# Banner name subtitle
subtitle = banner_name
sub_font = _get_font(26, bold=False)
# Shadow + text
draw.text(((CANVAS_W - tw) / 2 + 2, HEADER_Y + 2), title, fill=(0, 0, 0, 100), font=HEADER_FONT)
draw.text(((CANVAS_W - tw) / 2, HEADER_Y), title, fill=(255, 255, 255, 240), font=HEADER_FONT)
# Subtitle
sw = draw.textlength(subtitle, font=sub_font)
draw.text(((CANVAS_W - sw) / 2, HEADER_Y + 60), subtitle, fill=(255, 255, 255, 200), font=sub_font)
# Decorative line under header
line_y = HEADER_Y + 100
draw.line(
[(GRID_X, line_y), (GRID_X + GRID_W, line_y)],
fill=(255, 255, 255, 120),
width=2,
)
def _draw_footer(draw: ImageDraw.Draw, spark_count: int) -> None:
"""Draw spark counter at the bottom."""
text = f"Recruitment Points: {spark_count} / {SPARK_TARGET}"
tw = draw.textlength(text, font=FOOTER_FONT)
draw.text(
((CANVAS_W - tw) / 2 + 1, FOOTER_Y + 1),
text,
fill=(0, 0, 0, 80),
font=FOOTER_FONT,
)
draw.text(
((CANVAS_W - tw) / 2, FOOTER_Y),
text,
fill=(255, 255, 255, 200),
font=FOOTER_FONT,
)
def _draw_card(
canvas: Image.Image,
draw: ImageDraw.Draw,
x: int,
y: int,
student: dict,
portrait: Optional[Image.Image],
) -> None:
"""Draw a single recruitment result card at (x, y)."""
rarity = student["StarGrade"]
color = RARITY_COLOR.get(rarity, (150, 150, 150))
bg = RARITY_BG.get(rarity, (20, 20, 20))
# Card background with rounded corners
draw.rounded_rectangle((x, y, x + CARD_W, y + CARD_H), radius=CARD_RADIUS, fill=bg + (255,))
# Card border
draw.rounded_rectangle(
(x, y, x + CARD_W, y + CARD_H), radius=CARD_RADIUS, outline=color, width=2
)
# ── Star banner strip at top ────────────────────────────────────────
banner_top = y + 1
banner_bottom = y + STAR_BANNER_H
# Semi-transparent dark strip
draw.rounded_rectangle(
(x + 3, banner_top, x + CARD_W - 3, banner_bottom),
radius=CARD_RADIUS - 2,
fill=(0, 0, 0, 80),
)
# Draw star icons centered in the banner
_draw_star_icons(
draw,
x + CARD_W // 2,
y + STAR_BANNER_H // 2,
rarity,
color,
size=8,
)
# ── Portrait area ───────────────────────────────────────────────────
px = x + 6
py = y + PORTRAIT_TOP
pw = CARD_W - 12
ph = PORTRAIT_H
if portrait:
portrait = portrait.copy()
p_w, p_h = portrait.size
scale = min(pw / p_w, ph / p_h)
new_w, new_h = int(p_w * scale), int(p_h * scale)
portrait = portrait.resize((new_w, new_h), Image.LANCZOS)
pox = px + (pw - new_w) // 2
poy = py + (ph - new_h) // 2
canvas.paste(portrait, (pox, poy), portrait if portrait.mode == "RGBA" else None)
else:
draw.rounded_rectangle((px, py, px + pw, py + ph), radius=6, fill=(30, 30, 40, 180))
ph_font = _get_font(16)
msg = "No Image"
mw = draw.textlength(msg, font=ph_font)
draw.text((px + (pw - mw) / 2, py + ph / 2 - 10), msg, fill=(150, 150, 150), font=ph_font)
# ── Name strip at bottom ────────────────────────────────────────────
name_y = y + CARD_H - NAME_STRIP_H + 2
draw.rounded_rectangle(
(x + 3, name_y, x + CARD_W - 3, y + CARD_H - 3),
radius=CARD_RADIUS - 3,
fill=(0, 0, 0, 150),
)
# Student name
name = student["Name"]
name_font = _get_font(20, bold=True)
nw = draw.textlength(name, font=name_font)
draw.text(
(x + (CARD_W - nw) / 2 + 1, name_y + 2),
name,
fill=(0, 0, 0),
font=name_font,
)
draw.text(
(x + (CARD_W - nw) / 2, name_y + 1),
name,
fill=(255, 255, 255),
font=name_font,
)
# School label
school = student.get("School", "")
if school:
sch_font = _get_font(14)
sw2 = draw.textlength(school, font=sch_font)
draw.text(
(x + (CARD_W - sw2) / 2 + 1, name_y + 23),
school,
fill=(0, 0, 0),
font=sch_font,
)
draw.text(
(x + (CARD_W - sw2) / 2, name_y + 22),
school,
fill=color + (200,),
font=sch_font,
)