mirror of
https://github.com/Bratah123/Spirit-PTCGO.git
synced 2026-09-12 22:55:31 -05:00
FEAT: Add better dynamic pages features
This commit is contained in:
24
spirit/assets/landing_pages/README.md
Normal file
24
spirit/assets/landing_pages/README.md
Normal file
@@ -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.
|
||||
@@ -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; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@@ -460,7 +478,7 @@
|
||||
</div>
|
||||
|
||||
<input type="hidden" id="pageId">
|
||||
<input type="hidden" id="pageTemplate" value="LandingPageRight">
|
||||
<input type="hidden" id="pageTemplate" value="LandingPageLeft">
|
||||
<div class="page-meta-grid">
|
||||
<div><label>Page type</label>
|
||||
<select id="pageType" onchange="pageTypeChanged()">
|
||||
@@ -482,7 +500,10 @@
|
||||
<div class="asset-controls">
|
||||
<label>Client asset path</label>
|
||||
<input id="pageAssetPath" placeholder="LandingPage/asset_name" oninput="pageAssetPathChanged()">
|
||||
<button class="btn ghost" onclick="openPageAssetPicker()">Browse game artwork...</button>
|
||||
<div class="asset-action-row">
|
||||
<button class="btn ghost" onclick="openPageAssetPicker()">Browse game artwork...</button>
|
||||
<button class="btn ghost" onclick="openCustomPageArtwork()">+ Add custom artwork</button>
|
||||
</div>
|
||||
<div class="asset-status" id="pageAssetStatus"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -646,9 +667,25 @@
|
||||
<button class="mini" onclick="refreshPageAssets()">Rescan bundles</button>
|
||||
<button class="mini" onclick="closePageAssetPicker()">Close</button>
|
||||
</div>
|
||||
<details class="custom-artwork-panel" id="pageCustomArtworkPanel">
|
||||
<summary>+ Add your own landing-page artwork</summary>
|
||||
<div class="custom-artwork-body">
|
||||
<div class="custom-artwork-preview" id="pageCustomPreview"><span>Choose an image</span></div>
|
||||
<div class="custom-artwork-fields">
|
||||
<input id="pageCustomFile" type="file" accept="image/png,image/jpeg,image/webp" onchange="customPageFileChanged()">
|
||||
<div class="custom-upload-row">
|
||||
<input id="pageCustomName" type="text" maxlength="96" placeholder="summer_event" aria-label="Unity asset name" oninput="this.dataset.auto='false'">
|
||||
<label class="custom-replace"><input id="pageCustomReplace" type="checkbox"> Replace existing</label>
|
||||
<button class="btn" id="pageCustomUploadButton" onclick="uploadCustomPageArtwork()">Build & add</button>
|
||||
</div>
|
||||
<div class="custom-upload-help">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.</div>
|
||||
<div class="custom-upload-status" id="pageCustomStatus"></div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<div class="asset-picker-tools">
|
||||
<input id="pageAssetSearch" placeholder="Search artwork (set, Pokemon, ladder...)" oninput="renderPageAssetGrid()">
|
||||
<select id="pageAssetFilter" onchange="renderPageAssetGrid()"><option value="all">All artwork</option><option value="installed">Ready for players</option><option value="source">Needs install</option></select>
|
||||
<select id="pageAssetFilter" onchange="renderPageAssetGrid()"><option value="all">All artwork</option><option value="custom">My custom artwork</option><option value="installed">Ready for players</option><option value="source">Needs install</option></select>
|
||||
</div>
|
||||
<div class="asset-source-summary" id="pageAssetSourceSummary">Looking for LandingPage bundles...</div>
|
||||
<div class="landing-asset-grid" id="pageAssetGrid"></div>
|
||||
@@ -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 = `<span class="pill tag">Custom path</span> <span class="muted">${esc(name)}</span>`;
|
||||
} else if (meta.custom) {
|
||||
$('#pageAssetStatus').innerHTML = `<span class="pill tag">Custom · ready for players</span> <span class="muted">${esc(meta.title)}</span>`;
|
||||
} else if (meta.installed) {
|
||||
$('#pageAssetStatus').innerHTML = `<span class="pill on">Ready for players</span> <span class="muted">${esc(meta.title)}</span>`;
|
||||
} 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 = '<span>Choose an image</span>';
|
||||
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 = `<img src="${PAGE_CUSTOM_PREVIEW_URL}" alt="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 = '<span>Choose an image</span>';
|
||||
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 => `
|
||||
<button class="landing-asset ${a.name.toLowerCase() === selected ? 'sel' : ''}" data-asset="${esc(a.name)}" onclick="selectPageAsset(this.dataset.asset)">
|
||||
<img src="${pageAssetUrl(a.name, 'thumb')}" loading="lazy" alt="" onerror="this.style.visibility='hidden'">
|
||||
<img src="${pageAssetUrl(a.name, 'thumb')}" loading="lazy" decoding="async" alt="Preview of ${esc(a.title)}" onerror="this.style.visibility='hidden'">
|
||||
<span class="landing-asset-info"><span class="landing-asset-name">${esc(a.title)}</span>
|
||||
<span class="landing-asset-sub"><span class="pill ${a.installed ? 'on' : 'tag'}">${a.installed ? 'ready' : 'installs on save'}</span><span>${esc(a.bundle)}</span></span>
|
||||
<span class="landing-asset-path">${esc(a.request_path)}</span>
|
||||
<span class="landing-asset-sub"><span class="pill ${a.custom ? 'tag' : (a.installed ? 'on' : 'tag')}">${a.custom ? 'custom' : (a.installed ? 'ready' : 'installs on save')}</span><span>${a.width}×${a.height}</span><span>${esc(a.bundle)}</span></span>
|
||||
</span>
|
||||
</button>`).join('') || '<div class="empty-page-library">No artwork matches this filter.</div>';
|
||||
}
|
||||
|
||||
@@ -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/'):])
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user