forked from phenom/healer-man
167 lines
8.3 KiB
JavaScript
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();
|
|
}
|