Files
2026-08-14 15:56:39 -04:00

275 lines
9.0 KiB
JavaScript

import { spawn } from "node:child_process";
import { cpus } from "node:os";
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";
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 executableName = process.platform === "win32" ? "toktx.exe" : "toktx";
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", "--quality", "--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 findToktxDirectory() {
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, executableName))) return path.resolve(candidate);
}
return null;
}
async function collectGlbs(target) {
const resolved = path.resolve(projectRoot, target);
const details = await stat(resolved);
if (details.isFile()) return resolved.toLowerCase().endsWith(".glb") ? [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;
}
async function inspectGlb(file) {
const buffer = await readFile(file);
if (buffer.length < 20 || buffer.toString("utf8", 0, 4) !== "glTF") {
throw new Error(`${file} is not a binary glTF file.`);
}
const jsonLength = buffer.readUInt32LE(12);
const json = JSON.parse(buffer.toString("utf8", 20, 20 + jsonLength));
return {
textureCount: json.textures?.length ?? 0,
compressed: json.extensionsUsed?.includes("KHR_texture_basisu") ?? false,
};
}
function runTransform(args, environment) {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [gltfTransformCli, ...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();
else reject(new Error(`gltf-transform exited with ${code}.\n${output.trim()}`));
});
});
}
async function removeIfPresent(file) {
try {
await unlink(file);
} catch (error) {
if (error?.code !== "ENOENT") throw error;
}
}
export async function compressGlbTextures(file, options) {
const inspection = await inspectGlb(file);
if (!inspection.textureCount) return { file, status: "no-textures", before: (await stat(file)).size };
if (inspection.compressed && !options.force) {
return { file, status: "already-compressed", before: (await stat(file)).size };
}
const before = (await stat(file)).size;
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 uastcOutput = path.join(directory, `.${stem}.${nonce}.uastc.glb`);
const finalOutput = path.join(directory, `.${stem}.${nonce}.ktx2.glb`);
const environment = {
...process.env,
PATH: `${options.ktxBinDirectory}${path.delimiter}${process.env.PATH ?? ""}`,
};
try {
await runTransform([
"uastc",
file,
uastcOutput,
"--slots",
"{normalTexture,occlusionTexture,metallicRoughnessTexture}",
"--level",
"2",
"--rdo",
"--rdo-lambda",
"1",
"--zstd",
"18",
"--jobs",
String(options.jobs),
], environment);
await runTransform([
"etc1s",
uastcOutput,
finalOutput,
"--quality",
String(options.quality),
"--jobs",
String(options.jobs),
], environment);
await copyFile(finalOutput, file);
} finally {
await removeIfPresent(uastcOutput);
await removeIfPresent(finalOutput);
}
const after = (await stat(file)).size;
return { file, status: "compressed", before, after };
}
async function main() {
const args = process.argv.slice(2);
const force = args.includes("--force");
const visualOnly = args.includes("--visual-only");
const creaturesOnly = args.includes("--creatures-only");
const concurrency = Math.max(1, Number.parseInt(argumentValue(args, "--concurrency", "2"), 10));
const jobs = Math.max(
1,
Number.parseInt(argumentValue(args, "--jobs", String(Math.max(1, Math.floor(cpus().length / concurrency)))), 10),
);
const quality = Math.min(255, Math.max(1, Number.parseInt(argumentValue(args, "--quality", "180"), 10)));
const reportPath = argumentValue(args, "--report", null);
const targets = positionalArguments(args);
if (!targets.length) {
targets.push("src/assets/game/dungeons", "public/assets/game/manastorm");
}
const ktxBinDirectory = await findToktxDirectory();
if (!ktxBinDirectory) {
throw new Error(
"toktx was not found. Run scripts/asset-pipeline/bootstrap-ktx.ps1 on Windows, "
+ "or set KTX_BIN_DIR to a KTX-Software 4.3+ bin directory.",
);
}
await mkdir(path.join(projectRoot, ".tools"), { recursive: true });
const discovered = (await Promise.all(targets.map(collectGlbs))).flat();
const files = [...new Set(discovered)]
.filter((file) => !visualOnly || /(?:^|[-_])visual\.glb$/i.test(path.basename(file)))
.filter((file) => !creaturesOnly || file.split(path.sep).includes("creatures"))
.sort();
console.log(`KTX2: ${files.length} GLB files, concurrency=${concurrency}, encoder jobs=${jobs}.`);
let cursor = 0;
let beforeTotal = 0;
let afterTotal = 0;
let compressedCount = 0;
let skippedCount = 0;
const errors = [];
const results = [];
const workers = Array.from({ length: Math.min(concurrency, files.length) }, async () => {
while (cursor < files.length) {
const file = files[cursor];
cursor += 1;
try {
const result = await compressGlbTextures(file, {
force,
jobs,
quality,
ktxBinDirectory,
});
results.push(result);
beforeTotal += result.before;
afterTotal += result.after ?? result.before;
if (result.status === "compressed") compressedCount += 1;
else skippedCount += 1;
const relative = path.relative(projectRoot, file);
const sizeNote = result.after
? ` ${(result.before / 1_048_576).toFixed(1)} -> ${(result.after / 1_048_576).toFixed(1)} MiB`
: "";
console.log(`[${compressedCount + skippedCount}/${files.length}] ${result.status}: ${relative}${sizeNote}`);
} catch (error) {
errors.push({ file, error });
console.error(`KTX2 failed: ${path.relative(projectRoot, file)}\n${error.message}`);
}
}
});
await Promise.all(workers);
console.log(
`KTX2 complete: ${compressedCount} compressed, ${skippedCount} skipped, ${errors.length} failed; `
+ `${(beforeTotal / 1_048_576).toFixed(1)} -> ${(afterTotal / 1_048_576).toFixed(1)} MiB.`,
);
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(),
codec: {
color: `ETC1S quality ${quality}`,
data: "UASTC level 2, RDO lambda 1, Zstandard 18",
},
totals: {
files: files.length,
compressed: compressedCount,
skipped: skippedCount,
failed: errors.length,
beforeBytes: beforeTotal,
afterBytes: afterTotal,
},
files: results.map((result) => ({
...result,
file: path.relative(projectRoot, result.file).replaceAll("\\", "/"),
})),
errors: errors.map(({ file, error }) => ({
file: path.relative(projectRoot, file).replaceAll("\\", "/"),
message: error.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();
}