224 lines
6.9 KiB
JavaScript
224 lines
6.9 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'
|
|
];
|
|
const expectedWidth = 1920;
|
|
const expectedHeight = 1080;
|
|
const maximumAssetBytes = 2 * 1024 * 1024;
|
|
const maximumTotalBytes = 4 * 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(`Prologue exploration 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 two 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`;
|
|
}
|