mirror of
https://github.com/storytold/spark.git
synced 2026-10-09 00:09:53 +00:00
SPZ writing, exporting, & viewer. Creates a new npm run to-spz which runs scripts/to-spz.js to convert any Gsplat input file to compress .spz. Also includes a configurable viewer examples/viewer that can load splats via drag-n-drop, from url query param, the URL text list, can edit sets of SplatMesh, and export composite .SPZ with some filters. Refactored some loading code from worker.ts to loader files so they can be called from Nodejs. Created api transcodeSpz with options to combine multiple source Gsplat files into one without losing coding quality along the way.
This commit is contained in:
committed by
Diego Marcos Segura
parent
b4257bcd64
commit
156bbf400e
@@ -0,0 +1,270 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execSync } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { URL } from "node:url";
|
||||
|
||||
// Import directly from source to avoid worker system
|
||||
import { transcodeSpz } from "../dist/forge.module.js";
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length === 0 || args.includes("-h") || args.includes("--help")) {
|
||||
console.log(`Usage: to-spz.js [options] <file_or_url>...
|
||||
|
||||
Convert Gaussian Splat files to optimized .spz format.
|
||||
|
||||
Supported input formats:
|
||||
.ply PLY Gaussian Splat files
|
||||
.wlg WorldLabs Gaussian format
|
||||
.spz SPZ format (for reprocessing/filtering)
|
||||
.splat AntiSplat format
|
||||
.ksplat KSplat format
|
||||
http(s) Direct URLs to splat files
|
||||
|
||||
Options:
|
||||
--filter-zero Filter out splats with zero opacity
|
||||
--filter-opacity N Filter out splats with opacity <= N (0.0-1.0)
|
||||
--min x,y,z Minimum AABB (e.g. --min 0,0,0)
|
||||
--max x,y,z Maximum AABB (e.g. --max 1,1,1)
|
||||
--min-x N Minimum X coordinate
|
||||
--max-x N Maximum X coordinate
|
||||
--min-y N Minimum Y coordinate
|
||||
--max-y N Maximum Y coordinate
|
||||
--min-z N Minimum Z coordinate
|
||||
--max-z N Maximum Z coordinate
|
||||
--x-range min,max X coordinate range (e.g. --x-range -1,1)
|
||||
--y-range min,max Y coordinate range (e.g. --y-range -1,1)
|
||||
--z-range min,max Z coordinate range (e.g. --z-range -1,1)
|
||||
--max-sh N Maximum SH degree to output (0-3, default: auto-detect from input)
|
||||
--fractional-bits N Fractional bits for coordinate precision (default: 12, range: 6-24)
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let opacityThreshold = null; // null = no filtering
|
||||
let minAABB = [
|
||||
Number.NEGATIVE_INFINITY,
|
||||
Number.NEGATIVE_INFINITY,
|
||||
Number.NEGATIVE_INFINITY,
|
||||
];
|
||||
let maxAABB = [
|
||||
Number.POSITIVE_INFINITY,
|
||||
Number.POSITIVE_INFINITY,
|
||||
Number.POSITIVE_INFINITY,
|
||||
];
|
||||
let maxShDegree = null; // null = auto-detect
|
||||
let fractionalBits = 12; // default value
|
||||
const inputs = [];
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (arg === "--filter-zero") {
|
||||
opacityThreshold = 0;
|
||||
} else if (arg === "--filter-opacity") {
|
||||
const val = Number(args[++i]);
|
||||
if (Number.isNaN(val) || val < 0 || val > 1) {
|
||||
throw new Error("Invalid --filter-opacity value, expected 0.0-1.0");
|
||||
}
|
||||
opacityThreshold = val;
|
||||
} else if (arg === "--min") {
|
||||
const val = args[++i];
|
||||
minAABB = val.split(",").map(Number);
|
||||
if (minAABB.length !== 3 || minAABB.some(Number.isNaN)) {
|
||||
throw new Error("Invalid --min value, expected comma-separated x,y,z");
|
||||
}
|
||||
} else if (arg === "--max") {
|
||||
const val = args[++i];
|
||||
maxAABB = val.split(",").map(Number);
|
||||
if (maxAABB.length !== 3 || maxAABB.some(Number.isNaN)) {
|
||||
throw new Error("Invalid --max value, expected comma-separated x,y,z");
|
||||
}
|
||||
} else if (arg === "--max-sh") {
|
||||
const val = Number(args[++i]);
|
||||
if (Number.isNaN(val) || val < 0 || val > 3) {
|
||||
throw new Error("Invalid --max-sh value, expected 0-3");
|
||||
}
|
||||
maxShDegree = val;
|
||||
} else if (arg === "--fractional-bits") {
|
||||
const val = Number(args[++i]);
|
||||
if (Number.isNaN(val) || val < 6 || val > 24) {
|
||||
throw new Error("Invalid --fractional-bits value, expected 6-24");
|
||||
}
|
||||
fractionalBits = val;
|
||||
} else if (arg === "--min-x") {
|
||||
const val = Number(args[++i]);
|
||||
if (Number.isNaN(val)) {
|
||||
throw new Error("Invalid --min-x value, expected a number");
|
||||
}
|
||||
minAABB[0] = val;
|
||||
} else if (arg === "--max-x") {
|
||||
const val = Number(args[++i]);
|
||||
if (Number.isNaN(val)) {
|
||||
throw new Error("Invalid --max-x value, expected a number");
|
||||
}
|
||||
maxAABB[0] = val;
|
||||
} else if (arg === "--min-y") {
|
||||
const val = Number(args[++i]);
|
||||
if (Number.isNaN(val)) {
|
||||
throw new Error("Invalid --min-y value, expected a number");
|
||||
}
|
||||
minAABB[1] = val;
|
||||
} else if (arg === "--max-y") {
|
||||
const val = Number(args[++i]);
|
||||
if (Number.isNaN(val)) {
|
||||
throw new Error("Invalid --max-y value, expected a number");
|
||||
}
|
||||
maxAABB[1] = val;
|
||||
} else if (arg === "--min-z") {
|
||||
const val = Number(args[++i]);
|
||||
if (Number.isNaN(val)) {
|
||||
throw new Error("Invalid --min-z value, expected a number");
|
||||
}
|
||||
minAABB[2] = val;
|
||||
} else if (arg === "--max-z") {
|
||||
const val = Number(args[++i]);
|
||||
if (Number.isNaN(val)) {
|
||||
throw new Error("Invalid --max-z value, expected a number");
|
||||
}
|
||||
maxAABB[2] = val;
|
||||
} else if (arg === "--x-range") {
|
||||
const val = args[++i];
|
||||
const range = val.split(",").map(Number);
|
||||
if (range.length !== 2 || range.some(Number.isNaN)) {
|
||||
throw new Error("Invalid --x-range value, expected min,max");
|
||||
}
|
||||
minAABB[0] = range[0];
|
||||
maxAABB[0] = range[1];
|
||||
} else if (arg === "--y-range") {
|
||||
const val = args[++i];
|
||||
const range = val.split(",").map(Number);
|
||||
if (range.length !== 2 || range.some(Number.isNaN)) {
|
||||
throw new Error("Invalid --y-range value, expected min,max");
|
||||
}
|
||||
minAABB[1] = range[0];
|
||||
maxAABB[1] = range[1];
|
||||
} else if (arg === "--z-range") {
|
||||
const val = args[++i];
|
||||
const range = val.split(",").map(Number);
|
||||
if (range.length !== 2 || range.some(Number.isNaN)) {
|
||||
throw new Error("Invalid --z-range value, expected min,max");
|
||||
}
|
||||
minAABB[2] = range[0];
|
||||
maxAABB[2] = range[1];
|
||||
} else {
|
||||
inputs.push(arg);
|
||||
}
|
||||
}
|
||||
|
||||
if (inputs.length === 0) {
|
||||
console.error("No input files or URLs specified.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Collect all file inputs first
|
||||
const transcodeInputs = [];
|
||||
|
||||
for (const input of inputs) {
|
||||
console.log(`Loading ${input}...`);
|
||||
let fileBytes;
|
||||
let pathOrUrl;
|
||||
|
||||
if (input.startsWith("http://") || input.startsWith("https://")) {
|
||||
const res = await fetch(input);
|
||||
if (!res.ok) {
|
||||
console.error(
|
||||
`Failed to fetch ${input}: ${res.status} ${res.statusText}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const buffer = await res.arrayBuffer();
|
||||
fileBytes = new Uint8Array(buffer);
|
||||
pathOrUrl = input;
|
||||
} else {
|
||||
const data = await fs.readFile(input);
|
||||
fileBytes = new Uint8Array(data);
|
||||
pathOrUrl = input;
|
||||
}
|
||||
|
||||
transcodeInputs.push({
|
||||
fileBytes: fileBytes.slice(),
|
||||
pathOrUrl,
|
||||
transform: {
|
||||
translate: [0, 0, 0],
|
||||
quaternion: [0, 0, 0, 1],
|
||||
scale: 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (transcodeInputs.length === 0) {
|
||||
console.error("No valid inputs to process.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Setup clipping bounds if specified
|
||||
let clipXyz = undefined;
|
||||
if (
|
||||
minAABB[0] !== Number.NEGATIVE_INFINITY ||
|
||||
minAABB[1] !== Number.NEGATIVE_INFINITY ||
|
||||
minAABB[2] !== Number.NEGATIVE_INFINITY ||
|
||||
maxAABB[0] !== Number.POSITIVE_INFINITY ||
|
||||
maxAABB[1] !== Number.POSITIVE_INFINITY ||
|
||||
maxAABB[2] !== Number.POSITIVE_INFINITY
|
||||
) {
|
||||
clipXyz = {
|
||||
min: minAABB,
|
||||
max: maxAABB,
|
||||
};
|
||||
}
|
||||
|
||||
// Setup opacity threshold filtering
|
||||
if (opacityThreshold !== null) {
|
||||
console.log(
|
||||
`Applying opacity filtering: removing splats with opacity <= ${opacityThreshold}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Processing ${transcodeInputs.length} input file(s) with transcodeSpz...`,
|
||||
);
|
||||
const transcode = await transcodeSpz({
|
||||
inputs: transcodeInputs,
|
||||
maxSh: maxShDegree,
|
||||
clipXyz,
|
||||
fractionalBits,
|
||||
opacityThreshold,
|
||||
});
|
||||
|
||||
// Write output file
|
||||
const firstInput = transcodeInputs[0];
|
||||
const baseName = path.basename(firstInput.pathOrUrl);
|
||||
const nameNoExt = baseName.replace(/\.[^.]+$/, "");
|
||||
const outDir =
|
||||
firstInput.pathOrUrl.startsWith("http://") ||
|
||||
firstInput.pathOrUrl.startsWith("https://")
|
||||
? process.cwd()
|
||||
: path.dirname(firstInput.pathOrUrl);
|
||||
|
||||
const outputName =
|
||||
transcodeInputs.length === 1
|
||||
? `${nameNoExt}.spz`
|
||||
: `combined_${transcodeInputs.length}_files.spz`;
|
||||
|
||||
const outPath = path.join(outDir, outputName);
|
||||
await fs.writeFile(outPath, transcode.fileBytes);
|
||||
console.log(`Wrote ${outPath} (${transcode.fileBytes.length} bytes)`);
|
||||
|
||||
if (transcode.clippedCount && transcode.clippedCount > 0) {
|
||||
console.log(`Clipped ${transcode.clippedCount} splats.`);
|
||||
console.log(
|
||||
`Consider decreasing fractional-bits from ${fractionalBits} to reduce clipping.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user