#!/usr/bin/env node "use strict"; // GLB compression wrapper. The caller supplies distinct input and output files; // build-area.js uses staging files before promoting the compressed delivery. 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); if (metadata.asset !== undefined) metadata.asset = glbName; const assets = Array.isArray(metadata.assets) ? metadata.assets : []; const main = assets.find((asset) => asset.id === "main"); metadata.assets = assets.length ? assets.map((asset) => asset.id === "main" ? { ...asset, label: "Compressed Scene", ...(Object.hasOwn(asset, "uri") ? { uri: `models/${glbName}`, defaultLoad: true } : { url: glbName, enabled: true }), } : asset) : [{ id: "main", label: "Compressed Scene", role: "scene", category: "scene", uri: `models/${glbName}`, defaultLoad: true, }]; if (!main && assets.length) { metadata.assets.unshift({ id: "main", label: "Compressed Scene", role: "scene", category: "scene", uri: `models/${glbName}`, defaultLoad: 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))};`, ) .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 }); } } if (require.main === module) { try { main(); } catch (error) { console.error(error.message); process.exit(1); } } module.exports = { writeCompressedMetadata };