fuck you php

This commit is contained in:
esef committed 2026-10-21 22:59:39 +03:00
1 parent 810a9a6b12
commit ff579807f1
4 files changed
+111 -18

No files matched your search

+8
View File
@@ -8,6 +8,7 @@ _PKG_DIR = Path(__file__).parent
_DATA_DIR = _PKG_DIR.parent / "data" _DATA_DIR = _PKG_DIR.parent / "data"
BUNDLED_DB = _PKG_DIR / "ba_students.json" BUNDLED_DB = _PKG_DIR / "ba_students.json"
BANNER_FILE = _DATA_DIR / "ba_banner_state.json" BANNER_FILE = _DATA_DIR / "ba_banner_state.json"
PORTRAIT_CACHE_DIR = _DATA_DIR / "portraits"
ASSETS_DIR = _PKG_DIR / "assets" ASSETS_DIR = _PKG_DIR / "assets"
BG_PATH = ASSETS_DIR / "gacha_bg.png" BG_PATH = ASSETS_DIR / "gacha_bg.png"
GACHA_ANIM_PATH = ASSETS_DIR / "Arona_Blue_Gacha_Animation.gif" 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" BANNER_API = "https://api.ennead.cc/buruaka/banner"
CHARACTER_API = "https://api.ennead.cc/buruaka/character" CHARACTER_API = "https://api.ennead.cc/buruaka/character"
PORTRAIT_CDN = "https://schaledb.com/images" 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_PORTRAIT_BASE = "https://bluearchive.wiki/wiki/Special:FilePath/"
WIKI_VARIANT_MAP: dict[str, str] = { WIKI_VARIANT_MAP: dict[str, str] = {
"Bunny": "Bunny Girl", "Bunny": "Bunny Girl",
@@ -80,6 +84,10 @@ STAR_BANNER_H = 36
NAME_STRIP_H = 42 NAME_STRIP_H = 42
PORTRAIT_TOP = STAR_BANNER_H + 4 PORTRAIT_TOP = STAR_BANNER_H + 4
PORTRAIT_H = CARD_H - PORTRAIT_TOP - NAME_STRIP_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 ─────────────────────────────────────────────────────── # ── Inventory display ───────────────────────────────────────────────────────
PAGE_SIZE = 10 PAGE_SIZE = 10
+5 -1
View File
@@ -355,8 +355,12 @@ class StudentDB:
name = f"{base.strip()} ({variant})" name = f"{base.strip()} ({variant})"
return WIKI_PORTRAIT_BASE + quote(f"Portrait {name}.png", safe="") 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: 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" return f"{PORTRAIT_CDN}/student/portrait/{student['Id']}.webp"
+83 -11
View File
@@ -6,6 +6,9 @@ import functools
import io import io
import logging import logging
import math import math
import os
import time
import uuid
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -21,7 +24,9 @@ from .constants import (
HEADER_Y, FOOTER_Y, HEADER_Y, FOOTER_Y,
STAR_BANNER_H, NAME_STRIP_H, PORTRAIT_TOP, PORTRAIT_H, STAR_BANNER_H, NAME_STRIP_H, PORTRAIT_TOP, PORTRAIT_H,
RARITY_COLOR, RARITY_BG, RARITY_COLOR, RARITY_BG,
BUST_HEIGHT_FRAC, BUST_HEAD_FRAC,
BG_PATH, SPARK_TARGET, BG_PATH, SPARK_TARGET,
PORTRAIT_CACHE_DIR, WIKI_RETRY_HOURS,
FONT_PATHS, FONT_PATHS,
) )
@@ -47,20 +52,87 @@ FOOTER_FONT = _get_font(18, bold=False)
# ── Image fetching ───────────────────────────────────────────────────────── # ── 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]: async def _fetch_portrait(session: aiohttp.ClientSession, student: dict) -> Optional[Image.Image]:
"""Download a student's portrait (wiki bust art first, full sprite fallback).""" """A student's portrait: disk cache, else wiki art, collection art, sprite."""
for url in (db.cdn_portrait(student), db.cdn_sprite(student)): 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: try:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp: async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp:
if resp.status == 200: if resp.status != 200:
data = await resp.read() continue
img = Image.open(io.BytesIO(data)).convert("RGBA") data = await resp.read()
# Full-body art is tall; crop the upper portion to fit the card. img = Image.open(io.BytesIO(data)).convert("RGBA")
p_w, p_h = img.size if kind == "sprite":
crop_h = int(p_w * 1.20) img = _bust_crop(img)
if crop_h < p_h: elif kind == "wiki":
img = img.crop((0, 0, p_w, crop_h)) # Wiki art is a face shot; only trim it if it is very tall.
return img 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: except Exception:
log.debug("portrait fetch failed via %s", url) log.debug("portrait fetch failed via %s", url)
+15 -6
View File
@@ -17,7 +17,7 @@ from PIL import Image
from . import db as gacha_db from . import db as gacha_db
from .data import db, fetch_banners, banner_unavailable_reason from .data import db, fetch_banners, banner_unavailable_reason
from .gacha_renderer import render_pull 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 = "✅", "⚠️", "❌", "ℹ️" OK, WARN, FAIL, INFO = "✅", "⚠️", "❌", "ℹ️"
REFRESH_INTERVAL = 6 * 3600 # must match BlueArchiveGacha._periodic_refresh 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]: 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" s = "Portraits"
if not students: if not students:
return [(INFO, s, "No live rate-up students to probe")] 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 timeout = aiohttp.ClientTimeout(total=30, connect=5) # same as render_pull
async with aiohttp.ClientSession(timeout=timeout) as session: async with aiohttp.ClientSession(timeout=timeout) as session:
results = await asyncio.gather(*( 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 in sample
)) ))
for st, (wiki, sprite) in zip(sample, results): for st, (wiki, collection, sprite) in zip(sample, results):
if wiki is None: if wiki is None:
rows.append((OK, s, f"{st['Name']}: wiki art OK")) 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: 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: 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 return rows