Update Healer Man server
This commit is contained in:
1 parent
6114a2295f
commit
69809f7228
593 files changed
+903643
-40774
No files matched your search
@@ -0,0 +1,597 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import {
|
||||
access,
|
||||
copyFile,
|
||||
mkdir,
|
||||
readFile,
|
||||
readdir,
|
||||
stat,
|
||||
unlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import sharp from "sharp";
|
||||
|
||||
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(scriptDirectory, "../..");
|
||||
const gltfTransformCli = path.join(projectRoot, "node_modules/@gltf-transform/cli/bin/cli.js");
|
||||
const genericCompressor = path.join(projectRoot, "scripts/asset-pipeline/compress-ktx2.mjs");
|
||||
const KTX2_IDENTIFIER = Buffer.from([0xab, 0x4b, 0x54, 0x58, 0x20, 0x32, 0x30, 0xbb, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const REVIEWED_TEXTURE_NAMES = [
|
||||
"obj_forge01_lava",
|
||||
"lava",
|
||||
"magma_01",
|
||||
"magic_magma",
|
||||
"magic_magma_02",
|
||||
"floor_m_02",
|
||||
"lymor grave_relief_01",
|
||||
"treasure",
|
||||
"wretched_tunnel",
|
||||
"rom-barren-wretched-tunnel-u0-v0",
|
||||
"rom-barren-wretched-tunnel-u3-v0",
|
||||
"rom-barren-wretched-tunnel-u0-v1",
|
||||
"rom-barren-wretched-tunnel-u3-v1",
|
||||
"rom-barren-wretched-tunnel-wall-detail-u1-v0",
|
||||
"echoes_of_the_sea_01",
|
||||
"echoes_of_the_sea_02",
|
||||
"echoes_of_the_sea_03",
|
||||
"echoes_of_the_sea_04",
|
||||
"elves_vine",
|
||||
];
|
||||
const REVIEWED_TEXTURE_NAME_SET = new Set(REVIEWED_TEXTURE_NAMES.map((name) => name.toLowerCase()));
|
||||
const TEMP_TARGET_NAME_PREFIX = "__rom_high_quality__";
|
||||
const TEMP_TARGET_NAME_SUFFIX = "__";
|
||||
const REVIEWED_TEXTURE_PATTERN = `${TEMP_TARGET_NAME_PREFIX}{${REVIEWED_TEXTURE_NAMES.join(",")}}${TEMP_TARGET_NAME_SUFFIX}`;
|
||||
|
||||
function argumentValue(args, name, fallback) {
|
||||
const index = args.indexOf(name);
|
||||
return index >= 0 && args[index + 1] ? args[index + 1] : fallback;
|
||||
}
|
||||
|
||||
function positionalArguments(args) {
|
||||
const valueOptions = new Set(["--concurrency", "--jobs", "--report"]);
|
||||
const result = [];
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
if (valueOptions.has(args[index])) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (!args[index].startsWith("--")) result.push(args[index]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function exists(file) {
|
||||
try {
|
||||
await access(file);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function collectGlbs(target) {
|
||||
const resolved = path.resolve(projectRoot, target);
|
||||
const details = await stat(resolved);
|
||||
if (details.isFile()) {
|
||||
if (!resolved.toLowerCase().endsWith(".glb")) {
|
||||
throw new Error(`Expected a .glb input, received ${path.relative(projectRoot, resolved)}.`);
|
||||
}
|
||||
return [resolved];
|
||||
}
|
||||
const files = [];
|
||||
for (const entry of await readdir(resolved, { withFileTypes: true })) {
|
||||
const child = path.join(resolved, entry.name);
|
||||
if (entry.isDirectory()) files.push(...await collectGlbs(child));
|
||||
else if (entry.isFile() && !entry.name.startsWith(".") && entry.name.toLowerCase().endsWith(".glb")) {
|
||||
files.push(child);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function parseGlb(buffer, label) {
|
||||
if (buffer.length < 20 || buffer.toString("utf8", 0, 4) !== "glTF") {
|
||||
throw new Error(`${label} is not a binary glTF file.`);
|
||||
}
|
||||
const chunks = [];
|
||||
let json;
|
||||
let jsonChunkIndex = -1;
|
||||
let binaryChunk;
|
||||
let offset = 12;
|
||||
while (offset + 8 <= buffer.length) {
|
||||
const length = buffer.readUInt32LE(offset);
|
||||
const type = buffer.readUInt32LE(offset + 4);
|
||||
const dataStart = offset + 8;
|
||||
const dataEnd = dataStart + length;
|
||||
if (dataEnd > buffer.length) throw new Error(`${label} contains a truncated GLB chunk.`);
|
||||
const data = buffer.subarray(dataStart, dataEnd);
|
||||
chunks.push({ type, data });
|
||||
if (type === 0x4e4f534a) {
|
||||
jsonChunkIndex = chunks.length - 1;
|
||||
json = JSON.parse(data.toString("utf8").replace(/\0+$/u, "").trim());
|
||||
}
|
||||
if (type === 0x004e4942) binaryChunk = data;
|
||||
offset = dataEnd;
|
||||
}
|
||||
if (!json || !binaryChunk) throw new Error(`${label} must contain JSON and BIN chunks.`);
|
||||
return { json, chunks, jsonChunkIndex, binaryChunk };
|
||||
}
|
||||
|
||||
function serializeGlb(source, json, chunks, jsonChunkIndex) {
|
||||
const encodedJson = Buffer.from(JSON.stringify(json));
|
||||
const paddedJson = Buffer.alloc(Math.ceil(encodedJson.length / 4) * 4, 0x20);
|
||||
encodedJson.copy(paddedJson);
|
||||
const updatedChunks = chunks.map((chunk, index) => (
|
||||
index === jsonChunkIndex ? { ...chunk, data: paddedJson } : chunk
|
||||
));
|
||||
const totalLength = 12 + updatedChunks.reduce((total, chunk) => total + 8 + chunk.data.length, 0);
|
||||
const output = Buffer.alloc(totalLength);
|
||||
source.copy(output, 0, 0, 12);
|
||||
output.writeUInt32LE(totalLength, 8);
|
||||
let offset = 12;
|
||||
for (const chunk of updatedChunks) {
|
||||
output.writeUInt32LE(chunk.data.length, offset);
|
||||
output.writeUInt32LE(chunk.type, offset + 4);
|
||||
chunk.data.copy(output, offset + 8);
|
||||
offset += 8 + chunk.data.length;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function patchGlbJson(source, transform) {
|
||||
const parsed = parseGlb(source, "temporary GLB");
|
||||
transform(parsed.json);
|
||||
return serializeGlb(source, parsed.json, parsed.chunks, parsed.jsonChunkIndex);
|
||||
}
|
||||
|
||||
function basenameWithoutExtension(value) {
|
||||
const basename = path.posix.basename(String(value ?? "").replaceAll("\\", "/"));
|
||||
return basename.replace(/\.[^.]+$/u, "");
|
||||
}
|
||||
|
||||
function canonicalTextureName(value) {
|
||||
return basenameWithoutExtension(value).replace(/^[0-9a-f]{8}_/iu, "");
|
||||
}
|
||||
|
||||
function imageIndexForTexture(texture) {
|
||||
return texture?.extensions?.KHR_texture_basisu?.source ?? texture?.source;
|
||||
}
|
||||
|
||||
function imageBytes(parsed, image, label) {
|
||||
if (image.bufferView === undefined) {
|
||||
throw new Error(`${label} has an external or missing image buffer; embedded GLB images are required.`);
|
||||
}
|
||||
const view = parsed.json.bufferViews?.[image.bufferView];
|
||||
if (!view) throw new Error(`${label} references missing buffer view ${image.bufferView}.`);
|
||||
const start = (view.byteOffset ?? 0);
|
||||
return parsed.binaryChunk.subarray(start, start + view.byteLength);
|
||||
}
|
||||
|
||||
function textureSlotUsage(json) {
|
||||
const baseColor = new Set();
|
||||
for (const material of json.materials ?? []) {
|
||||
const index = material.pbrMetallicRoughness?.baseColorTexture?.index;
|
||||
if (Number.isInteger(index)) baseColor.add(index);
|
||||
}
|
||||
return baseColor;
|
||||
}
|
||||
|
||||
function inspectKtx2(bytes, label) {
|
||||
if (bytes.length < 48 || !bytes.subarray(0, 12).equals(KTX2_IDENTIFIER)) {
|
||||
throw new Error(`${label} declares image/ktx2 but has an invalid KTX2 header.`);
|
||||
}
|
||||
const vkFormat = bytes.readUInt32LE(12);
|
||||
const supercompressionScheme = bytes.readUInt32LE(44);
|
||||
return {
|
||||
width: bytes.readUInt32LE(20),
|
||||
height: bytes.readUInt32LE(24),
|
||||
mipLevels: Math.max(1, bytes.readUInt32LE(40)),
|
||||
vkFormat,
|
||||
supercompressionScheme,
|
||||
codec: supercompressionScheme === 1 ? "ETC1S" : vkFormat === 0 ? "UASTC" : "unknown",
|
||||
};
|
||||
}
|
||||
|
||||
async function imageDimensions(parsed, image, label) {
|
||||
const bytes = imageBytes(parsed, image, label);
|
||||
if (image.mimeType === "image/ktx2") return inspectKtx2(bytes, label);
|
||||
const metadata = await sharp(bytes).metadata();
|
||||
if (!metadata.width || !metadata.height) throw new Error(`${label} has no readable dimensions.`);
|
||||
return { width: metadata.width, height: metadata.height, mipLevels: 0, codec: "source" };
|
||||
}
|
||||
|
||||
async function textureInventory(buffer, label) {
|
||||
const parsed = parseGlb(buffer, label);
|
||||
const baseColorTextures = textureSlotUsage(parsed.json);
|
||||
const textures = [];
|
||||
for (const [textureIndex, texture] of (parsed.json.textures ?? []).entries()) {
|
||||
const imageIndex = imageIndexForTexture(texture);
|
||||
const image = parsed.json.images?.[imageIndex];
|
||||
if (!image) throw new Error(`${label} texture ${textureIndex} has no source image.`);
|
||||
const imageName = canonicalTextureName(image.name ?? image.uri);
|
||||
const bytes = imageBytes(parsed, image, `${label} image ${image.name ?? imageIndex}`);
|
||||
const dimensions = image.mimeType === "image/ktx2"
|
||||
? inspectKtx2(bytes, `${label} image ${image.name ?? imageIndex}`)
|
||||
: await imageDimensions(parsed, image, `${label} image ${image.name ?? imageIndex}`);
|
||||
textures.push({
|
||||
textureIndex,
|
||||
imageIndex,
|
||||
name: imageName,
|
||||
imageName: image.name ?? image.uri ?? `image-${imageIndex}`,
|
||||
mimeType: image.mimeType,
|
||||
isBaseColor: baseColorTextures.has(textureIndex),
|
||||
dimensions,
|
||||
alphaModes: (parsed.json.materials ?? [])
|
||||
.filter((material) => material.pbrMetallicRoughness?.baseColorTexture?.index === textureIndex)
|
||||
.map((material) => material.alphaMode ?? "OPAQUE"),
|
||||
});
|
||||
}
|
||||
return { parsed, textures };
|
||||
}
|
||||
|
||||
function normalizeTargetTextureNames(sourceBuffer, label) {
|
||||
const parsed = parseGlb(sourceBuffer, label);
|
||||
const baseColorTextures = textureSlotUsage(parsed.json);
|
||||
// Decide from immutable image names before touching aliases. Several
|
||||
// textures can reference one image while carrying independent samplers;
|
||||
// mutating the image name on the first alias must not make the next alias
|
||||
// look like a non-target.
|
||||
const originalImageNames = (parsed.json.images ?? []).map((image) => canonicalTextureName(image.name ?? image.uri));
|
||||
const baseColorImageIndexes = new Set();
|
||||
for (const textureIndex of baseColorTextures) {
|
||||
const imageIndex = imageIndexForTexture(parsed.json.textures?.[textureIndex]);
|
||||
if (Number.isInteger(imageIndex)) baseColorImageIndexes.add(imageIndex);
|
||||
}
|
||||
const selectedImageNames = new Map();
|
||||
for (const imageIndex of baseColorImageIndexes) {
|
||||
const name = originalImageNames[imageIndex];
|
||||
if (name && REVIEWED_TEXTURE_NAME_SET.has(name.toLowerCase())) selectedImageNames.set(imageIndex, name);
|
||||
}
|
||||
const selected = [];
|
||||
for (const textureIndex of baseColorTextures) {
|
||||
const texture = parsed.json.textures?.[textureIndex];
|
||||
if (!texture) continue;
|
||||
const imageIndex = imageIndexForTexture(texture);
|
||||
const image = parsed.json.images?.[imageIndex];
|
||||
const normalized = originalImageNames[imageIndex];
|
||||
if (!image || !normalized) continue;
|
||||
if (selectedImageNames.has(imageIndex)) {
|
||||
const temporaryName = `${TEMP_TARGET_NAME_PREFIX}${normalized}${TEMP_TARGET_NAME_SUFFIX}`;
|
||||
texture.name = temporaryName;
|
||||
selected.push({ textureIndex, imageIndex, name: normalized, mimeType: image.mimeType });
|
||||
} else {
|
||||
// The CLI matcher uses micromatch's `contains: true` option, so bare
|
||||
// names like "lava" would also match "lava_roughness". Keep non-target
|
||||
// base-color names out of the reviewed-name matcher for this one pass.
|
||||
const temporaryName = `__rom_quality_exclude_${textureIndex}__`;
|
||||
texture.name = temporaryName;
|
||||
}
|
||||
}
|
||||
for (const imageIndex of baseColorImageIndexes) {
|
||||
const image = parsed.json.images?.[imageIndex];
|
||||
if (!image) continue;
|
||||
const targetName = selectedImageNames.get(imageIndex);
|
||||
image.name = targetName
|
||||
? `${TEMP_TARGET_NAME_PREFIX}${targetName}${TEMP_TARGET_NAME_SUFFIX}`
|
||||
: `__rom_quality_exclude_image_${imageIndex}__`;
|
||||
}
|
||||
return {
|
||||
buffer: selected.length ? serializeGlb(sourceBuffer, parsed.json, parsed.chunks, parsed.jsonChunkIndex) : sourceBuffer,
|
||||
selected,
|
||||
};
|
||||
}
|
||||
|
||||
function runProcess(executable, args, environment, label) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(executable, args, {
|
||||
cwd: projectRoot,
|
||||
env: environment,
|
||||
windowsHide: true,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let output = "";
|
||||
child.stdout.on("data", (chunk) => { output += chunk; });
|
||||
child.stderr.on("data", (chunk) => { output += chunk; });
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code) => {
|
||||
if (code === 0) resolve(output);
|
||||
else reject(new Error(`${label} failed with exit code ${code}.\n${output.trim()}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function findToktxDirectory() {
|
||||
const executable = process.platform === "win32" ? "toktx.exe" : "toktx";
|
||||
const candidates = [
|
||||
process.env.KTX_BIN_DIR,
|
||||
path.join(projectRoot, ".tools/ktx/bin"),
|
||||
...(process.env.PATH ?? "").split(path.delimiter),
|
||||
].filter(Boolean);
|
||||
for (const candidate of candidates) {
|
||||
if (await exists(path.join(candidate, executable))) return path.resolve(candidate);
|
||||
}
|
||||
throw new Error("toktx was not found. Run scripts/asset-pipeline/bootstrap-ktx.ps1 or set KTX_BIN_DIR.");
|
||||
}
|
||||
|
||||
function fullMipLevelCount(width, height) {
|
||||
return Math.floor(Math.log2(Math.max(width, height))) + 1;
|
||||
}
|
||||
|
||||
function sameOrderedValues(left, right) {
|
||||
return left.length === right.length && left.every((value, index) => value === right[index]);
|
||||
}
|
||||
|
||||
async function compressEnvironmentGlb(file, { jobs, ktxBinDirectory }) {
|
||||
const sourceBuffer = await readFile(file);
|
||||
const before = sourceBuffer.length;
|
||||
const label = path.relative(projectRoot, file).replaceAll("\\", "/");
|
||||
const inputInventory = await textureInventory(sourceBuffer, label);
|
||||
const normalized = normalizeTargetTextureNames(sourceBuffer, label);
|
||||
const selectedTargets = normalized.selected;
|
||||
for (const target of selectedTargets) {
|
||||
if (target.mimeType !== "image/png" && target.mimeType !== "image/jpeg" && target.mimeType !== "image/ktx2") {
|
||||
throw new Error(`${label}: reviewed target ${target.name} has unsupported source type ${target.mimeType}.`);
|
||||
}
|
||||
const source = inputInventory.textures.find((entry) => entry.textureIndex === target.textureIndex);
|
||||
if (target.mimeType === "image/ktx2"
|
||||
&& (source?.dimensions.codec !== "UASTC" || source.dimensions.supercompressionScheme !== 2)) {
|
||||
throw new Error(
|
||||
`${label}: reviewed target ${target.name} is already KTX2 but is not UASTC+Zstandard. `
|
||||
+ "Rebuild from its prepared PNG source to apply the reviewed codec policy.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const directory = path.dirname(file);
|
||||
const stem = path.basename(file, path.extname(file));
|
||||
const nonce = `${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const namedInput = path.join(directory, `.${stem}.${nonce}.named.glb`);
|
||||
const uastcOutput = path.join(directory, `.${stem}.${nonce}.uastc.glb`);
|
||||
const temporaryOutput = path.join(directory, `.${stem}.${nonce}.mixed.glb`);
|
||||
const environment = {
|
||||
...process.env,
|
||||
KTX_BIN_DIR: ktxBinDirectory,
|
||||
PATH: `${ktxBinDirectory}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||
};
|
||||
|
||||
try {
|
||||
await writeFile(namedInput, normalized.buffer);
|
||||
await runProcess(process.execPath, [
|
||||
gltfTransformCli,
|
||||
"uastc",
|
||||
namedInput,
|
||||
uastcOutput,
|
||||
"--pattern",
|
||||
REVIEWED_TEXTURE_PATTERN,
|
||||
"--slots",
|
||||
"baseColorTexture",
|
||||
"--level",
|
||||
"2",
|
||||
"--zstd",
|
||||
"18",
|
||||
"--jobs",
|
||||
String(jobs),
|
||||
], environment, `UASTC pass for ${label}`);
|
||||
const selectiveInventory = await textureInventory(await readFile(uastcOutput), label);
|
||||
for (const target of selectedTargets) {
|
||||
const output = selectiveInventory.textures.find((entry) => entry.textureIndex === target.textureIndex);
|
||||
if (output?.dimensions.codec !== "UASTC") {
|
||||
throw new Error(`${label}: UASTC pattern pass did not encode reviewed target ${target.name} `
|
||||
+ `(texture ${output?.textureIndex ?? "missing"}, image ${output?.imageName ?? "missing"}, `
|
||||
+ `${output?.mimeType ?? "missing"}, codec ${output?.dimensions?.codec ?? "missing"}; `
|
||||
+ `output=${JSON.stringify(selectiveInventory.textures.map((entry) => ({ textureIndex: entry.textureIndex, imageIndex: entry.imageIndex, imageName: entry.imageName, mimeType: entry.mimeType, codec: entry.dimensions.codec })))}.`);
|
||||
}
|
||||
}
|
||||
for (const source of inputInventory.textures) {
|
||||
if (!source.isBaseColor || REVIEWED_TEXTURE_NAME_SET.has(source.name.toLowerCase())) continue;
|
||||
const output = selectiveInventory.textures.find((entry) => entry.textureIndex === source.textureIndex);
|
||||
if (source.mimeType !== "image/ktx2" && output?.mimeType === "image/ktx2") {
|
||||
throw new Error(
|
||||
`${label}: selective UASTC pattern matched non-target base-color ${source.name} `
|
||||
+ "before the ETC1S fallback pass.",
|
||||
);
|
||||
}
|
||||
}
|
||||
await runProcess(process.execPath, [
|
||||
genericCompressor,
|
||||
uastcOutput,
|
||||
"--force",
|
||||
"--concurrency",
|
||||
"1",
|
||||
"--jobs",
|
||||
String(jobs),
|
||||
], environment, `ETC1S fallback pass for ${label}`);
|
||||
|
||||
const encodedBuffer = await readFile(uastcOutput);
|
||||
const outputBuffer = patchGlbJson(encodedBuffer, (json) => {
|
||||
for (const [textureIndex, originalTexture] of inputInventory.parsed.json.textures.entries()) {
|
||||
const outputTexture = json.textures?.[textureIndex];
|
||||
if (!outputTexture) throw new Error(`${label}: a texture was removed during compression.`);
|
||||
if (originalTexture.name === undefined) delete outputTexture.name;
|
||||
else outputTexture.name = originalTexture.name;
|
||||
}
|
||||
for (const [imageIndex, originalImage] of inputInventory.parsed.json.images.entries()) {
|
||||
const outputImage = json.images?.[imageIndex];
|
||||
if (!outputImage) throw new Error(`${label}: an image was removed during compression.`);
|
||||
if (originalImage.name === undefined) delete outputImage.name;
|
||||
else outputImage.name = originalImage.name;
|
||||
}
|
||||
});
|
||||
const outputInventory = await textureInventory(outputBuffer, label);
|
||||
const outputImageNames = outputInventory.textures.map((entry) => `${entry.imageIndex}:${entry.name}`);
|
||||
const inputImageNames = inputInventory.textures.map((entry) => `${entry.imageIndex}:${entry.name}`);
|
||||
if (!sameOrderedValues(inputImageNames, outputImageNames)) {
|
||||
throw new Error(`${label}: texture/image linkage or ordering changed during compression.`);
|
||||
}
|
||||
if (outputInventory.textures.length !== inputInventory.textures.length) {
|
||||
throw new Error(`${label}: texture count changed during compression.`);
|
||||
}
|
||||
for (const [textureIndex, sourceTexture] of inputInventory.parsed.json.textures.entries()) {
|
||||
if (outputInventory.parsed.json.textures?.[textureIndex]?.name !== sourceTexture.name) {
|
||||
throw new Error(`${label}: texture name ${textureIndex} changed during compression.`);
|
||||
}
|
||||
}
|
||||
for (const [imageIndex, sourceImage] of inputInventory.parsed.json.images.entries()) {
|
||||
if (outputInventory.parsed.json.images?.[imageIndex]?.name !== sourceImage.name) {
|
||||
throw new Error(`${label}: image name ${imageIndex} changed during compression.`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const source of inputInventory.textures) {
|
||||
const output = outputInventory.textures.find((entry) => entry.textureIndex === source.textureIndex);
|
||||
if (!output || output.mimeType !== "image/ktx2") {
|
||||
throw new Error(`${label}: texture ${source.imageName} was not KTX2-compressed.`);
|
||||
}
|
||||
if (source.dimensions.width !== output.dimensions.width || source.dimensions.height !== output.dimensions.height) {
|
||||
throw new Error(
|
||||
`${label}: texture ${source.imageName} changed dimensions `
|
||||
+ `${source.dimensions.width}x${source.dimensions.height} -> `
|
||||
+ `${output.dimensions.width}x${output.dimensions.height}.`,
|
||||
);
|
||||
}
|
||||
const expectedMips = fullMipLevelCount(source.dimensions.width, source.dimensions.height);
|
||||
if (output.dimensions.mipLevels !== expectedMips) {
|
||||
throw new Error(`${label}: texture ${source.imageName} has ${output.dimensions.mipLevels} mips; expected ${expectedMips}.`);
|
||||
}
|
||||
if (source.isBaseColor && !REVIEWED_TEXTURE_NAME_SET.has(source.name.toLowerCase())
|
||||
&& output.dimensions.codec !== "ETC1S") {
|
||||
throw new Error(
|
||||
`${label}: non-target base-color ${source.name} did not remain ETC1S `
|
||||
+ `(image ${output.imageName}, observed ${output.dimensions.codec}, `
|
||||
+ `supercompression=${output.dimensions.supercompressionScheme}).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const materialModes = (buffer) => parseGlb(buffer, label).json.materials?.map((material) => ({
|
||||
name: material.name ?? "",
|
||||
alphaMode: material.alphaMode ?? "OPAQUE",
|
||||
alphaCutoff: material.alphaCutoff ?? null,
|
||||
})) ?? [];
|
||||
if (JSON.stringify(materialModes(sourceBuffer)) !== JSON.stringify(materialModes(outputBuffer))) {
|
||||
throw new Error(`${label}: material alpha mode/cutoff changed during compression.`);
|
||||
}
|
||||
|
||||
const targetOutputs = selectedTargets.map((target) => {
|
||||
const output = outputInventory.textures.find((entry) => entry.textureIndex === target.textureIndex);
|
||||
if (output?.dimensions.codec !== "UASTC" || output.dimensions.supercompressionScheme !== 2) {
|
||||
throw new Error(`${label}: selective UASTC encoder did not upgrade reviewed texture ${target.name}.`);
|
||||
}
|
||||
return {
|
||||
name: target.name,
|
||||
image: output.imageName,
|
||||
width: output.dimensions.width,
|
||||
height: output.dimensions.height,
|
||||
supercompressionScheme: output.dimensions.supercompressionScheme,
|
||||
};
|
||||
});
|
||||
await writeFile(temporaryOutput, outputBuffer);
|
||||
await copyFile(temporaryOutput, file);
|
||||
const after = (await stat(file)).size;
|
||||
return {
|
||||
file: label,
|
||||
status: targetOutputs.length ? "mixed-quality-compressed" : "etc1s-compressed-no-targets",
|
||||
beforeBytes: before,
|
||||
afterBytes: after,
|
||||
textureCounts: {
|
||||
total: outputInventory.textures.length,
|
||||
etc1s: outputInventory.textures.filter((entry) => entry.dimensions.codec === "ETC1S").length,
|
||||
uastc: outputInventory.textures.filter((entry) => entry.dimensions.codec === "UASTC").length,
|
||||
baseColorTargets: targetOutputs.length,
|
||||
},
|
||||
uastcTargets: targetOutputs,
|
||||
};
|
||||
} finally {
|
||||
for (const temporaryFile of [namedInput, uastcOutput, temporaryOutput]) {
|
||||
try {
|
||||
await unlink(temporaryFile);
|
||||
} catch (error) {
|
||||
if (error?.code !== "ENOENT") throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const jobs = Math.max(1, Number.parseInt(argumentValue(args, "--jobs", "2"), 10));
|
||||
const concurrency = Math.max(1, Number.parseInt(argumentValue(args, "--concurrency", "1"), 10));
|
||||
const reportPath = argumentValue(args, "--report", null);
|
||||
const targets = positionalArguments(args);
|
||||
if (!targets.length) {
|
||||
throw new Error("Provide one or more GLB files or directories; this wrapper does not scan the workspace implicitly.");
|
||||
}
|
||||
|
||||
const ktxBinDirectory = await findToktxDirectory();
|
||||
const files = [...new Set((await Promise.all(targets.map(collectGlbs))).flat())].sort();
|
||||
if (!files.length) throw new Error("No GLB inputs were found.");
|
||||
console.log(
|
||||
`RoM environment texture policy: ${files.length} GLBs, UASTC targets=${REVIEWED_TEXTURE_NAMES.length}, `
|
||||
+ `UASTC level 2 + Zstandard 18 (RDO disabled by CLI default), ETC1S fallback, concurrency=${concurrency}, jobs=${jobs}.`,
|
||||
);
|
||||
|
||||
const results = [];
|
||||
const errors = [];
|
||||
let cursor = 0;
|
||||
const workers = Array.from({ length: Math.min(concurrency, files.length) }, async () => {
|
||||
while (cursor < files.length) {
|
||||
const file = files[cursor++];
|
||||
try {
|
||||
const result = await compressEnvironmentGlb(file, { jobs, ktxBinDirectory });
|
||||
results.push(result);
|
||||
console.log(
|
||||
`[${results.length + errors.length}/${files.length}] ${result.status}: ${result.file} `
|
||||
+ `${result.textureCounts.uastc} UASTC / ${result.textureCounts.etc1s} ETC1S`,
|
||||
);
|
||||
} catch (error) {
|
||||
errors.push({ file, message: error.message });
|
||||
console.error(`Texture compression failed: ${path.relative(projectRoot, file)}\n${error.message}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
|
||||
const totals = results.reduce((total, result) => ({
|
||||
files: total.files + 1,
|
||||
textures: total.textures + result.textureCounts.total,
|
||||
etc1s: total.etc1s + result.textureCounts.etc1s,
|
||||
uastc: total.uastc + result.textureCounts.uastc,
|
||||
baseColorTargets: total.baseColorTargets + result.textureCounts.baseColorTargets,
|
||||
beforeBytes: total.beforeBytes + result.beforeBytes,
|
||||
afterBytes: total.afterBytes + result.afterBytes,
|
||||
}), { files: 0, textures: 0, etc1s: 0, uastc: 0, baseColorTargets: 0, beforeBytes: 0, afterBytes: 0 });
|
||||
console.log(
|
||||
`RoM texture compression complete: ${results.length} files, ${totals.baseColorTargets} reviewed UASTC targets, `
|
||||
+ `${totals.etc1s} ETC1S / ${totals.uastc} UASTC textures, ${errors.length} failed.`,
|
||||
);
|
||||
|
||||
if (reportPath) {
|
||||
const absoluteReportPath = path.resolve(projectRoot, reportPath);
|
||||
await mkdir(path.dirname(absoluteReportPath), { recursive: true });
|
||||
await writeFile(absoluteReportPath, `${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
policy: {
|
||||
reviewedBaseColorNames: REVIEWED_TEXTURE_NAMES,
|
||||
targetCodec: "UASTC level 2, Zstandard 18, RDO disabled",
|
||||
fallbackCodec: "ETC1S existing pipeline defaults",
|
||||
preservesSourceDimensionsAndFullMipChains: true,
|
||||
preservesMaterialAlphaModes: true,
|
||||
},
|
||||
totals,
|
||||
files: results,
|
||||
errors: errors.map(({ file, message }) => ({
|
||||
file: path.relative(projectRoot, file).replaceAll("\\", "/"),
|
||||
message,
|
||||
})),
|
||||
}, null, 2)}\n`, "utf8");
|
||||
}
|
||||
if (errors.length) process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
await main();
|
||||
}
|
||||
Reference in new issue
Block a user