From ff579807f14023e3aef488dd67e2ad96b4d792b9 Mon Sep 17 00:00:00 2001 From: esefxdz Date: Wed, 21 Oct 2026 22:59:39 +0300 Subject: [PATCH] fuck you php --- blue_archive/constants.py | 8 +++ blue_archive/data.py | 6 ++- blue_archive/gacha_renderer.py | 94 ++++++++++++++++++++++++++++++---- blue_archive/health.py | 21 +++++--- 4 files changed, 111 insertions(+), 18 deletions(-) diff --git a/blue_archive/constants.py b/blue_archive/constants.py index 183ec0b..9cc5ff5 100644 --- a/blue_archive/constants.py +++ b/blue_archive/constants.py @@ -8,6 +8,7 @@ _PKG_DIR = Path(__file__).parent _DATA_DIR = _PKG_DIR.parent / "data" BUNDLED_DB = _PKG_DIR / "ba_students.json" BANNER_FILE = _DATA_DIR / "ba_banner_state.json" +PORTRAIT_CACHE_DIR = _DATA_DIR / "portraits" ASSETS_DIR = _PKG_DIR / "assets" BG_PATH = ASSETS_DIR / "gacha_bg.png" GACHA_ANIM_PATH = ASSETS_DIR / "Arona_Blue_Gacha_Animation.gif" @@ -17,6 +18,9 @@ GACHA_ANIM_DURATION = 3.5 # 195 frames × 100 ms (used to be 19.5 will be chang BANNER_API = "https://api.ennead.cc/buruaka/banner" CHARACTER_API = "https://api.ennead.cc/buruaka/character" PORTRAIT_CDN = "https://schaledb.com/images" +# Wiki art is the nicest but its host goes down for days at a time. Once a +# student is cached from a lesser source, re-check the wiki this often. +WIKI_RETRY_HOURS = 24 WIKI_PORTRAIT_BASE = "https://bluearchive.wiki/wiki/Special:FilePath/" WIKI_VARIANT_MAP: dict[str, str] = { "Bunny": "Bunny Girl", @@ -80,6 +84,10 @@ STAR_BANNER_H = 36 NAME_STRIP_H = 42 PORTRAIT_TOP = STAR_BANNER_H + 4 PORTRAIT_H = CARD_H - PORTRAIT_TOP - NAME_STRIP_H - 4 +# Fraction of a full-body sprite kept when cropping it to a bust, and the +# fraction of it searched to locate the head horizontally. +BUST_HEIGHT_FRAC = 0.45 +BUST_HEAD_FRAC = 0.25 # ── Inventory display ─────────────────────────────────────────────────────── PAGE_SIZE = 10 diff --git a/blue_archive/data.py b/blue_archive/data.py index 826d9b9..26ede97 100644 --- a/blue_archive/data.py +++ b/blue_archive/data.py @@ -355,8 +355,12 @@ class StudentDB: name = f"{base.strip()} ({variant})" return WIKI_PORTRAIT_BASE + quote(f"Portrait {name}.png", safe="") + def cdn_collection(self, student: dict) -> str: + """SchaleDB CDN collection art URL (in-game card portrait, keyed by ID).""" + return f"{PORTRAIT_CDN}/student/collection/{student['Id']}.webp" + def cdn_sprite(self, student: dict) -> str: - """SchaleDB CDN full-body sprite URL (fallback, keyed by ID).""" + """SchaleDB CDN full-body sprite URL (last resort, keyed by ID).""" return f"{PORTRAIT_CDN}/student/portrait/{student['Id']}.webp" diff --git a/blue_archive/gacha_renderer.py b/blue_archive/gacha_renderer.py index 3b26c89..2615732 100644 --- a/blue_archive/gacha_renderer.py +++ b/blue_archive/gacha_renderer.py @@ -6,6 +6,9 @@ import functools import io import logging import math +import os +import time +import uuid from pathlib import Path from typing import Optional @@ -21,7 +24,9 @@ from .constants import ( 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, ) @@ -47,20 +52,87 @@ 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]: - """Download a student's portrait (wiki bust art first, full sprite fallback).""" - for url in (db.cdn_portrait(student), db.cdn_sprite(student)): + """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: - 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 + 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) diff --git a/blue_archive/health.py b/blue_archive/health.py index 627e771..e6c74d7 100644 --- a/blue_archive/health.py +++ b/blue_archive/health.py @@ -17,7 +17,7 @@ from PIL import Image from . import db as gacha_db from .data import db, fetch_banners, banner_unavailable_reason from .gacha_renderer import render_pull -from .constants import CHARACTER_API, BG_PATH, GACHA_ANIM_PATH, FONT_PATHS +from .constants import CHARACTER_API, BG_PATH, GACHA_ANIM_PATH, FONT_PATHS, PORTRAIT_CACHE_DIR OK, WARN, FAIL, INFO = "✅", "⚠️", "❌", "ℹ️" REFRESH_INTERVAL = 6 * 3600 # must match BlueArchiveGacha._periodic_refresh @@ -121,7 +121,7 @@ def _check_rateups(live_banners: list[dict]) -> tuple[list[Row], list[dict]]: async def _check_portraits(students: list[dict]) -> list[Row]: - """Probe wiki art and the sprite fallback separately for rate-up students.""" + """Probe each art source separately for rate-up students.""" s = "Portraits" if not students: return [(INFO, s, "No live rate-up students to probe")] @@ -136,16 +136,25 @@ async def _check_portraits(students: list[dict]) -> list[Row]: timeout = aiohttp.ClientTimeout(total=30, connect=5) # same as render_pull async with aiohttp.ClientSession(timeout=timeout) as session: results = await asyncio.gather(*( - asyncio.gather(_probe_image(session, db.cdn_portrait(st)), _probe_image(session, db.cdn_sprite(st))) + asyncio.gather( + _probe_image(session, db.cdn_portrait(st)), + _probe_image(session, db.cdn_collection(st)), + _probe_image(session, db.cdn_sprite(st)), + ) for st in sample )) - for st, (wiki, sprite) in zip(sample, results): + for st, (wiki, collection, sprite) in zip(sample, results): if wiki is None: rows.append((OK, s, f"{st['Name']}: wiki art OK")) + elif collection is None: + rows.append((WARN, s, f"{st['Name']}: wiki art failed ({wiki}), collection art OK")) elif sprite is None: - rows.append((WARN, s, f"{st['Name']}: wiki art failed ({wiki}), sprite fallback OK")) + rows.append((WARN, s, f"{st['Name']}: wiki+collection failed, sprite fallback OK")) else: - rows.append((FAIL, s, f"{st['Name']}: no art (wiki {wiki}, sprite {sprite}) — card renders blank")) + rows.append((FAIL, s, f"{st['Name']}: no art (wiki {wiki}, collection {collection}, " + f"sprite {sprite}) — card renders blank")) + cached = len(list(PORTRAIT_CACHE_DIR.glob("*.png"))) if PORTRAIT_CACHE_DIR.exists() else 0 + rows.append((INFO, s, f"{cached} portraits cached on disk")) return rows