feat: upgrade prologue exploration presentation
This commit is contained in:
223
scripts/verify-prologue-exploration-asset-data.mjs
Normal file
223
scripts/verify-prologue-exploration-asset-data.mjs
Normal file
@@ -0,0 +1,223 @@
|
||||
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`;
|
||||
}
|
||||
Reference in New Issue
Block a user