Files
osmWorkflow/scripts/compress-glb.js

279 lines
8.9 KiB
JavaScript

#!/usr/bin/env node
"use strict";
// Experimental GLB compression wrapper. Keeps the source GLB intact and writes
// a separate compressed artifact for visual comparison in Cesium.
const fs = require("fs");
const os = require("os");
const path = require("path");
const { spawnSync } = require("child_process");
const GLTF_TRANSFORM_VERSION = "4.1.4";
const PREVIEW_CONFIG_PATTERN =
/window\.OSM_ASSET_PREVIEW_CONFIG\s*=\s*(\{[\s\S]*?\});\s*<\/script>/;
function parseArgs(argv) {
const values = {};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (!arg.startsWith("--")) continue;
const key = arg.slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase());
const next = argv[i + 1];
if (!next || next.startsWith("--")) {
values[key] = "true";
} else {
values[key] = next;
i += 1;
}
}
return values;
}
function usage() {
return [
"usage: node scripts/compress-glb.js --input in.glb --output out.glb [options]",
"",
"Options:",
" --texture-size 1024 Max texture width/height. Default: 1024",
" --quality 82 WebP quality. Default: 82",
" --effort 80 WebP encoder effort. Default: 80",
" --meshopt Also apply EXT_meshopt_compression",
" --metadata scene.json Write sidecar metadata for the compressed GLB",
" --metadata-output out.json Override compressed metadata path",
" --preview preview.html Write a copied preview pointing to compressed metadata",
" --preview-output out.html Override compressed preview path",
].join("\n");
}
function requireText(value, label) {
if (!value || !String(value).trim()) {
throw new Error(`${label} is required\n\n${usage()}`);
}
return String(value);
}
function numberOption(value, fallback, label, min, max) {
const number = value === undefined ? fallback : Number(value);
if (!Number.isFinite(number) || number < min || number > max) {
throw new Error(`${label} must be a finite number in [${min}, ${max}]`);
}
return number;
}
function ensureFile(file, label) {
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
throw new Error(`${label} not found: ${file}`);
}
}
function runGltfTransform(args) {
const command = process.env.NPX_BINARY || "npx";
const result = spawnSync(
command,
["--yes", `@gltf-transform/cli@${GLTF_TRANSFORM_VERSION}`, ...args],
{ stdio: "inherit" },
);
if (result.error) throw result.error;
if (result.status !== 0) {
const signal = result.signal ? ` signal=${result.signal}` : "";
throw new Error(`gltf-transform ${args[0]} failed with status=${result.status}${signal}`);
}
}
function readGlbJson(file) {
const buffer = fs.readFileSync(file);
if (buffer.length < 12 || buffer.readUInt32LE(0) !== 0x46546c67) {
throw new Error(`Not a GLB (bad magic): ${file}`);
}
let offset = 12;
while (offset + 8 <= buffer.length) {
const chunkLength = buffer.readUInt32LE(offset);
const chunkType = buffer.readUInt32LE(offset + 4);
const start = offset + 8;
if (chunkType === 0x4e4f534a) {
return JSON.parse(buffer.slice(start, start + chunkLength).toString("utf8"));
}
offset = start + chunkLength;
}
throw new Error(`No JSON chunk found in ${file}`);
}
function glbStats(file) {
const gltf = readGlbJson(file);
const bufferViews = gltf.bufferViews || [];
const imageViews = new Set(
(gltf.images || []).map((image) => image.bufferView).filter((value) => value !== undefined),
);
let imageBytes = 0;
let nonImageBytes = 0;
bufferViews.forEach((view, index) => {
if (imageViews.has(index)) imageBytes += view.byteLength || 0;
else nonImageBytes += view.byteLength || 0;
});
return {
fileBytes: fs.statSync(file).size,
imageBytes,
nonImageBytes,
counts: {
nodes: (gltf.nodes || []).length,
meshes: (gltf.meshes || []).length,
materials: (gltf.materials || []).length,
images: (gltf.images || []).length,
accessors: (gltf.accessors || []).length,
},
extensionsUsed: (gltf.extensionsUsed || []).slice().sort(),
};
}
function mb(bytes) {
return Number((bytes / 1024 / 1024).toFixed(2));
}
function metadataOutputPath(output, explicitPath) {
if (explicitPath) return path.resolve(explicitPath);
return output.replace(/\.glb$/i, ".json");
}
function previewOutputPath(output, explicitPath) {
if (explicitPath) return path.resolve(explicitPath);
return output.replace(/\.glb$/i, "-cesium-preview.html");
}
function writeCompressedMetadata(sourceMetadata, outputGlb, outputMetadata) {
const metadata = JSON.parse(fs.readFileSync(sourceMetadata, "utf8"));
const glbName = path.basename(outputGlb);
metadata.asset = glbName;
metadata.assets = [{
id: "main",
label: "Compressed Scene",
type: "model",
url: glbName,
enabled: true,
}];
if (typeof metadata.cesium_js === "string") {
metadata.cesium_js = metadata.cesium_js.replace(
/url: '[^']+\.glb'/,
`url: '${glbName}'`,
);
}
fs.mkdirSync(path.dirname(outputMetadata), { recursive: true });
fs.writeFileSync(outputMetadata, `${JSON.stringify(metadata, null, 2)}\n`);
}
function readPreviewConfig(html) {
const match = html.match(PREVIEW_CONFIG_PATTERN);
if (!match) return null;
return JSON.parse(match[1]);
}
function escapeScriptJson(value) {
return String(value)
.replaceAll("<", "\\u003c")
.replaceAll(">", "\\u003e")
.replaceAll("&", "\\u0026")
.replaceAll("\u2028", "\\u2028")
.replaceAll("\u2029", "\\u2029");
}
function writeCompressedPreview(sourcePreview, outputPreview, outputGlb, outputMetadata) {
const html = fs.readFileSync(sourcePreview, "utf8");
const config = readPreviewConfig(html);
if (!config) {
throw new Error(`Preview config not found in ${sourcePreview}`);
}
config.glbName = path.basename(outputGlb);
config.metadataName = path.basename(outputMetadata);
const nextHtml = html
.replace(
PREVIEW_CONFIG_PATTERN,
`window.OSM_ASSET_PREVIEW_CONFIG = ${escapeScriptJson(JSON.stringify(config))};</script>`,
)
.replace(/Loading [^<]+\.glb\.\.\./, `Loading ${path.basename(outputGlb)}...`);
fs.mkdirSync(path.dirname(outputPreview), { recursive: true });
fs.writeFileSync(outputPreview, nextHtml);
}
function main() {
const args = parseArgs(process.argv.slice(2));
const input = path.resolve(requireText(args.input, "--input"));
const output = path.resolve(requireText(args.output, "--output"));
const textureSize = numberOption(args.textureSize, 1024, "--texture-size", 64, 4096);
const quality = numberOption(args.quality, 82, "--quality", 1, 100);
const effort = numberOption(args.effort, 80, "--effort", 0, 100);
const meshopt = args.meshopt === "true";
ensureFile(input, "Input GLB");
if (input === output) {
throw new Error("--output must differ from --input");
}
fs.mkdirSync(path.dirname(output), { recursive: true });
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "osm-glb-compress-"));
const resized = path.join(tempDir, "resized.glb");
const webp = meshopt ? path.join(tempDir, "webp.glb") : output;
try {
const before = glbStats(input);
runGltfTransform([
"resize", input, resized,
"--width", String(textureSize),
"--height", String(textureSize),
]);
runGltfTransform([
"webp", resized, webp,
"--quality", String(quality),
"--effort", String(effort),
]);
if (meshopt) {
runGltfTransform(["meshopt", webp, output, "--level", "high"]);
}
let metadataOutput = null;
let previewOutput = null;
if (args.metadata) {
const metadata = path.resolve(args.metadata);
ensureFile(metadata, "Source metadata");
metadataOutput = metadataOutputPath(output, args.metadataOutput);
writeCompressedMetadata(metadata, output, metadataOutput);
}
if (args.preview) {
if (!metadataOutput) {
throw new Error("--preview requires --metadata so the copied preview has a metadata file");
}
const preview = path.resolve(args.preview);
ensureFile(preview, "Source preview");
previewOutput = previewOutputPath(output, args.previewOutput);
writeCompressedPreview(preview, previewOutput, output, metadataOutput);
}
const after = glbStats(output);
console.log("GLB_COMPRESS_DONE " + JSON.stringify({
input,
output,
texture_size: textureSize,
quality,
effort,
meshopt,
input_mb: mb(before.fileBytes),
output_mb: mb(after.fileBytes),
image_mb_before: mb(before.imageBytes),
image_mb_after: mb(after.imageBytes),
non_image_mb_before: mb(before.nonImageBytes),
non_image_mb_after: mb(after.nonImageBytes),
counts: after.counts,
extensions_used: after.extensionsUsed,
metadata: metadataOutput,
preview: previewOutput,
}));
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
}
try {
main();
} catch (error) {
console.error(error.message);
process.exit(1);
}