441 lines
16 KiB
JavaScript
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: 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 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 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)} | ${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';
|
|
}
|