diff --git a/CHANGELOG.md b/CHANGELOG.md index 996a8691..a8ad8d39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,8 @@ - 1 KB 以上的 WebSocket 消息用 permessage-deflate 压缩,一局单人对局的服务器消息实测从 173 KB 降到 60 KB。 - 素材地址带内容哈希:浏览器把图片、模型和音频缓存一年,服务器更新、重新解压或 Docker 重建后只重新下载内容变了的文件;哈希存在 `.cache/asset-hashes.json`,`SP_ASSET_INDEX=0` 可关闭。 - 支持 brotli 压缩(浏览器只在 HTTPS 页面和 localhost 上使用),`.skel` 和数据文件比 gzip 再小约四分之一。 -- 本地提取的「玩法说明」教程图另存无损 WebP(25.6 MB → 17.4 MB),看说明时只预载下一页。 -- 流量统计:`/healthz?traffic=1` 显示服务器发出的各类流量;「问题反馈」的诊断信息增加本页下载量。 +- 本地提取的「玩法说明」教程图另存 WebP(质量 90,25.6 MB → 4.2 MB),看说明时只预载下一页。 +- 流量统计:`/healthz?traffic=1` 显示服务器发出的各类流量(只对本机和局域网的访问显示);「问题反馈」的诊断信息增加本页下载量。 - 部署指南:固定访问地址、反代开 HTTP/2、Cloudflare 缓存规则。 ## 0.2.5 — 2026-10-12 diff --git a/docs/ASSETS.md b/docs/ASSETS.md index d553e524..ac1a6245 100644 --- a/docs/ASSETS.md +++ b/docs/ASSETS.md @@ -211,7 +211,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. 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`. The 19 local 玩法说明 pages get a WebP copy too (`GUIDE_WEBP_MODE`, lossless: 25.6 MB → 17.4 MB on the CN client's pages; lossy quality 90 would be 4.2 MB and waits for the maintainer's side-by-side look), listed in place of the PNG, which stays beside it; the mirror copies under `ui/guide/` stay PNG. `extract.py --webp` adds the copies to an existing extraction without the client. +- 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`. The 19 local 玩法说明 pages get a WebP copy too (`GUIDE_WEBP_MODE`, lossy quality 90 since the maintainer's side-by-side look of 2026-10-12: 25.6 MB → 4.2 MB on the CN client's pages; lossless would be 17.4 MB), listed in place of the PNG, which stays beside it; the mirror copies under `ui/guide/` stay PNG. `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 6f09eeb6..9b05872b 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -296,7 +296,7 @@ services: | 本地提取失败 | 游戏照常运行,只是第 6 节表格里的几样换成替代样式。确认客户端已下载全部资源;Python 版本太新导致依赖安装失败时,安装 Python 3.12 后删除 `.venv-extract` 再运行 `node tools/setup.mjs --local` | | 3D 棋盘没出现 | 需要本地提取的棋盘贴图(`node tools/doctor.mjs` 会显示「3D 棋盘可用」),以及支持 WebGL2 的浏览器。没有客户端的服务器可以从同一版本的整合包复制本地素材(第 6 节) | | 素材地址里的 `~` 加一串字母数字 | `/assets/~<哈希>/…` 是 0.2.6 起的带版本地址(见第 0 节「网络」),浏览器可以长期缓存,属于正常现象。怀疑它导致问题时,可以用 `SP_ASSET_INDEX=0` 启动服务器临时关闭(清单改回普通地址),并请在 issue 里说明 | -| 想知道开服电脑上传了多少流量 | 浏览器打开 `http://localhost:3000/healthz?traffic=1`(换成你的端口):`traffic.http` 是服务器启动以来各类内容(`assets` 图片和模型、`audio` 音频、`code` 页面和脚本、`data` 游戏数据和素材清单)发出的字节数和请求数(压缩后的实际字节,`notModified` 是只回了「没变」的 304),`traffic.ws` 是 WebSocket 的线上字节(`wireOut` / `wireIn`,压缩后)和压缩前的消息字节(`payloadOut` / `payloadIn`)。数字只在内存里,重启后从 0 开始。玩家那边:设置 →「问题反馈」复制的诊断里有一行 `traffic:`,是这个页面从打开起下载的字节数(`cached` 是浏览器缓存直接给的,不占流量;`external` 是其他网站,例如 Google 字体) | +| 想知道开服电脑上传了多少流量 | 浏览器打开 `http://localhost:3000/healthz?traffic=1`(换成你的端口):`traffic.http` 是服务器启动以来各类内容(`assets` 图片和模型、`audio` 音频、`code` 页面和脚本、`data` 游戏数据和素材清单)发出的字节数和请求数(压缩后的实际字节,`notModified` 是只回了「没变」的 304),`traffic.ws` 是 WebSocket 的线上字节(`wireOut` / `wireIn`,压缩后)和压缩前的消息字节(`payloadOut` / `payloadIn`)。数字只在内存里,重启后从 0 开始;只有本机和局域网(含虚拟局域网地址)的访问能看到,公网访问者(包括经隧道 / 反代转发来的)只拿到普通的 `/healthz`。玩家那边:设置 →「问题反馈」复制的诊断里有一行 `traffic:`,是这个页面从打开起下载的字节数(`cached` 是浏览器缓存直接给的,不占流量;`external` 是其他网站,例如 Google 字体) | | 断线 | 同盟模拟 10 分钟内、独立模拟 24 小时内(`config.constants.singleReconnectTime`)用同一浏览器重新打开页面,自动回到原座位。同盟掉线期间按原阵容自动作战、到时自动准备(不会代为购买;想让 AI 代打请用「离开模拟 → 暂离(AI 托管)」);独立模拟不计时,等你回来 | ## 6. 本地客户端素材(可选) @@ -323,7 +323,7 @@ services: **3D 棋盘贴图的下载量**:每位玩家进入对局时都要从开服的电脑下载 3D 棋盘的 12 张贴图。提取时会给这 12 张各写一份 WebP(颜色贴图有损、质量 95,法线和数据贴图无损),清单里列的是 WebP,同名 PNG 留在旁边给裁切工具和 setup 用。这部分下载量从约 6.7 MB 降到约 2 MB,网速慢的远程联机最明显。只有 PNG 的本地素材(例如在这一改动之前提取的)可以用提取时的 Python 环境运行 `tools/local-extract/extract.py --webp` 就地补上,只需要 Pillow,不需要客户端。 -**「玩法说明」教程图的下载量**(0.2.6 起):本地提取的 19 页教程图每页 1.2–1.5 MB,玩家每翻一页都要从开服的电脑下载。提取时同样给每页写一份无损 WebP,清单列 WebP,PNG 留在旁边;19 页合计从约 25.6 MB 降到约 17.4 MB,画面完全不变。看说明时只预载下一页(以前前后两页都预载)。已经提取过的同样用 `tools/local-extract/extract.py --webp` 补上。 +**「玩法说明」教程图的下载量**(0.2.6 起):本地提取的 19 页教程图每页 1.2–1.5 MB,玩家每翻一页都要从开服的电脑下载。提取时同样给每页写一份 WebP(有损,质量 90,放大两倍对比看不出差别),清单列 WebP,PNG 留在旁边;19 页合计从约 25.6 MB 降到约 4.2 MB。看说明时只预载下一页(以前前后两页都预载)。已经提取过的同样用 `tools/local-extract/extract.py --webp` 补上。 ## 7. 打包发布(维护者) diff --git a/docs/design/client.md b/docs/design/client.md index b0f14202..13c504f0 100644 --- a/docs/design/client.md +++ b/docs/design/client.md @@ -75,7 +75,7 @@ Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer - `scene//` and `scene/tex/` — the official scene of a battlefield (§15 "Official battlefield scenes", DESIGN §30.21): `scene.json` (materials, key light, chunk records), `scene.bin` (the meshes in board space), the lightmap pages `lm`, and the materials' albedo textures shared by the scenes. Read from the scene bundles `scenes/activities//level_/…`, which the client carries only while the season runs: `extract.py --only scene` takes them from the client or from `--scenes ` / `.cache/official-scenes/*` (`tools/local-extract/fetch_scenes.py` downloads them from the official CDN's in-season version). The 0.2.5 full release zip includes the extracted files when the maintainer extracted them before packaging; a lite zip and a source install may fetch and extract them (`node tools/setup.mjs --scenes`). The repository never stores the bundles or the extracted files. - `ui/{common,battle,outer}/*` — every sprite of the three official autochess UI bundles (tier chips I–VI, bond disc rings, frames, banners, HUD pieces, badges, backgrounds, mode art). - `emoticon//.png` — the 36 official battle emotes, 6 themes (`shared/constants.js → EMOTE_THEMES / EMOTES`, `emoteArtPath`; data/emotes.json); also downloaded from the mirror (§22.5). -- `guide/autochess_{home,shop,handbook}_N.png` — the 19 official tutorial pages; stored squashed to 1024² — **display at 16:9** (the mirror copies too, §22.5). `extract.py` writes a lossless WebP copy beside each page and the manifest lists the copy (`GUIDE_WEBP_MODE`; 25.6 MB → 17.4 MB); the viewer preloads only the next page (`ui/guide.js guidePreloadUrl`), not the previous one too. +- `guide/autochess_{home,shop,handbook}_N.png` — the 19 official tutorial pages; stored squashed to 1024² — **display at 16:9** (the mirror copies too, §22.5). `extract.py` writes a WebP copy (lossy, quality 90) beside each page and the manifest lists the copy (`GUIDE_WEBP_MODE`; 25.6 MB → 4.2 MB); the viewer preloads only the next page (`ui/guide.js guidePreloadUrl`), not the previous one too. - `projectiles/*` — battle projectile sprites. - `spine/enemy//` — enemy models no web dump carries (灼热 / 炽焰源石虫, `extract.py ENEMY_SPINES`): manifest `enemies[id].spineLocal` (metadata from the committed `tools/assets/local-enemy-spines.json`); drawn instead of the tinted web alias when every file is listed (§21.14). - `spine/token//` — token (summon) models no web dump carries (40: most 自选 summons, 凯瑟琳's 爬行号·防护单元, 凛御银灰's 风雪之眼; `extract.py TOKEN_SPINES`, the Front renderer's skeleton of each battle token prefab): manifest `tokens[id].spineLocal` (metadata from the committed `tools/assets/local-token-spines.json`); drawn instead of the avatar diamond when every file is listed (docs/ASSETS.md "Token models from the local client"). diff --git a/docs/history/0.2.6.md b/docs/history/0.2.6.md index 03f44202..d9fadb85 100644 --- a/docs/history/0.2.6.md +++ b/docs/history/0.2.6.md @@ -24,14 +24,17 @@ committed data. line: design/network.md §8 Transport. - **玩法说明 pages as WebP.** `tools/local-extract/extract.py` writes a WebP copy beside each of the 19 local pages and the manifest lists it (`GUIDE_WEBP_MODE`); the guide preloads only the next page instead of both neighbours. The - copies are lossless (25.6 MB → 17.4 MB on the CN client's pages). Lossy quality 90 would make them 4.2 MB; per the - decision that tutorial pages may go lossy only after a side-by-side comparison, that switch (one constant, then - `extract.py --webp`) is left to the maintainer. Normative lines: design/client.md §13, ASSETS.md Post-processing. + copies are lossy at quality 90: 25.6 MB → 4.2 MB on the CN client's pages (lossless would be 17.4 MB). Tutorial pages + could go lossy only after a side-by-side comparison; the maintainer looked at 2× crops of four pages on 2026-10-12 and + chose quality 90 (`GUIDE_WEBP_MODE`; `extract.py --webp` rewrites existing copies). Normative lines: design/client.md + §13, ASSETS.md Post-processing. - **Traffic counters.** `GET /healthz?traffic=1` adds `traffic` (HTTP body bytes and requests by category — assets, audio, code, data, other — and 304s; WebSocket wire bytes after compression and payload bytes before it) and `assetHashes` (the index state). The 问题反馈 diagnostics gain a `traffic:` line: the page's Resource Timing `transferSize` by the same categories (`shared/assetPaths.js trafficCategory`), other sites apart. Nothing is stored - or uploaded. + or uploaded. The maintainer's decision of 2026-10-12: only a loopback or private-network client gets the counters + (`server/http/routes.js`, the address from `net.js clientAddress`, so TRUST_PROXY applies as for the WebSocket + limits); any other client — also a visitor that a tunnel or proxy on this machine forwards — gets the plain report. - **DEPLOY.md**: a stable address keeps every player's cache, settings and reconnect token (a quick tunnel's new domain is a new site to the browser); HTTP/2 through the proxy saves round trips, not bytes; browsers offer brotli on HTTPS pages and localhost only; a Cloudflare cache rule for `/assets/`, `/fonts/`, `/media/` (with the risk note). @@ -82,7 +85,7 @@ changed, while a hashed URL is never asked for again until its bytes change. ### 31.4 Open items and tests Open: `/fonts/fonts.css` (linked by index.html) and the woff2 files it names keep plain URLs (content ETags only); the -optional versioned vendor URLs (layer 1.5) are not done; lossy tutorial pages wait for the comparison. +optional versioned vendor URLs (layer 1.5) are not done. Tests: `test/ui/audio.test.js` (BGM gating; hashed audio through `/media/~hash/`), `test/ws-compression.test.js`, `test/local-extract.test.js` (guide WebP), `test/ui/localArt.test.js` (next-page preload), `test/traffic.test.js`, diff --git a/server/http/routes.js b/server/http/routes.js index 3bef18da..fe3f505d 100644 --- a/server/http/routes.js +++ b/server/http/routes.js @@ -5,7 +5,9 @@ // * any method but GET / HEAD → 405 with `Allow: GET, HEAD`; // * GET /healthz → JSON status (protocol `version`, release `app`, uptime, the served `build`, sockets, sessions, // rooms, matches), never cached; `?traffic=1` adds the traffic counters (traffic.js: bytes by category since the -// start, WebSocket wire and payload bytes); +// start, WebSocket wire and payload bytes) for a loopback or private-network client only — the maintainer's +// decision of 2026-10-12: the figures are the host's, so any other client, also one a tunnel or proxy on this +// machine forwards, gets the plain report (net.js clientAddress, honouring TRUST_PROXY like the WebSocket limits); // * everything else → the static files (static.js). // Every response's body bytes count in the traffic counters (traffic.js meter). A route that throws is logged and // answers 500. @@ -13,6 +15,7 @@ import { PROTOCOL_VERSION, APP_VERSION } from '../../shared/constants.js'; import { buildTag } from './buildTag.js'; import { setSecurityHeaders, sendError, sendJson, splitUrl } from './common.js'; +import { clientAddress, isLocalIp } from '../net.js'; const MAX_URL_LENGTH = 4096; @@ -45,10 +48,11 @@ const wantsTraffic = (query) => /(?:^|&)traffic(?:=(?:1|true|yes))?(?:&|$)/i.tes * The request listener for `http.createServer`. * @param {{ serveStatic: (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse, * rawPath: string, query: string) => Promise, - * health: Parameters[0], log: object }} deps + * health: Parameters[0], log: object, trustProxy?: 'auto' | boolean }} deps + * trustProxy: whose forwarding headers name the client (net.js clientAddress; the server's TRUST_PROXY) * @returns {(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => void} */ -export function createRequestHandler({ serveStatic, health, log }) { +export function createRequestHandler({ serveStatic, health, log, trustProxy = 'auto' }) { async function handleRequest(req, res) { const url = req.url || '/'; if (url.length > MAX_URL_LENGTH) { sendError(req, res, 414, '请求地址过长 · URI too long'); return; } @@ -60,7 +64,8 @@ export function createRequestHandler({ serveStatic, health, log }) { return; } if (parts.rawPath === '/healthz') { - sendJson(req, res, 200, healthReport(health, wantsTraffic(parts.query))); + const withTraffic = wantsTraffic(parts.query) && isLocalIp(clientAddress(req, trustProxy).ip); + sendJson(req, res, 200, healthReport(health, withTraffic)); return; } await serveStatic(req, res, parts.rawPath, parts.query); diff --git a/server/index.js b/server/index.js index bf9e9bb7..9d2f9943 100644 --- a/server/index.js +++ b/server/index.js @@ -90,7 +90,7 @@ export async function startServer(opts = {}) { buildTag(); const traffic = createTrafficCounter(); - const server = http.createServer(createRequestHandler({ serveStatic, health: { startedAt, network, registry, lobby, traffic, assetIndex, views }, log })); + const server = http.createServer(createRequestHandler({ serveStatic, health: { startedAt, network, registry, lobby, traffic, assetIndex, views }, log, trustProxy: network.opts.trustProxy })); server.on('clientError', answerClientError); const wss = attachWebSocket(server, { network, log, traffic }); diff --git a/test/local-extract.test.js b/test/local-extract.test.js index e22ca151..c7980b4c 100644 --- a/test/local-extract.test.js +++ b/test/local-extract.test.js @@ -210,11 +210,11 @@ print(json.dumps(e.prefab_node(go)))`); assert.equal(raw.stdout.trim(), '[0.0, 0, 0.0]'); }); - test('guide pages (host bandwidth layer 0): --webp writes a lossless WebP copy of every page PNG, the manifest lists it, the PNG stays', + test('guide pages (host bandwidth layer 0): --webp writes a WebP copy of every page PNG (lossy q90 by default, lossless on request), the manifest lists it, the PNG stays', { skip: spawnSync(PY, ['-c', "import sys; from PIL import features; sys.exit(0 if features.check('webp') else 1)"], { env: ENV }).status !== 0 && 'no Pillow with WebP' }, () => { const jobs = JSON.parse(spawnSync(PY, [path.join(TOOL, 'extract.py'), '--print-jobs'], { encoding: 'utf8', env: ENV }).stdout); assert.deepEqual(jobs.guideWebp.sub, 'guide'); - assert.equal(jobs.guideWebp.mode, 'lossless', 'lossy pages wait for the maintainer\'s comparison'); + assert.equal(jobs.guideWebp.mode, 'lossy', 'quality 90, the maintainer\'s choice of 2026-10-12'); assert.ok(!jobs.webp.some((w) => w.sub === 'guide'), 'the board texture table is unchanged'); const out = py(` import tempfile, pathlib @@ -235,20 +235,25 @@ mf.write_text(json.dumps({'version': 1, 'source': 'local-client', 'count': 4, 'g code = e.webp_only(root, mf, lambda *a: None) doc = json.loads(mf.read_text()) g = doc['groups']['guide'] +modes = {k: v.get('webp') for k, v in g.items()} px = lambda p: list(Image.open(p).convert('RGBA').getdata()) -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()}, +e.GUIDE_WEBP_MODE = 'lossless' +again = e.run_webp(root, 'guide', doc['groups'], lambda *a: None) +print(json.dumps({'code': code, 'paths': {k: v['path'] for k, v in g.items()}, 'modes': modes, + 'relisted': {k: v.get('webp') for k, v in g.items()}, 'other': doc['groups']['ui/battle']['x']['path'], 'same': all(px(sub / f'{n}.png') == px(sub / f'{n}.webp') for n in ['autochess_home_1', 'autochess_shop_2']), 'png': all((sub / f'{n}.png').exists() for n in ['autochess_home_1', 'autochess_shop_2']), - 'again': e.run_webp(root, 'guide', doc['groups'], lambda *a: None)}))`); + 'again': again}))`); assert.equal(out.code, 0); const at = (n, ext) => `/assets/local/guide/${n}.${ext}`; assert.deepEqual(out.paths, { autochess_home_1: at('autochess_home_1', 'webp'), autochess_shop_2: at('autochess_shop_2', 'webp'), autochess_handbook_9: at('autochess_handbook_9', 'png') }); - assert.deepEqual(out.modes, { autochess_home_1: 'lossless', autochess_shop_2: 'lossless', autochess_handbook_9: null }); + assert.deepEqual(out.modes, { autochess_home_1: 'lossy', autochess_shop_2: 'lossy', autochess_handbook_9: null }); assert.equal(out.other, '/assets/local/ui/battle/x.png', 'other groups keep their PNGs'); - assert.ok(out.same, 'lossless: the same pixels, alpha included'); assert.ok(out.png, 'the PNG stays beside the copy'); assert.equal(out.again, 2, 'a second run (another GUIDE_WEBP_MODE) rewrites the copies the manifest already lists'); + assert.deepEqual(out.relisted, { autochess_home_1: 'lossless', autochess_shop_2: 'lossless', autochess_handbook_9: null }); + assert.ok(out.same, 'lossless: the same pixels, alpha included'); }); }); diff --git a/test/traffic.test.js b/test/traffic.test.js index 183978d2..2ebf266e 100644 --- a/test/traffic.test.js +++ b/test/traffic.test.js @@ -103,3 +103,35 @@ test('the counter on its own: a string body in another encoding, an aborted resp assert.equal(ws.payloadIn, 7); assert.equal(tc.snapshot().seconds, 0); }); + +test('/healthz?traffic=1 answers the counters to a loopback / LAN client only (maintainer, 2026-10-12)', async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-traffic-lan-')); + fs.mkdirSync(path.join(tmp, 'public'), { recursive: true }); + fs.mkdirSync(path.join(tmp, 'data'), { recursive: true }); + fs.mkdirSync(path.join(tmp, 'shared'), { recursive: true }); + fs.writeFileSync(path.join(tmp, 'public/index.html'), '

hi

'); + const dirs = { publicDir: path.join(tmp, 'public'), dataDir: path.join(tmp, 'data'), sharedDir: path.join(tmp, 'shared') }; + const has = async (port, headers) => { + const r = await get(port, '/healthz?traffic=1', headers); + assert.equal(r.status, 200); + const body = JSON.parse(r.body); + assert.equal(body.ok, true, 'the plain report still answers'); + return 'traffic' in body; + }; + const auto = await startServer({ port: 0, host: '127.0.0.1', quiet: true, ...dirs }); + try { + assert.equal(await has(auto.port), true, 'the host itself (loopback)'); + assert.equal(await has(auto.port, { 'x-forwarded-for': '192.168.1.20' }), true, 'a LAN client behind a local proxy'); + assert.equal(await has(auto.port, { 'x-forwarded-for': '100.101.102.103' }), true, 'a virtual-LAN (CGNAT range) address'); + assert.equal(await has(auto.port, { 'x-forwarded-for': '203.0.113.9' }), false, 'a public visitor through a tunnel / proxy on this machine'); + assert.equal(await has(auto.port, { 'cf-connecting-ip': '2001:db8::7' }), false, 'a public IPv6 visitor through cloudflared'); + } finally { + await auto.close(); + } + const ignore = await startServer({ port: 0, host: '127.0.0.1', quiet: true, trustProxy: false, ...dirs }); + try { + assert.equal(await has(ignore.port, { 'x-forwarded-for': '203.0.113.9' }), true, 'TRUST_PROXY=0: the loopback peer itself counts'); + } finally { + await ignore.close(); + } +}); diff --git a/tools/local-extract/extract.py b/tools/local-extract/extract.py index cc7b43d9..9f8cdaf0 100644 --- a/tools/local-extract/extract.py +++ b/tools/local-extract/extract.py @@ -191,12 +191,12 @@ WEBP_OPTIONS = { # The 19 玩法说明 pages (output subdir guide: 1024², opaque) get a WebP copy too — every page PNG of the group, each a # 1.2–1.5 MB download whenever a player opens the guide (public/js/ui/guide.js shows the local page first and preloads -# the next one). Lossless by default: 25.6 MB → 17.4 MB on the CN client's pages. Lossy quality 90 would make them -# 4.2 MB; the maintainer decides on that after a side-by-side comparison (DESIGN §31.1, docs/history/0.2.6.md), -# and then only GUIDE_WEBP_MODE changes (`extract.py --webp` rewrites the copies). Method 4: method 6 spends about 8 s +# the next one). Lossy quality 90, the maintainer's choice of 2026-10-12 after a side-by-side comparison at 2× (DESIGN +# §31.1, docs/history/0.2.6.md): 25.6 MB of PNG → 4.2 MB on the CN client's pages (lossless would be 17.4 MB); +# `extract.py --webp` rewrites the copies when GUIDE_WEBP_MODE changes. Method 4: method 6 spends about 8 s # a page for 0.4 % less. GUIDE_WEBP_SUB = 'guide' -GUIDE_WEBP_MODE = 'lossless' +GUIDE_WEBP_MODE = 'lossy' GUIDE_WEBP_OPTIONS = { 'lossless': {'lossless': True, 'quality': 100, 'method': 4, 'exact': True}, 'lossy': {'quality': 90, 'method': 4, 'alpha_quality': 100},