diff --git a/docs/visual-asset-quality-audit.md b/docs/visual-asset-quality-audit.md new file mode 100644 index 0000000..805526a --- /dev/null +++ b/docs/visual-asset-quality-audit.md @@ -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`
`twentySeventhBattleIntroPages/twenty-seventh-pang-tong-rumor`
`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`
`fortyEighthBattleIntroPages/forty-eighth-zhuge-amnesty`
`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`
`tenthBattleIntroPages/tenth-lu-bu-takes-xuzhou`
`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`
`thirtyFifthBattleIntroPages/thirty-fifth-ma-chao-first-sortie`
`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`
`twentyThirdBattleIntroPages/twenty-third-zhuge-plan`
`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`
`nineteenthBattleIntroPages/nineteenth-zhao-yun-rescue`
`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`
`firstBattleVictoryPages/first-victory-liu-bei`
`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`
`thirtyNinthBattleIntroPages/thirty-ninth-guan-yu-north`
`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`
`sixteenthBattleIntroPages/sixteenth-zhao-yun-returns`
`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`
`thirtyEighthBattleIntroPages/thirty-eighth-guan-yu-front`
`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`
`seventhBattleIntroPages/seventh-liu-bei-xuzhou`
`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`
`fiftySeventhBattleIntroPages/fifty-seventh-jieting-order`
`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 | diff --git a/package.json b/package.json index b23d71b..60f5219 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/audit-visual-asset-quality.mjs b/scripts/audit-visual-asset-quality.mjs new file mode 100644 index 0000000..92c36e0 --- /dev/null +++ b/scripts/audit-visual-asset-quality.mjs @@ -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('
')} |` + ), + '', + '## 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('
')} | ${row.pageExamples.map((example) => `\`${example}\``).join('
') || '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('
') || '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('
') || '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'; +}