The 3D board textures can be extracted as WebP: each player downloads about 4.7 MB less on a cold start (#186)
Only the extraction (tools/local-extract) and the manifest pointer change; no official textures are committed and nothing in play changes. Contributed by @Cloudnyco.
This commit is contained in:
1 parent
54cdad2602
commit
a9dfd17bee
6 files changed
+209
-12
No files matched your search
+1
-1
@@ -129,7 +129,7 @@ The `stem` of a Spine model is the upstream file name. Two examples: `char_107_l
|
||||
- **Fonts:** OTF/TTF files are converted to WOFF2 by a built-in encoder (`tools/assets/woff2.mjs`: Brotli with null transforms).
|
||||
- Its output was verified lossless against Google's reference `woff2` decoder.
|
||||
- `fonts.css` lists WOFF2 first and falls back to the original file.
|
||||
- Images stay PNG. WebP conversion is not done: it would need a native dependency.
|
||||
- Images stay PNG. WebP conversion is not done: it would need a native dependency. The local-client board textures are the exception: `tools/local-extract/extract.py` (Python, where Pillow is already a dependency) writes WebP copies of the 12 textures the 3D board downloads (`WEBP`: colour maps lossy at quality 95 with the alpha and the RGB under transparent texels kept, normal and data maps lossless) and `data/local-assets.json` lists the copies; the PNGs stay beside them for `tools/crop-board-atlas.mjs`. `extract.py --webp` adds the copies to an existing extraction without the client.
|
||||
|
||||
## Manifest schema (`data/assets.json`)
|
||||
|
||||
|
||||
@@ -257,3 +257,5 @@ services:
|
||||
表情(6 套 × 6 个)和「玩法说明」的 19 页教程图公开镜像也有:`node tools/setup.mjs` 会和其他素材一起下载(约 21 MB),不需要客户端;有本地素材时优先显示本地的。
|
||||
|
||||
**没有客户端的服务器**想要上表中的官方素材:从**同一版本**的完整包([Releases](https://github.com/sganggs/Stronghold-Protocol/releases))里,把 `public/assets/local/` 文件夹和 `data/local-assets.json` 复制到服务器项目目录下的相同位置。服务器每次请求都会重新读取这两处,不必重启,玩家刷新页面即可。一定要用与服务器代码相同版本的完整包:各版本提取的内容和清单可能不同(例如灼热 / 炽焰源石虫的模型是 0.1.0 之后才加入的),混用其他版本的文件会缺图或用错图。复制后 `node tools/doctor.mjs` 会显示本地素材的条目数和「3D 棋盘可用」。
|
||||
|
||||
**3D 棋盘贴图的下载量**:每位玩家进入对局时都要从开服的电脑下载 3D 棋盘的 12 张贴图。提取时会给这 12 张各写一份 WebP(颜色贴图有损、质量 95,法线和数据贴图无损),清单里列的是 WebP,同名 PNG 留在旁边给裁切工具和 setup 用。这部分下载量从约 6.7 MB 降到约 2 MB,网速慢的远程联机最明显。只有 PNG 的本地素材(例如在这一改动之前提取的)可以用提取时的 Python 环境运行 `tools/local-extract/extract.py --webp` 就地补上,只需要 Pillow,不需要客户端。
|
||||
@@ -7,11 +7,29 @@
|
||||
// `map/autochess → TX_autochessi_D`; its crop table `tiles.json` sits next to it (written by
|
||||
// tools/crop-board-atlas.mjs). Every image referenced by the table is loaded through the shared image cache; a
|
||||
// missing image drops only the materials that use it. Everything is cached per page (one load for every view).
|
||||
// A table image is fetched at the URL the manifest lists for the same texture (sourceUrl), so the 3D board
|
||||
// (board3d/load.js, which reads the manifest only) and this art share one download — also when the manifest lists
|
||||
// the WebP copy tools/local-extract writes and the table still names the PNG.
|
||||
|
||||
let cached = null;
|
||||
|
||||
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
||||
|
||||
/** The manifest group of the board textures (and of their crop table). */
|
||||
export const BOARD_GROUP = 'map/autochess';
|
||||
|
||||
/**
|
||||
* URL of a crop-table image: the local-art manifest's entry for the same texture (group `BOARD_GROUP`, name = the
|
||||
* file name without its extension), else the table's own path.
|
||||
* @param {any} assets store of public/js/assets.js
|
||||
* @param {string} path tiles.json `source[k].path`
|
||||
*/
|
||||
export function sourceUrl(assets, path) {
|
||||
const name = path.replace(/^.*\//, '').replace(/\.[^.]*$/, '');
|
||||
const listed = name && typeof assets?.localUrl === 'function' ? assets.localUrl(BOARD_GROUP, name) : null;
|
||||
return typeof listed === 'string' && listed ? listed : path;
|
||||
}
|
||||
|
||||
/** @param {any} assets store of public/js/assets.js (needs local(), localUrl(), image()) */
|
||||
export function loadBoardArt(assets) {
|
||||
if (cached) return cached;
|
||||
@@ -19,7 +37,7 @@ export function loadBoardArt(assets) {
|
||||
if (!assets || typeof assets.local !== 'function' || typeof assets.image !== 'function') return null;
|
||||
const manifest = await assets.local().catch(() => null);
|
||||
if (!manifest) return null;
|
||||
const atlasUrl = assets.localUrl ? assets.localUrl('map/autochess', 'TX_autochessi_D') : null;
|
||||
const atlasUrl = assets.localUrl ? assets.localUrl(BOARD_GROUP, 'TX_autochessi_D') : null;
|
||||
if (!atlasUrl) return null;
|
||||
const dir = atlasUrl.replace(/\/[^/]*$/, '');
|
||||
let tiles = null;
|
||||
@@ -30,7 +48,7 @@ export function loadBoardArt(assets) {
|
||||
if (!isObj(tiles) || !isObj(tiles.materials) || !isObj(tiles.source)) return null;
|
||||
const images = {};
|
||||
await Promise.all(Object.entries(tiles.source).map(async ([k, s]) => {
|
||||
const url = isObj(s) && typeof s.path === 'string' ? s.path : null;
|
||||
const url = isObj(s) && typeof s.path === 'string' && s.path ? sourceUrl(assets, s.path) : null;
|
||||
if (!url) return;
|
||||
const img = await assets.image(url).catch(() => null);
|
||||
if (img && img.width > 0) images[k] = img;
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
// test/render/board3d-extract.test.js — the local-client exports behind the official 3D board (DESIGN §13/§15):
|
||||
// tools/local-extract/extract.py's board jobs (gate / objective effects, blower texture, water & noise maps), the
|
||||
// derived three.js maps (BC5 normal → RGB normal, Unity metallic/gloss → roughness), the prefab mesh keys, and —
|
||||
// derived three.js maps (BC5 normal → RGB normal, Unity metallic/gloss → roughness), the WebP copies of the textures
|
||||
// the renderer downloads (extract.py WEBP), the prefab mesh keys, and —
|
||||
// when a client was extracted here — the files and manifest entries the renderer loads (render/board3d/load.js).
|
||||
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { readFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { decodePng } from '../../tools/crop-board-atlas.mjs';
|
||||
@@ -18,6 +20,7 @@ const TOOL = path.join(ROOT, 'tools/local-extract');
|
||||
const ENV = { ...process.env, PYTHONDONTWRITEBYTECODE: '1' };
|
||||
const PY = ['python3', 'python'].find((bin) => spawnSync(bin, ['--version']).status === 0);
|
||||
const HAS_PIL = !!PY && spawnSync(PY, ['-c', 'import PIL'], { env: ENV }).status === 0;
|
||||
const HAS_WEBP = HAS_PIL && spawnSync(PY, ['-c', "import sys; from PIL import features; sys.exit(0 if features.check('webp') else 1)"], { env: ENV }).status === 0;
|
||||
const manifest = (() => { try { return JSON.parse(readFileSync(path.join(ROOT, 'data/local-assets.json'), 'utf8')); } catch { return null; } })();
|
||||
|
||||
function py(code) {
|
||||
@@ -62,6 +65,68 @@ print(json.dumps({'n': [list(rn.getpixel((i, 0))) for i in range(3)], 'm': [list
|
||||
assert.deepEqual(out.m, [[255, 255, 0], [255, 55, 0]]);
|
||||
});
|
||||
|
||||
test('WebP copies: exactly the board textures the renderer downloads; normal and data maps stay lossless', () => {
|
||||
const r = spawnSync(PY, [path.join(TOOL, 'extract.py'), '--print-jobs'], { encoding: 'utf8', env: ENV });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
const { webp } = JSON.parse(r.stdout);
|
||||
assert.deepEqual(webp.map((w) => `${w.sub}/${w.name}`).sort(), Object.values(PACK_IMAGES).map(([g, n]) => `${g}/${n}`).sort());
|
||||
const mode = Object.fromEntries(webp.map((w) => [w.name, w.mode]));
|
||||
for (const n of ['TX_autochessi_N_rgb', '[ucp]TX_water_normal', 'TX_autochessi_M_rough', 'T_noise_clouds_01']) assert.equal(mode[n], 'lossless', n);
|
||||
for (const n of ['TX_autochessi_D', 'TX_autochessi_BG', 'TX_autochessi_common_D']) assert.equal(mode[n], 'lossy', n);
|
||||
});
|
||||
|
||||
test('run_webp / --webp: a copy beside the PNG that the manifest lists; normals exact, alpha and hidden RGB kept', { skip: !HAS_WEBP && 'no Pillow with WebP' }, () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), 'sp-webp-'));
|
||||
try {
|
||||
const out = py(`
|
||||
from pathlib import Path
|
||||
from PIL import Image
|
||||
root = Path(${JSON.stringify(dir)})
|
||||
sub = root / 'map' / 'autochess'
|
||||
sub.mkdir(parents=True)
|
||||
d = Image.new('RGBA', (64, 64), (200, 40, 40, 0)) # transparent texels whose colour the opaque board material shows
|
||||
for x in range(32):
|
||||
for y in range(64): d.putpixel((x, y), (x * 8, y * 4, 90, 255))
|
||||
d.save(sub / 'TX_autochessi_D.png')
|
||||
n = Image.new('RGB', (64, 64))
|
||||
for x in range(64):
|
||||
for y in range(64): n.putpixel((x, y), ((x * 37) % 256, (y * 53) % 256, 200 + (x + y) % 56))
|
||||
n.save(sub / 'TX_autochessi_N_rgb.png')
|
||||
entry = lambda name, kind: {'path': f'/assets/local/map/autochess/{name}.png', 'w': 64, 'h': 64, 'kind': kind}
|
||||
groups = {'map/autochess': {'TX_autochessi_D': entry('TX_autochessi_D', 'Texture2D'),
|
||||
'TX_autochessi_N_rgb': entry('TX_autochessi_N_rgb', 'Derived'),
|
||||
'TX_autochessi_E': entry('TX_autochessi_E', 'Texture2D')}}
|
||||
mf = root / 'local-assets.json'
|
||||
mf.write_text(json.dumps({'version': 1, 'source': 'local-client', 'count': 3, 'groups': groups}))
|
||||
logs = []
|
||||
code = e.webp_only(root, mf, logs.append)
|
||||
doc = json.loads(mf.read_text())
|
||||
g = doc['groups']['map/autochess']
|
||||
px = lambda p: list(Image.open(p).convert('RGBA').getdata())
|
||||
a, b = px(sub / 'TX_autochessi_D.png'), px(sub / 'TX_autochessi_D.webp')
|
||||
hidden = [q for p, q in zip(a, b) if p[3] == 0]
|
||||
print(json.dumps({'code': code, 'paths': {k: v['path'] for k, v in g.items()}, 'modes': {k: v.get('webp') for k, v in g.items()},
|
||||
'count': doc['count'], 'pngKept': (sub / 'TX_autochessi_D.png').exists(),
|
||||
'alphaSame': all(p[3] == q[3] for p, q in zip(a, b)),
|
||||
'normalSame': px(sub / 'TX_autochessi_N_rgb.png') == px(sub / 'TX_autochessi_N_rgb.webp'),
|
||||
'hidden': [sum(q[i] for q in hidden) / len(hidden) for i in range(3)],
|
||||
'again': e.run_webp(root, 'map/autochess', doc['groups'], logs.append)}))`);
|
||||
assert.equal(out.code, 0);
|
||||
const at = (n, ext) => `/assets/local/map/autochess/${n}.${ext}`;
|
||||
// TX_autochessi_E is listed without a PNG on disk: its entry stays as it was
|
||||
assert.deepEqual(out.paths, { TX_autochessi_D: at('TX_autochessi_D', 'webp'), TX_autochessi_N_rgb: at('TX_autochessi_N_rgb', 'webp'), TX_autochessi_E: at('TX_autochessi_E', 'png') });
|
||||
assert.deepEqual(out.modes, { TX_autochessi_D: 'lossy', TX_autochessi_N_rgb: 'lossless', TX_autochessi_E: null });
|
||||
assert.equal(out.count, 3);
|
||||
assert.ok(out.pngKept, 'the PNG stays beside the copy (crop tool, setup)');
|
||||
assert.ok(out.alphaSame, 'alpha lossless');
|
||||
assert.ok(out.normalSame, 'normal map lossless');
|
||||
out.hidden.forEach((v, i) => assert.ok(Math.abs(v - [200, 40, 40][i]) < 12, `RGB under alpha 0 kept: ${out.hidden}`));
|
||||
assert.equal(out.again, 2, 'a second run rewrites both copies');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('prefab_node names the exported OBJ of a mesh whose name is shared (map/fx has two Start_back meshes)', () => {
|
||||
const out = py(`
|
||||
from types import SimpleNamespace as NS
|
||||
@@ -92,6 +157,7 @@ describe('vendored three.js', () => {
|
||||
|
||||
describe('extracted board files (when a local client was extracted)', { skip: !manifest?.groups?.['map/fx'] && 'map/fx not extracted' }, () => {
|
||||
const onDisk = (p) => path.join(ROOT, 'public', decodeURIComponent(p));
|
||||
const pngOf = (p) => p.replace(/\.webp$/, '.png'); // a WebP copy (extract.py WEBP) keeps its PNG beside it
|
||||
const hasFiles = existsSync(path.join(ROOT, 'public/assets/local/map/fx'));
|
||||
|
||||
test('every pack slot the renderer loads is in the manifest (and on disk)', () => {
|
||||
@@ -113,13 +179,24 @@ describe('extracted board files (when a local client was extracted)', { skip: !m
|
||||
|
||||
test('derived maps on disk: RGB normals point out of the surface; roughness mirrors the smoothness', { skip: !hasFiles && 'files not extracted' }, () => {
|
||||
const g = manifest.groups['map/autochess'];
|
||||
const n = decodePng(readFileSync(onDisk(g.TX_autochessi_N_rgb.path)));
|
||||
const n = decodePng(readFileSync(onDisk(pngOf(g.TX_autochessi_N_rgb.path))));
|
||||
let zSum = 0, cnt = 0;
|
||||
for (let i = 0; i < n.rgba.length; i += 4 * 97) { zSum += n.rgba[i + 2]; cnt++; }
|
||||
assert.ok(zSum / cnt > 230, `mean z ${zSum / cnt}`);
|
||||
const m = decodePng(readFileSync(onDisk(g.TX_autochessi_M.path)));
|
||||
const r = decodePng(readFileSync(onDisk(g.TX_autochessi_M_rough.path)));
|
||||
const r = decodePng(readFileSync(onDisk(pngOf(g.TX_autochessi_M_rough.path))));
|
||||
assert.deepEqual([r.w, r.h], [m.w, m.h]);
|
||||
for (let i = 0; i < m.rgba.length; i += 4 * 131) assert.equal(r.rgba[i + 1], 255 - m.rgba[i + 3]);
|
||||
});
|
||||
|
||||
test('the WebP copies the manifest lists are on disk with their PNGs beside them', { skip: !hasFiles && 'files not extracted' }, () => {
|
||||
for (const [g, entries] of Object.entries(manifest.groups)) {
|
||||
for (const [n, e] of Object.entries(entries)) {
|
||||
if (!/\.webp$/.test(e?.path || '')) continue;
|
||||
assert.ok(existsSync(onDisk(e.path)), `${g}/${n}`);
|
||||
assert.ok(existsSync(onDisk(pngOf(e.path))), `${g}/${n}: PNG`);
|
||||
assert.ok(e.webp === 'lossy' || e.webp === 'lossless', `${g}/${n}: mode ${e.webp}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import { FX_KINDS, fxSpec, tilesAround } from '../../public/js/render/fx.js';
|
||||
import { statusIconKey } from '../../public/js/render/style.js';
|
||||
import { STATUS_KEYS } from '../../public/js/render/textures.js';
|
||||
import { createAssets, localAssetUrl } from '../../public/js/assets.js';
|
||||
import { loadBoardArt, resetBoardArt, sourceUrl } from '../../public/js/render/boardArt.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const stages = JSON.parse(readFileSync(path.join(ROOT, 'data/stages.json'), 'utf8'));
|
||||
@@ -311,4 +312,26 @@ describe('local-art manifest helpers', () => {
|
||||
const broken = createAssets({ manifest: {}, fetch: async () => { throw new Error('offline'); } });
|
||||
assert.equal(await broken.local(), null);
|
||||
});
|
||||
|
||||
test('board art loads the crop-table textures at the URLs the manifest lists (one download with the 3D board)', async () => {
|
||||
const at = (n, ext) => `/assets/local/map/autochess/${n}.${ext}`;
|
||||
const m = { groups: { 'map/autochess': { TX_autochessi_D: { path: at('TX_autochessi_D', 'webp') }, TX_autochessi_BG: { path: at('TX_autochessi_BG', 'webp') } } } };
|
||||
const asked = [];
|
||||
const store = { local: async () => m, localUrl: (g, n) => localAssetUrl(m, g, n), image: async (u) => { asked.push(u); return { width: 4 }; } };
|
||||
assert.equal(sourceUrl(store, at('TX_autochessi_D', 'png')), at('TX_autochessi_D', 'webp'));
|
||||
assert.equal(sourceUrl(store, at('TX_autochessi_common_D', 'png')), at('TX_autochessi_common_D', 'png'), 'not in the manifest: the table path');
|
||||
assert.equal(sourceUrl({}, '/x/y.png'), '/x/y.png');
|
||||
const tiles = { version: 2, materials: {}, source: { D: { path: at('TX_autochessi_D', 'png') }, common: { path: at('TX_autochessi_common_D', 'png') }, BG: { path: at('TX_autochessi_BG', 'png') } } };
|
||||
const realFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (u) => (u === '/assets/local/map/autochess/tiles.json' ? { ok: true, json: async () => tiles } : { ok: false, status: 404 });
|
||||
try {
|
||||
resetBoardArt();
|
||||
const art = await loadBoardArt(store);
|
||||
assert.ok(art?.images.D && art.images.common && art.images.BG);
|
||||
assert.deepEqual(asked.sort(), [at('TX_autochessi_BG', 'webp'), at('TX_autochessi_D', 'webp'), at('TX_autochessi_common_D', 'png')]);
|
||||
} finally {
|
||||
globalThis.fetch = realFetch;
|
||||
resetBoardArt();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -26,15 +26,19 @@ aklz4.py registers a decoder for it. This script pulls the art the web sources l
|
||||
- derived PBR maps for three.js (DERIVED): Unity stores the theme's normal map as two channels (BC5: RG = XY, B = 0)
|
||||
and metallic/gloss with the smoothness in A; the board renderer needs an RGB normal map (Z rebuilt) and a
|
||||
roughness map in G (1 − smoothness; metalness B = 0), written next to the source as <name>_rgb.png / _rough.png
|
||||
- WebP copies of the board textures every player downloads with the 3D board (WEBP), written next to their PNGs;
|
||||
the manifest lists the copy
|
||||
|
||||
Usage:
|
||||
python3 -m venv .venv && .venv/bin/pip install -r tools/local-extract/requirements.txt
|
||||
.venv/bin/python tools/local-extract/extract.py [--game <AB root>] [--out public/assets/local] [--only <subdir prefix>]
|
||||
python3 tools/local-extract/extract.py --print-jobs (the job table as JSON; needs no dependencies)
|
||||
python3 tools/local-extract/extract.py --webp (only the WebP copies, from the PNGs already extracted —
|
||||
e.g. the local art copied from a release bundle; needs Pillow)
|
||||
|
||||
Writes <out>/**.png|.skel|.atlas and data/local-assets.json (manifest of what was extracted). With --only, just the
|
||||
jobs whose output subdir starts with one of the prefixes run, and their groups replace those of the existing manifest
|
||||
(every other group is kept as is).
|
||||
Writes <out>/**.png|.webp|.skel|.atlas and data/local-assets.json (manifest of what was extracted). With --only, just
|
||||
the jobs whose output subdir starts with one of the prefixes run, and their groups replace those of the existing
|
||||
manifest (every other group is kept as is).
|
||||
Everything is (c) Hypergryph; for private, non-commercial fan use only.
|
||||
"""
|
||||
import argparse
|
||||
@@ -118,6 +122,32 @@ DERIVED = [
|
||||
('map/autochess', 'TX_autochessi_M', 'rough_from_gloss', 'TX_autochessi_M_rough'),
|
||||
]
|
||||
|
||||
# The board textures every player downloads when a match shows the 3D board (public/js/render/board3d/load.js
|
||||
# PACK_IMAGES; D / common_D / BG also feed the 2D board art, render/boardArt.js): (output subdir, name, mode). A WebP
|
||||
# copy is written next to the PNG and the manifest lists the copy instead (≈ 6.7 MB → 2.0 MB per cold start); the PNG
|
||||
# stays for tools/crop-board-atlas.mjs and setup's check. 'lossy' = colour maps at quality 95 with the alpha lossless
|
||||
# and the RGB under transparent texels kept (`exact`: the board material is opaque and samples it); 'lossless' =
|
||||
# normal and data maps, whose channels hold independent values that lossy WebP's chroma subsampling would mix (a
|
||||
# normal map ends up tens of degrees off). A Pillow without WebP support keeps the PNG.
|
||||
WEBP = [
|
||||
('map/autochess', 'TX_autochessi_D', 'lossy'),
|
||||
('map/autochess', 'TX_autochessi_BG', 'lossy'),
|
||||
('map/autochess', 'TX_autochessi_common_D', 'lossy'),
|
||||
('map/autochess', 'TX_autochessi_N_rgb', 'lossless'),
|
||||
('map/autochess', 'TX_autochessi_M_rough', 'lossless'),
|
||||
('map/autochess', 'TX_autochessi_E', 'lossless'),
|
||||
('map/autochess', 'TX_autochessi_common_E', 'lossless'),
|
||||
('map/common', 'TX_wind_device', 'lossy'),
|
||||
('map/fx', '[opt]merged_textures', 'lossless'),
|
||||
('map/water', '[ucp]TX_water_normal', 'lossless'),
|
||||
('map/water', 'TX_Caustics256', 'lossy'),
|
||||
('map/water', 'T_noise_clouds_01', 'lossless'),
|
||||
]
|
||||
WEBP_OPTIONS = {
|
||||
'lossy': {'quality': 95, 'method': 6, 'alpha_quality': 100, 'exact': True},
|
||||
'lossless': {'lossless': True, 'quality': 100, 'method': 6, 'exact': True},
|
||||
}
|
||||
|
||||
# Shader bundles loaded beside every job that exports Materials, only so that the materials' shader references
|
||||
# (external CABs) resolve to a name in materials.json (e.g. Torappu/Scene/StandardDirectional); nothing is exported
|
||||
# from them.
|
||||
@@ -314,6 +344,44 @@ def run_derived(out_root, sub, manifest, log):
|
||||
return n
|
||||
|
||||
|
||||
def run_webp(out_root, sub, manifest, log):
|
||||
"""Write the WEBP copies of output subdir `sub` from its PNGs and point their manifest entries at them."""
|
||||
n = 0
|
||||
for wsub, name, mode in WEBP:
|
||||
entry = manifest.get(sub, {}).get(name) if wsub == sub else None
|
||||
png = Path(out_root) / sub / f'{name}.png'
|
||||
if not entry or not png.exists():
|
||||
continue
|
||||
dst = png.with_name(f'{name}.webp')
|
||||
try:
|
||||
from PIL import Image
|
||||
with Image.open(png) as img:
|
||||
img.save(dst, 'WEBP', **WEBP_OPTIONS[mode])
|
||||
except Exception as e: # no WebP support in this Pillow, or a broken PNG: the entry keeps the PNG
|
||||
log(f' warn webp {name}: {e}')
|
||||
continue
|
||||
entry.update(path=f'/assets/local/{sub}/{dst.name}', webp=mode)
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
def webp_only(out_root, manifest_path, log):
|
||||
"""--webp: the WEBP copies of an existing extraction (e.g. the local art copied from a release bundle)."""
|
||||
try:
|
||||
doc = json.loads(Path(manifest_path).read_text(encoding='utf-8'))
|
||||
except (OSError, ValueError) as e:
|
||||
print(f'No readable manifest at {manifest_path} ({e}). Extract the local art first.', file=sys.stderr)
|
||||
return 2
|
||||
groups = doc.get('groups') if isinstance(doc, dict) else None
|
||||
if not isinstance(groups, dict):
|
||||
print(f'{manifest_path} has no groups. Extract the local art first.', file=sys.stderr)
|
||||
return 2
|
||||
n = sum(run_webp(out_root, sub, groups, log) for sub in dict.fromkeys(s for s, _, _ in WEBP))
|
||||
Path(manifest_path).write_text(json.dumps(doc, ensure_ascii=False, separators=(',', ':')), encoding='utf-8')
|
||||
log(f'done: {n} WebP copies, manifest {manifest_path}')
|
||||
return 0 if n else 1
|
||||
|
||||
|
||||
def merge_manifest(old_groups, new_groups, ran_subs):
|
||||
"""Groups of the previous manifest minus the subdirs that were re-extracted, plus the new ones (sorted keys)."""
|
||||
out = {g: v for g, v in (old_groups or {}).items() if g not in ran_subs}
|
||||
@@ -415,6 +483,9 @@ def export_bundle(ab_root, job, out_root, manifest, log):
|
||||
n += 1
|
||||
n += run_derived(out_root, sub, manifest, log)
|
||||
log(f'{rel}: {n} files -> {sub}')
|
||||
k = run_webp(out_root, sub, manifest, log)
|
||||
if k:
|
||||
log(f' {k} WebP copies -> {sub}')
|
||||
return n
|
||||
|
||||
|
||||
@@ -555,16 +626,22 @@ def main():
|
||||
ap.add_argument('--only', action='append', default=[], metavar='SUBDIR',
|
||||
help='only run the jobs whose output subdir starts with this prefix (repeatable), e.g. emoticon')
|
||||
ap.add_argument('--print-jobs', action='store_true', help='print the job table as JSON and exit')
|
||||
ap.add_argument('--webp', action='store_true',
|
||||
help='only write the WebP copies of the board textures from the PNGs already under --out and list '
|
||||
'them in the manifest (needs Pillow, not the client)')
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.print_jobs:
|
||||
jobs = [{'bundle': rel, 'sub': sub, 'kinds': sorted(kinds), 'keep': keep.pattern if keep else None}
|
||||
for rel, sub, kinds, keep in map(job_parts, JOBS)]
|
||||
derived = [{'sub': sub, 'from': src, 'derive': kind, 'name': name} for sub, src, kind, name in DERIVED]
|
||||
webp = [{'sub': sub, 'name': name, 'mode': mode} for sub, name, mode in WEBP]
|
||||
print(json.dumps({'emoteThemes': [{'themeId': t, 'dir': d} for t, d in EMOTE_THEMES], 'jobs': jobs,
|
||||
'derived': derived, 'enemySpines': {'bundles': ENEMY_ART, 'sub': ENEMY_SPINE_SUB,
|
||||
'ids': ENEMY_SPINES}}, ensure_ascii=False))
|
||||
'derived': derived, 'webp': webp, 'enemySpines': {'bundles': ENEMY_ART, 'sub': ENEMY_SPINE_SUB,
|
||||
'ids': ENEMY_SPINES}}, ensure_ascii=False))
|
||||
return 0
|
||||
if args.webp:
|
||||
return webp_only(Path(args.out), Path(args.manifest), print)
|
||||
|
||||
ab_root = Path(args.game) if args.game else next((p for p in CANDIDATES if p.exists()), None)
|
||||
if not ab_root or not ab_root.exists():
|
||||
|
||||
Reference in new issue
Block a user