import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; const args = new Map(); for (let index = 2; index < process.argv.length; index += 2) { args.set(process.argv[index], process.argv[index + 1]); } function required(name) { const value = args.get(name); if (!value) throw new Error(`VDB_MANIFEST_ARGUMENT_MISSING: ${name}`); return value; } function sha256(file) { return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); } function semantic(name) { const known = new Map([ ["density", "DENSITY"], ["temperature", "TEMPERATURE"], ["color", "COLOR"], ["emission", "EMISSION"], ["flame", "EMISSION"], ["velocity", "VELOCITY"], ]); return known.get(name.toLowerCase()) ?? "CUSTOM"; } const sourceFile = path.resolve(required("--source")); const bundleFile = path.resolve(required("--bundle")); const reportFile = path.resolve(required("--report")); const outputFile = path.resolve(required("--output")); const converterFile = path.resolve(required("--converter")); const projectId = args.get("--project-id") ?? "vdb-fixtures"; const sourcePath = args.get("--source-path") ?? `//volumes/${path.basename(sourceFile)}`; const bundlePath = args.get("--bundle-path") ?? `//volumes/${path.basename(bundleFile)}`; const blenderVersion = args.get("--blender-version") ?? "5.2.0"; const converterTarget = args.get("--converter-target") ?? "DESKTOP"; const chunkByteLength = Number(args.get("--chunk-bytes") ?? 4 * 1024 * 1024); if (converterTarget !== "DESKTOP" && converterTarget !== "SERVER") { throw new Error("VDB_MANIFEST_ARGUMENT_INVALID: --converter-target"); } if (!Number.isSafeInteger(chunkByteLength) || chunkByteLength < 64 * 1024 || chunkByteLength > 16 * 1024 * 1024 || chunkByteLength % 32 !== 0) { throw new Error("VDB_MANIFEST_ARGUMENT_INVALID: --chunk-bytes"); } for (const file of [sourceFile, bundleFile, reportFile, converterFile]) { if (!fs.statSync(file).isFile()) throw new Error(`VDB_MANIFEST_RESOURCE_MISSING: ${file}`); } const report = JSON.parse(fs.readFileSync(reportFile, "utf8")); if (report.schemaVersion !== 1 || !Array.isArray(report.grids) || report.grids.length === 0) throw new Error("VDB_CONVERSION_INVALID: native report"); if (path.resolve(report.input) !== sourceFile || path.resolve(report.output) !== bundleFile) throw new Error("VDB_CONVERSION_INVALID: report paths do not match artifacts"); const sourceSha256 = sha256(sourceFile); const converter = { target: converterTarget, blenderVersion, openVDBVersion: report.openVDBVersion, nanoVDBVersion: report.nanoVDBVersion, executableSha256: sha256(converterFile), }; const sourceGrids = report.grids.map((grid) => ({ name: grid.name, valueType: grid.sourceType.toUpperCase(), voxelCount: grid.activeVoxelCount, activeVoxelCount: grid.activeVoxelCount, bounds: grid.indexBounds, })); const conversionRequest = { schemaVersion: 1, source: { byteLength: fs.statSync(sourceFile).size, sha256: sourceSha256, grids: sourceGrids, }, selectedGrids: report.grids.map((grid) => grid.name), quantization: report.quantization, chunkByteLength, converter, }; const conversionRequestSha256 = crypto.createHash("sha256").update(JSON.stringify(conversionRequest)).digest("hex"); const bundleBytes = fs.readFileSync(bundleFile); const chunks = []; for (let byteOffset = 0, index = 0; byteOffset < bundleBytes.length; byteOffset += chunkByteLength, index += 1) { const data = bundleBytes.subarray(byteOffset, Math.min(bundleBytes.length, byteOffset + chunkByteLength)); chunks.push({ index, byteOffset, byteLength: data.length, sha256: crypto.createHash("sha256").update(data).digest("hex") }); } const grids = report.grids.map((grid) => ({ name: grid.name, valueType: grid.valueType, gridClass: grid.gridClass, semantic: semantic(grid.name), activeVoxelCount: grid.activeVoxelCount, segmentByteOffset: grid.segmentByteOffset, segmentByteLength: grid.segmentByteLength, byteOffset: grid.byteOffset, byteLength: grid.byteLength, indexBounds: grid.indexBounds, worldBounds: grid.worldBounds, voxelSize: grid.voxelSize, indexToWorld: grid.indexToWorld, })); const gridBySemantic = new Map(grids.map((grid) => [grid.semantic, grid.name])); if (!gridBySemantic.has("DENSITY")) throw new Error("NANOVDB_GRID_UNSUPPORTED: a browser volume bundle requires a density grid"); const manifest = { schemaVersion: 1, projectId, sourcePath, sourceSha256, conversionRequestSha256, bundlePath, bundleByteLength: bundleBytes.length, bundleSha256: crypto.createHash("sha256").update(bundleBytes).digest("hex"), converter, grids, chunks, material: { densityGrid: gridBySemantic.get("DENSITY"), ...(gridBySemantic.has("TEMPERATURE") ? { temperatureGrid: gridBySemantic.get("TEMPERATURE") } : {}), ...(gridBySemantic.has("COLOR") ? { colorGrid: gridBySemantic.get("COLOR") } : {}), ...(gridBySemantic.has("EMISSION") ? { emissionGrid: gridBySemantic.get("EMISSION") } : {}), ...(gridBySemantic.has("VELOCITY") ? { velocityGrid: gridBySemantic.get("VELOCITY") } : {}), densityScale: 1, emissionScale: 0, temperatureScale: 1, anisotropy: 0, interpolation: "LINEAR", }, gpu: { representation: "NANOVDB_STORAGE_BUFFER", byteAlignment: 32, pageByteLength: chunkByteLength, maxResidentBytes: 256 * 1024 * 1024, shaderSemanticVersion: "volume-wgsl-v1", ...(report.float32TreeLayout ? { float32TreeLayout: report.float32TreeLayout } : {}), ...(report.vec3fTreeLayout ? { vec3fTreeLayout: report.vec3fTreeLayout } : {}), }, }; fs.mkdirSync(path.dirname(outputFile), { recursive: true }); fs.writeFileSync(outputFile, `${JSON.stringify(manifest, null, 2)}\n`); process.stdout.write(`nanovdb-manifest-ok grids=${grids.length} chunks=${chunks.length} bytes=${bundleBytes.length} sha256=${manifest.bundleSha256}\n`);