diff --git a/docs/ASSETS.md b/docs/ASSETS.md index 59070f37..10fcfa32 100644 --- a/docs/ASSETS.md +++ b/docs/ASSETS.md @@ -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`) diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index cc8dda17..f201500e 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -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,不需要客户端。 diff --git a/public/js/render/boardArt.js b/public/js/render/boardArt.js index ebddf742..0d130fdd 100644 --- a/public/js/render/boardArt.js +++ b/public/js/render/boardArt.js @@ -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; diff --git a/test/render/board3d-extract.test.js b/test/render/board3d-extract.test.js index 1b4647e4..864a7a08 100644 --- a/test/render/board3d-extract.test.js +++ b/test/render/board3d-extract.test.js @@ -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}`); + } + } + }); }); diff --git a/test/render/boardart.test.js b/test/render/boardart.test.js index b868803f..a4530dcd 100644 --- a/test/render/boardart.test.js +++ b/test/render/boardart.test.js @@ -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(); + } + }); }); diff --git a/tools/local-extract/extract.py b/tools/local-extract/extract.py index 9e28ffad..1d3a6e55 100644 --- a/tools/local-extract/extract.py +++ b/tools/local-extract/extract.py @@ -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 _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 ] [--out public/assets/local] [--only ] 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 /**.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 /**.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():