Files
PozzettiAndrea-ComfyUI-Turn…/web/js/gaussian_preview.js
T
PozzettiAndrea d4be0bda1d Fix /view 404 for PLYs under input/; remove Spark heartbeat log
Two paper cuts spotted while wiring LoadPLY through PreviewGaussians:

## 1. Hardcoded `type=output` in the /view fetch

Repro: LoadPLY hands /home/work/.../ComfyUI/input/apple.ply to
PreviewGaussians. The JS fetches `/view?filename=apple.ply&type=output
&subfolder=` and gets 404 because the file lives in input/, not
output/. PLYs produced by GaussianMerge/GaussianExport/LiToExportPLY
(under output/) worked because the hardcoded `type=output` matched.

Fix is two-sided:

- Python (nodes/preview_gaussian.py): new `_resolve_for_view()`
  walks the abs path against folder_paths.get_{output,input}_directory()
  and returns (filename, subfolder, folder_kind). The kind is shipped
  as a new UI field `ply_type` ("input" | "output"), plus
  `ply_subfolder` for nested locations.

- JS (web/js/gaussian_preview.js): reads `ply_type` / `ply_subfolder`
  off the onExecuted message and substitutes them into both URLs —
  `/view?type=<kind>&subfolder=<sub>` for raw PLY transport, and
  `/gaussianpack/spz?type=<kind>&subfolder=<sub>` for the SPZ
  transcode route. The Python SPZ route already accepts both
  type=input and type=output (spz_route.py:53-56), so no server-side
  change needed.

Files truly outside ComfyUI's input/output trees still 404 (the
fallback returns type=output + bare basename), which matches the
prior behavior — the previewer can't serve arbitrary disk paths.

## 2. Spark heartbeat log spam

Every 120 frames (~2 s) the iframe printed
"[Spark] heartbeat: N frames" to the console. Useful while bringing
up the rendering loop, noise now that everything works. Dropped the
line; the "first frame rendered" trace stays as the smoke test.
2026-05-16 15:24:22 +02:00

629 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* ComfyUI-GaussianPack - Gaussian Splat Preview Widget
* Interactive 3D Gaussian Splatting viewer using gsplat.js
*/
import { app } from "../../../scripts/app.js";
import { api } from "../../../scripts/api.js";
// Auto-detect extension folder name (so this copy doesn't collide with upstream comfyui-PlyPreview).
const EXTENSION_FOLDER = (() => {
const url = import.meta.url;
const match = url.match(/\/extensions\/([^/]+)\//);
return match ? match[1] : "ComfyUI-GaussianPack";
})();
console.log("[GaussianPack] Loading extension...");
app.registerExtension({
name: "gaussianpack.previewgaussians",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
// Auto-refresh PLY dropdown list for the file selector node
if (nodeData.name === "PlyPreviewLoadGaussianPLYEnhance") {
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
const widget = (this.widgets || []).find(w => w.name === "ply_file");
const refreshList = async () => {
try {
const resp = await api.fetchApi("/plypreview/files");
const json = await resp.json();
const files = Array.isArray(json.files) && json.files.length > 0 ? json.files : ["No PLY files found"];
if (widget) {
// Update dropdown choices and keep current selection if still present
widget.options = { ...(widget.options || {}), values: files };
if (!files.includes(widget.value)) {
widget.value = files[0];
widget.callback?.(widget.value);
}
// Force UI redraw
widget.computeSize?.();
this.setDirtyCanvas(true);
app.graph.setDirtyCanvas(true, true);
}
} catch (e) {
console.warn("[GaussianPack] Failed to refresh PLY list", e);
}
};
this.refreshPlyList = refreshList;
refreshList();
// Hint label: user must right-click "Reload Node" to refresh PLY list
const hintEl = document.createElement("div");
hintEl.style.cssText = "font-size:10px;color:#888;text-align:center;padding:4px 8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;";
hintEl.textContent = "Right-click -> Refresh Node to load new PLY files";
this.addDOMWidget("ply_hint", "PLY_HINT", hintEl, {
serialize: false,
hideOnZoom: false,
});
return r;
};
}
if (nodeData.name === "PreviewGaussians") {
console.log("[GaussianPack] Registering Preview Gaussians node");
// After ComfyUI applies serialized widgets_values, defend
// against stale workflow schemas. Two shapes have shipped
// to users over time:
//
// (a) Pre-renderer-widget era: widgets_values was
// [fov, w, h, "<camera-state JSON>"] — only 4
// entries, with the camera state glued onto the end.
// (b) Post-renderer-widget era: widgets_values is
// [fov, w, h, renderer, transport_format, ""].
//
// When (a) is loaded against today's 5-widget schema,
// LiteGraph applies the JSON string to widgets[3] which is
// now `renderer` (a combo) — the prompt validator refuses
// because "<JSON>" is not in ["spark","playcanvas"]. The
// walk below rescues that JSON back to
// node.properties["Camera Config"] and resets the widget.
//
// onConfigure runs AFTER configure() has stamped the saved
// values onto the widgets, so this is where the migration
// has to live (onNodeCreated runs before and only sees
// defaults).
const _origOnConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function(info) {
// Build a name -> value map from the saved metadata BEFORE
// calling configure. ComfyUI's positional widget pairing
// breaks when the live frontend renders a widget that
// wasn't a widget at save time (most commonly a
// `forceInput: True` STRING input — saved as a connection
// socket only, but rendered today as widget[0]). Saved
// `inputs[]` carries a `widget: {name}` annotation only on
// inputs that had widgets, so it's the authoritative
// ordering for widgets_values.
const r = _origOnConfigure ? _origOnConfigure.apply(this, arguments) : undefined;
// ComfyUI's widgetInputs extension strips one entry from
// info.widgets_values for `forceInput: True` inputs like
// ply_path. The strip silently corrupts the
// value->widget pairing (fov ends up with image_width's
// value, etc.). The disk file is correct; the in-memory
// info we receive is not.
//
// To survive that, we mirror every named widget into
// node.properties["Widget Values"] (same pattern as
// "Camera Config") on every change. On load we trust
// properties over info.widgets_values when available.
//
// For old workflows without "Widget Values", we also fall
// back to "Camera Config".fov which captures the fov the
// user was actually using.
this.properties = this.properties || {};
const saved = this.properties["Widget Values"] || {};
const camCfg = this.properties["Camera Config"] || {};
if (typeof camCfg.fov === "number" && saved.fov_degrees === undefined) {
saved.fov_degrees = camCfg.fov;
}
for (const w of (this.widgets || [])) {
if (w && w.name && w.name in saved) {
const v = saved[w.name];
if (v !== undefined) w.value = v;
}
}
for (const w of (this.widgets || [])) {
const optsValues = w.options && Array.isArray(w.options.values);
const isCombo = w.type === "combo" || optsValues;
// (1) Stranded camera-state JSON in any widget value.
if (typeof w.value === "string"
&& w.value.length > 20
&& w.value[0] === "{"
&& w.value.includes('"pos"')) {
try {
const parsed = JSON.parse(w.value);
if (Array.isArray(parsed.pos) && Array.isArray(parsed.target)) {
this.properties = this.properties || {};
const cfg = this.properties["Camera Config"] || {};
cfg.cameraType = cfg.cameraType || "perspective";
cfg.fov = (typeof parsed.fov === "number") ? parsed.fov : (cfg.fov ?? 50);
cfg.state = cfg.state || w.value;
this.properties["Camera Config"] = cfg;
console.warn("[GaussianPack] rescued stranded camera state from widget",
w.name, "-> properties['Camera Config']");
w.value = w.options?.default
?? (optsValues ? w.options.values[0] : "");
continue;
}
} catch (_) { /* not JSON; fall through */ }
}
// (2) Combo widget value isn't a valid option.
if (isCombo && optsValues && !w.options.values.includes(w.value)) {
const def = (w.options.default !== undefined)
? w.options.default
: w.options.values[0];
console.warn("[GaussianPack] resetting combo widget",
w.name, "(value", JSON.stringify(w.value), "not in options) ->", def);
w.value = def;
continue;
}
// (3) Numeric widgets given a string (legacy shift).
if ((w.type === "number" || w.type === "INT" || w.type === "FLOAT")
&& typeof w.value === "string") {
const def = w.options?.default;
console.warn("[GaussianPack] resetting corrupted widget",
w.name, "(was string) ->", def);
w.value = (def !== undefined) ? def : 0;
}
// (4) Out-of-range numerics (e.g. fov_degrees=512
// from a saved-then-shifted workflow).
if (typeof w.value === "number" && w.options) {
const { min, max, default: def } = w.options;
if ((typeof min === "number" && w.value < min) ||
(typeof max === "number" && w.value > max)) {
console.warn("[GaussianPack] resetting out-of-range widget",
w.name, w.value, "->", def);
w.value = (def !== undefined) ? def : (min ?? 0);
}
}
}
return r;
};
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
// Mirror every named widget into node.properties["Widget
// Values"] so we survive ComfyUI's widget_values stripping
// (see onConfigure comment above). Apply current values
// immediately + wrap each widget's callback to keep the
// mirror up to date.
const TRACKED = new Set([
"fov_degrees", "image_width", "image_height",
"renderer", "transport_format",
]);
this.properties = this.properties || {};
if (!this.properties["Widget Values"]) {
this.properties["Widget Values"] = {};
}
const propVals = this.properties["Widget Values"];
for (const w of (this.widgets || [])) {
if (!w || !TRACKED.has(w.name)) continue;
if (propVals[w.name] === undefined) propVals[w.name] = w.value;
const origCb = w.callback;
w.callback = function(v) {
propVals[w.name] = v;
return origCb ? origCb.apply(this, arguments) : undefined;
};
}
// Create container for viewer + info panel
const container = document.createElement("div");
container.style.width = "100%";
container.style.height = "100%";
container.style.display = "flex";
container.style.flexDirection = "column";
container.style.backgroundColor = "#1a1a1a";
container.style.overflow = "hidden";
container.style.position = "relative"; // anchor for loading bar overlay
// Loading-progress overlay (download + parse). Sits over the
// top edge of the iframe so the user sees activity during the
// long PLY fetch + parse window.
const loadBar = document.createElement("div");
loadBar.style.cssText =
"position:absolute;top:0;left:0;right:0;height:4px;" +
"background:rgba(0,0,0,0.35);z-index:100;" +
"transition:opacity 0.3s ease;pointer-events:none;opacity:0;";
const loadFill = document.createElement("div");
loadFill.style.cssText =
"height:100%;width:0%;background:linear-gradient(90deg,#3a8,#6c6);" +
"transition:width 0.15s linear;";
loadBar.appendChild(loadFill);
const loadLabel = document.createElement("div");
loadLabel.style.cssText =
"position:absolute;top:6px;left:8px;" +
"font:10px monospace;color:#cfc;" +
"text-shadow:0 0 4px rgba(0,0,0,0.8);pointer-events:none;";
loadBar.appendChild(loadLabel);
function showLoadBar(label) {
loadFill.style.width = "0%";
loadLabel.textContent = label || "";
loadBar.style.opacity = "1";
}
function setLoadProgress(pct, label) {
const p = Math.max(0, Math.min(100, pct || 0));
loadFill.style.width = p.toFixed(1) + "%";
if (label != null) loadLabel.textContent = label;
}
function hideLoadBar() {
loadBar.style.opacity = "0";
}
this._showLoadBar = showLoadBar;
this._setLoadProgress = setLoadProgress;
this._hideLoadBar = hideLoadBar;
// Create iframe for gsplat.js viewer
const iframe = document.createElement("iframe");
iframe.style.width = "100%";
iframe.style.flex = "1 1 0";
iframe.style.minHeight = "0";
iframe.style.border = "none";
iframe.style.backgroundColor = "#1a1a1a";
// Point to gsplat.js HTML viewer (with cache buster)
iframe.src = `/extensions/${EXTENSION_FOLDER}/viewer_gaussian.html?v=` + Date.now();
// Create info panel
const infoPanel = document.createElement("div");
infoPanel.style.backgroundColor = "#1a1a1a";
infoPanel.style.borderTop = "1px solid #444";
infoPanel.style.padding = "6px 12px";
infoPanel.style.fontSize = "10px";
infoPanel.style.fontFamily = "monospace";
infoPanel.style.color = "#ccc";
infoPanel.style.lineHeight = "1.3";
infoPanel.style.flexShrink = "0";
infoPanel.style.overflow = "hidden";
infoPanel.innerHTML = '<span style="color: #888;">Gaussian splat info will appear here after execution</span>';
// Add iframe + info panel + loading-bar overlay to container.
// loadBar last so its overlay sits above the iframe.
container.appendChild(iframe);
container.appendChild(infoPanel);
container.appendChild(loadBar);
// --- Persistent camera-state plumbing ---------------------
// Mirrors ComfyUI's built-in Load3D widget: state lives on
// node.properties["Camera Config"] (named dict), NOT inside
// widgets_values (positional). Putting the JSON string into
// widgets_values shifts the standard fov/width/height widgets
// by one slot on reload and breaks prompt validation.
let pendingRestoreJSON = "";
const sendRestore = () => {
if (!pendingRestoreJSON || !iframe.contentWindow) return;
try {
const state = JSON.parse(pendingRestoreJSON);
iframe.contentWindow.postMessage(
{ type: "RESTORE_CAMERA_STATE", state }, "*",
);
} catch (e) {
console.warn("[GaussianPack] bad saved camera state:", e);
}
};
// Seed pendingRestoreJSON from node.properties (Load3D pattern)
// if the workflow JSON had a saved pose.
const savedCfg = this.properties && this.properties["Camera Config"];
if (savedCfg && typeof savedCfg.state === "string") {
pendingRestoreJSON = savedCfg.state;
}
// Iframe-display widget. serialize: false -> never lands in
// widgets_values; we own persistence via node.properties.
const widget = this.addDOMWidget(
"preview_gaussian", "GAUSSIAN_PREVIEW", container,
{ serialize: false },
);
// Store reference to node for dynamic resizing
const node = this;
let currentNodeSize = [512, 580];
widget.computeSize = () => currentNodeSize;
// Store references
this.gaussianViewerIframe = iframe;
this.gaussianInfoPanel = infoPanel;
// Function to resize node dynamically
this.resizeToAspectRatio = function(imageWidth, imageHeight) {
const aspectRatio = imageWidth / imageHeight;
const nodeWidth = 512;
const viewerHeight = Math.round(nodeWidth / aspectRatio);
const nodeHeight = viewerHeight + 60; // Add space for info panel
currentNodeSize = [nodeWidth, nodeHeight];
node.setSize(currentNodeSize);
node.setDirtyCanvas(true, true);
app.graph.setDirtyCanvas(true, true);
console.log("[GaussianPack] Resized node to:", nodeWidth, "x", nodeHeight, "(aspect ratio:", aspectRatio.toFixed(2), ")");
};
// Track iframe load state
let iframeLoaded = false;
iframe.addEventListener('load', () => {
iframeLoaded = true;
// Push any pending saved camera state now that the iframe is alive
// (e.g. on workflow reload before the node has been re-queued).
sendRestore();
});
// Listen for messages from iframe
window.addEventListener('message', async (event) => {
// Only react to messages from OUR iframe — otherwise a
// second PreviewGaussians node's iframe would stomp this
// node's state.
if (event.source !== iframe.contentWindow) return;
// Camera-state pushes from iframe -> persist on
// node.properties["Camera Config"] (Load3D pattern).
if (event.data?.type === "CAMERA_STATE" && event.data.state) {
const s = event.data.state;
node.properties = node.properties || {};
const cfg = node.properties["Camera Config"] || {};
cfg.cameraType = cfg.cameraType || "perspective";
cfg.fov = (typeof s.fov === "number") ? s.fov : (cfg.fov ?? 50);
cfg.state = JSON.stringify(s);
node.properties["Camera Config"] = cfg;
return;
}
// Iframe just finished a PLY load -> if we have a saved
// pose, replay it. The iframe's loadPLYFromData already
// frames on bounds first, so this restore wins.
if (event.data?.type === "MESH_LOADED") {
node._hideLoadBar?.();
sendRestore();
}
if (event.data?.type === "MESH_ERROR") {
// Surface a red bar so failure is visible
if (loadBar && loadFill) {
loadFill.style.background = "#c44";
loadFill.style.width = "100%";
loadLabel.textContent = "Error: " + (event.data.error || "load failed");
}
}
// Handle screenshot messages
if (event.data.type === 'SCREENSHOT' && event.data.image) {
try {
// Convert base64 data URL to blob
const base64Data = event.data.image.split(',')[1];
const byteString = atob(base64Data);
const arrayBuffer = new ArrayBuffer(byteString.length);
const uint8Array = new Uint8Array(arrayBuffer);
for (let i = 0; i < byteString.length; i++) {
uint8Array[i] = byteString.charCodeAt(i);
}
const blob = new Blob([uint8Array], { type: 'image/png' });
// Generate filename with timestamp
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `gaussian-screenshot-${timestamp}.png`;
// Create FormData for upload
const formData = new FormData();
formData.append('image', blob, filename);
formData.append('type', 'output');
formData.append('subfolder', '');
// Upload to ComfyUI backend
const response = await fetch('/upload/image', {
method: 'POST',
body: formData
});
if (response.ok) {
const result = await response.json();
console.log('[GaussianPack] Screenshot saved:', result.name);
} else {
throw new Error(`Upload failed: ${response.status}`);
}
} catch (error) {
console.error('[GaussianPack] Error saving screenshot:', error);
}
}
// Handle error messages from iframe
else if (event.data.type === 'MESH_ERROR' && event.data.error) {
console.error('[GaussianPack] Error from viewer:', event.data.error);
if (infoPanel) {
infoPanel.innerHTML = `<div style="color: #ff6b6b;">Error: ${event.data.error}</div>`;
}
}
});
// Set initial node size
this.setSize([512, 580]);
// Handle execution
const onExecuted = this.onExecuted;
this.onExecuted = function(message) {
console.log("[GaussianPack] onExecuted called with:", message);
onExecuted?.apply(this, arguments);
// Check for errors
if (message?.error && message.error[0]) {
infoPanel.innerHTML = `<div style="color: #ff6b6b;">Error: ${message.error[0]}</div>`;
return;
}
// The message IS the UI data (not message.ui)
if (message?.ply_file && message.ply_file[0]) {
const filename = message.ply_file[0];
const displayName = message.filename?.[0] || filename;
const fileSizeMb = message.file_size_mb?.[0] || 'N/A';
const numGaussians = message.num_gaussians?.[0] || 0;
// Extract camera parameters if provided
const extrinsics = message.extrinsics?.[0] || null;
const intrinsics = message.intrinsics?.[0] || null;
const renderer = message.renderer?.[0] || "spark";
const transportFormat = message.transport_format?.[0] || "ply";
// Which Comfy folder the PLY lives under — "input" or
// "output". Set by the Python preview node so /view can
// resolve files dropped via LoadPLY (under input/) as
// well as artifacts written by GaussianMerge / Export
// / LiToExportPLY (under output/).
const plyType = message.ply_type?.[0] || "output";
const plySubfolder = message.ply_subfolder?.[0] || "";
// Resize node to match image aspect ratio from intrinsics
if (intrinsics && intrinsics[0] && intrinsics[1]) {
const imageWidth = intrinsics[0][2] * 2; // cx * 2
const imageHeight = intrinsics[1][2] * 2; // cy * 2
this.resizeToAspectRatio(imageWidth, imageHeight);
}
// Update info panel
const gaussianRow = numGaussians > 0
? `<span style="color: #888;">Gaussians:</span>
<span>${numGaussians.toLocaleString()}</span>`
: '';
infoPanel.innerHTML = `
<div style="display: grid; grid-template-columns: auto 1fr; gap: 2px 8px;">
<span style="color: #888;">File:</span>
<span style="color: #6cc;">${displayName}</span>
<span style="color: #888;">Size:</span>
<span>${fileSizeMb} MB</span>
${gaussianRow}
</div>
`;
// Wire URL + iframe filename based on transport_format.
// "ply" -> ComfyUI's /view streams the raw PLY.
// "spz" -> /gaussianpack/spz lazy-transcodes
// via vendored spz-js (~9× smaller). On
// first call the server pays the transcode
// cost; subsequent calls hit the cached
// sibling .spz file.
// The iframe filename gets the matching extension so
// the renderer's auto-detect (Spark sniffs bytes
// either way, but extension keeps the info panel
// honest).
const qsType = encodeURIComponent(plyType);
const qsSub = encodeURIComponent(plySubfolder);
let filepath, iframeFilename, loadLabel;
if (transportFormat === "spz") {
filepath = `/gaussianpack/spz?filename=${encodeURIComponent(filename)}&type=${qsType}&subfolder=${qsSub}`;
iframeFilename = filename.replace(/\.ply$/i, ".spz");
loadLabel = "Transcoding + downloading SPZ...";
} else {
filepath = `/view?filename=${encodeURIComponent(filename)}&type=${qsType}&subfolder=${qsSub}`;
iframeFilename = filename;
loadLabel = "Downloading PLY...";
}
// Function to fetch and send data to iframe
const fetchAndSend = async () => {
if (!iframe.contentWindow) {
console.error("[GaussianPack] Iframe contentWindow not available");
return;
}
try {
// Fetch the file from parent context (authenticated).
// Stream so we can log download progress.
const t0 = performance.now();
console.log("[GaussianPack] fetch start:", filepath);
node._showLoadBar?.(loadLabel);
const response = await fetch(filepath);
if (!response.ok) {
// Bubble the server's body up — for /gaussianpack/spz
// we put an actionable message there (e.g. "spz not installed").
let body = "";
try { body = (await response.text() || "").slice(0, 400); } catch (_) {}
throw new Error(`HTTP ${response.status}: ${response.statusText}${body ? " - " + body : ""}`);
}
const totalHdr = response.headers.get("content-length");
const total = totalHdr ? parseInt(totalHdr, 10) : 0;
console.log(`[GaussianPack] fetch response 200; content-length=${total || "unknown"}`);
const chunks = [];
let received = 0;
let lastPctLogged = -1;
const reader = response.body?.getReader();
if (reader) {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
received += value.byteLength;
if (total > 0) {
const pct = (received / total) * 100;
const mb = (received / (1024*1024)).toFixed(1);
const tmb = (total / (1024*1024)).toFixed(1);
node._setLoadProgress?.(pct, `Downloading: ${mb} / ${tmb} MB`);
const ipct = Math.floor(pct);
if (ipct !== lastPctLogged && (ipct % 5 === 0 || ipct === 100)) {
console.log(`[GaussianPack] download ${ipct}% ${mb}/${tmb} MB`);
lastPctLogged = ipct;
}
} else {
node._setLoadProgress?.(50, `Downloading: ${(received/(1024*1024)).toFixed(1)} MB`);
if (chunks.length % 64 === 0) {
console.log(`[GaussianPack] downloaded ${(received/(1024*1024)).toFixed(1)} MB so far`);
}
}
}
}
// Stitch chunks into one ArrayBuffer.
const u8 = new Uint8Array(received);
let off = 0;
for (const c of chunks) { u8.set(c, off); off += c.byteLength; }
const arrayBuffer = u8.buffer;
const dt = performance.now() - t0;
const speed = received > 0 ? (received / (1024*1024)) / (dt / 1000) : 0;
console.log(`[GaussianPack] fetch done: ${arrayBuffer.byteLength} bytes in ${dt.toFixed(0)} ms (${speed.toFixed(1)} MB/s)`);
// Send the data to iframe — filename carries
// the extension so the renderer can sniff
// format (Spark/PlayCanvas both auto-detect).
console.log("[GaussianPack] posting LOAD_MESH_DATA to iframe, renderer:", renderer, "filename:", iframeFilename);
node._setLoadProgress?.(100, "Parsing splats...");
iframe.contentWindow.postMessage({
type: "LOAD_MESH_DATA",
data: arrayBuffer,
filename: iframeFilename,
extrinsics: extrinsics,
intrinsics: intrinsics,
renderer: renderer,
timestamp: Date.now()
}, "*", [arrayBuffer]);
} catch (error) {
console.error("[GaussianPack] Error fetching splat:", error);
infoPanel.innerHTML = `<div style="color: #ff6b6b;">Error loading splat: ${error.message}</div>`;
}
};
// Fetch and send when iframe is ready
if (iframeLoaded) {
fetchAndSend();
} else {
setTimeout(fetchAndSend, 500);
}
}
};
return r;
};
}
}
});