#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 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, BG_PATH, SPARK_TARGET, 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 ───────────────────────────────────────────────────────── async def _fetch_portrait(session: aiohttp.ClientSession, student: dict) -> Optional[Image.Image]: """Download a student's portrait (wiki bust art first, full sprite fallback).""" for url in (db.cdn_portrait(student), db.cdn_sprite(student)): try: async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp: if resp.status == 200: data = await resp.read() img = Image.open(io.BytesIO(data)).convert("RGBA") # Full-body art is tall; crop the upper portion to fit the card. 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)) 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 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, )