Add optional GLB compression stage
This commit is contained in:
@@ -44,6 +44,9 @@ if (stages.cesium) {
|
||||
if (stages.preview) {
|
||||
writeCesiumPreview(area);
|
||||
}
|
||||
if (stages.compress) {
|
||||
compressCesiumGlb(area);
|
||||
}
|
||||
|
||||
console.log("Done.");
|
||||
|
||||
@@ -82,6 +85,9 @@ function normalizeAreaConfig(raw) {
|
||||
const outputOverrides = raw.outputs || {};
|
||||
const areaDir = path.resolve(outputOverrides.areaDir || path.join(outputRoot, id));
|
||||
const fileStem = outputOverrides.fileStem || id;
|
||||
const compress = normalizeCompressConfig(raw.compress);
|
||||
const compressedFileStem = outputOverrides.compressedFileStem ||
|
||||
`${fileStem}-compressed-webp${compress.textureSize}${compress.meshopt ? "-meshopt" : ""}`;
|
||||
const outputs = {
|
||||
areaDir,
|
||||
geojsonDir: path.resolve(outputOverrides.geojsonDir || path.join(areaDir, "osm2streets_web_out")),
|
||||
@@ -95,6 +101,15 @@ function normalizeAreaConfig(raw) {
|
||||
cesiumPreview: path.resolve(
|
||||
outputOverrides.cesiumPreview || path.join(areaDir, `${fileStem}-cesium-preview.html`),
|
||||
),
|
||||
compressedGlb: path.resolve(
|
||||
outputOverrides.compressedGlb || path.join(areaDir, `${compressedFileStem}.glb`),
|
||||
),
|
||||
compressedMetadata: path.resolve(
|
||||
outputOverrides.compressedMetadata || path.join(areaDir, `${compressedFileStem}.json`),
|
||||
),
|
||||
compressedCesiumPreview: path.resolve(
|
||||
outputOverrides.compressedCesiumPreview || path.join(areaDir, `${compressedFileStem}-cesium-preview.html`),
|
||||
),
|
||||
vehicleRoute: path.resolve(outputOverrides.vehicleRoute || path.join(areaDir, `${fileStem}-vehicle-route.json`)),
|
||||
vehicleModel: path.resolve(outputOverrides.vehicleModel || path.join(areaDir, `${fileStem}-vehicle-car.gltf`)),
|
||||
pipelineDir: path.resolve(outputOverrides.pipelineDir || path.join(areaDir, "_pipeline")),
|
||||
@@ -112,6 +127,7 @@ function normalizeAreaConfig(raw) {
|
||||
cesium: raw.stages?.cesium ?? true,
|
||||
reimport: false,
|
||||
preview: false,
|
||||
compress: false,
|
||||
},
|
||||
qgis: {
|
||||
arrowScale: raw.qgis?.arrowScale ?? raw.arrowScale ?? 0.8,
|
||||
@@ -136,6 +152,7 @@ function normalizeAreaConfig(raw) {
|
||||
treeStyle: raw.blender?.treeStyle || "natural",
|
||||
officeOverrides: raw.blender?.officeOverrides || raw.blender?.office_overrides || "",
|
||||
},
|
||||
compress,
|
||||
outputs,
|
||||
};
|
||||
}
|
||||
@@ -147,6 +164,32 @@ function requireText(value, key) {
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeCompressConfig(raw) {
|
||||
const value = raw || {};
|
||||
return {
|
||||
textureSize: numberOption(value.textureSize, 768, "compress.textureSize", 64, 4096),
|
||||
quality: numberOption(value.quality, 82, "compress.quality", 1, 100),
|
||||
effort: numberOption(value.effort, 80, "compress.effort", 0, 100),
|
||||
meshopt: booleanOption(value.meshopt, false, "compress.meshopt"),
|
||||
};
|
||||
}
|
||||
|
||||
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 booleanOption(value, fallback, label) {
|
||||
if (value === undefined) return fallback;
|
||||
if (typeof value === "boolean") return value;
|
||||
if (value === "true") return true;
|
||||
if (value === "false") return false;
|
||||
throw new Error(`${label} must be boolean`);
|
||||
}
|
||||
|
||||
function splitList(value) {
|
||||
return String(value)
|
||||
.split(",")
|
||||
@@ -158,6 +201,8 @@ function resolveStages(defaults, requested) {
|
||||
if (!requested) return defaults;
|
||||
// 'reimport' is deliberately absent from 'all': it is a recovery step for
|
||||
// hand-edited GeoPackages, never part of a full build.
|
||||
// 'compress' is also absent from 'all': it creates an alternate Cesium GLB,
|
||||
// not the baseline asset.
|
||||
const aliases = {
|
||||
all: ["intermediates", "blender", "cesium"],
|
||||
qgis: ["intermediates"],
|
||||
@@ -174,12 +219,22 @@ function resolveStages(defaults, requested) {
|
||||
preview: ["preview"],
|
||||
html: ["preview"],
|
||||
cesiumPreview: ["preview"],
|
||||
compress: ["compress"],
|
||||
compression: ["compress"],
|
||||
compressedCesium: ["compress"],
|
||||
};
|
||||
const out = {
|
||||
intermediates: false,
|
||||
reimport: false,
|
||||
blender: false,
|
||||
cesium: false,
|
||||
preview: false,
|
||||
compress: false,
|
||||
};
|
||||
const out = { intermediates: false, reimport: false, blender: false, cesium: false, preview: false };
|
||||
for (const stage of requested) {
|
||||
const mapped = aliases[stage];
|
||||
if (!mapped) {
|
||||
throw new Error(`Unknown stage '${stage}'. Use intermediates, reimport, blender, cesium, preview, or all.`);
|
||||
throw new Error(`Unknown stage '${stage}'. Use intermediates, reimport, blender, cesium, preview, compress, or all.`);
|
||||
}
|
||||
for (const key of mapped) out[key] = true;
|
||||
}
|
||||
@@ -288,6 +343,44 @@ function exportCesium(area) {
|
||||
writeCesiumPreview(area);
|
||||
}
|
||||
|
||||
function compressCesiumGlb(area) {
|
||||
ensureFile(area.outputs.glb, "Cesium GLB");
|
||||
ensureFile(area.outputs.metadata, "Cesium metadata");
|
||||
ensureFile(area.outputs.cesiumPreview, "Cesium preview");
|
||||
ensureFile(path.join(repoRoot, "scripts", "compress-glb.js"), "GLB compressor");
|
||||
fs.mkdirSync(path.dirname(area.outputs.compressedGlb), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(area.outputs.compressedMetadata), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(area.outputs.compressedCesiumPreview), { recursive: true });
|
||||
|
||||
const compressArgs = [
|
||||
path.join(repoRoot, "scripts", "compress-glb.js"),
|
||||
"--input",
|
||||
area.outputs.glb,
|
||||
"--output",
|
||||
area.outputs.compressedGlb,
|
||||
"--texture-size",
|
||||
String(area.compress.textureSize),
|
||||
"--quality",
|
||||
String(area.compress.quality),
|
||||
"--effort",
|
||||
String(area.compress.effort),
|
||||
"--metadata",
|
||||
area.outputs.metadata,
|
||||
"--metadata-output",
|
||||
area.outputs.compressedMetadata,
|
||||
"--preview",
|
||||
area.outputs.cesiumPreview,
|
||||
"--preview-output",
|
||||
area.outputs.compressedCesiumPreview,
|
||||
];
|
||||
if (area.compress.meshopt) {
|
||||
compressArgs.push("--meshopt");
|
||||
}
|
||||
|
||||
console.log("Stage: compress (Cesium GLB texture resize + WebP)");
|
||||
runCommand(process.execPath, compressArgs, "compress");
|
||||
}
|
||||
|
||||
function blenderExecutable(area) {
|
||||
return path.join(area.blenderApp, "Contents", "MacOS", "Blender");
|
||||
}
|
||||
|
||||
278
scripts/compress-glb.js
Normal file
278
scripts/compress-glb.js
Normal file
@@ -0,0 +1,278 @@
|
||||
#!/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);
|
||||
}
|
||||
Reference in New Issue
Block a user