v0.4.2: host preview icons on OSS for slim Pages deploys.

Add STATIC_ASSET_BASE so Cloudflare Pages ships ~2MB HTML/JS/data; icons served from climperor OSS via _oss_static_assets.py. New machines can deploy without local fetch scripts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
voson
2026-07-27 21:28:01 +08:00
co-authored by Cursor
parent 74deb17888
commit 9898dd39fb
9 changed files with 384 additions and 93 deletions
+81 -49
View File
@@ -2,7 +2,7 @@
Usage:
python export_relations_site.py [--out dist/relations] [--with-videos]
[--ability-video-base URL]
[--ability-video-base URL] [--static-asset-base URL]
Copies web/relations/ + a snapshot of the /api/data payload (data.json) +
the referenced image assets into one directory, ready for any static host
@@ -16,6 +16,8 @@ Notes:
- Ability videos (several GB locally) are skipped unless --with-videos is
given; production deploys point the UI at OSS via --ability-video-base
(or ABILITY_VIDEO_BASE env) instead of bundling videos into Pages.
- When --static-asset-base is set, image dirs are omitted from dist/ and the
UI loads icons/portraits from OSS (see _oss_static_assets.py to sync).
"""
from __future__ import annotations
@@ -39,7 +41,8 @@ from common import (
)
from serve_relations import WEB_DIR, build_payload
SITE_VERSION = "0.4.1"
SITE_VERSION = "0.4.2"
DEFAULT_OSS_BASE = "https://climperor.oss-cn-shanghai.aliyuncs.com"
def copy_glob(src: Path, dst: Path, pattern: str = "*.png") -> int:
@@ -54,17 +57,57 @@ def copy_glob(src: Path, dst: Path, pattern: str = "*.png") -> int:
return n
def write_config_js(out: Path, ability_video_base: str, site_version: str) -> None:
"""Write config.js consumed by app.js (SITE_VERSION, ABILITY_VIDEO_BASE)."""
base = (ability_video_base or "").strip().rstrip("/")
def populate_static_assets(out: Path, payload: dict) -> dict[str, int]:
"""Copy preview image dirs into ``out`` (portrait/item/ability/...)."""
counts = {
"attr": copy_glob(ATTR_ICONS, out / "attr"),
"item": copy_glob(ITEM_ICONS, out / "item"),
"item-cat": copy_glob(ITEM_CAT_ICONS, out / "item-cat", "itemcat_*.png"),
"ability": copy_glob(ABILITY_ICONS, out / "ability"),
"ui-icon": copy_glob(UI_ICONS, out / "ui-icon"),
}
portrait_dst = out / "portrait"
portrait_dst.mkdir(exist_ok=True)
n_portrait = 0
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
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:
continue
src = HERO_PORTRAITS / f"{key}.png"
if src.is_file() and not (portrait_dst / f"{key}.png").is_file():
shutil.copy2(src, portrait_dst / f"{key}.png")
n_portrait += 1
counts["portrait"] = n_portrait
return counts
def write_config_js(
out: Path,
*,
ability_video_base: str,
static_asset_base: str,
site_version: str,
) -> None:
"""Write config.js consumed by app.js."""
video = (ability_video_base or "").strip().rstrip("/")
static = (static_asset_base or "").strip().rstrip("/")
ver = (site_version or "").strip()
# JSON-encode so values are safe JS string literals.
ver_lit = json.dumps(ver, ensure_ascii=False)
base_lit = json.dumps(base, ensure_ascii=False)
(out / "config.js").write_text(
f"/* generated by export_relations_site.py — do not edit */\n"
f"var SITE_VERSION = {ver_lit};\n"
f"var ABILITY_VIDEO_BASE = {base_lit};\n",
"/* generated by export_relations_site.py — do not edit */\n"
f"var SITE_VERSION = {json.dumps(ver, ensure_ascii=False)};\n"
f"var ABILITY_VIDEO_BASE = {json.dumps(video, ensure_ascii=False)};\n"
f"var STATIC_ASSET_BASE = {json.dumps(static, ensure_ascii=False)};\n",
encoding="utf-8",
)
@@ -85,6 +128,13 @@ def main() -> None:
help="public base URL for ability demos (writes config.js); "
"falls back to ABILITY_VIDEO_BASE env, else empty (same-origin)",
)
ap.add_argument(
"--static-asset-base",
default=None,
help="public base URL for icons/portraits (writes config.js); "
"when set, image dirs are not copied into dist; "
"falls back to STATIC_ASSET_BASE env, else empty",
)
args = ap.parse_args()
video_base = (
@@ -92,6 +142,11 @@ def main() -> None:
if args.ability_video_base is not None
else os.environ.get("ABILITY_VIDEO_BASE", "")
)
static_base = (
args.static_asset_base
if args.static_asset_base is not None
else os.environ.get("STATIC_ASSET_BASE", "")
)
out = Path(args.out).resolve()
if out == ROOT.resolve() or out.parent == out:
@@ -100,54 +155,28 @@ def main() -> None:
shutil.rmtree(out)
out.mkdir(parents=True)
# Frontend (uses relative paths — works from any sub-path).
# `_headers` is a Cloudflare Pages config file (cache TTLs for HTML/JSON/JS).
for name in ("index.html", "router.js", "app.js", "style.css", "_headers"):
src = WEB_DIR / name
if src.is_file():
shutil.copy2(src, out / name)
write_config_js(out, video_base, SITE_VERSION)
write_config_js(
out,
ability_video_base=video_base,
static_asset_base=static_base,
site_version=SITE_VERSION,
)
# Data snapshot (same payload as serve_relations /api/data).
payload = build_payload()
(out / "data.json").write_text(
json.dumps(payload, ensure_ascii=False, separators=(",", ":")),
encoding="utf-8",
)
# Assets.
counts = {
"attr": copy_glob(ATTR_ICONS, out / "attr"),
"item": copy_glob(ITEM_ICONS, out / "item"),
"item-cat": copy_glob(ITEM_CAT_ICONS, out / "item-cat", "itemcat_*.png"),
"ability": copy_glob(ABILITY_ICONS, out / "ability"),
"ui-icon": copy_glob(UI_ICONS, out / "ui-icon"),
}
# Portraits: official wide cards, fall back to CDN match templates.
portrait_dst = out / "portrait"
portrait_dst.mkdir(exist_ok=True)
n_portrait = 0
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
# Non-hero units in patch lookup (e.g. 熊灵) use bundled portraits too.
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:
continue
src = HERO_PORTRAITS / f"{key}.png"
if src.is_file() and not (portrait_dst / f"{key}.png").is_file():
shutil.copy2(src, portrait_dst / f"{key}.png")
n_portrait += 1
counts["portrait"] = n_portrait
if static_base:
counts = {name: 0 for name in ("attr", "item", "item-cat", "ability", "ui-icon", "portrait")}
print(" static assets: omitted (STATIC_ASSET_BASE set — served from OSS)")
else:
counts = populate_static_assets(out, payload)
n_videos = 0
if args.with_videos and ABILITY_VIDEOS.is_dir():
@@ -160,7 +189,10 @@ def main() -> None:
print(f" {name}/: {n} files")
if args.with_videos:
print(f" ability-video/: {n_videos} files")
print(f" config.js SITE_VERSION={SITE_VERSION!r} ABILITY_VIDEO_BASE={video_base!r}")
print(
f" config.js SITE_VERSION={SITE_VERSION!r} "
f"ABILITY_VIDEO_BASE={video_base!r} STATIC_ASSET_BASE={static_base!r}"
)
print(f" total: {total / 1e6:.1f} MB")
print(f"local check: python -m http.server -d {out} 8080")
print("deploy: upload the directory to any static host (Pages / nginx / ...)")