diff --git a/web/_oss_static_assets.py b/web/_oss_static_assets.py index 1ba1612..b4c3092 100644 --- a/web/_oss_static_assets.py +++ b/web/_oss_static_assets.py @@ -84,6 +84,51 @@ def _build_staging() -> tuple[Path, dict[str, int]]: return staging, counts +def _png_size(path: Path) -> tuple[int, int] | None: + """Return (width, height) for a PNG, or None if unreadable.""" + try: + raw = path.read_bytes() + except OSError: + return None + if len(raw) < 24 or raw[:8] != b"\x89PNG\r\n\x1a\n": + return None + # IHDR: length(4) + type(4) + width(4) + height(4) + if raw[12:16] != b"IHDR": + return None + w = int.from_bytes(raw[16:20], "big") + h = int.from_bytes(raw[20:24], "big") + return w, h + + +def _assert_wide_portraits(staging: Path) -> None: + """Refuse top-bar match crops (square ~96×96) before any OSS put. + + Official cards are usually 256×144; Steam occasionally ships half-res + 128×72 landscape (still fine for the grid). Match templates are square. + """ + portrait_dir = staging / "portrait" + if not portrait_dir.is_dir(): + raise SystemExit("staging missing portrait/ — run fetch_hero_portraits.py") + bad: list[str] = [] + for path in sorted(portrait_dir.glob("*.png")): + size = _png_size(path) + if size is None: + bad.append(f"{path.name}: unreadable") + continue + w, h = size + # Landscape Heroes cards: aspect ≈ 16:9. Match CDN faces are square. + if h <= 0 or w / h < 1.4: + bad.append(f"{path.name}: {w}x{h}") + if bad: + sample = "; ".join(bad[:6]) + more = f" (+{len(bad) - 6} more)" if len(bad) > 6 else "" + raise SystemExit( + f"refusing to upload {len(bad)} non-wide portrait(s) " + f"(need landscape aspect ≥1.4, not match-template squares): " + f"{sample}{more}. Run: python web/fetch_hero_portraits.py" + ) + + def _iter_files(root: Path) -> list[Path]: files: list[Path] = [] for sub in ASSET_DIRS: @@ -97,6 +142,7 @@ def _iter_files(root: Path) -> list[Path]: def upload(bucket_name: str, *, force: bool = False) -> None: staging, counts = _build_staging() try: + _assert_wide_portraits(staging) files = _iter_files(staging) total_bytes = sum(f.stat().st_size for f in files) print(f"staging {staging} ({len(files)} files, {total_bytes / 1e6:.1f} MB)") diff --git a/web/deploy_relations.py b/web/deploy_relations.py index a0aefc9..352e646 100644 --- a/web/deploy_relations.py +++ b/web/deploy_relations.py @@ -202,7 +202,7 @@ def check_integrity(dist: Path) -> None: tag = "MISSING (abort)" if required else "empty (warn)" problems.append(f" {sub}/: {tag}") hint = { - "portrait": "run: python fetch_cdn_templates.py && python fetch_hero_portraits.py", + "portrait": "run: python fetch_hero_portraits.py", "ability": "run: python fetch_hero_abilities.py --icons-only", "item": "run: python fetch_hero_items.py", "attr": "assets/attr_icons is committed; check git checkout", diff --git a/web/export_relations_site.py b/web/export_relations_site.py index 70df83c..76172aa 100644 --- a/web/export_relations_site.py +++ b/web/export_relations_site.py @@ -48,7 +48,6 @@ from shared.paths import ( ROOT, STREAMER_AVATARS, STREAMER_VIDEOS, - TEMPLATES_CDN, UI_ICONS, WEB_DIST, ) @@ -56,7 +55,7 @@ from shared.paths import ( from seo_prerender import DEFAULT_SITE_ORIGIN, write_seo_bundle from serve_relations import WEB_DIR, build_payload -SITE_VERSION = "0.6.8" +SITE_VERSION = "0.6.9" DEFAULT_OSS_BASE = "https://climperor.oss-cn-shanghai.aliyuncs.com" @@ -106,28 +105,40 @@ def populate_static_assets(out: Path, payload: dict) -> dict[str, int]: n_vid += 1 counts["streamer-video"] = n_vid + # Wide Heroes-page cards only — never fall back to pc/templates/cdn + # (96×96 match crops). That fallback previously overwrote OSS portraits. portrait_dst = out / "portrait" portrait_dst.mkdir(exist_ok=True) n_portrait = 0 + missing: list[str] = [] for hero in payload.get("heroes") or []: key = hero.get("key") if not key: continue src = HERO_PORTRAITS / f"{key}.png" - if not src.is_file(): - src = TEMPLATES_CDN / f"{key}.png" if src.is_file(): shutil.copy2(src, portrait_dst / f"{key}.png") n_portrait += 1 + else: + missing.append(key) + # Patch-only units (e.g. spirit_bear) are optional; copy when present. for cell in ((payload.get("patch_lookup") or {}).get("heroes") or {}).values(): key = cell.get("key") if isinstance(cell, dict) else None - if not key: + if not key or (portrait_dst / f"{key}.png").is_file(): continue src = HERO_PORTRAITS / f"{key}.png" - if src.is_file() and not (portrait_dst / f"{key}.png").is_file(): + if src.is_file(): shutil.copy2(src, portrait_dst / f"{key}.png") n_portrait += 1 counts["portrait"] = n_portrait + if missing: + sample = ", ".join(missing[:8]) + more = f" (+{len(missing) - 8} more)" if len(missing) > 8 else "" + raise SystemExit( + f"missing {len(missing)} wide hero portrait(s) under " + f"{HERO_PORTRAITS}: {sample}{more}. " + "Run: python web/fetch_hero_portraits.py" + ) return counts diff --git a/web/frontend/app.js b/web/frontend/app.js index 09f2b12..2faa00b 100644 --- a/web/frontend/app.js +++ b/web/frontend/app.js @@ -31,7 +31,8 @@ function attrIconSrc(key) { } function portraitSrc(key) { - return assetUrl(`portrait/${encodeURIComponent(key)}.png?v=wide`); + // Cache-bust if OSS was briefly polluted with 96×96 match crops. + return assetUrl(`portrait/${encodeURIComponent(key)}.png?v=wide2`); } function abilityIconSrc(abilityKey) { diff --git a/web/frontend/config.js b/web/frontend/config.js index 6b5998b..0140075 100644 --- a/web/frontend/config.js +++ b/web/frontend/config.js @@ -1,5 +1,5 @@ /* Local defaults; production export overwrites via export_relations_site.py. */ -var SITE_VERSION = "0.6.8"; +var SITE_VERSION = "0.6.9"; var SITE_ORIGIN = ""; var ABILITY_VIDEO_BASE = ""; var STATIC_ASSET_BASE = ""; diff --git a/web/frontend/index.html b/web/frontend/index.html index 13d1331..6a48b29 100644 --- a/web/frontend/index.html +++ b/web/frontend/index.html @@ -43,8 +43,8 @@ } - - + +