From 53d08d53a3f4845bd15e0fed0da47b8b58073dad Mon Sep 17 00:00:00 2001 From: Brandon Nguyen <58405975+Bratah123@users.noreply.github.com> Date: Thu, 16 Jul 2026 22:48:21 -0700 Subject: [PATCH] FEAT: Add better dynamic pages features --- spirit/assets/landing_pages/README.md | 24 ++ spirit/server/admin/dashboard.html | 203 +++++++++++++--- spirit/server/admin_api.py | 59 ++++- spirit/server/auto_bundle.py | 7 + spirit/server/dynamic_pages.py | 333 +++++++++++++++++++++++++- spirit/server/manifest_manager.py | 22 +- tests/test_dynamic_pages.py | 63 +++++ 7 files changed, 671 insertions(+), 40 deletions(-) create mode 100644 spirit/assets/landing_pages/README.md diff --git a/spirit/assets/landing_pages/README.md b/spirit/assets/landing_pages/README.md new file mode 100644 index 0000000..0a9b2a9 --- /dev/null +++ b/spirit/assets/landing_pages/README.md @@ -0,0 +1,24 @@ +# Custom landing-page artwork + +Add a 16:9 PNG here to make it available in **Admin Dashboard → Dynamic +Pages → Browse game artwork**. A 1920×1080 source is recommended. + +Spirit uses the PNG filename as the Unity asset name, normalizes it to lowercase, +and adds `_landingpage` when needed. For example: + +```text +summer-event.png → LandingPage/summer_event_landingpage +``` + +The server compiles every PNG in this folder into +`bundleCache/en_US_LandingPage_Custom` at startup. The dashboard's **Rescan +bundles** button performs the same check, and **Add custom artwork** uploads a +file here and rebuilds the bundle immediately. + +Images that are not exactly 16:9 are center-cropped. The compiler then packs the +visible image into the original game's 2048×2048 LandingPage texture layout, so +the browser preview and Unity client use the same crop. + +The first build needs one original `en_US_LandingPage_*` bundle as a template. +Place an original cache under `original_game_cache`, configure `PTCGO_CACHE_DIR`, +or install game artwork through the dashboard first. diff --git a/spirit/server/admin/dashboard.html b/spirit/server/admin/dashboard.html index 81b040b..5cf16bf 100644 --- a/spirit/server/admin/dashboard.html +++ b/spirit/server/admin/dashboard.html @@ -151,6 +151,7 @@ .asset-selected-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; } .asset-controls { min-width: 0; } .asset-controls input { width: 100%; margin-bottom: 7px; } + .asset-action-row { display: flex; flex-wrap: wrap; gap: 7px; } .asset-status { margin-top: 5px; min-height: 18px; } .page-form-actions { display: flex; gap: 8px; align-items: center; padding-top: 14px; margin-top: 14px; border-top: 1px solid var(--line); } .page-form-actions .save-state { flex: 1; font-size: 11px; color: var(--muted); } @@ -215,20 +216,33 @@ #pageAssetOverlay { position: fixed; inset: 0; z-index: 35; display: none; align-items: center; justify-content: center; padding: 3vh 3vw; background: rgba(4,8,14,.82); } #pageAssetOverlay.open { display: flex; } - .page-asset-picker { width: min(1120px,95vw); max-height: 92vh; display: flex; flex-direction: column; border: 1px solid #435778; border-radius: 12px; overflow: hidden; background: var(--panel); box-shadow: 0 20px 60px rgba(0,0,0,.55); } + .page-asset-picker { width: min(1120px,95vw); height: min(900px,92vh); min-height: 520px; display: flex; flex-direction: column; border: 1px solid #435778; border-radius: 12px; overflow: hidden; background: var(--panel); box-shadow: 0 20px 60px rgba(0,0,0,.55); } .asset-picker-head { display: flex; align-items: center; gap: 10px; padding: 14px 16px; border-bottom: 1px solid var(--line); } .asset-picker-head > div:first-child { flex: 1; } .asset-picker-head h3 { color: var(--accent); font-size: 15px; } + details.custom-artwork-panel { flex: 0 0 auto; border-bottom: 1px solid var(--line); background: rgba(61,125,202,.06); } + details.custom-artwork-panel > summary { cursor: pointer; padding: 10px 16px; color: #bcd9f3; font-size: 12px; font-weight: 650; } + .custom-artwork-body { display: grid; grid-template-columns: 180px minmax(0,1fr); gap: 13px; padding: 2px 16px 14px; } + .custom-artwork-preview { aspect-ratio: 16/9; border: 1px dashed #4d6788; border-radius: 7px; overflow: hidden; display: flex; align-items: center; justify-content: center; color: var(--muted); font-size: 10px; background: #07111e; } + .custom-artwork-preview img { width: 100%; height: 100%; object-fit: cover; display: block; } + .custom-artwork-fields { min-width: 0; } + .custom-artwork-fields input[type="file"] { width: 100%; margin-bottom: 8px; } + .custom-upload-row { display: grid; grid-template-columns: minmax(180px,1fr) auto auto; align-items: center; gap: 8px; } + .custom-upload-row input[type="text"] { width: 100%; } + .custom-replace { display: flex; align-items: center; gap: 5px; color: var(--muted); font-size: 10px; white-space: nowrap; } + .custom-upload-help { margin-top: 7px; color: var(--muted); font-size: 10px; } + .custom-upload-status { min-height: 17px; margin-top: 5px; font-size: 10px; color: #9ec8ec; } .asset-picker-tools { display: grid; grid-template-columns: minmax(220px,1fr) auto; gap: 10px; padding: 12px 16px; } .asset-picker-tools input { width: 100%; } - .landing-asset-grid { overflow-y: auto; padding: 0 16px 16px; display: grid; grid-template-columns: repeat(auto-fill,minmax(190px,1fr)); gap: 10px; } - .landing-asset { border: 2px solid transparent; border-radius: 9px; background: var(--panel2); overflow: hidden; cursor: pointer; color: var(--text); text-align: left; } + .landing-asset-grid { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 0 16px 16px; display: flex; flex-direction: column; gap: 12px; scrollbar-gutter: stable; } + .landing-asset { flex: 0 0 auto; display: grid; grid-template-columns: minmax(340px,48%) minmax(0,1fr); align-items: stretch; border: 2px solid transparent; border-radius: 9px; background: var(--panel2); overflow: hidden; cursor: pointer; color: var(--text); text-align: left; padding: 0; } .landing-asset:hover { border-color: var(--accent2); } .landing-asset.sel { border-color: var(--accent); } - .landing-asset img { width: 100%; aspect-ratio: 16/9; object-fit: cover; display: block; background: #07111e; } - .landing-asset-info { padding: 7px 8px; } - .landing-asset-name { font-size: 11px; font-weight: 650; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } - .landing-asset-sub { display: flex; align-items: center; gap: 5px; color: var(--muted); font-size: 9px; margin-top: 2px; } + .landing-asset img { width: 100%; height: auto; aspect-ratio: 16/9; object-fit: cover; display: block; background: #07111e; } + .landing-asset-info { min-width: 0; padding: 14px 16px; display: flex; flex-direction: column; justify-content: center; } + .landing-asset-name { display: block; font-size: 14px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .landing-asset-path { display: block; color: #9ec8ec; font-size: 10px; margin: 5px 0 9px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .landing-asset-sub { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; color: var(--muted); font-size: 10px; } .asset-source-summary { color: var(--muted); font-size: 10px; padding: 0 16px 10px; } @media (max-width: 980px) { @@ -241,6 +255,10 @@ .asset-selection { grid-template-columns: 1fr; } .asset-selected-thumb { max-width: 220px; } .template-grid { grid-template-columns: 1fr; } + .page-asset-picker { min-height: 0; } + .custom-artwork-body, .custom-upload-row { grid-template-columns: 1fr; } + .custom-artwork-preview { max-width: 240px; } + .landing-asset { grid-template-columns: 1fr; } } @@ -460,7 +478,7 @@ - +
- +
+ + +
@@ -646,9 +667,25 @@ +
+ + Add your own landing-page artwork +
+
Choose an image
+
+ +
+ + + +
+
Use a 16:9 image (1920×1080 recommended). Spirit center-crops it, packs it into the native LandingPage texture layout, and builds a Unity bundle for players.
+
+
+
+
- +
Looking for LandingPage bundles...
@@ -919,10 +956,11 @@ async function deleteShopItem(id) { /* ---------------- Pages ---------------- */ const PAGE_FOREVER = 4102444800000; const PAGE_TEMPLATES = [ - {id:'LandingPageRight', label:'Artwork on right', sub:'Message and CTA on the left', visual:'visual-right', copy:'left', buttons:true}, - {id:'LandingPageLeft', label:'Artwork on left', sub:'Message and CTA on the right', visual:'visual-left', copy:'right', buttons:true}, - {id:'LandingPageRightNoButtons', label:'Artwork right, no CTA', sub:'Message on the left', visual:'visual-right no-buttons', copy:'left', buttons:false}, - {id:'LandingPageLeftNoButtons', label:'Artwork left, no CTA', sub:'Message on the right', visual:'visual-left no-buttons', copy:'right', buttons:false} + // Unity names these prefabs for the TEXT side, not the artwork side. + {id:'LandingPageLeft', label:'Artwork on right', sub:'Message and CTA on the left', visual:'visual-right', copy:'left', buttons:true}, + {id:'LandingPageRight', label:'Artwork on left', sub:'Message and CTA on the right', visual:'visual-left', copy:'right', buttons:true}, + {id:'LandingPageLeftNoButtons', label:'Artwork right, no CTA', sub:'Message on the left', visual:'visual-right no-buttons', copy:'left', buttons:false}, + {id:'LandingPageRightNoButtons', label:'Artwork left, no CTA', sub:'Message on the right', visual:'visual-left no-buttons', copy:'right', buttons:false} ]; const PAGE_CTA_LABELS = { shop:'VISIT SHOP', redeem:'REDEEM CODE', tournament:'VIEW EVENTS', trade:'OPEN TRADE', url:'LEARN MORE' @@ -934,6 +972,7 @@ let PAGE_ASSETS_LOADED = false; let PAGE_ASSET_REVISION = 0; let PAGE_BASE_CONTENT = {}; let PAGE_EDITOR_INITIALIZED = false; +let PAGE_CUSTOM_PREVIEW_URL = ''; const cloneJson = value => JSON.parse(JSON.stringify(value || {})); const pageAssetName = path => String(path || '').trim().replaceAll('\\', '/').split('/').pop(); @@ -954,7 +993,7 @@ function defaultDynamicContent(type='landing') { }; } return { - template: 'LandingPageRight', sortOrder: nextPageOrder(), startTime: 0, endTime: PAGE_FOREVER, + template: 'LandingPageLeft', sortOrder: nextPageOrder(), startTime: 0, endTime: PAGE_FOREVER, labels: {GameText: {token: 'spirit.dynamic_page.game_text', bundle: {en_US: ''}}}, images: {}, actions: {} }; @@ -1007,7 +1046,7 @@ function hydratePageEditor(page) { $('#pageSort').value = page.sort_order ?? content.sortOrder ?? nextPageOrder(); $('#pageEnabled').checked = page.enabled !== false; - const template = PAGE_TEMPLATES.some(t => t.id === content.template) ? content.template : 'LandingPageRight'; + const template = PAGE_TEMPLATES.some(t => t.id === content.template) ? content.template : 'LandingPageLeft'; $('#pageTemplate').value = template; $('#pageAssetPath').value = backgroundPath(content); $('#pageMessage').value = labelText(content, 'GameText'); @@ -1052,7 +1091,7 @@ function selectPageTemplate(template) { function pageTypeChanged() { const maintenance = $('#pageType').value === 'maintenance'; if (!maintenance && !PAGE_TEMPLATES.some(t => t.id === $('#pageTemplate').value)) { - $('#pageTemplate').value = 'LandingPageRight'; + $('#pageTemplate').value = 'LandingPageLeft'; } if (maintenance && !$('#pageStart').value && !$('#pageEnd').value) { const start = Math.floor(Date.now() / 60000) * 60000; @@ -1347,6 +1386,8 @@ function renderSelectedPageAsset() { } if (!meta) { $('#pageAssetStatus').innerHTML = `Custom path ${esc(name)}`; + } else if (meta.custom) { + $('#pageAssetStatus').innerHTML = `Custom · ready for players ${esc(meta.title)}`; } else if (meta.installed) { $('#pageAssetStatus').innerHTML = `Ready for players ${esc(meta.title)}`; } else { @@ -1404,30 +1445,36 @@ async function loadPageAssets(force=false) { try { const d = await api(force ? 'pages/assets/refresh' : 'pages/assets', force ? {} : undefined); if (force) PAGE_ASSET_REVISION += 1; - PAGE_ASSETS = d.assets || []; - PAGE_ASSET_BY_NAME = Object.fromEntries(PAGE_ASSETS.map(a => [a.name.toLowerCase(), a])); - PAGE_ASSETS_LOADED = true; - const installed = PAGE_ASSETS.filter(a => a.installed).length; - const sourceText = (d.sources || []).map(s => `${s.textures} from ${s.source}`).join(' + '); - const errorText = d.errors?.length - ? ` ${d.errors.length} bundle${d.errors.length === 1 ? '' : 's'} could not be read.` - : ''; - $('#pageAssetSourceSummary').textContent = PAGE_ASSETS.length - ? `${PAGE_ASSETS.length} artworks found (${installed} already server-ready). ${sourceText}.${errorText}` - : `No LandingPage bundles were found. Set PTCGO_CACHE_DIR or place the original cache in this repository.${errorText}`; - renderPageAssetGrid(); - renderSelectedPageAsset(); - renderPageLibrary(); - if (PAGE_EDITOR_INITIALIZED) pageEditorChanged(); + applyPageAssetCatalog(d); } catch(e) { $('#pageAssetSourceSummary').textContent = 'Could not scan landing-page bundles: ' + e.message; } } +function applyPageAssetCatalog(d) { + PAGE_ASSETS = d.assets || []; + PAGE_ASSET_BY_NAME = Object.fromEntries(PAGE_ASSETS.map(a => [a.name.toLowerCase(), a])); + PAGE_ASSETS_LOADED = true; + const installed = PAGE_ASSETS.filter(a => a.installed).length; + const custom = PAGE_ASSETS.filter(a => a.custom).length; + const sourceText = (d.sources || []).map(s => `${s.textures} from ${s.source}`).join(' + '); + const errorText = d.errors?.length + ? ` ${d.errors.length} bundle${d.errors.length === 1 ? '' : 's'} could not be read.` + : ''; + const customText = custom ? ` ${custom} custom.` : ''; + $('#pageAssetSourceSummary').textContent = PAGE_ASSETS.length + ? `${PAGE_ASSETS.length} artworks found (${installed} server-ready).${customText} ${sourceText}.${errorText}` + : `No LandingPage bundles were found. Set PTCGO_CACHE_DIR or place the original cache in this repository.${errorText}`; + renderPageAssetGrid(); + renderSelectedPageAsset(); + renderPageLibrary(); + if (PAGE_EDITOR_INITIALIZED) pageEditorChanged(); +} + async function refreshPageAssets() { PAGE_ASSETS_LOADED = false; await loadPageAssets(true); - toast(`Found ${PAGE_ASSETS.length} landing-page artworks`); + toast(`Found ${PAGE_ASSETS.length} landing-page artworks, including ${PAGE_ASSETS.filter(a => a.custom).length} custom`); } function openPageAssetPicker() { @@ -1439,6 +1486,90 @@ function openPageAssetPicker() { } function closePageAssetPicker() { $('#pageAssetOverlay').classList.remove('open'); } +function openCustomPageArtwork() { + openPageAssetPicker(); + $('#pageCustomArtworkPanel').open = true; + setTimeout(() => $('#pageCustomFile').focus(), 60); +} + +function customPageAssetName(filename) { + let name = String(filename || '').replace(/\.[^.]+$/, ''); + name = name.normalize('NFKD').replace(/[\u0300-\u036f]/g, ''); + name = name.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').toLowerCase(); + if (name && !name.endsWith('_landingpage')) name += '_landingpage'; + return name; +} + +function customPageFileChanged() { + const file = $('#pageCustomFile').files?.[0]; + const preview = $('#pageCustomPreview'); + $('#pageCustomStatus').textContent = ''; + if (PAGE_CUSTOM_PREVIEW_URL) URL.revokeObjectURL(PAGE_CUSTOM_PREVIEW_URL); + PAGE_CUSTOM_PREVIEW_URL = ''; + if (!file) { + preview.innerHTML = 'Choose an image'; + return; + } + const nameInput = $('#pageCustomName'); + if (!nameInput.value.trim() || nameInput.dataset.auto !== 'false') { + nameInput.value = customPageAssetName(file.name); + nameInput.dataset.auto = 'true'; + } + PAGE_CUSTOM_PREVIEW_URL = URL.createObjectURL(file); + preview.innerHTML = `Custom artwork preview`; + $('#pageCustomStatus').textContent = `${file.name} · ${(file.size / 1024 / 1024).toFixed(2)} MB`; +} + +function readPageImageDataUrl(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = () => reject(new Error('The browser could not read that image')); + reader.readAsDataURL(file); + }); +} + +async function uploadCustomPageArtwork() { + const file = $('#pageCustomFile').files?.[0]; + const name = $('#pageCustomName').value.trim(); + const status = $('#pageCustomStatus'); + if (!file) return toast('Choose an artwork image first', true); + if (!name) return toast('Give the custom artwork an asset name', true); + if (file.size > 12 * 1024 * 1024) return toast('Custom artwork must be 12 MB or smaller', true); + if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) return toast('Use a PNG, JPEG, or WebP image', true); + + const button = $('#pageCustomUploadButton'); + button.disabled = true; + button.textContent = 'Building Unity bundle...'; + status.textContent = 'Uploading and packing the native 2048×2048 LandingPage texture...'; + try { + const d = await api('pages/assets/custom', { + name, + image:await readPageImageDataUrl(file), + replace:$('#pageCustomReplace').checked + }); + PAGE_ASSET_REVISION += 1; + applyPageAssetCatalog(d); + const imported = d.asset?.catalog_asset; + if (!imported) throw new Error('The bundle built, but the new artwork was not cataloged'); + $('#pageCustomFile').value = ''; + $('#pageCustomName').value = ''; + $('#pageCustomName').dataset.auto = 'true'; + $('#pageCustomReplace').checked = false; + if (PAGE_CUSTOM_PREVIEW_URL) URL.revokeObjectURL(PAGE_CUSTOM_PREVIEW_URL); + PAGE_CUSTOM_PREVIEW_URL = ''; + $('#pageCustomPreview').innerHTML = 'Choose an image'; + selectPageAsset(imported.name); + toast(`${imported.title} is built, selected, and ready for players`); + } catch(e) { + status.textContent = e.message; + toast(e.message, true); + } finally { + button.disabled = false; + button.textContent = 'Build & add'; + } +} + function renderPageAssetGrid() { const grid = $('#pageAssetGrid'); if (!grid) return; @@ -1446,15 +1577,17 @@ function renderPageAssetGrid() { const filter = $('#pageAssetFilter')?.value || 'all'; const selected = pageAssetName($('#pageAssetPath')?.value || '').toLowerCase(); const matches = PAGE_ASSETS.filter(a => { + if (filter === 'custom' && !a.custom) return false; if (filter === 'installed' && !a.installed) return false; if (filter === 'source' && a.installed) return false; return !query || `${a.title} ${a.name} ${a.bundle}`.toLowerCase().includes(query); }); grid.innerHTML = matches.map(a => ` `).join('') || '
No artwork matches this filter.
'; } diff --git a/spirit/server/admin_api.py b/spirit/server/admin_api.py index 5407fb6..806f97d 100644 --- a/spirit/server/admin_api.py +++ b/spirit/server/admin_api.py @@ -1,9 +1,12 @@ +import base64 +import binascii import json import logging import os import re -from urllib.parse import unquote +from urllib.parse import unquote +from spirit.server import http_server from spirit.database import db_session, Account, TradeOffer from spirit.database import economy_data from spirit.game import season_manager @@ -32,6 +35,27 @@ def _err(status, message): return _json(status, {"ok": False, "error": message}) +def _decode_image_data_url(value): + if not isinstance(value, str): + raise ValueError("image data is required") + header, separator, encoded = value.partition(',') + allowed = { + 'data:image/png;base64', + 'data:image/jpeg;base64', + 'data:image/jpg;base64', + 'data:image/webp;base64', + } + if separator != ',' or header.lower() not in allowed: + raise ValueError("upload a PNG, JPEG, or WebP image") + max_encoded = ((dynamic_pages.MAX_CUSTOM_IMAGE_BYTES + 2) // 3) * 4 + if len(encoded) > max_encoded: + raise ValueError("custom artwork must be 12 MB or smaller") + try: + return base64.b64decode(encoded, validate=True) + except (binascii.Error, ValueError) as exc: + raise ValueError("image upload is not valid base64 data") from exc + + def _products_summary(): from spirit.game.scripts.products import loader as product_loader if not product_loader.products: @@ -493,7 +517,38 @@ def _dispatch(method, endpoint, data): if method == 'POST' and endpoint == 'pages/assets/refresh': dynamic_pages.invalidate_asset_catalog() - return _ok(dynamic_pages.asset_catalog_payload()) + try: + build = dynamic_pages.compile_custom_landing_bundle(force=True) + except dynamic_pages.PageValidationError as exc: + return _err(400, str(exc)) + dynamic_pages.invalidate_asset_catalog() + if build.get("built"): + http_server.register_asset_path(dynamic_pages.BUNDLE_CACHE_DIR) + return _ok({**dynamic_pages.asset_catalog_payload(), "custom_build": build}) + + if method == 'POST' and endpoint == 'pages/assets/custom': + try: + image_bytes = _decode_image_data_url(data.get("image")) + imported = dynamic_pages.save_custom_landing_image( + data.get("name", ""), + image_bytes, + replace=bool(data.get("replace", False)), + ) + except (ValueError, dynamic_pages.PageValidationError) as exc: + return _err(400, str(exc)) + + from spirit.server import http_server + http_server.register_asset_path(dynamic_pages.BUNDLE_CACHE_DIR) + catalog = dynamic_pages.asset_catalog_payload() + imported["catalog_asset"] = next( + ( + asset + for asset in catalog["assets"] + if asset["name"].casefold() == imported["name"].casefold() + ), + None, + ) + return _ok({"asset": imported, **catalog}) if method == 'GET' and endpoint.startswith('pages/assets/thumb/'): asset_name = unquote(endpoint[len('pages/assets/thumb/'):]) diff --git a/spirit/server/auto_bundle.py b/spirit/server/auto_bundle.py index d0e8786..bc10a89 100644 --- a/spirit/server/auto_bundle.py +++ b/spirit/server/auto_bundle.py @@ -10,6 +10,7 @@ from PIL import Image, ImageFilter from spirit.game.attributes import AttrID, TrainerType from spirit.game.scripts.cards import loader from spirit.server.auto_bundle_cosmetics import compile_all_cosmetics +from spirit.server import dynamic_pages ASSETS_DIR = "spirit/assets" BUNDLE_CACHE_DIR = os.path.join(ASSETS_DIR, "bundleCache") @@ -223,6 +224,12 @@ def check_and_generate_bundles() -> int: except Exception as e: logging.error(f"[AutoBundle] Failed to compile cosmetics: {e}") + logging.info("[AutoBundle] Checking custom landing-page artwork...") + try: + dynamic_pages.compile_custom_landing_bundle() + except Exception as e: + logging.error(f"[AutoBundle] Failed to compile custom landing pages: {e}") + logging.info("[AutoBundle] Checking for missing card AssetBundles...") if not os.path.exists(DEFAULT_TEMPLATE): diff --git a/spirit/server/dynamic_pages.py b/spirit/server/dynamic_pages.py index cead3b5..27ebb2d 100644 --- a/spirit/server/dynamic_pages.py +++ b/spirit/server/dynamic_pages.py @@ -14,16 +14,19 @@ players can load the same art that an admin selected in the preview. from __future__ import annotations import copy +import hashlib import io import logging import os +import re import shutil import threading +import unicodedata from collections import OrderedDict from pathlib import Path import UnityPy -from PIL import Image +from PIL import Image, ImageOps LANDING_TEMPLATES = { @@ -49,6 +52,14 @@ _PROJECT_DIR = _SPIRIT_DIR.parent BUNDLE_CACHE_DIR = _SPIRIT_DIR / "assets" / "bundleCache" EXTERNAL_CACHE_DIR = _SPIRIT_DIR / "assets" / "externalCache" ORIGINAL_CACHE_DIR = _PROJECT_DIR / "original_game_cache" +CUSTOM_IMAGE_DIR = _SPIRIT_DIR / "assets" / "landing_pages" +CUSTOM_BUNDLE_NAME = "en_US_LandingPage_Custom" +CUSTOM_BUNDLE_DIR = BUNDLE_CACHE_DIR / CUSTOM_BUNDLE_NAME +CUSTOM_BUNDLE_DATA_PATH = ( + CUSTOM_BUNDLE_DIR / "00000000000000000000000001000000" / "__data" +) +MAX_CUSTOM_IMAGE_BYTES = 12 * 1024 * 1024 +MAX_CUSTOM_IMAGE_PIXELS = 40_000_000 _catalog_lock = threading.RLock() _catalog: dict[str, dict] | None = None @@ -59,6 +70,8 @@ _image_lock = threading.RLock() _image_cache: OrderedDict[tuple, bytes] = OrderedDict() _IMAGE_CACHE_MAX = 128 +_custom_bundle_lock = threading.RLock() + class PageValidationError(ValueError): """Raised when a page would violate the Unity client's wire contract.""" @@ -150,6 +163,29 @@ def _asset_rank(asset: dict) -> tuple: ) +def _bundle_texture_path_ids(env) -> set[int] | None: + """Returns Texture2D PathIDs exported by an AssetBundle container. + + UnityPy exposes orphaned serialized objects too. Custom bundles are rebuilt + from a real landing bundle and may retain unused prototype objects, so only + container-exported textures belong in the dashboard/manifest catalog. + Environments without an AssetBundle object (including loose test fixtures) + intentionally return ``None`` and keep the historical scan behavior. + """ + for obj in env.objects: + if obj.type.name != "AssetBundle": + continue + try: + bundle = obj.read() + return { + int(info.asset.m_PathID) + for _, info in getattr(bundle, "m_Container", []) + } + except Exception: + return None + return None + + def _scan_catalog() -> tuple[dict[str, dict], list[dict], list[str]]: assets: dict[str, dict] = {} roots_public: list[dict] = [] @@ -180,9 +216,15 @@ def _scan_catalog() -> tuple[dict[str, dict], list[dict], list[str]]: try: stat = data_path.stat() env = UnityPy.load(str(data_path)) + exported_texture_ids = _bundle_texture_path_ids(env) for obj in env.objects: if obj.type.name != "Texture2D": continue + if ( + exported_texture_ids is not None + and int(obj.path_id) not in exported_texture_ids + ): + continue texture = obj.read() name = str(getattr(texture, "m_Name", "") or "").strip() if not name: @@ -197,6 +239,8 @@ def _scan_catalog() -> tuple[dict[str, dict], list[dict], list[str]]: "width": int(getattr(texture, "m_Width", 0) or 0), "height": int(getattr(texture, "m_Height", 0) or 0), "installed": bool(installed), + "custom": bundle_dir.name.casefold() + == CUSTOM_BUNDLE_NAME.casefold(), "source": source, "data_path": str(data_path), "bundle_dir": str(bundle_dir), @@ -253,7 +297,14 @@ def asset_catalog_payload() -> dict: roots = copy.deepcopy(_catalog_roots) errors = list(_catalog_errors) public_assets = [] - for item in sorted(catalog.values(), key=lambda a: (a["title"].casefold(), a["name"].casefold())): + for item in sorted( + catalog.values(), + key=lambda a: ( + not bool(a.get("custom")), + a["title"].casefold(), + a["name"].casefold(), + ), + ): public_assets.append( { key: item[key] @@ -265,6 +316,7 @@ def asset_catalog_payload() -> dict: "width", "height", "installed", + "custom", "source", ) } @@ -344,6 +396,283 @@ def render_asset_jpeg(name_or_path: str, variant: str = "preview") -> bytes | No return rendered +def normalize_custom_asset_name(value: str) -> str: + """Turns an upload/file name into a collision-resistant Unity asset name.""" + text = Path(str(value or "").strip().replace("\\", "/")).name + text = Path(text).stem + text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode("ascii") + text = re.sub(r"[^A-Za-z0-9]+", "_", text).strip("_").lower() + text = re.sub(r"_+", "_", text) + if not text: + raise PageValidationError("Custom artwork needs a file-safe asset name") + suffix = "_landingpage" + if not text.endswith(suffix): + text += suffix + if len(text) > 96: + text = text[: 96 - len(suffix)].rstrip("_") + suffix + return text + + +def _custom_source_images() -> list[tuple[str, Path]]: + if not CUSTOM_IMAGE_DIR.is_dir(): + return [] + sources: dict[str, Path] = {} + paths = ( + path + for path in CUSTOM_IMAGE_DIR.iterdir() + if path.is_file() and path.suffix.casefold() == ".png" + ) + for path in sorted(paths, key=lambda item: item.name.casefold()): + asset_name = normalize_custom_asset_name(path.name) + previous = sources.get(asset_name) + if previous is not None: + raise PageValidationError( + f"{previous.name} and {path.name} normalize to the same landing asset name" + ) + sources[asset_name] = path + return list(sources.items()) + + +def _prepare_custom_texture(image: Image.Image, size: tuple[int, int]) -> Image.Image: + """Packs a normal 16:9 image into the square texture sampled by the prefab.""" + width, height = size + x, y, crop_w, crop_h = _LANDING_UV_RECT + left = max(0, round(width * x)) + top = max(0, round(height * y)) + right = min(width, round(width * (x + crop_w))) + bottom = min(height, round(height * (y + crop_h))) + visible_size = (max(1, right - left), max(1, bottom - top)) + artwork = ImageOps.fit( + ImageOps.exif_transpose(image).convert("RGBA"), + visible_size, + method=Image.Resampling.LANCZOS, + centering=(0.5, 0.5), + ) + texture = Image.new("RGBA", size, (0, 0, 0, 255)) + texture.paste(artwork, (left, top)) + return texture + + +def _custom_bundle_template_path() -> Path: + catalog = _get_catalog() + candidates = [ + Path(item["data_path"]) + for item in catalog.values() + if item.get("bundle", "").casefold() != CUSTOM_BUNDLE_NAME.casefold() + ] + if candidates: + return sorted(candidates, key=lambda path: str(path).casefold())[0] + if CUSTOM_BUNDLE_DATA_PATH.is_file(): + return CUSTOM_BUNDLE_DATA_PATH + raise PageValidationError( + "A real LandingPage Unity bundle is required as the custom-art template. " + "Set PTCGO_CACHE_DIR or add the original game cache, then rescan." + ) + + +def _rename_custom_bundle_cab(env) -> None: + new_cab = f"CAB-{hashlib.md5(CUSTOM_BUNDLE_NAME.encode('utf-8')).hexdigest()}" + bundle_file = env.file + files = getattr(bundle_file, "files", None) + if isinstance(files, dict): + old_keys = [key for key in list(files) if str(key).startswith("CAB-")] + if old_keys: + serialized_file = files.pop(old_keys[0]) + for duplicate in old_keys[1:]: + files.pop(duplicate, None) + files[new_cab] = serialized_file + for asset in env.assets: + if hasattr(asset, "name"): + asset.name = new_cab + + +def compile_custom_landing_bundle(force: bool = False) -> dict: + """Builds all ``assets/landing_pages/*.png`` files into one Unity bundle. + + This mirrors the custom-card workflow: source PNGs remain easy to edit and + the ignored bundleCache output is regenerated on startup or from the admin + artwork rescan button. + """ + with _custom_bundle_lock: + sources = _custom_source_images() + if not sources: + return { + "built": False, + "assets": 0, + "bundle": CUSTOM_BUNDLE_NAME, + } + + newest_source = max( + [path.stat().st_mtime_ns for _, path in sources] + + [Path(__file__).stat().st_mtime_ns] + ) + if ( + not force + and CUSTOM_BUNDLE_DATA_PATH.is_file() + and CUSTOM_BUNDLE_DATA_PATH.stat().st_mtime_ns >= newest_source + ): + return { + "built": False, + "assets": len(sources), + "bundle": CUSTOM_BUNDLE_NAME, + } + + template_path = _custom_bundle_template_path() + env = UnityPy.load(str(template_path)) + + serialized_asset = None + asset_bundle_obj = None + texture_objects = [] + for asset in env.assets: + objects = list(asset.objects.values()) + bundle_obj = next( + (obj for obj in objects if obj.type.name == "AssetBundle"), None + ) + textures = [obj for obj in objects if obj.type.name == "Texture2D"] + if bundle_obj is not None and textures: + serialized_asset = asset + asset_bundle_obj = bundle_obj + texture_objects = textures + break + if serialized_asset is None or asset_bundle_obj is None or not texture_objects: + raise PageValidationError("LandingPage template has no editable Texture2D assets") + + asset_bundle = asset_bundle_obj.read() + prototype = texture_objects[0] + prototype_info = next( + ( + info + for _, info in asset_bundle.m_Container + if info.asset.m_PathID == prototype.path_id + ), + None, + ) + if prototype_info is None: + raise PageValidationError("LandingPage template has no texture container mapping") + + # Reuse existing texture slots first. Clone the prototype only when the + # custom collection is larger than its source template. + target_objects = texture_objects[: len(sources)] + next_path_id = max(serialized_asset.objects) + 1 + while len(target_objects) < len(sources): + cloned = copy.copy(prototype) + cloned.path_id = next_path_id + serialized_asset.objects[next_path_id] = cloned + target_objects.append(cloned) + next_path_id += 1 + + new_mappings = [] + for (asset_name, source_path), target_obj in zip(sources, target_objects): + texture = target_obj.read() + with Image.open(source_path) as source_image: + texture.image = _prepare_custom_texture( + source_image, + (int(texture.m_Width), int(texture.m_Height)), + ) + texture.m_Name = asset_name + target_obj.save_typetree(texture) + + mapping = copy.copy(prototype_info) + mapping.asset = copy.copy(prototype_info.asset) + mapping.asset.m_PathID = target_obj.path_id + new_mappings.append((asset_name, mapping)) + + asset_bundle.m_Container = new_mappings + asset_bundle.m_Name = CUSTOM_BUNDLE_NAME + asset_bundle_obj.save_typetree(asset_bundle) + + used_path_ids = {obj.path_id for obj in target_objects} + for obj in texture_objects: + if obj.path_id not in used_path_ids: + serialized_asset.objects.pop(obj.path_id, None) + + _rename_custom_bundle_cab(env) + CUSTOM_BUNDLE_DATA_PATH.parent.mkdir(parents=True, exist_ok=True) + temp_path = CUSTOM_BUNDLE_DATA_PATH.with_name( + f"{CUSTOM_BUNDLE_DATA_PATH.name}.{threading.get_ident()}.tmp" + ) + try: + temp_path.write_bytes(env.file.save(packer="lz4")) + os.replace(temp_path, CUSTOM_BUNDLE_DATA_PATH) + finally: + temp_path.unlink(missing_ok=True) + + invalidate_asset_catalog() + logging.info( + "[Admin] Built %s with %d custom landing artworks", + CUSTOM_BUNDLE_NAME, + len(sources), + ) + return { + "built": True, + "assets": len(sources), + "bundle": CUSTOM_BUNDLE_NAME, + } + + +def save_custom_landing_image( + name: str, + payload: bytes, + *, + replace: bool = False, +) -> dict: + """Validates, stores, and bundles an admin-uploaded landing-page image.""" + if not isinstance(payload, bytes) or not payload: + raise PageValidationError("Choose a PNG, JPEG, or WebP image to upload") + if len(payload) > MAX_CUSTOM_IMAGE_BYTES: + raise PageValidationError("Custom artwork must be 12 MB or smaller") + + asset_name = normalize_custom_asset_name(name) + existing_catalog_asset = _get_catalog().get(asset_name.casefold()) + if existing_catalog_asset and not existing_catalog_asset.get("custom"): + raise PageValidationError( + f"{asset_name} conflicts with original game artwork; choose another name" + ) + + try: + with Image.open(io.BytesIO(payload)) as uploaded: + width, height = uploaded.size + if width * height > MAX_CUSTOM_IMAGE_PIXELS: + raise PageValidationError("Custom artwork may not exceed 40 megapixels") + if width < 320 or height < 180: + raise PageValidationError("Custom artwork must be at least 320x180 pixels") + if uploaded.format not in {"PNG", "JPEG", "WEBP"}: + raise PageValidationError("Custom artwork must be PNG, JPEG, or WebP") + uploaded.load() + normalized_image = ImageOps.exif_transpose(uploaded).convert("RGBA") + except PageValidationError: + raise + except Exception as exc: + raise PageValidationError("The uploaded file is not a readable image") from exc + + with _custom_bundle_lock: + CUSTOM_IMAGE_DIR.mkdir(parents=True, exist_ok=True) + target = CUSTOM_IMAGE_DIR / f"{asset_name}.png" + if target.exists() and not replace: + raise PageValidationError( + f"{asset_name} already exists; enable Replace existing artwork to update it" + ) + temp_path = target.with_name(f".{target.name}.{threading.get_ident()}.tmp") + try: + normalized_image.save(temp_path, format="PNG", optimize=True) + os.replace(temp_path, target) + finally: + temp_path.unlink(missing_ok=True) + + build = compile_custom_landing_bundle(force=True) + + asset = _get_catalog().get(asset_name.casefold()) + if asset is None: + raise PageValidationError("Custom artwork bundle was built but could not be indexed") + return { + "name": asset_name, + "request_path": f"LandingPage/{asset_name}", + "width": width, + "height": height, + "build": build, + } + + def _background_asset_paths(content: dict) -> set[str]: paths: set[str] = set() images = content.get("images") diff --git a/spirit/server/manifest_manager.py b/spirit/server/manifest_manager.py index b8e1756..ffce329 100644 --- a/spirit/server/manifest_manager.py +++ b/spirit/server/manifest_manager.py @@ -162,9 +162,29 @@ class ManifestManager: prefix = "vstartoken" elif "landingpage" in lower_entry: prefix = "LandingPage" - + + exported_texture_ids = None + if "landingpage" in lower_entry: + for obj in env.objects: + if obj.type.name != "AssetBundle": + continue + try: + bundle = obj.read() + exported_texture_ids = { + int(info.asset.m_PathID) + for _, info in bundle.m_Container + } + except Exception: + exported_texture_ids = None + break + for obj in env.objects: if obj.type.name == "Texture2D": + if ( + exported_texture_ids is not None + and int(obj.path_id) not in exported_texture_ids + ): + continue tex_name = obj.read().m_Name.lower() exact_assets.append(tex_name) if prefix: diff --git a/tests/test_dynamic_pages.py b/tests/test_dynamic_pages.py index 90d6bab..bccb062 100644 --- a/tests/test_dynamic_pages.py +++ b/tests/test_dynamic_pages.py @@ -262,6 +262,69 @@ class DynamicPageAssetTests(unittest.TestCase): self.assertFalse(installed) self.assertEqual(unresolved, ["Custom/preview_art"]) + def test_custom_art_is_packed_into_the_native_visible_texture_region(self): + source = Image.new("RGB", (1600, 900), (25, 140, 220)) + + texture = dynamic_pages._prepare_custom_texture(source, (2048, 2048)) + + self.assertEqual(texture.size, (2048, 2048)) + self.assertEqual(texture.getpixel((0, 0)), (0, 0, 0, 255)) + red, green, blue, alpha = texture.getpixel((1024, 1024)) + self.assertLess(red, 35) + self.assertGreater(green, 130) + self.assertGreater(blue, 210) + self.assertEqual(alpha, 255) + + def test_custom_upload_normalizes_saves_and_rebuilds_the_bundle(self): + image = Image.new("RGB", (1280, 720), "purple") + payload = io.BytesIO() + image.save(payload, format="PNG") + asset_name = "summer_event_landingpage" + catalog_asset = { + "name": asset_name, + "custom": True, + "request_path": f"LandingPage/{asset_name}", + } + + with workspace_temp_dir() as root: + with ( + patch.object(dynamic_pages, "CUSTOM_IMAGE_DIR", root), + patch.object( + dynamic_pages, + "_get_catalog", + side_effect=[{}, {asset_name: catalog_asset}], + ), + patch.object( + dynamic_pages, + "compile_custom_landing_bundle", + return_value={"built": True, "assets": 1}, + ) as compile_bundle, + ): + imported = dynamic_pages.save_custom_landing_image( + "Summer Event.jpg", payload.getvalue() + ) + + saved = root / f"{asset_name}.png" + self.assertTrue(saved.is_file()) + with Image.open(saved) as saved_image: + self.assertEqual(saved_image.size, (1280, 720)) + self.assertEqual(imported["name"], asset_name) + compile_bundle.assert_called_once_with(force=True) + + def test_custom_upload_does_not_shadow_original_game_art(self): + image = Image.new("RGB", (640, 360), "blue") + payload = io.BytesIO() + image.save(payload, format="PNG") + original = {"summer_landingpage": {"custom": False}} + + with patch.object(dynamic_pages, "_get_catalog", return_value=original): + with self.assertRaisesRegex( + dynamic_pages.PageValidationError, "conflicts with original game artwork" + ): + dynamic_pages.save_custom_landing_image( + "summer", payload.getvalue() + ) + class LandingPageManifestTests(unittest.TestCase): def test_manifest_publishes_prefixed_landing_texture_key(self):