Files
healer-man/scripts/runewaker-pipeline/reoptimize-environment-visuals.mjs
T
2026-10-06 22:25:34 -04:00

246 lines
11 KiB
JavaScript

import { spawn } from "node:child_process";
import { createHash } from "node:crypto";
import {
copyFile,
readFile,
readdir,
rename,
rm,
stat,
writeFile,
} from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import { environmentOptimizationArguments } from "./lib/environment-optimization.mjs";
import { applyNativeMaterialSemantics } from "./lib/native-material-semantics.mjs";
import { applyRunewakerVisualUvAdaptationsToFile } from "./lib/rom-visual-uv-adaptations-file.mjs";
import { applyRunewakerDerivedSupportStylingToFile } from "./lib/rom-derived-support-styling-file.mjs";
import { listRunewakerDerivedSupportStylingPolicies } from "./lib/rom-derived-support-styling.mjs";
import { projectRoot, readJson } from "./lib/recipe.mjs";
const recipeDirectory = path.join(projectRoot, "scripts", "runewaker-pipeline", "recipes");
const workRoot = path.join(projectRoot, "runewaker-export-work");
const assetRoot = path.join(projectRoot, "src", "assets", "game", "dungeons");
const gltfTransformCli = path.join(projectRoot, "node_modules", "@gltf-transform", "cli", "bin", "cli.js");
const ktx2Compressor = path.join(projectRoot, "scripts", "runewaker-pipeline", "compress-environment-textures.mjs");
const args = process.argv.slice(2);
const selectedSlug = args.includes("--slug") ? args[args.indexOf("--slug") + 1] : null;
const applyStaged = args.includes("--apply-staged");
function sha256(bytes) {
return createHash("sha256").update(bytes).digest("hex");
}
function run(command, commandArgs, label) {
return new Promise((resolve, reject) => {
const child = spawn(command, commandArgs, {
cwd: projectRoot,
windowsHide: true,
stdio: ["ignore", "pipe", "pipe"],
});
child.stdout.on("data", (chunk) => process.stdout.write(chunk));
child.stderr.on("data", (chunk) => process.stderr.write(chunk));
child.once("error", reject);
child.once("exit", (code) => {
if (code === 0) resolve();
else reject(new Error(`${label} exited with ${code}.`));
});
});
}
async function collectDungeons() {
const recipeFiles = (await readdir(recipeDirectory, { withFileTypes: true }))
.filter((entry) => entry.isFile() && entry.name.endsWith(".json") && !entry.name.endsWith("-population.json"))
.map((entry) => path.join(recipeDirectory, entry.name));
const recipes = [];
for (const recipeFile of recipeFiles) {
const recipe = await readJson(recipeFile);
if (recipe.slug && recipe.source?.model && recipe.source?.wdb) recipes.push(recipe);
}
recipes.sort((left, right) => left.slug.localeCompare(right.slug));
const expectedRecipes = selectedSlug ? recipes.filter((recipe) => recipe.slug === selectedSlug) : recipes;
if (selectedSlug && !expectedRecipes.length) {
throw new Error(`No converted and shipped environment found for ${selectedSlug}.`);
}
const dungeons = [];
for (const recipe of expectedRecipes) {
const slug = recipe.slug;
const convertedDirectory = path.join(workRoot, slug, "converted");
const source = path.join(convertedDirectory, `${slug}-visual.glb`);
const metadataFile = path.join(assetRoot, slug, "import-metadata.json");
try {
await stat(source);
await stat(metadataFile);
} catch (error) {
throw new Error(`Expected RoM environment ${slug} is incomplete: ${error.message}`);
}
dungeons.push({ slug, recipe, convertedDirectory, source, metadataFile });
}
if (!dungeons.length) throw new Error("No RoM environment recipes were found.");
return dungeons;
}
async function stageDungeon(dungeon, index, count) {
const { slug, convertedDirectory, source } = dungeon;
const stagedVisual = path.join(convertedDirectory, `${slug}-visual.reoptimized.glb`);
const stagedOptimizationReport = path.join(convertedDirectory, "optimization-report.reoptimized.json");
let sourceReport;
if (dungeon.recipe.visualUvAdaptationPolicies?.length || dungeon.recipe.visualSurfaceQualityPolicies?.length || dungeon.recipe.derivedSupportStylingPolicy) {
sourceReport = await readJson(path.join(workRoot, slug, "prepared", "export-report.json"));
sourceReport.sourceResourceSha256 ??= (await readJson(dungeon.metadataFile)).source?.modelSha256;
}
const visualUvAdaptations = await applyRunewakerVisualUvAdaptationsToFile(source, {
dungeonId: slug,
policyIds: dungeon.recipe.visualUvAdaptationPolicies ?? [],
surfaceQualityPolicyIds: dungeon.recipe.visualSurfaceQualityPolicies ?? [],
sourceReport,
});
const supportPolicy = listRunewakerDerivedSupportStylingPolicies().find(p => p.id === dungeon.recipe.derivedSupportStylingPolicy);
const derivedSupportStyling = await applyRunewakerDerivedSupportStylingToFile(source, {
dungeonId: slug, policyId: dungeon.recipe.derivedSupportStylingPolicy, sourceReport,
sourceDdsFile: supportPolicy ? path.join(workRoot, slug, "raw", "textures", supportPolicy.stagedTexture) : null,
});
const nativeMaterialSemantics = await applyNativeMaterialSemantics(source);
const sourceSize = (await stat(source)).size;
await rm(stagedVisual, { force: true });
console.log(`\n[${index}/${count}] Optimizing ${slug} from converted geometry (simplification disabled).`);
await run(
process.execPath,
[gltfTransformCli, ...environmentOptimizationArguments(source, stagedVisual, {
preserveNativeWaterLayers: Boolean(dungeon.recipe.source?.nativeWater),
preserveMaterialVariantCarriers: Boolean(dungeon.recipe.visualSurfaceQualityPolicies?.length || dungeon.recipe.visualUvAdaptationPolicies?.length),
})],
`${slug} glTF optimization`,
);
await run(
process.execPath,
[ktx2Compressor, stagedVisual, "--concurrency", "1", "--jobs", "2", "--report", path.join(convertedDirectory, "texture-quality-report.reoptimized.json")],
`${slug} KTX2 texture compression`,
);
const optimizedBytes = await readFile(stagedVisual);
const report = {
schemaVersion: 1,
dungeonId: slug,
status: "review-required",
source: { file: path.basename(source), size: sourceSize },
optimized: {
file: `${slug}-visual.optimized.glb`,
size: optimizedBytes.length,
checksum: sha256(optimizedBytes),
},
compression: "meshopt+ktx2",
commands: [
"node scripts/runewaker-pipeline/lib/native-material-semantics.mjs <converted.glb> (before optimization)",
"gltf-transform optimize --compress meshopt --meshopt-level high --instance true --instance-min 3 --simplify false --texture-compress false",
"node scripts/runewaker-pipeline/compress-environment-textures.mjs <optimized.glb> --concurrency 1 --jobs 2",
],
textureQuality: JSON.parse(await readFile(path.join(convertedDirectory, "texture-quality-report.reoptimized.json"), "utf8")),
nativeMaterialSemantics,
visualUvAdaptations,
derivedSupportStyling,
};
await writeFile(stagedOptimizationReport, `${JSON.stringify(report, null, 2)}\n`, "utf8");
console.log(`[${index}/${count}] Staged ${slug}: ${optimizedBytes.length} bytes, SHA-256 ${report.optimized.checksum}.`);
return { ...dungeon, stagedVisual, stagedOptimizationReport };
}
async function validateStagedDungeon(dungeon) {
const { slug, convertedDirectory } = dungeon;
const stagedVisual = path.join(convertedDirectory, `${slug}-visual.reoptimized.glb`);
const stagedOptimizationReport = path.join(convertedDirectory, "optimization-report.reoptimized.json");
const stagedBytes = await readFile(stagedVisual);
const stagedReport = JSON.parse(await readFile(stagedOptimizationReport, "utf8"));
if (stagedReport.dungeonId !== slug
|| stagedReport.optimized?.size !== stagedBytes.length
|| stagedReport.optimized?.checksum !== sha256(stagedBytes)) {
throw new Error(`${slug} staged visual and optimization report do not match.`);
}
return { ...dungeon, stagedVisual, stagedOptimizationReport, stagedReport, stagedBytes };
}
async function preparePackage(dungeon) {
const {
slug,
convertedDirectory,
stagedVisual,
stagedOptimizationReport,
metadataFile,
stagedBytes,
} = dungeon;
const shippingDirectory = path.dirname(metadataFile);
const shippingVisual = path.join(shippingDirectory, `${slug}-visual.glb`);
const shippingReport = path.join(shippingDirectory, "optimization-report.json");
const metadata = JSON.parse(await readFile(metadataFile, "utf8"));
const visualEntry = metadata.assets?.find((entry) => entry.role === "visual" && entry.fileName === path.basename(shippingVisual));
if (!visualEntry) throw new Error(`${slug} import metadata has no matching visual asset record.`);
const stagedOptimized = path.join(convertedDirectory, `${slug}-visual.optimized.pending.glb`);
const stagedShipping = path.join(shippingDirectory, `.${slug}-visual.pending.glb`);
const stagedShippingReport = path.join(shippingDirectory, ".optimization-report.pending.json");
const stagedMetadata = `${metadataFile}.pending`;
await rm(stagedOptimized, { force: true });
await rm(stagedShipping, { force: true });
await rm(stagedShippingReport, { force: true });
await rm(stagedMetadata, { force: true });
visualEntry.size = stagedBytes.length;
visualEntry.sha256 = sha256(stagedBytes);
metadata.generatedAt = new Date().toISOString();
await copyFile(stagedVisual, stagedOptimized);
await copyFile(stagedVisual, stagedShipping);
await copyFile(stagedOptimizationReport, stagedShippingReport);
await writeFile(stagedMetadata, `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
return {
...dungeon,
stagedOptimized,
stagedShipping,
stagedShippingReport,
stagedMetadata,
shippingVisual,
shippingReport,
metadataFile,
};
}
async function applyPackage(dungeon) {
const {
slug,
convertedDirectory,
stagedVisual,
stagedOptimizationReport,
stagedOptimized,
stagedShipping,
stagedShippingReport,
stagedMetadata,
shippingVisual,
shippingReport,
metadataFile,
} = dungeon;
await rename(stagedOptimized, path.join(convertedDirectory, `${slug}-visual.optimized.glb`));
await rename(stagedShipping, shippingVisual);
await rename(stagedShippingReport, shippingReport);
await rename(stagedMetadata, metadataFile);
await rm(stagedVisual, { force: true });
await rm(stagedOptimizationReport, { force: true });
console.log(`[package] ${slug}: visual checksum/size and optimization report refreshed.`);
}
const dungeons = await collectDungeons();
console.log(`[runewaker-reoptimize] ${dungeons.length} environment visual(s) selected.`);
let staged;
if (applyStaged) {
staged = [];
for (const dungeon of dungeons) staged.push(await validateStagedDungeon(dungeon));
const preparedPackages = [];
for (const dungeon of staged) preparedPackages.push(await preparePackage(dungeon));
for (const dungeon of preparedPackages) await applyPackage(dungeon);
console.log(`[runewaker-reoptimize] Applied ${staged.length} staged visuals without re-encoding textures.`);
} else {
staged = [];
for (const [index, dungeon] of dungeons.entries()) {
staged.push(await stageDungeon(dungeon, index + 1, dungeons.length));
}
console.log(`[runewaker-reoptimize] Staged ${staged.length} corrected visuals. After the audit baseline is frozen, re-run with --apply-staged to replace shipped visuals.`);
}