Add visual asset quality audit

This commit is contained in:
2026-07-10 00:44:05 +09:00
parent 7ff5baa12c
commit 9565ca31d4
3 changed files with 691 additions and 0 deletions

View File

@@ -0,0 +1,274 @@
# Visual Asset Quality Audit
Generated by `scripts/audit-visual-asset-quality.mjs`.
## Summary
- Target desktop viewport: 1280x720
- Battle map assets: 66
- Flagged battle map review rows: 43
- Battle map source pixel density range: 112.0-224.0 px/tile
- Story source backgrounds: 136
- Story page background references: 466
- Flagged story background review rows: 120
## Main Findings
- Existing verification already gates missing files and minimum dimensions; this audit ranks visual-review risk instead of failing the build.
- Battle maps are scored by source pixels per scenario tile, source/image aspect match, and WebP byte density.
- Story backgrounds are scored by standard 1672x941 dimensions, repeat usage, and PNG byte density.
- The audit cannot judge composition or painterly quality by itself; top rows are candidates for desktop browser screenshot review.
## Recommended Next Batch
- Battle map candidate: `sixty-fourth-battle-weishui-camps` / `battle-map-sixty-fourth`
- Battle reason: low tile pixel density
- Story background candidate: `src/assets/images/story/25-yizhou-luo-road.png`
- Story reason: high-repeat story background
- Suggested workflow: capture the top battle candidate in a 1280x720 battle scene, inspect terrain readability under units/grid, then regenerate or repaint only that asset if the screenshot confirms the issue.
## Top Battle Map Review Queue
| score | battle id | map key | grid | image | px/tile | bytes/px | concerns |
| ---: | --- | --- | ---: | ---: | ---: | ---: | --- |
| 6 | `sixty-fourth-battle-weishui-camps` | `battle-map-sixty-fourth` | 128x110 | 14336x12320 | 112.0 | 0.1034 | low tile pixel density |
| 6 | `sixty-sixth-battle-wuzhang-final` | `battle-map-sixty-sixth` | 132x114 | 14784x12768 | 112.0 | 0.1058 | low tile pixel density |
| 6 | `sixty-fifth-battle-weishui-northbank` | `battle-map-sixty-fifth` | 130x112 | 14560x12544 | 112.0 | 0.1059 | low tile pixel density |
| 6 | `sixty-third-battle-lucheng-pursuit` | `battle-map-sixty-third` | 126x108 | 14112x12096 | 112.0 | 0.1200 | low tile pixel density |
| 6 | `sixtieth-battle-wudu-yinping` | `battle-map-sixtieth` | 120x102 | 14400x12240 | 120.0 | 0.1116 | low tile pixel density |
| 6 | `sixty-first-battle-hanzhong-rain-defense` | `battle-map-sixty-first` | 122x104 | 14640x12480 | 120.0 | 0.1162 | low tile pixel density |
| 6 | `sixty-second-battle-qishan-renewed-offensive` | `battle-map-sixty-second` | 124x106 | 14880x12720 | 120.0 | 0.1222 | low tile pixel density |
| 4 | `fifty-sixth-battle-tianshui-advance` | `battle-map-fifty-sixth` | 112x94 | 14336x12032 | 128.0 | 0.0939 | reduced tile pixel density |
| 4 | `fifty-seventh-battle-jieting-crisis` | `battle-map-fifty-seventh` | 114x96 | 14592x12288 | 128.0 | 0.0987 | reduced tile pixel density |
| 4 | `fifty-eighth-battle-qishan-retreat` | `battle-map-fifty-eighth` | 116x98 | 14848x12544 | 128.0 | 0.1003 | reduced tile pixel density |
| 4 | `fifty-ninth-battle-chencang-siege` | `battle-map-fifty-ninth` | 118x100 | 15104x12800 | 128.0 | 0.1049 | reduced tile pixel density |
| 4 | `fifty-fifth-battle-northern-qishan-road` | `battle-map-fifty-fifth` | 110x92 | 14080x11776 | 128.0 | 0.1146 | reduced tile pixel density |
## Top Story Background Review Queue
| score | source | page refs | image | bytes/px | concerns | examples |
| ---: | --- | ---: | ---: | ---: | --- | --- |
| 4 | `src/assets/images/story/25-yizhou-luo-road.png` | 49 | 1672x941 | 2.4004 | high-repeat story background | `twentySeventhBattleIntroPages/twenty-seventh-yizhou-request`<br>`twentySeventhBattleIntroPages/twenty-seventh-pang-tong-rumor`<br>`twentySeventhBattleIntroPages/twenty-seventh-gate-warning` |
| 4 | `src/assets/images/story/28-nanzhong-seven-captures.png` | 42 | 1672x941 | 2.4106 | high-repeat story background | `fortyEighthBattleIntroPages/forty-eighth-meng-huo-gathers`<br>`fortyEighthBattleIntroPages/forty-eighth-zhuge-amnesty`<br>`fortyEighthBattleIntroPages/forty-eighth-sortie` |
| 4 | `src/assets/images/story/09-wandering-road.png` | 39 | 1672x941 | 1.3390 | high-repeat story background | `tenthBattleIntroPages/tenth-zhang-fei-fault`<br>`tenthBattleIntroPages/tenth-lu-bu-takes-xuzhou`<br>`tenthBattleIntroPages/tenth-brothers-regroup` |
| 4 | `src/assets/images/story/13-northern-expedition.png` | 36 | 1672x941 | 1.5690 | high-repeat story background | `thirtyFifthBattleIntroPages/thirty-fifth-hanzhong-front`<br>`thirtyFifthBattleIntroPages/thirty-fifth-ma-chao-first-sortie`<br>`thirtyFifthBattleIntroPages/thirty-fifth-huang-quan-map` |
| 4 | `src/assets/images/story/12-yizhou-mountain-pass.png` | 29 | 1672x941 | 1.6969 | high-repeat story background | `twentyThirdBattleIntroPages/twenty-third-jingzhou-cause`<br>`twentyThirdBattleIntroPages/twenty-third-zhuge-plan`<br>`twentyThirdBattleIntroPages/twenty-third-ma-liang-rumor` |
| 4 | `src/assets/images/story/11-red-cliffs-fire.png` | 28 | 1672x941 | 1.5183 | high-repeat story background | `nineteenthBattleIntroPages/nineteenth-cao-army-south`<br>`nineteenthBattleIntroPages/nineteenth-zhao-yun-rescue`<br>`nineteenthBattleIntroPages/nineteenth-zhang-fei-bridge` |
| 4 | `src/assets/images/story/06-yellow-turban-pursuit.png` | 26 | 1672x941 | 1.5888 | high-repeat story background | `firstBattleVictoryPages/first-victory-village`<br>`firstBattleVictoryPages/first-victory-liu-bei`<br>`firstBattleVictoryPages/first-victory-guan-yu` |
| 4 | `src/assets/images/story/29-fan-castle-flood.png` | 21 | 1672x941 | 1.5085 | high-repeat story background | `thirtyNinthBattleIntroPages/thirty-ninth-fan-castle-shadow`<br>`thirtyNinthBattleIntroPages/thirty-ninth-guan-yu-north`<br>`thirtyNinthBattleIntroPages/thirty-ninth-flooded-fields` |
| 4 | `src/assets/images/story/10-wolong-cottage.png` | 21 | 1672x941 | 1.6899 | high-repeat story background | `sixteenthBattleIntroPages/sixteenth-yuan-camp-shifts`<br>`sixteenthBattleIntroPages/sixteenth-zhao-yun-returns`<br>`sixteenthBattleIntroPages/sixteenth-liu-biao-road` |
| 4 | `src/assets/images/story/27-jingzhou-crisis.png` | 21 | 1672x941 | 2.1512 | high-repeat story background | `thirtyEighthBattleIntroPages/thirty-eighth-jing-warning`<br>`thirtyEighthBattleIntroPages/thirty-eighth-guan-yu-front`<br>`thirtyEighthBattleIntroPages/thirty-eighth-two-fronts` |
| 4 | `src/assets/images/story/08-xuzhou-handover.png` | 18 | 1672x941 | 1.3700 | high-repeat story background | `seventhBattleIntroPages/seventh-xuzhou-envoy-arrives`<br>`seventhBattleIntroPages/seventh-liu-bei-xuzhou`<br>`seventhBattleIntroPages/seventh-guan-yu-field` |
| 4 | `src/assets/images/story/18-jieting-crisis.png` | 13 | 1672x941 | 1.5299 | high-repeat story background | `fiftySixthBattleVictoryPages/fifty-sixth-next-jieting`<br>`fiftySeventhBattleIntroPages/fifty-seventh-jieting-order`<br>`fiftySeventhBattleIntroPages/fifty-seventh-ma-su-insists` |
## Battle Map Inventory
| battle id | map key | grid | image | px/tile | MiB | concerns |
| --- | --- | ---: | ---: | ---: | ---: | --- |
| `eighteenth-battle-bowang-ambush` | `battle-map-eighteenth` | 36x26 | 8064x5824 | 224.0 | 1.99 | low WebP byte density |
| `eighth-battle-xiaopei-supply-road` | `battle-map-eighth` | 26x22 | 5824x4928 | 224.0 | 0.99 | low WebP byte density |
| `eleventh-battle-xudu-refuge-road` | `battle-map-eleventh` | 28x22 | 6272x4928 | 224.0 | 1.11 | low WebP byte density |
| `fifteenth-battle-yuan-refuge-road` | `battle-map-fifteenth` | 34x24 | 7616x5376 | 224.0 | 1.33 | very low WebP byte density |
| `fifth-battle-sishui-vanguard` | `battle-map-fifth` | 24x20 | 5376x4480 | 224.0 | 0.62 | very low WebP byte density |
| `fiftieth-battle-meng-huo-third-capture` | `battle-map-fiftieth` | 100x82 | 15200x12464 | 152.0 | 19.13 | watch tile pixel density |
| `fifty-eighth-battle-qishan-retreat` | `battle-map-fifty-eighth` | 116x98 | 14848x12544 | 128.0 | 17.82 | reduced tile pixel density |
| `fifty-fifth-battle-northern-qishan-road` | `battle-map-fifty-fifth` | 110x92 | 14080x11776 | 128.0 | 18.11 | reduced tile pixel density |
| `fifty-first-battle-meng-huo-fourth-capture` | `battle-map-fifty-first` | 102x84 | 14688x12096 | 144.0 | 18.73 | watch tile pixel density |
| `fifty-fourth-battle-meng-huo-final-capture` | `battle-map-fifty-fourth` | 108x90 | 14688x12240 | 136.0 | 20.03 | reduced tile pixel density |
| `fifty-ninth-battle-chencang-siege` | `battle-map-fifty-ninth` | 118x100 | 15104x12800 | 128.0 | 19.33 | reduced tile pixel density |
| `fifty-second-battle-meng-huo-fifth-capture` | `battle-map-fifty-second` | 104x86 | 14976x12384 | 144.0 | 18.84 | watch tile pixel density |
| `fifty-seventh-battle-jieting-crisis` | `battle-map-fifty-seventh` | 114x96 | 14592x12288 | 128.0 | 16.89 | reduced tile pixel density |
| `fifty-sixth-battle-tianshui-advance` | `battle-map-fifty-sixth` | 112x94 | 14336x12032 | 128.0 | 15.45 | reduced tile pixel density |
| `fifty-third-battle-meng-huo-sixth-capture` | `battle-map-fifty-third` | 106x88 | 14416x11968 | 136.0 | 19.00 | reduced tile pixel density |
| `first-battle-zhuo-commandery` | `battle-map-first` | 20x18 | 4480x4032 | 224.0 | 0.50 | very low WebP byte density |
| `fortieth-battle-han-river-flood` | `battle-map-fortieth` | 80x62 | 15360x11904 | 192.0 | 11.76 | none |
| `forty-eighth-battle-meng-huo-main-force` | `battle-map-forty-eighth` | 96x78 | 15360x12480 | 160.0 | 16.44 | none |
| `forty-fifth-battle-yiling-vanguard` | `battle-map-forty-fifth` | 90x72 | 14400x11520 | 160.0 | 13.98 | none |
| `forty-first-battle-fan-castle-siege` | `battle-map-forty-first` | 82x64 | 15744x12288 | 192.0 | 12.58 | none |
| `forty-fourth-battle-maicheng-isolation` | `battle-map-forty-fourth` | 88x70 | 15488x12320 | 176.0 | 15.70 | none |
| `forty-ninth-battle-meng-huo-second-capture` | `battle-map-forty-ninth` | 98x80 | 14896x12160 | 152.0 | 17.61 | watch tile pixel density |
| `forty-second-battle-jing-rear-crisis` | `battle-map-forty-second` | 84x66 | 14784x11616 | 176.0 | 12.54 | none |
| `forty-seventh-battle-nanzhong-stabilization` | `battle-map-forty-seventh` | 94x76 | 15040x12160 | 160.0 | 16.22 | none |
| `forty-sixth-battle-yiling-fire` | `battle-map-forty-sixth` | 92x74 | 14720x11840 | 160.0 | 14.87 | none |
| `forty-third-battle-gongan-collapse` | `battle-map-forty-third` | 86x68 | 15136x11968 | 176.0 | 13.34 | none |
| `fourteenth-battle-cao-break` | `battle-map-fourteenth` | 32x24 | 7168x5376 | 224.0 | 1.31 | low WebP byte density |
| `fourth-battle-guangzong-camp` | `battle-map-fourth` | 24x20 | 5376x4480 | 224.0 | 0.63 | very low WebP byte density |
| `nineteenth-battle-changban-refuge` | `battle-map-nineteenth` | 38x28 | 8512x6272 | 224.0 | 2.08 | low WebP byte density |
| `ninth-battle-xuzhou-gate-night-raid` | `battle-map-ninth` | 26x22 | 5824x4928 | 224.0 | 1.00 | low WebP byte density |
| `second-battle-yellow-turban-pursuit` | `battle-map-second` | 24x20 | 5376x4480 | 224.0 | 0.65 | very low WebP byte density |
| `seventeenth-battle-wolong-visit-road` | `battle-map-seventeenth` | 34x26 | 7616x5824 | 224.0 | 1.81 | low WebP byte density |
| `seventh-battle-xuzhou-rescue` | `battle-map-seventh` | 24x20 | 5376x4480 | 224.0 | 0.65 | very low WebP byte density |
| `sixteenth-battle-liu-biao-refuge` | `battle-map-sixteenth` | 34x26 | 7616x5824 | 224.0 | 1.49 | low WebP byte density |
| `sixth-battle-jieqiao-relief` | `battle-map-sixth` | 24x20 | 5376x4480 | 224.0 | 0.64 | very low WebP byte density |
| `sixtieth-battle-wudu-yinping` | `battle-map-sixtieth` | 120x102 | 14400x12240 | 120.0 | 18.76 | low tile pixel density |
| `sixty-fifth-battle-weishui-northbank` | `battle-map-sixty-fifth` | 130x112 | 14560x12544 | 112.0 | 18.44 | low tile pixel density |
| `sixty-first-battle-hanzhong-rain-defense` | `battle-map-sixty-first` | 122x104 | 14640x12480 | 120.0 | 20.24 | low tile pixel density |
| `sixty-fourth-battle-weishui-camps` | `battle-map-sixty-fourth` | 128x110 | 14336x12320 | 112.0 | 17.41 | low tile pixel density |
| `sixty-second-battle-qishan-renewed-offensive` | `battle-map-sixty-second` | 124x106 | 14880x12720 | 120.0 | 22.05 | low tile pixel density |
| `sixty-sixth-battle-wuzhang-final` | `battle-map-sixty-sixth` | 132x114 | 14784x12768 | 112.0 | 19.05 | low tile pixel density |
| `sixty-third-battle-lucheng-pursuit` | `battle-map-sixty-third` | 126x108 | 14112x12096 | 112.0 | 19.53 | low tile pixel density |
| `tenth-battle-xuzhou-breakout` | `battle-map-tenth` | 28x22 | 6272x4928 | 224.0 | 1.16 | low WebP byte density |
| `third-battle-guangzong-road` | `battle-map-third` | 24x20 | 5376x4480 | 224.0 | 0.65 | very low WebP byte density |
| `thirteenth-battle-xiapi-final` | `battle-map-thirteenth` | 32x24 | 7168x5376 | 224.0 | 1.32 | low WebP byte density |
| `thirtieth-battle-luofeng-ambush` | `battle-map-thirtieth` | 60x42 | 11520x8064 | 192.0 | 5.18 | none |
| `thirty-eighth-battle-jing-defense` | `battle-map-thirty-eighth` | 76x58 | 14592x11136 | 192.0 | 10.88 | none |
| `thirty-fifth-battle-yangping-scout` | `battle-map-thirty-fifth` | 70x52 | 13440x9984 | 192.0 | 8.67 | none |
| `thirty-first-battle-luo-main-gate` | `battle-map-thirty-first` | 62x44 | 11904x8448 | 192.0 | 6.17 | none |
| `thirty-fourth-battle-jiameng-pass` | `battle-map-thirty-fourth` | 68x50 | 13056x9600 | 192.0 | 7.99 | none |
| `thirty-ninth-battle-fan-castle-vanguard` | `battle-map-thirty-ninth` | 78x60 | 14976x11520 | 192.0 | 11.19 | none |
| `thirty-second-battle-mianzhu-gate` | `battle-map-thirty-second` | 64x46 | 12288x8832 | 192.0 | 6.75 | none |
| `thirty-seventh-battle-hanzhong-decisive` | `battle-map-thirty-seventh` | 74x56 | 14208x10752 | 192.0 | 9.92 | none |
| `thirty-sixth-battle-dingjun-vanguard` | `battle-map-thirty-sixth` | 72x54 | 13824x10368 | 192.0 | 9.26 | none |
| `thirty-third-battle-chengdu-surrender` | `battle-map-thirty-third` | 66x48 | 12672x9216 | 192.0 | 7.44 | none |
| `twelfth-battle-xiapi-outer-siege` | `battle-map-twelfth` | 30x24 | 6720x5376 | 224.0 | 1.25 | low WebP byte density |
| `twentieth-battle-jiangdong-envoy` | `battle-map-twentieth` | 40x28 | 8960x6272 | 224.0 | 2.05 | low WebP byte density |
| `twenty-eighth-battle-fu-pass-entry` | `battle-map-twenty-eighth` | 56x38 | 10752x7296 | 192.0 | 4.51 | none |
| `twenty-fifth-battle-wuling-mountain-road` | `battle-map-twenty-fifth` | 50x34 | 11200x7616 | 224.0 | 3.69 | none |
| `twenty-first-battle-red-cliffs-vanguard` | `battle-map-twenty-first` | 42x30 | 9408x6720 | 224.0 | 2.19 | low WebP byte density |
| `twenty-fourth-battle-guiyang-persuasion` | `battle-map-twenty-fourth` | 48x32 | 10752x7168 | 224.0 | 2.81 | low WebP byte density |
| `twenty-ninth-battle-luo-outer-wall` | `battle-map-twenty-ninth` | 58x40 | 11136x7680 | 192.0 | 4.74 | none |
| `twenty-second-battle-red-cliffs-fire` | `battle-map-twenty-second` | 44x30 | 9856x6720 | 224.0 | 2.47 | low WebP byte density |
| `twenty-seventh-battle-yizhou-relief-road` | `battle-map-twenty-seventh` | 54x36 | 10368x6912 | 192.0 | 3.89 | none |
| `twenty-sixth-battle-changsha-veteran` | `battle-map-twenty-sixth` | 52x34 | 9984x6528 | 192.0 | 2.73 | low WebP byte density |
| `twenty-third-battle-jingzhou-south-entry` | `battle-map-twenty-third` | 46x32 | 10304x7168 | 224.0 | 2.66 | low WebP byte density |
## Story Background Inventory
| source | page refs | image | MiB | concerns |
| --- | ---: | ---: | ---: | --- |
| `src/assets/images/story/01-yellow-turban-chaos.png` | 1 | 1672x941 | 2.17 | none |
| `src/assets/images/story/02-liu-bei-resolve.png` | 1 | 1672x941 | 2.05 | none |
| `src/assets/images/story/03-three-heroes-meet.png` | 2 | 1672x941 | 2.21 | none |
| `src/assets/images/story/04-volunteer-militia.png` | 1 | 1672x941 | 2.26 | none |
| `src/assets/images/story/05-first-sortie.png` | 4 | 1672x941 | 2.39 | none |
| `src/assets/images/story/06-yellow-turban-pursuit.png` | 26 | 1672x941 | 2.38 | high-repeat story background |
| `src/assets/images/story/07-anti-dong-pass.png` | 12 | 1672x941 | 2.61 | high-repeat story background |
| `src/assets/images/story/08-xuzhou-handover.png` | 18 | 1672x941 | 2.06 | high-repeat story background |
| `src/assets/images/story/09-wandering-road.png` | 39 | 1672x941 | 2.01 | high-repeat story background |
| `src/assets/images/story/10-wolong-cottage.png` | 21 | 1672x941 | 2.54 | high-repeat story background |
| `src/assets/images/story/100-jieting-crisis-wooden-palisade.png` | 0 | 1672x941 | 2.81 | unused source background |
| `src/assets/images/story/101-chengdu-surrender-river-gate.png` | 0 | 1672x941 | 2.67 | unused source background |
| `src/assets/images/story/102-yiling-baidi-lantern-vigil.png` | 0 | 1672x941 | 2.26 | unused source background |
| `src/assets/images/story/103-weishui-northbank-frozen-ferry.png` | 0 | 1672x941 | 2.75 | unused source background |
| `src/assets/images/story/104-lucheng-pursuit-dust-road.png` | 0 | 1672x941 | 3.01 | unused source background |
| `src/assets/images/story/105-qishan-renewed-snow-relay.png` | 0 | 1672x941 | 2.81 | unused source background |
| `src/assets/images/story/106-hanzhong-rain-supply-trench.png` | 0 | 1672x941 | 2.49 | unused source background |
| `src/assets/images/story/107-maicheng-isolation-abandoned-gate.png` | 0 | 1672x941 | 2.45 | unused source background |
| `src/assets/images/story/108-yiling-fire-forest-corridor.png` | 0 | 1672x941 | 2.44 | unused source background |
| `src/assets/images/story/109-wudu-yinping-cliff-granary.png` | 0 | 1672x941 | 2.87 | unused source background |
| `src/assets/images/story/11-red-cliffs-fire.png` | 28 | 1672x941 | 2.28 | high-repeat story background |
| `src/assets/images/story/110-baidi-entrustment-medicine-chamber.png` | 0 | 1672x941 | 2.40 | unused source background |
| `src/assets/images/story/111-tianshui-front-market-command.png` | 0 | 1672x941 | 2.46 | unused source background |
| `src/assets/images/story/112-chencang-siege-ladder-yard.png` | 0 | 1672x941 | 2.65 | unused source background |
| `src/assets/images/story/113-anti-dong-coalition-oath-camp.png` | 0 | 1672x941 | 2.57 | unused source background |
| `src/assets/images/story/114-red-cliffs-wind-altar.png` | 0 | 1672x941 | 2.33 | unused source background |
| `src/assets/images/story/115-qishan-renewed-frozen-scout-line.png` | 0 | 1672x941 | 2.50 | unused source background |
| `src/assets/images/story/116-yiling-fire-attack-river-ashes.png` | 0 | 1672x941 | 2.26 | unused source background |
| `src/assets/images/story/117-baidi-entrustment-river-watch.png` | 0 | 1672x941 | 2.10 | unused source background |
| `src/assets/images/story/118-tianshui-front-hidden-letter.png` | 0 | 1672x941 | 2.35 | unused source background |
| `src/assets/images/story/119-hanzhong-rain-mountain-shrine.png` | 0 | 1672x941 | 2.67 | unused source background |
| `src/assets/images/story/12-yizhou-mountain-pass.png` | 29 | 1672x941 | 2.55 | high-repeat story background |
| `src/assets/images/story/120-maicheng-isolation-snow-bridge.png` | 0 | 1672x941 | 2.29 | unused source background |
| `src/assets/images/story/121-wudu-yinping-cloud-signal.png` | 0 | 1672x941 | 2.68 | unused source background |
| `src/assets/images/story/122-chencang-siege-broken-ram.png` | 0 | 1672x941 | 2.46 | unused source background |
| `src/assets/images/story/123-yellow-pursuit-grain-relief.png` | 0 | 1672x941 | 2.51 | unused source background |
| `src/assets/images/story/124-nanzhong-captures-mist-stockade.png` | 0 | 1672x941 | 2.48 | unused source background |
| `src/assets/images/story/125-chaos-market-panic.png` | 0 | 1672x941 | 2.57 | unused source background |
| `src/assets/images/story/126-militia-training-yard.png` | 0 | 1672x941 | 2.52 | unused source background |
| `src/assets/images/story/127-sortie-dawn-muster.png` | 0 | 1672x941 | 2.45 | unused source background |
| `src/assets/images/story/128-liu-bei-village-resolve.png` | 0 | 1672x941 | 2.27 | unused source background |
| `src/assets/images/story/129-three-heroes-rain-shelter.png` | 0 | 1672x941 | 2.31 | unused source background |
| `src/assets/images/story/13-northern-expedition.png` | 36 | 1672x941 | 2.35 | high-repeat story background |
| `src/assets/images/story/130-lucheng-pursuit-ravine-signal.png` | 0 | 1672x941 | 2.72 | unused source background |
| `src/assets/images/story/131-weishui-camps-frozen-watch.png` | 0 | 1672x941 | 2.22 | unused source background |
| `src/assets/images/story/132-weishui-northbank-siege-line.png` | 0 | 1672x941 | 2.55 | unused source background |
| `src/assets/images/story/133-yiling-fire-attack-smoke-crossing.png` | 0 | 1672x941 | 2.37 | unused source background |
| `src/assets/images/story/134-chengdu-surrender-sealed-gate.png` | 0 | 1672x941 | 2.50 | unused source background |
| `src/assets/images/story/135-wuzhang-jiangwei-inheritance.png` | 2 | 1672x941 | 2.09 | none |
| `src/assets/images/story/14-nanzhong-pacification.png` | 7 | 1672x941 | 2.41 | none |
| `src/assets/images/story/15-yiling-baidi-aftermath.png` | 8 | 1672x941 | 2.20 | repeat story background |
| `src/assets/images/story/16-qishan-renewed-offensive.png` | 6 | 1672x941 | 2.26 | none |
| `src/assets/images/story/17-tianshui-front.png` | 5 | 1672x941 | 2.12 | none |
| `src/assets/images/story/18-jieting-crisis.png` | 13 | 1672x941 | 2.30 | high-repeat story background |
| `src/assets/images/story/19-chencang-siege.png` | 6 | 1672x941 | 2.38 | none |
| `src/assets/images/story/20-wudu-yinping.png` | 6 | 1672x941 | 2.55 | none |
| `src/assets/images/story/21-hanzhong-rain-defense.png` | 9 | 1672x941 | 2.25 | repeat story background |
| `src/assets/images/story/22-lucheng-pursuit.png` | 6 | 1672x941 | 2.03 | none |
| `src/assets/images/story/23-weishui-camps.png` | 6 | 1672x941 | 2.31 | none |
| `src/assets/images/story/24-weishui-northbank.png` | 8 | 1672x941 | 2.22 | repeat story background |
| `src/assets/images/story/25-yizhou-luo-road.png` | 49 | 1672x941 | 3.60 | high-repeat story background |
| `src/assets/images/story/26-chengdu-surrender.png` | 11 | 1672x941 | 3.64 | repeat story background |
| `src/assets/images/story/27-jingzhou-crisis.png` | 21 | 1672x941 | 3.23 | high-repeat story background |
| `src/assets/images/story/28-nanzhong-seven-captures.png` | 42 | 1672x941 | 3.62 | high-repeat story background |
| `src/assets/images/story/29-fan-castle-flood.png` | 21 | 1672x941 | 2.26 | high-repeat story background |
| `src/assets/images/story/30-maicheng-isolation.png` | 7 | 1672x941 | 1.80 | low PNG byte density |
| `src/assets/images/story/31-yiling-fire-attack.png` | 7 | 1672x941 | 2.10 | none |
| `src/assets/images/story/32-baidi-entrustment.png` | 5 | 1672x941 | 1.96 | none |
| `src/assets/images/story/33-yellow-pursuit-refugees.png` | 0 | 1672x941 | 2.41 | unused source background |
| `src/assets/images/story/34-xuzhou-gates-handover.png` | 0 | 1672x941 | 2.24 | unused source background |
| `src/assets/images/story/35-wolong-night-strategy.png` | 0 | 1672x941 | 2.12 | unused source background |
| `src/assets/images/story/36-red-cliffs-chain-fleet.png` | 0 | 1672x941 | 2.13 | unused source background |
| `src/assets/images/story/37-yizhou-luo-road-ambush.png` | 0 | 1672x941 | 2.60 | unused source background |
| `src/assets/images/story/38-yizhou-luo-road-supply-train.png` | 0 | 1672x941 | 2.96 | unused source background |
| `src/assets/images/story/39-nanzhong-captures-jungle-net.png` | 0 | 1672x941 | 3.12 | unused source background |
| `src/assets/images/story/40-nanzhong-captures-river-ford.png` | 0 | 1672x941 | 2.73 | unused source background |
| `src/assets/images/story/41-wandering-night-camp.png` | 0 | 1672x941 | 2.55 | unused source background |
| `src/assets/images/story/42-wandering-river-crossing.png` | 0 | 1672x941 | 2.60 | unused source background |
| `src/assets/images/story/43-northern-qishan-snow-march.png` | 0 | 1672x941 | 3.03 | unused source background |
| `src/assets/images/story/44-northern-wei-fortress.png` | 0 | 1672x941 | 2.93 | unused source background |
| `src/assets/images/story/45-yizhou-council-at-river.png` | 0 | 1672x941 | 2.89 | unused source background |
| `src/assets/images/story/46-yizhou-mountain-gate.png` | 0 | 1672x941 | 3.01 | unused source background |
| `src/assets/images/story/47-jingzhou-crisis-watchtower-alarm.png` | 0 | 1672x941 | 2.81 | unused source background |
| `src/assets/images/story/48-jingzhou-crisis-refugee-court.png` | 0 | 1672x941 | 2.78 | unused source background |
| `src/assets/images/story/49-fan-castle-flood-rising-water.png` | 0 | 1672x941 | 2.62 | unused source background |
| `src/assets/images/story/50-fan-castle-flood-siege-engines.png` | 0 | 1672x941 | 2.71 | unused source background |
| `src/assets/images/story/51-jieting-crisis-supply-line.png` | 0 | 1672x941 | 3.08 | unused source background |
| `src/assets/images/story/52-jieting-crisis-mountain-camp.png` | 0 | 1672x941 | 2.64 | unused source background |
| `src/assets/images/story/53-chengdu-surrender-hall.png` | 0 | 1672x941 | 2.96 | unused source background |
| `src/assets/images/story/54-anti-dong-hulao-pass.png` | 0 | 1672x941 | 2.85 | unused source background |
| `src/assets/images/story/55-yellow-pursuit-ambush.png` | 0 | 1672x941 | 2.65 | unused source background |
| `src/assets/images/story/56-yellow-pursuit-village-relief.png` | 0 | 1672x941 | 3.07 | unused source background |
| `src/assets/images/story/57-wolong-straw-boats.png` | 0 | 1672x941 | 2.40 | unused source background |
| `src/assets/images/story/58-red-cliffs-wind-prayer.png` | 0 | 1672x941 | 2.78 | unused source background |
| `src/assets/images/story/59-yiling-baidi-mountain-retreat.png` | 0 | 1672x941 | 2.84 | unused source background |
| `src/assets/images/story/60-maicheng-isolation-snow-road.png` | 0 | 1672x941 | 2.74 | unused source background |
| `src/assets/images/story/61-yiling-fire-attack-riverbank.png` | 0 | 1672x941 | 2.60 | unused source background |
| `src/assets/images/story/62-hanzhong-rain-night-watch.png` | 0 | 1672x941 | 2.55 | unused source background |
| `src/assets/images/story/63-weishui-northbank-cavalry-screen.png` | 0 | 1672x941 | 2.74 | unused source background |
| `src/assets/images/story/64-nanzhong-forest-council.png` | 0 | 1672x941 | 2.73 | unused source background |
| `src/assets/images/story/65-yizhou-luo-road-cliff-fortress.png` | 0 | 1672x941 | 2.36 | unused source background |
| `src/assets/images/story/66-yizhou-luo-road-mountain-scouts.png` | 0 | 1672x941 | 2.59 | unused source background |
| `src/assets/images/story/67-nanzhong-captures-bamboo-gorge.png` | 0 | 1672x941 | 2.46 | unused source background |
| `src/assets/images/story/68-nanzhong-captures-river-night-camp.png` | 0 | 1672x941 | 2.29 | unused source background |
| `src/assets/images/story/69-wandering-market-shelter.png` | 0 | 1672x941 | 2.31 | unused source background |
| `src/assets/images/story/70-wandering-cold-mountain-pass.png` | 0 | 1672x941 | 2.10 | unused source background |
| `src/assets/images/story/71-northern-supply-valley.png` | 0 | 1672x941 | 2.65 | unused source background |
| `src/assets/images/story/72-northern-mountain-war-council.png` | 0 | 1672x941 | 2.05 | unused source background |
| `src/assets/images/story/73-red-cliffs-chain-smoke.png` | 0 | 1672x941 | 2.20 | unused source background |
| `src/assets/images/story/74-wolong-river-strategy.png` | 0 | 1672x941 | 2.29 | unused source background |
| `src/assets/images/story/75-jingzhou-crisis-river-watchtowers.png` | 0 | 1672x941 | 2.38 | unused source background |
| `src/assets/images/story/76-fan-castle-flood-river-breach.png` | 0 | 1672x941 | 2.45 | unused source background |
| `src/assets/images/story/77-xuzhou-gate-council.png` | 0 | 1672x941 | 2.49 | unused source background |
| `src/assets/images/story/78-xuzhou-night-evacuation.png` | 0 | 1672x941 | 2.33 | unused source background |
| `src/assets/images/story/79-anti-dong-coalition-council.png` | 0 | 1672x941 | 2.62 | unused source background |
| `src/assets/images/story/80-anti-dong-night-assault.png` | 0 | 1672x941 | 2.37 | unused source background |
| `src/assets/images/story/81-chengdu-surrender-court.png` | 0 | 1672x941 | 2.50 | unused source background |
| `src/assets/images/story/82-chengdu-outer-gate-surrender.png` | 0 | 1672x941 | 2.73 | unused source background |
| `src/assets/images/story/83-wudu-yinping-cloud-road.png` | 0 | 1672x941 | 2.66 | unused source background |
| `src/assets/images/story/84-chencang-siege-fortress-standoff.png` | 0 | 1672x941 | 2.84 | unused source background |
| `src/assets/images/story/85-chencang-siege-engineering-camp.png` | 0 | 1672x941 | 2.49 | unused source background |
| `src/assets/images/story/86-weishui-camps-logistics.png` | 0 | 1672x941 | 2.53 | unused source background |
| `src/assets/images/story/87-weishui-camps-night-alarm.png` | 0 | 1672x941 | 2.40 | unused source background |
| `src/assets/images/story/88-lucheng-pursuit-valley-road.png` | 0 | 1672x941 | 2.59 | unused source background |
| `src/assets/images/story/89-yizhou-luo-road-hidden-bridge.png` | 0 | 1672x941 | 2.42 | unused source background |
| `src/assets/images/story/90-nanzhong-captures-monsoon-ravine.png` | 0 | 1672x941 | 2.80 | unused source background |
| `src/assets/images/story/91-wandering-snow-shrine.png` | 0 | 1672x941 | 2.59 | unused source background |
| `src/assets/images/story/92-northern-supply-depot.png` | 0 | 1672x941 | 2.73 | unused source background |
| `src/assets/images/story/93-yizhou-mountain-pass-command-post.png` | 0 | 1672x941 | 2.70 | unused source background |
| `src/assets/images/story/94-red-cliffs-dawn-aftermath.png` | 0 | 1672x941 | 2.43 | unused source background |
| `src/assets/images/story/95-yellow-pursuit-rural-crossroads.png` | 0 | 1672x941 | 2.95 | unused source background |
| `src/assets/images/story/96-wolong-autumn-study.png` | 0 | 1672x941 | 2.56 | unused source background |
| `src/assets/images/story/97-fan-castle-flood-prison-camp.png` | 0 | 1672x941 | 2.15 | unused source background |
| `src/assets/images/story/98-jingzhou-crisis-secret-letter.png` | 0 | 1672x941 | 2.22 | unused source background |
| `src/assets/images/story/99-xuzhou-flooded-courtyard.png` | 0 | 1672x941 | 2.69 | unused source background |
| `src/assets/images/taoyuan-oath-title.png` | 3 | 1672x941 | 2.24 | none |

View File

@@ -10,6 +10,7 @@
"generate:audio": "node scripts/generate-bgm.mjs",
"generate:sfx": "node scripts/generate-sfx.mjs",
"audit:equipment-icons": "node scripts/audit-equipment-icon-quality.mjs",
"audit:visual-assets": "node scripts/audit-visual-asset-quality.mjs",
"preview": "vite preview --host 0.0.0.0",
"measure:performance": "node scripts/measure-performance.mjs",
"verify:performance": "node scripts/measure-performance.mjs",

View File

@@ -0,0 +1,416 @@
import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { join, sep } from 'node:path';
import { createServer } from 'vite';
const battleMapDir = join('src', 'assets', 'images', 'battle');
const storyDir = join('src', 'assets', 'images', 'story');
const titleImagePath = join('src', 'assets', 'images', 'taoyuan-oath-title.png');
const reportPath = join('docs', 'visual-asset-quality-audit.md');
const targetViewport = { width: 1280, height: 720 };
const expectedStorySize = { width: 1672, height: 941 };
const server = await createServer({
logLevel: 'error',
server: { middlewareMode: true },
appType: 'custom'
});
try {
const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts');
const { battleMapAssets } = await server.ssrLoadModule('/src/game/data/battleMapAssets.ts');
const { storyBackgroundAssets, storyBackgroundKeysFor } = await server.ssrLoadModule('/src/game/data/storyAssets.ts');
const scenarioModule = await server.ssrLoadModule('/src/game/data/scenario.ts');
const battleRows = collectBattleRows(battleScenarios, battleMapAssets);
const storyUsage = collectStoryUsage(scenarioModule, storyBackgroundAssets, storyBackgroundKeysFor);
const storyRows = collectStoryRows(storyBackgroundAssets, storyUsage);
const report = renderReport({ battleRows, storyRows, storyUsage });
writeFileSync(reportPath, report, 'utf8');
console.log(`Wrote ${reportPath}`);
} finally {
await server.close();
}
function collectBattleRows(battleScenarios, battleMapAssets) {
return Object.values(battleScenarios)
.map((scenario) => {
const assetUrl = battleMapAssets[scenario.mapTextureKey];
const path = assetUrlToPath(assetUrl, 'src/assets/images/battle/');
const image = path ? readImageMetadata(path) : undefined;
const widthPxPerTile = image ? image.width / scenario.map.width : 0;
const heightPxPerTile = image ? image.height / scenario.map.height : 0;
const pxPerTile = Math.min(widthPxPerTile, heightPxPerTile);
const expectedAspect = scenario.map.width / scenario.map.height;
const actualAspect = image ? image.width / image.height : 0;
const aspectDelta = image ? Math.abs(actualAspect - expectedAspect) / expectedAspect : 1;
const concerns = battleConcerns({ image, pxPerTile, aspectDelta });
const score = concerns.reduce((sum, concern) => sum + concern.weight, 0);
return {
id: scenario.id,
title: scenario.title,
mapKey: scenario.mapTextureKey,
path,
mapColumns: scenario.map.width,
mapRows: scenario.map.height,
width: image?.width ?? 0,
height: image?.height ?? 0,
bytes: image?.bytes ?? 0,
bytesPerPixel: image ? image.bytes / (image.width * image.height) : 0,
pxPerTile,
aspectDelta,
concerns: concerns.map((concern) => concern.label),
score
};
})
.sort((left, right) => battleOrder(left, right));
}
function battleConcerns({ image, pxPerTile, aspectDelta }) {
const concerns = [];
if (!image) {
concerns.push({ label: 'missing readable image metadata', weight: 8 });
return concerns;
}
if (pxPerTile < 128) {
concerns.push({ label: 'low tile pixel density', weight: 6 });
} else if (pxPerTile < 144) {
concerns.push({ label: 'reduced tile pixel density', weight: 4 });
} else if (pxPerTile < 160) {
concerns.push({ label: 'watch tile pixel density', weight: 2 });
}
if (image.bytes / (image.width * image.height) < 0.035) {
concerns.push({ label: 'very low WebP byte density', weight: 4 });
} else if (image.bytes / (image.width * image.height) < 0.045) {
concerns.push({ label: 'low WebP byte density', weight: 2 });
}
if (aspectDelta > 0.005) {
concerns.push({ label: 'map/image aspect mismatch', weight: 5 });
}
return concerns;
}
function battleOrder(left, right) {
return (
right.score - left.score ||
left.pxPerTile - right.pxPerTile ||
left.bytesPerPixel - right.bytesPerPixel ||
left.id.localeCompare(right.id)
);
}
function collectStoryUsage(scenarioModule, storyBackgroundAssets, storyBackgroundKeysFor) {
const usageByPath = new Map();
const pageReferences = [];
Object.entries(scenarioModule).forEach(([exportName, value]) => {
if (!Array.isArray(value)) {
return;
}
value
.filter((entry) => entry && typeof entry === 'object' && typeof entry.background === 'string')
.forEach((page) => {
const candidateKeys = storyBackgroundKeysFor(page.background);
const resolvedKey = candidateKeys.find((key) => storyBackgroundAssets[key]) ?? page.background;
const path = assetUrlToPath(storyBackgroundAssets[resolvedKey], 'src/assets/images/');
const usage = usageByPath.get(path) ?? {
path,
keys: new Set(),
baseBackgrounds: new Set(),
pageCount: 0,
pageExamples: []
};
usage.keys.add(resolvedKey);
usage.baseBackgrounds.add(page.background);
usage.pageCount += 1;
if (usage.pageExamples.length < 3) {
usage.pageExamples.push(`${exportName}/${page.id ?? 'unnamed'}`);
}
usageByPath.set(path, usage);
pageReferences.push({ exportName, pageId: page.id, background: page.background, resolvedKey, path });
});
});
return {
pageReferences,
byPath: usageByPath
};
}
function collectStoryRows(storyBackgroundAssets, storyUsage) {
const sourcePaths = new Set([
...imageFiles(storyDir).map((fileName) => join(storyDir, fileName)),
titleImagePath
]);
Object.values(storyBackgroundAssets).forEach((url) => {
const path = assetUrlToPath(url, 'src/assets/images/');
if (path) {
sourcePaths.add(path);
}
});
return [...sourcePaths]
.sort()
.map((path) => {
const image = readImageMetadata(path);
const usage = storyUsage.byPath.get(path);
const concerns = storyConcerns({ image, usage });
const score = concerns.reduce((sum, concern) => sum + concern.weight, 0);
return {
path,
keySummary: usage ? [...usage.keys].sort().join(', ') : storyKeyFromPath(path),
width: image?.width ?? 0,
height: image?.height ?? 0,
bytes: image?.bytes ?? 0,
bytesPerPixel: image ? image.bytes / (image.width * image.height) : 0,
pageCount: usage?.pageCount ?? 0,
pageExamples: usage?.pageExamples ?? [],
concerns: concerns.map((concern) => concern.label),
score
};
})
.sort((left, right) => storyOrder(left, right));
}
function storyConcerns({ image, usage }) {
const concerns = [];
if (!image) {
concerns.push({ label: 'missing readable image metadata', weight: 8 });
return concerns;
}
if (image.width !== expectedStorySize.width || image.height !== expectedStorySize.height) {
concerns.push({ label: 'non-standard story dimensions', weight: 6 });
}
if ((usage?.pageCount ?? 0) >= 12) {
concerns.push({ label: 'high-repeat story background', weight: 4 });
} else if ((usage?.pageCount ?? 0) >= 8) {
concerns.push({ label: 'repeat story background', weight: 2 });
}
if (image.bytes / (image.width * image.height) < 1.2) {
concerns.push({ label: 'low PNG byte density', weight: 2 });
}
if ((usage?.pageCount ?? 0) === 0) {
concerns.push({ label: 'unused source background', weight: 1 });
}
return concerns;
}
function storyOrder(left, right) {
return (
right.score - left.score ||
right.pageCount - left.pageCount ||
left.bytesPerPixel - right.bytesPerPixel ||
left.path.localeCompare(right.path)
);
}
function renderReport({ battleRows, storyRows, storyUsage }) {
const flaggedBattleRows = battleRows.filter((row) => row.concerns.length > 0);
const flaggedStoryRows = storyRows.filter((row) => row.concerns.length > 0);
const topBattleRows = flaggedBattleRows.slice(0, 12);
const topStoryRows = flaggedStoryRows.slice(0, 12);
const nextBattle = topBattleRows[0];
const nextStory = topStoryRows[0];
const battlePxPerTileValues = battleRows.map((row) => row.pxPerTile);
const storyPageReferenceCount = storyUsage.pageReferences.length;
return `${[
'# Visual Asset Quality Audit',
'',
'Generated by `scripts/audit-visual-asset-quality.mjs`.',
'',
'## Summary',
'',
`- Target desktop viewport: ${targetViewport.width}x${targetViewport.height}`,
`- Battle map assets: ${battleRows.length}`,
`- Flagged battle map review rows: ${flaggedBattleRows.length}`,
`- Battle map source pixel density range: ${formatNumber(Math.min(...battlePxPerTileValues))}-${formatNumber(Math.max(...battlePxPerTileValues))} px/tile`,
`- Story source backgrounds: ${storyRows.length}`,
`- Story page background references: ${storyPageReferenceCount}`,
`- Flagged story background review rows: ${flaggedStoryRows.length}`,
'',
'## Main Findings',
'',
'- Existing verification already gates missing files and minimum dimensions; this audit ranks visual-review risk instead of failing the build.',
'- Battle maps are scored by source pixels per scenario tile, source/image aspect match, and WebP byte density.',
'- Story backgrounds are scored by standard 1672x941 dimensions, repeat usage, and PNG byte density.',
'- The audit cannot judge composition or painterly quality by itself; top rows are candidates for desktop browser screenshot review.',
'',
'## Recommended Next Batch',
'',
`- Battle map candidate: ${nextBattle ? `\`${nextBattle.id}\` / \`${nextBattle.mapKey}\`` : 'none flagged'}`,
`- Battle reason: ${nextBattle ? nextBattle.concerns.join(', ') : 'no battle map review rows are currently flagged'}`,
`- Story background candidate: ${nextStory ? `\`${shortPath(nextStory.path)}\`` : 'none flagged'}`,
`- Story reason: ${nextStory ? nextStory.concerns.join(', ') : 'no story background review rows are currently flagged'}`,
'- Suggested workflow: capture the top battle candidate in a 1280x720 battle scene, inspect terrain readability under units/grid, then regenerate or repaint only that asset if the screenshot confirms the issue.',
'',
'## Top Battle Map Review Queue',
'',
'| score | battle id | map key | grid | image | px/tile | bytes/px | concerns |',
'| ---: | --- | --- | ---: | ---: | ---: | ---: | --- |',
...topBattleRows.map(
(row) =>
`| ${row.score} | \`${row.id}\` | \`${row.mapKey}\` | ${row.mapColumns}x${row.mapRows} | ${row.width}x${row.height} | ${formatNumber(row.pxPerTile)} | ${formatNumber(row.bytesPerPixel, 4)} | ${row.concerns.join('<br>')} |`
),
'',
'## Top Story Background Review Queue',
'',
'| score | source | page refs | image | bytes/px | concerns | examples |',
'| ---: | --- | ---: | ---: | ---: | --- | --- |',
...topStoryRows.map(
(row) =>
`| ${row.score} | \`${shortPath(row.path)}\` | ${row.pageCount} | ${row.width}x${row.height} | ${formatNumber(row.bytesPerPixel, 4)} | ${row.concerns.join('<br>')} | ${row.pageExamples.map((example) => `\`${example}\``).join('<br>') || 'none'} |`
),
'',
'## Battle Map Inventory',
'',
'| battle id | map key | grid | image | px/tile | MiB | concerns |',
'| --- | --- | ---: | ---: | ---: | ---: | --- |',
...battleRows
.slice()
.sort((left, right) => left.id.localeCompare(right.id))
.map(
(row) =>
`| \`${row.id}\` | \`${row.mapKey}\` | ${row.mapColumns}x${row.mapRows} | ${row.width}x${row.height} | ${formatNumber(row.pxPerTile)} | ${formatNumber(row.bytes / 1024 / 1024, 2)} | ${row.concerns.join('<br>') || 'none'} |`
),
'',
'## Story Background Inventory',
'',
'| source | page refs | image | MiB | concerns |',
'| --- | ---: | ---: | ---: | --- |',
...storyRows
.slice()
.sort((left, right) => left.path.localeCompare(right.path))
.map(
(row) =>
`| \`${shortPath(row.path)}\` | ${row.pageCount} | ${row.width}x${row.height} | ${formatNumber(row.bytes / 1024 / 1024, 2)} | ${row.concerns.join('<br>') || 'none'} |`
),
''
].join('\n')}`;
}
function readImageMetadata(path) {
let bytes;
let stats;
try {
bytes = readFileSync(path);
stats = statSync(path);
} catch {
return undefined;
}
const lowerPath = path.toLowerCase();
if (lowerPath.endsWith('.png')) {
const dimensions = readPngDimensions(bytes);
return dimensions ? { ...dimensions, bytes: stats.size } : undefined;
}
if (lowerPath.endsWith('.webp')) {
const dimensions = readWebpDimensions(bytes);
return dimensions ? { ...dimensions, bytes: stats.size } : undefined;
}
return undefined;
}
function readPngDimensions(bytes) {
const pngSignature = '89504e470d0a1a0a';
if (bytes.subarray(0, 8).toString('hex') !== pngSignature || bytes.subarray(12, 16).toString('ascii') !== 'IHDR') {
return undefined;
}
return {
width: bytes.readUInt32BE(16),
height: bytes.readUInt32BE(20)
};
}
function readWebpDimensions(bytes) {
if (bytes.subarray(0, 4).toString('ascii') !== 'RIFF' || bytes.subarray(8, 12).toString('ascii') !== 'WEBP') {
return undefined;
}
let offset = 12;
while (offset + 8 <= bytes.length) {
const chunkType = bytes.subarray(offset, offset + 4).toString('ascii');
const chunkSize = bytes.readUInt32LE(offset + 4);
const dataOffset = offset + 8;
if (dataOffset + chunkSize > bytes.length) {
return undefined;
}
if (chunkType === 'VP8X' && chunkSize >= 10) {
return {
width: readUInt24LE(bytes, dataOffset + 4) + 1,
height: readUInt24LE(bytes, dataOffset + 7) + 1
};
}
if (chunkType === 'VP8L' && chunkSize >= 5 && bytes[dataOffset] === 0x2f) {
const bits =
bytes[dataOffset + 1] |
(bytes[dataOffset + 2] << 8) |
(bytes[dataOffset + 3] << 16) |
(bytes[dataOffset + 4] << 24);
return {
width: (bits & 0x3fff) + 1,
height: ((bits >>> 14) & 0x3fff) + 1
};
}
if (
chunkType === 'VP8 ' &&
chunkSize >= 10 &&
bytes[dataOffset + 3] === 0x9d &&
bytes[dataOffset + 4] === 0x01 &&
bytes[dataOffset + 5] === 0x2a
) {
return {
width: bytes.readUInt16LE(dataOffset + 6) & 0x3fff,
height: bytes.readUInt16LE(dataOffset + 8) & 0x3fff
};
}
offset = dataOffset + chunkSize + (chunkSize % 2);
}
return undefined;
}
function readUInt24LE(bytes, offset) {
return bytes[offset] | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16);
}
function assetUrlToPath(url, marker) {
if (typeof url !== 'string') {
return undefined;
}
const index = url.indexOf(marker);
if (index < 0) {
return undefined;
}
return url.slice(index).replaceAll('/', sep);
}
function imageFiles(dir) {
return readdirSync(dir, { withFileTypes: true })
.filter((entry) => entry.isFile())
.map((entry) => entry.name)
.sort()
.filter((fileName) => /\.(png|webp)$/i.test(fileName));
}
function storyKeyFromPath(path) {
const fileName = path.split(/[\\/]/).pop() ?? path;
const slug = fileName.replace(/\.png$/i, '').replace(/^\d+-/, '');
return `story-${slug}`;
}
function shortPath(path) {
return path.replaceAll('\\', '/');
}
function formatNumber(value, digits = 1) {
return Number.isFinite(value) ? value.toFixed(digits) : '0';
}