mirror of
https://github.com/storytold/spark.git
synced 2026-10-09 00:09:53 +00:00
271 lines
8.4 KiB
JavaScript
271 lines
8.4 KiB
JavaScript
#!/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);
|
|
});
|