import { spawn } from "node:child_process"; import { copyFile, mkdtemp, rm, stat } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { exists, loadRecipe, projectRoot, readJson, workRoot, writeJson } from "./lib.mjs"; import { preprocessShadowfangVisual } from "./shadowfang-fallback-preprocess.mjs"; function runCli(args) { const cli = path.join(projectRoot, "node_modules/@gltf-transform/cli/bin/cli.js"); return new Promise((resolve, reject) => { const child = spawn(process.execPath, [cli, ...args], { cwd: projectRoot, 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(file) { const compressor = path.join(projectRoot, "scripts/asset-pipeline/compress-ktx2.mjs"); return new Promise((resolve, reject) => { const child = spawn(process.execPath, [ compressor, file, "--concurrency", "1", "--jobs", "2", ], { cwd: projectRoot, stdio: "inherit", windowsHide: true }); child.once("error", reject); child.once("exit", (code) => code === 0 ? resolve() : reject(new Error(`KTX2 compression exited with code ${code}.`))); }); } export function dungeonEnvironmentOptimizationArguments(sourceFile, outputFile) { return [ "optimize", sourceFile, outputFile, "--compress", "meshopt", "--meshopt-level", "high", "--instance", "true", "--instance-min", "3", "--simplify", "false", "--texture-compress", "false", ]; } export function dungeonEnvironmentFinalPackingArguments(sourceFile, outputFile) { return dungeonEnvironmentOptimizationArguments(sourceFile, outputFile); } export async function optimizeDungeonEnvironment(slug) { const recipe = await loadRecipe(slug); const directory = path.join(workRoot, slug, "staging", "environment"); const conversionFile = path.join(directory, "conversion-report.json"); if (!await exists(conversionFile)) return { status: "blocked", dungeonId: slug, blockers: ["Run dungeon:convert first."] }; const conversion = await readJson(conversionFile); const source = path.join(directory, conversion.visual.file); if (!await exists(source)) return { status: "blocked", dungeonId: slug, blockers: ["The lossless visual GLB is missing."] }; const selectiveProfile = recipe.visualOptimization?.selectiveFallbackSimplification; if (slug === "shadowfang-keep" && !selectiveProfile) { throw new Error("Shadowfang optimization requires its source-pinned selective fallback recipe profile."); } if (slug !== "shadowfang-keep" && selectiveProfile) { throw new Error(`The Shadowfang-only selective fallback profile cannot be applied to ${slug}.`); } const optimized = path.join(directory, `${slug}-visual.optimized.glb`); const packed = path.join(directory, `${slug}-visual.meshopt-final.glb`); let temporaryDirectory = null; let optimizationSource = source; let sourcePreprocessing = null; try { if (slug === "shadowfang-keep") { temporaryDirectory = await mkdtemp(path.join(os.tmpdir(), "shadowfang-optimizer-source-")); optimizationSource = path.join(temporaryDirectory, "shadowfang-selective-source.glb"); sourcePreprocessing = await preprocessShadowfangVisual(source, optimizationSource); } await runCli(dungeonEnvironmentOptimizationArguments(optimizationSource, optimized)); await compressKtx2(optimized); await rm(packed, { force: true }); await runCli(dungeonEnvironmentFinalPackingArguments(optimized, packed)); await copyFile(packed, optimized); await rm(packed, { force: true }); const finalPackingCommand = "gltf-transform optimize --compress meshopt --meshopt-level high --instance true --instance-min 3 --simplify false --texture-compress false"; const report = { schemaVersion: 1, dungeonId: slug, status: "review-required", source: { file: path.basename(source), size: (await stat(source)).size }, sourcePreprocessing: sourcePreprocessing ? { method: "source-pinned selective simplification of untextured ADT_Terrain and ADT_Liquid_B2 fallback materials", sourceSha256: sourcePreprocessing.source.sha256, texturedPrimitiveAccessorHashesUnchanged: sourcePreprocessing.texturedPrimitiveAccessorHashesUnchanged, sourceTextureBytesUnchanged: sourcePreprocessing.sourceTextureBytesUnchanged, ...sourcePreprocessing.profile, selectedByMaterial: sourcePreprocessing.selectedByMaterial, } : null, optimized: { file: path.basename(optimized), size: (await stat(optimized)).size }, compression: "meshopt+ktx2", meshoptPackingRestoredAfterTextureCompression: true, finalPackedStage: { status: "complete", file: path.basename(optimized), size: (await stat(optimized)).size, extension: "EXT_meshopt_compression", command: finalPackingCommand, }, commands: [ ...(sourcePreprocessing ? ["source-pinned Shadowfang fallback preprocessing (see sourcePreprocessing profile)"] : []), "gltf-transform optimize --compress meshopt --meshopt-level high --instance true --instance-min 3 --simplify false --texture-compress false", "node scripts/asset-pipeline/compress-ktx2.mjs --concurrency 1 --jobs 2", finalPackingCommand, ], }; await writeJson(path.join(directory, "optimization-report.json"), report); return report; } finally { if (temporaryDirectory) await rm(temporaryDirectory, { recursive: true, force: true }); } }