PC SOGS decoding from .Zip archive (#100)

* Implement PC SOGS decoding from .zip file archive. Update viewer and editor to accept .zip files.

* Restore GL WEBP decoding.
This commit is contained in:
Andreas Sundquist
2025-07-07 17:18:11 -07:00
committed by GitHub
parent cd40e139d1
commit 4266db3259
5 changed files with 136 additions and 12 deletions
+5 -4
View File
@@ -63,7 +63,7 @@
<canvas id="canvas" tabindex="0"></canvas>
<div id="main-gui"></div>
<div id="second-gui"></div>
<input id="file-input" type="file" accept=".ply,.spz,.splat,.ksplat" multiple="true" style="display: none;" />
<input id="file-input" type="file" accept=".ply,.spz,.splat,.ksplat,.zip" multiple="true" style="display: none;" />
<script type="module">
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
@@ -154,7 +154,7 @@
loadFiles(urls);
guiOptions.loadFromText = ""; // Clear after loading
} else {
alert("No valid URLs found in text. URLs must start with http:// or https:// and end with .ply, .spz, .splat, .ksplat, or .json");
alert("No valid URLs found in text. URLs must start with http:// or https:// and end with .ply, .spz, .splat, .ksplat, .json, or .zip");
}
}
},
@@ -683,7 +683,8 @@
file.name.toLowerCase().endsWith('.ply') ||
file.name.toLowerCase().endsWith('.spz') ||
file.name.toLowerCase().endsWith('.splat') ||
file.name.toLowerCase().endsWith('.ksplat')
file.name.toLowerCase().endsWith('.ksplat') ||
file.name.toLowerCase().endsWith('.zip')
);
if (splatFiles.length > 0) {
@@ -693,7 +694,7 @@
// Parse URLs from text (handles single URLs, multiple lines, mixed content)
function parseURLsFromText(text) {
const supportedExtensions = [".ply", ".spz", ".splat", ".ksplat", ".json"];
const supportedExtensions = [".ply", ".spz", ".splat", ".ksplat", ".zip", ".json"];
const urls = [];
// Split by lines, commas, and semicolons
+1 -1
View File
@@ -347,7 +347,7 @@
</div>
<div class="drop-zone">Drag and drop a splat file here</div>
<label for="file-input" class="button upload"><img class="upload-icon" src="upload-icon.svg" alt="upload" />Choose file</label>
<input id="file-input" class="hidden" accept=".ply,.spz,.splat,.ksplat" type="file" />
<input id="file-input" class="hidden" accept=".ply,.spz,.splat,.ksplat,.zip" type="file" />
<form class="url-form">
<input class="url-input" type="text" placeholder="Copy splat URL" />
</form>
+55 -1
View File
@@ -1,3 +1,4 @@
import { unzipSync } from "fflate";
import { FileLoader, Loader, type LoadingManager } from "three";
import { PackedSplats } from "./PackedSplats";
import { SplatMesh } from "./SplatMesh";
@@ -138,6 +139,7 @@ export enum SplatFileType {
SPLAT = "splat",
KSPLAT = "ksplat",
PCSOGS = "pcsogs",
PCSOGSZIP = "pcsogszip",
}
export function getSplatFileType(
@@ -157,6 +159,14 @@ export function getSplatFileType(
// Unknown Gzipped file type
return undefined;
}
if (view.getUint32(0, true) === 0x04034b50) {
// PKZip file
if (tryPcSogsZip(fileBytes)) {
return SplatFileType.PCSOGSZIP;
}
// Unknown PKZip file type
return undefined;
}
// Unknown file type
return undefined;
}
@@ -218,7 +228,7 @@ export type PcSogsJson = {
maxs: number[];
files: string[];
};
shN: {
shN?: {
shape: number[];
dtype: string;
mins: number;
@@ -277,6 +287,37 @@ export function tryPcSogs(
}
}
export function tryPcSogsZip(
input: ArrayBuffer | Uint8Array,
): { name: string; json: PcSogsJson } | undefined {
try {
const fileBytes =
input instanceof ArrayBuffer ? new Uint8Array(input) : input;
let metaFilename: string | null = null;
const unzipped = unzipSync(fileBytes, {
filter: ({ name }) => {
const filename = name.split(/[\\/]/).pop() as string;
if (filename === "meta.json") {
metaFilename = name;
return true;
}
return false;
},
});
if (!metaFilename) {
return undefined;
}
const json = tryPcSogs(unzipped[metaFilename]);
if (!json) {
return undefined;
}
return { name: metaFilename, json };
} catch {
return undefined;
}
}
export async function unpackSplats({
input,
extraFiles,
@@ -373,6 +414,19 @@ export async function unpackSplats({
return { packedArray, numSplats, extra };
});
}
case SplatFileType.PCSOGSZIP: {
return await withWorker(async (worker) => {
const { packedArray, numSplats, extra } = (await worker.call(
"decodePcSogsZip",
{ fileBytes },
)) as {
packedArray: Uint32Array;
numSplats: number;
extra: Record<string, unknown>;
};
return { packedArray, numSplats, extra };
});
}
default: {
throw new Error(`Unknown splat file type: ${splatFileType}`);
}
+58 -3
View File
@@ -1,4 +1,5 @@
import type { PcSogsJson } from "./SplatLoader";
import { unzip } from "fflate";
import { type PcSogsJson, tryPcSogsZip } from "./SplatLoader";
import {
computeMaxSplats,
encodeSh1Rgb,
@@ -11,14 +12,13 @@ import {
} from "./utils";
export async function unpackPcSogs(
fileBytes: Uint8Array,
json: PcSogsJson,
extraFiles: Record<string, ArrayBuffer>,
): Promise<{
packedArray: Uint32Array;
numSplats: number;
extra: Record<string, unknown>;
}> {
const json = JSON.parse(new TextDecoder().decode(fileBytes)) as PcSogsJson;
if (json.quats.encoding !== "quaternion_packed") {
throw new Error("Unsupported quaternion encoding");
}
@@ -204,3 +204,58 @@ async function decodeImageRgba(fileBytes: ArrayBuffer) {
const { rgba } = await decodeImage(fileBytes);
return rgba;
}
export async function unpackPcSogsZip(fileBytes: Uint8Array): Promise<{
packedArray: Uint32Array;
numSplats: number;
extra: Record<string, unknown>;
}> {
const nameJson = tryPcSogsZip(fileBytes);
if (!nameJson) {
throw new Error("Invalid PC SOGS zip file");
}
const { name, json } = nameJson;
// Find path prefix, will be -1 if no / or \
const lastSlash = name.lastIndexOf("/");
const lastBackslash = name.lastIndexOf("\\");
const prefix = name.slice(0, Math.max(lastSlash, lastBackslash) + 1);
const fileMap = new Map<string, string>();
const refFiles = [
...json.means.files,
...json.scales.files,
...json.quats.files,
...json.sh0.files,
...(json.shN?.files ?? []),
];
for (const file of refFiles) {
fileMap.set(prefix + file, file);
}
const unzipped = await new Promise<Record<string, ArrayBuffer>>(
(resolve, reject) => {
unzip(
fileBytes,
{
filter: ({ name }) => {
return fileMap.has(name);
},
},
(err, files) => {
if (err) {
reject(err);
} else {
resolve(files);
}
},
);
},
);
const extraFiles: Record<string, ArrayBuffer> = {};
for (const [full, name] of fileMap.entries()) {
extraFiles[name] = unzipped[full];
}
return await unpackPcSogs(json, extraFiles);
}
+17 -3
View File
@@ -1,9 +1,9 @@
import init_wasm, { sort_splats } from "spark-internal-rs";
import type { TranscodeSpzInput } from "./SplatLoader";
import type { PcSogsJson, TranscodeSpzInput } from "./SplatLoader";
import { unpackAntiSplat } from "./antisplat";
import { SCALE_MIN, WASM_SPLAT_SORT } from "./defines";
import { unpackKsplat } from "./ksplat";
import { unpackPcSogs } from "./pcsogs";
import { unpackPcSogs, unpackPcSogsZip } from "./pcsogs";
import { PlyReader } from "./ply";
import { SpzReader, transcodeSpz } from "./spz";
import {
@@ -86,7 +86,21 @@ async function onMessage(event: MessageEvent) {
fileBytes: Uint8Array;
extraFiles: Record<string, ArrayBuffer>;
};
const decoded = await unpackPcSogs(fileBytes, extraFiles);
const json = JSON.parse(
new TextDecoder().decode(fileBytes),
) as PcSogsJson;
const decoded = await unpackPcSogs(json, extraFiles);
result = {
id,
numSplats: decoded.numSplats,
packedArray: decoded.packedArray,
extra: decoded.extra,
};
break;
}
case "decodePcSogsZip": {
const { fileBytes } = args as { fileBytes: Uint8Array };
const decoded = await unpackPcSogsZip(fileBytes);
result = {
id,
numSplats: decoded.numSplats,