Files
healer-man/scripts/dungeon-pipeline/build-shadowfang-selective-visual.mjs
T
2026-10-07 13:05:04 -04:00

167 lines
8.3 KiB
JavaScript

#!/usr/bin/env node
import { createHash } from "node:crypto";
import { copyFile, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { dungeonEnvironmentFinalPackingArguments, dungeonEnvironmentOptimizationArguments } from "./optimize-environment.mjs";
import { preprocessShadowfangVisual } from "./shadowfang-fallback-preprocess.mjs";
import { projectRoot, workRoot } from "./lib.mjs";
const DEFAULT_OUTPUT = path.join(projectRoot, "artifacts/dungeon-pipeline/shadowfang-keep-visual.selective-untextured.glb");
const BASELINE_VISUAL = path.join(projectRoot, "artifacts/playtest/wow-environment-quality/before-shadowfang/visual.glb");
const KTX2_IDENTIFIER = Buffer.from([0xab, 0x4b, 0x54, 0x58, 0x20, 0x32, 0x30, 0xbb, 0x0d, 0x0a, 0x1a, 0x0a]);
function sha256(bytes) {
return createHash("sha256").update(bytes).digest("hex");
}
function relative(file) {
return path.relative(projectRoot, file).replaceAll("\\", "/");
}
function parseGlb(bytes, label) {
if (bytes.length < 20 || bytes.toString("utf8", 0, 4) !== "glTF"
|| bytes.readUInt32LE(4) !== 2 || bytes.readUInt32LE(8) !== bytes.length) {
throw new Error(`${label} is not a complete binary glTF 2.0 file.`);
}
let json = null;
let binary = null;
let offset = 12;
while (offset + 8 <= bytes.length) {
const length = bytes.readUInt32LE(offset);
const type = bytes.readUInt32LE(offset + 4);
const start = offset + 8;
const end = start + length;
if (end > bytes.length) throw new Error(`${label} contains a truncated GLB chunk.`);
if (type === 0x4e4f534a) json = JSON.parse(bytes.toString("utf8", start, end).replace(/\0+$/u, "").trim());
if (type === 0x004e4942) binary = bytes.subarray(start, end);
offset = end;
}
if (!json || !binary) throw new Error(`${label} must contain JSON and BIN GLB chunks.`);
return { json, binary };
}
function embeddedImageHashes(buffer, label) {
const parsed = parseGlb(buffer, label);
return (parsed.json.images ?? []).map((image, index) => {
const view = parsed.json.bufferViews?.[image.bufferView];
if (!view) throw new Error(`${label} image ${index} references a missing buffer view.`);
const start = view.byteOffset ?? 0;
const end = start + view.byteLength;
const bytes = parsed.binary.subarray(start, end);
if (end > parsed.binary.length) throw new Error(`${label} image ${index} exceeds the GLB BIN chunk.`);
if (image.mimeType !== "image/ktx2" || !bytes.subarray(0, 12).equals(KTX2_IDENTIFIER)) {
throw new Error(`${label} image ${index} is not a valid embedded KTX2 texture.`);
}
return { index, name: image.name ?? "", hash: sha256(bytes), byteLength: bytes.length };
});
}
function runCli(projectDirectory, args) {
const cli = path.join(projectDirectory, "node_modules/@gltf-transform/cli/bin/cli.js");
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [cli, ...args], { cwd: projectDirectory, stdio: "inherit", windowsHide: true });
child.once("error", reject);
child.once("exit", (code) => code === 0 ? resolve() : reject(new Error(`gltf-transform exited with code ${code}.`)));
});
}
function compressKtx2(projectDirectory, file) {
const compressor = path.join(projectDirectory, "scripts/asset-pipeline/compress-ktx2.mjs");
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [compressor, file, "--concurrency", "1", "--jobs", "2"], {
cwd: projectDirectory,
stdio: "inherit",
windowsHide: true,
});
child.once("error", reject);
child.once("exit", (code) => code === 0 ? resolve() : reject(new Error(`KTX2 compression exited with code ${code}.`)));
});
}
async function main() {
const outputArgument = process.argv.indexOf("--output");
const output = path.resolve(outputArgument >= 0 ? process.argv[outputArgument + 1] : DEFAULT_OUTPUT);
const errorArgument = process.argv.indexOf("--simplify-error");
const simplifyError = errorArgument >= 0 ? Number(process.argv[errorArgument + 1]) : undefined;
if (simplifyError !== undefined && (!Number.isFinite(simplifyError) || simplifyError < 0)) {
throw new Error("--simplify-error must be a non-negative number.");
}
const artifactRoot = path.join(projectRoot, "artifacts");
const outputRelative = path.relative(artifactRoot, output);
if (!output.toLowerCase().endsWith(".glb") || outputRelative.startsWith("..") || path.isAbsolute(outputRelative)) {
throw new Error("Output must be a .glb candidate inside the repository artifacts directory.");
}
const source = path.join(workRoot, "shadowfang-keep/staging/environment/shadowfang-keep-visual.glb");
const temporaryDirectory = await mkdtemp(path.join(os.tmpdir(), "shadowfang-selective-"));
const preprocessed = path.join(temporaryDirectory, "shadowfang-selective.glb");
const optimized = path.join(temporaryDirectory, "shadowfang-optimized.glb");
const finalPacked = path.join(temporaryDirectory, "shadowfang-meshopt-final.glb");
try {
const preprocessing = await preprocessShadowfangVisual(source, preprocessed, { simplifyError });
await runCli(projectRoot, dungeonEnvironmentOptimizationArguments(preprocessed, optimized));
await compressKtx2(projectRoot, optimized);
await runCli(projectRoot, dungeonEnvironmentFinalPackingArguments(optimized, finalPacked));
await mkdir(path.dirname(output), { recursive: true });
await copyFile(finalPacked, output);
const candidateBytes = await readFile(output);
const candidateTextures = embeddedImageHashes(candidateBytes, output);
let baselineTextureComparison = null;
if (await stat(BASELINE_VISUAL).then(() => true, () => false)) {
const baselineTextures = embeddedImageHashes(await readFile(BASELINE_VISUAL), BASELINE_VISUAL);
const baselineByName = new Map(baselineTextures.map((image) => [image.name, image]));
const comparisons = candidateTextures.map((image) => {
const baseline = baselineByName.get(image.name);
return { name: image.name, matched: Boolean(baseline), identicalBytes: Boolean(baseline && baseline.hash === image.hash) };
});
baselineTextureComparison = {
baseline: relative(BASELINE_VISUAL),
matched: comparisons.filter((row) => row.matched).length,
identicalBytes: comparisons.filter((row) => row.identicalBytes).length,
candidateImageCount: candidateTextures.length,
};
if (baselineTextureComparison.matched !== candidateTextures.length
|| baselineTextureComparison.identicalBytes !== candidateTextures.length) {
throw new Error("Selective candidate KTX2 image bytes differ from the preserved published Shadowfang baseline.");
}
}
const report = {
schemaVersion: 1,
dungeonId: "shadowfang-keep",
source: preprocessing.source,
output: { file: relative(output), sha256: sha256(candidateBytes), byteLength: candidateBytes.length },
simplification: {
...preprocessing.profile,
api: "@gltf-transform/functions simplifyPrimitive",
texturedPrimitiveAccessorHashesUnchanged: preprocessing.texturedPrimitiveAccessorHashesUnchanged,
sourceTextureBytesUnchanged: preprocessing.sourceTextureBytesUnchanged,
selectedByMaterial: preprocessing.selectedByMaterial,
},
textureEncoding: {
codec: "ETC1S/BasisLZ from the existing KTX2 compressor policy",
imageCount: candidateTextures.length,
comparedToPreservedBaseline: baselineTextureComparison,
},
meshoptQuantization: { positionBits: 16, texcoordBits: 16 },
finalPacking: {
command: "gltf-transform meshopt <optimized.glb> <meshopt-final.glb> --level high --quantize-position 16 --quantize-texcoord 16",
extensionPresent: parseGlb(candidateBytes, output).json.extensionsUsed?.includes("EXT_meshopt_compression") ?? false,
},
};
const reportFile = `${output}.report.json`;
await writeFile(reportFile, `${JSON.stringify(report, null, 2)}\n`, "utf8");
console.log(JSON.stringify({ ...report, reportFile: relative(reportFile) }, null, 2));
} finally {
await rm(temporaryDirectory, { recursive: true, force: true });
}
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
await main();
}