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(); }