Files
heros_web/scripts/verify-prologue-exploration-asset-data.mjs

225 lines
7.0 KiB
JavaScript

import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
const explorationAssetDirectory = join('src', 'assets', 'images', 'exploration');
const expectedAssets = [
'prologue-village.webp',
'prologue-militia-camp.webp',
'second-pursuit-village-ferry.webp'
];
const expectedWidth = 1920;
const expectedHeight = 1080;
const maximumAssetBytes = 2 * 1024 * 1024;
const maximumTotalBytes = 6 * 1024 * 1024;
const rasterAssetPattern = /\.(?:avif|jpe?g|png|webp)$/i;
const errors = [];
let totalBytes = 0;
validateDirectoryCoverage(errors);
for (const fileName of expectedAssets) {
const path = join(explorationAssetDirectory, fileName);
const inspection = inspectWebpAsset(errors, path, fileName);
if (!inspection) {
continue;
}
totalBytes += inspection.size;
if (inspection.size > maximumAssetBytes) {
errors.push(
`${path}: ${formatBytes(inspection.size)} exceeds the per-background limit of ` +
`${formatBytes(maximumAssetBytes)}`
);
}
if (inspection.width !== expectedWidth || inspection.height !== expectedHeight) {
errors.push(
`${path}: expected ${expectedWidth}x${expectedHeight}, got ` +
`${inspection.width}x${inspection.height}`
);
}
if (inspection.hasAlpha) {
errors.push(`${path}: exploration backgrounds must be opaque WebP images without alpha`);
}
if (inspection.animated) {
errors.push(`${path}: exploration backgrounds must be static WebP images`);
}
}
if (totalBytes > maximumTotalBytes) {
errors.push(
`${explorationAssetDirectory}: combined background size ${formatBytes(totalBytes)} exceeds ` +
`${formatBytes(maximumTotalBytes)}`
);
}
if (errors.length > 0) {
console.error(`Exploration background asset verification failed with ${errors.length} issue(s):`);
errors.forEach((error) => console.error(`- ${error}`));
process.exitCode = 1;
} else {
console.log(
`Verified ${expectedAssets.length} opaque ${expectedWidth}x${expectedHeight} exploration ` +
`WebP backgrounds (${formatBytes(totalBytes)} total; ${formatBytes(maximumAssetBytes)} ` +
`per-file / ${formatBytes(maximumTotalBytes)} combined limits).`
);
}
function validateDirectoryCoverage(validationErrors) {
let entries;
try {
entries = readdirSync(explorationAssetDirectory, { withFileTypes: true });
} catch (error) {
validationErrors.push(
`${explorationAssetDirectory}: cannot read exploration asset directory: ${error.message}`
);
return;
}
const actualRasterFiles = entries
.filter((entry) => entry.isFile() && rasterAssetPattern.test(entry.name))
.map((entry) => entry.name)
.sort();
const expectedRasterFiles = [...expectedAssets].sort();
for (const fileName of expectedRasterFiles) {
if (!actualRasterFiles.includes(fileName)) {
validationErrors.push(`${explorationAssetDirectory}: missing required asset "${fileName}"`);
}
}
for (const fileName of actualRasterFiles) {
if (!expectedRasterFiles.includes(fileName)) {
validationErrors.push(
`${explorationAssetDirectory}: unexpected raster asset "${fileName}"; ` +
`only the optimized runtime WebP backgrounds should be tracked here`
);
}
}
}
function inspectWebpAsset(validationErrors, path, label) {
let bytes;
let stats;
try {
bytes = readFileSync(path);
stats = statSync(path);
} catch (error) {
validationErrors.push(`${path}: cannot read ${label}: ${error.message}`);
return undefined;
}
if (!stats.isFile()) {
validationErrors.push(`${path}: expected a regular WebP file`);
return undefined;
}
if (bytes.length < 20) {
validationErrors.push(`${path}: file is too small to be a valid WebP image`);
return undefined;
}
if (
bytes.subarray(0, 4).toString('ascii') !== 'RIFF' ||
bytes.subarray(8, 12).toString('ascii') !== 'WEBP'
) {
validationErrors.push(`${path}: missing the WebP RIFF header`);
return undefined;
}
const declaredLength = bytes.readUInt32LE(4) + 8;
if (declaredLength !== bytes.length) {
validationErrors.push(
`${path}: RIFF container declares ${declaredLength} bytes, file contains ${bytes.length}`
);
return undefined;
}
let dimensions;
let hasAlpha = false;
let animated = false;
let foundImagePayload = false;
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;
const paddedChunkEnd = dataOffset + chunkSize + (chunkSize % 2);
if (dataOffset + chunkSize > bytes.length || paddedChunkEnd > bytes.length) {
validationErrors.push(`${path}: "${chunkType}" chunk exceeds the RIFF container`);
return undefined;
}
if (chunkType === 'VP8X') {
if (chunkSize < 10) {
validationErrors.push(`${path}: VP8X chunk is too short`);
return undefined;
}
const featureFlags = bytes[dataOffset];
dimensions = {
width: readUInt24LE(bytes, dataOffset + 4) + 1,
height: readUInt24LE(bytes, dataOffset + 7) + 1
};
hasAlpha ||= (featureFlags & 0x10) !== 0;
animated ||= (featureFlags & 0x02) !== 0;
} else if (chunkType === 'VP8 ') {
if (
chunkSize < 10 ||
bytes[dataOffset + 3] !== 0x9d ||
bytes[dataOffset + 4] !== 0x01 ||
bytes[dataOffset + 5] !== 0x2a
) {
validationErrors.push(`${path}: invalid lossy VP8 image payload`);
return undefined;
}
dimensions ??= {
width: bytes.readUInt16LE(dataOffset + 6) & 0x3fff,
height: bytes.readUInt16LE(dataOffset + 8) & 0x3fff
};
foundImagePayload = true;
} else if (chunkType === 'VP8L') {
if (chunkSize < 5 || bytes[dataOffset] !== 0x2f) {
validationErrors.push(`${path}: invalid lossless VP8L image payload`);
return undefined;
}
const losslessHeader = bytes.readUInt32LE(dataOffset + 1);
dimensions ??= {
width: (losslessHeader & 0x3fff) + 1,
height: ((losslessHeader >>> 14) & 0x3fff) + 1
};
hasAlpha ||= (losslessHeader & 0x10000000) !== 0;
foundImagePayload = true;
} else if (chunkType === 'ALPH') {
hasAlpha = true;
} else if (chunkType === 'ANIM' || chunkType === 'ANMF') {
animated = true;
}
offset = paddedChunkEnd;
}
if (offset !== bytes.length) {
validationErrors.push(`${path}: RIFF container has an incomplete trailing chunk header`);
return undefined;
}
if (!dimensions || !foundImagePayload) {
validationErrors.push(`${path}: WebP dimensions or image payload could not be read`);
return undefined;
}
return {
size: stats.size,
width: dimensions.width,
height: dimensions.height,
hasAlpha,
animated
};
}
function readUInt24LE(bytes, offset) {
return bytes[offset] | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16);
}
function formatBytes(bytes) {
return `${(bytes / (1024 * 1024)).toFixed(2)} MiB`;
}