v0.6.9: stop serving match-template crops as hero portraits.
Export and OSS upload require wide Heroes-page cards only; bump cache stamp to wide2 so clients drop polluted squares. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -84,6 +84,51 @@ def _build_staging() -> tuple[Path, dict[str, int]]:
|
|||||||
return staging, counts
|
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]:
|
def _iter_files(root: Path) -> list[Path]:
|
||||||
files: list[Path] = []
|
files: list[Path] = []
|
||||||
for sub in ASSET_DIRS:
|
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:
|
def upload(bucket_name: str, *, force: bool = False) -> None:
|
||||||
staging, counts = _build_staging()
|
staging, counts = _build_staging()
|
||||||
try:
|
try:
|
||||||
|
_assert_wide_portraits(staging)
|
||||||
files = _iter_files(staging)
|
files = _iter_files(staging)
|
||||||
total_bytes = sum(f.stat().st_size for f in files)
|
total_bytes = sum(f.stat().st_size for f in files)
|
||||||
print(f"staging {staging} ({len(files)} files, {total_bytes / 1e6:.1f} MB)")
|
print(f"staging {staging} ({len(files)} files, {total_bytes / 1e6:.1f} MB)")
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ def check_integrity(dist: Path) -> None:
|
|||||||
tag = "MISSING (abort)" if required else "empty (warn)"
|
tag = "MISSING (abort)" if required else "empty (warn)"
|
||||||
problems.append(f" {sub}/: {tag}")
|
problems.append(f" {sub}/: {tag}")
|
||||||
hint = {
|
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",
|
"ability": "run: python fetch_hero_abilities.py --icons-only",
|
||||||
"item": "run: python fetch_hero_items.py",
|
"item": "run: python fetch_hero_items.py",
|
||||||
"attr": "assets/attr_icons is committed; check git checkout",
|
"attr": "assets/attr_icons is committed; check git checkout",
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ from shared.paths import (
|
|||||||
ROOT,
|
ROOT,
|
||||||
STREAMER_AVATARS,
|
STREAMER_AVATARS,
|
||||||
STREAMER_VIDEOS,
|
STREAMER_VIDEOS,
|
||||||
TEMPLATES_CDN,
|
|
||||||
UI_ICONS,
|
UI_ICONS,
|
||||||
WEB_DIST,
|
WEB_DIST,
|
||||||
)
|
)
|
||||||
@@ -56,7 +55,7 @@ from shared.paths import (
|
|||||||
from seo_prerender import DEFAULT_SITE_ORIGIN, write_seo_bundle
|
from seo_prerender import DEFAULT_SITE_ORIGIN, write_seo_bundle
|
||||||
from serve_relations import WEB_DIR, build_payload
|
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"
|
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
|
n_vid += 1
|
||||||
counts["streamer-video"] = n_vid
|
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 = out / "portrait"
|
||||||
portrait_dst.mkdir(exist_ok=True)
|
portrait_dst.mkdir(exist_ok=True)
|
||||||
n_portrait = 0
|
n_portrait = 0
|
||||||
|
missing: list[str] = []
|
||||||
for hero in payload.get("heroes") or []:
|
for hero in payload.get("heroes") or []:
|
||||||
key = hero.get("key")
|
key = hero.get("key")
|
||||||
if not key:
|
if not key:
|
||||||
continue
|
continue
|
||||||
src = HERO_PORTRAITS / f"{key}.png"
|
src = HERO_PORTRAITS / f"{key}.png"
|
||||||
if not src.is_file():
|
|
||||||
src = TEMPLATES_CDN / f"{key}.png"
|
|
||||||
if src.is_file():
|
if src.is_file():
|
||||||
shutil.copy2(src, portrait_dst / f"{key}.png")
|
shutil.copy2(src, portrait_dst / f"{key}.png")
|
||||||
n_portrait += 1
|
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():
|
for cell in ((payload.get("patch_lookup") or {}).get("heroes") or {}).values():
|
||||||
key = cell.get("key") if isinstance(cell, dict) else None
|
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
|
continue
|
||||||
src = HERO_PORTRAITS / f"{key}.png"
|
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")
|
shutil.copy2(src, portrait_dst / f"{key}.png")
|
||||||
n_portrait += 1
|
n_portrait += 1
|
||||||
counts["portrait"] = n_portrait
|
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
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -31,7 +31,8 @@ function attrIconSrc(key) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function portraitSrc(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) {
|
function abilityIconSrc(abilityKey) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/* Local defaults; production export overwrites via export_relations_site.py. */
|
/* 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 SITE_ORIGIN = "";
|
||||||
var ABILITY_VIDEO_BASE = "";
|
var ABILITY_VIDEO_BASE = "";
|
||||||
var STATIC_ASSET_BASE = "";
|
var STATIC_ASSET_BASE = "";
|
||||||
|
|||||||
@@ -43,8 +43,8 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<link rel="icon" href="/ui-icon/dota2_logo.png" type="image/png" />
|
<link rel="icon" href="/ui-icon/dota2_logo.png" type="image/png" />
|
||||||
<link rel="stylesheet" href="/style.css?v=0.6.8" />
|
<link rel="stylesheet" href="/style.css?v=0.6.9" />
|
||||||
<script src="/mobile-gate.js?v=0.6.8"></script>
|
<script src="/mobile-gate.js?v=0.6.9"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1 class="sr-only">DOTA2 上分帝</h1>
|
<h1 class="sr-only">DOTA2 上分帝</h1>
|
||||||
@@ -233,8 +233,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<footer class="heroes-site-foot" id="heroes-site-foot" aria-hidden="true"></footer>
|
<footer class="heroes-site-foot" id="heroes-site-foot" aria-hidden="true"></footer>
|
||||||
|
|
||||||
<script src="/config.js?v=0.6.8"></script>
|
<script src="/config.js?v=0.6.9"></script>
|
||||||
<script src="/router.js?v=0.6.8"></script>
|
<script src="/router.js?v=0.6.9"></script>
|
||||||
<script src="/app.js?v=0.6.8"></script>
|
<script src="/app.js?v=0.6.9"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+13
-5
@@ -1029,16 +1029,24 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
else:
|
else:
|
||||||
self._send(200, fpath.read_bytes(), ctype)
|
self._send(200, fpath.read_bytes(), ctype)
|
||||||
return
|
return
|
||||||
if path.startswith("/portrait/") or path.startswith("/cdn/"):
|
if path.startswith("/portrait/"):
|
||||||
prefix = "/portrait/" if path.startswith("/portrait/") else "/cdn/"
|
key = path[len("/portrait/") :]
|
||||||
key = path[len(prefix) :]
|
|
||||||
if "/" in key or "\\" in key or not key.endswith(".png"):
|
if "/" in key or "\\" in key or not key.endswith(".png"):
|
||||||
self._json(400, {"error": "bad path"})
|
self._json(400, {"error": "bad path"})
|
||||||
return
|
return
|
||||||
# Prefer official Heroes-page cards; fall back to match templates.
|
# Wide Heroes-page cards only — never serve match templates here.
|
||||||
fpath = HERO_PORTRAITS / key
|
fpath = HERO_PORTRAITS / key
|
||||||
if not fpath.is_file():
|
if not fpath.is_file():
|
||||||
fpath = TEMPLATES_CDN / key
|
self.send_error(404)
|
||||||
|
return
|
||||||
|
self._send(200, fpath.read_bytes(), "image/png")
|
||||||
|
return
|
||||||
|
if path.startswith("/cdn/"):
|
||||||
|
key = path[len("/cdn/") :]
|
||||||
|
if "/" in key or "\\" in key or not key.endswith(".png"):
|
||||||
|
self._json(400, {"error": "bad path"})
|
||||||
|
return
|
||||||
|
fpath = TEMPLATES_CDN / key
|
||||||
if not fpath.is_file():
|
if not fpath.is_file():
|
||||||
self.send_error(404)
|
self.send_error(404)
|
||||||
return
|
return
|
||||||
|
|||||||
Reference in New Issue
Block a user