Files
heros_web/scripts/audit-visual-asset-quality.mjs

441 lines
16 KiB
JavaScript

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: 1920, height: 1080 };
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 reviewedBattleMapPrefixes = collectReviewedBattleMapPrefixes();
const battleRows = collectBattleRows(battleScenarios, battleMapAssets, reviewedBattleMapPrefixes);
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, reviewedBattleMapPrefixes) {
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),
hasMapRepaintReport: reviewedBattleMapPrefixes.has(battleOrdinalPrefix(scenario.id)),
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 collectReviewedBattleMapPrefixes() {
try {
return new Set(
readdirSync('docs')
.map((fileName) => fileName.match(/^visual-asset-(.+)-map-repaint-v\d+-report\.md$/)?.[1])
.filter(Boolean)
);
} catch {
return new Set();
}
}
function battleOrdinalPrefix(battleId) {
return battleId.match(/^(.+)-battle-/)?.[1] ?? battleId;
}
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 reviewedBattleRows = flaggedBattleRows.filter((row) => row.hasMapRepaintReport);
const pendingBattleRows = flaggedBattleRows.filter((row) => !row.hasMapRepaintReport);
const flaggedStoryRows = storyRows.filter((row) => row.concerns.length > 0);
const topBattleRows = pendingBattleRows.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;
const battleConcernText = (row) =>
[...row.concerns, row.hasMapRepaintReport ? 'repaint report exists' : undefined].filter(Boolean).join('<br>') || 'none';
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}`,
`- Flagged battle map rows with repaint reports: ${reviewedBattleRows.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.',
'- Battle maps with repaint reports stay in the inventory, but are skipped for the next-batch recommendation.',
'- 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 1920x1080 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)} | ${battleConcernText(row)} |`
),
'',
'## 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)} | ${battleConcernText(row)} |`
),
'',
'## 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';
}