Add visual asset quality audit
This commit is contained in:
274
docs/visual-asset-quality-audit.md
Normal file
274
docs/visual-asset-quality-audit.md
Normal 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 |
|
||||
@@ -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",
|
||||
|
||||
416
scripts/audit-visual-asset-quality.mjs
Normal file
416
scripts/audit-visual-asset-quality.mjs
Normal 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';
|
||||
}
|
||||
Reference in New Issue
Block a user