Files
PozzettiAndrea a342f20402 Serve frontend JS from javascript/, keep vendor bundles out of the main realm
Move web/ to javascript/ and declare it via [tool.comfy] web.

Rename the seven third-party bundles (playcanvas, spark, three.js, gsplat,
OrbitControls, TrackballControls) to .mjs. Every reference to them is inside the
HTML viewers, but ComfyUI auto-imports **/*.js under the web dir into the shared
browser realm, so ~8MB of vendor code was being evaluated in the main page on
every startup -- and with it 36 collision errors: document.body injection,
document-level listeners, unguarded message listeners and self.onmessage writes.
The importmap values, script srcs and three.module's internal ./three.core ref
are rewritten to match.

DisplayName becomes "GaussianPack": the namespace enforced on
registerExtension names is derived from it, and "ComfyUI-GaussianPack" made
that prefix invalid. The analysis extension is renamed to match.

js/gaussian_preview.js hooked "PlyPreviewLoadGaussianPLYEnhance", a node id
that appears nowhere in this repo, so the PLY dropdown auto-refresh had never
run; it now targets LoadPLY and LoadPLYOutput, the two nodes that actually carry
the ply_file widget. Guard its message listener on event.source.

Asset seeding resolves the input directory through
folder_paths.get_input_directory(), so it follows ComfyUI Desktop and
--input-directory instead of writing into the code tree.

comfy-test.toml moves to the current schema: [test.workflows] cuda (there is
no "gpu" key -- the accelerator is named by backend, and the old spelling parsed
to an empty list, so CUDA runners silently ran the full unfiltered set), an
explicit [test.platforms] platforms allowlist (per-platform booleans are no
longer supported), and the javascript level.
2026-08-22 20:56:09 +02:00

1027 lines
55 KiB
JavaScript
Raw Permalink 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...");
// Build a small WASD / arrows / Shift+Space+C keypad that lights up as keys
// are pressed (driven by KEY_STATE messages the viewer iframe forwards). Handy
// for screen-recording navigation. Returns { el, set(code,on), reset() }.
function makeKeyPad() {
const codes = {};
const wrap = document.createElement("div");
Object.assign(wrap.style, {
display: "flex", gap: "10px", alignItems: "center",
padding: "4px 10px", flexShrink: "0", background: "#1a1a1a",
borderTop: "1px solid #444", borderLeft: "1px solid #333",
});
const keyEl = (label, code, w = 18) => {
const k = document.createElement("div");
k.textContent = label;
Object.assign(k.style, {
minWidth: w + "px", height: "18px", lineHeight: "16px", textAlign: "center",
fontSize: "10px", fontFamily: "monospace", color: "#aaa",
background: "#2a2a2a", border: "1px solid #444", borderRadius: "3px",
padding: "0 3px", boxSizing: "border-box", transition: "all 0.04s",
});
codes[code] = k;
return k;
};
const grid = (rows) => {
const g = document.createElement("div");
Object.assign(g.style, { display: "grid", gridTemplateColumns: "repeat(3, 18px)", gap: "2px" });
rows.forEach(r => r.forEach(cell => g.appendChild(cell || document.createElement("div"))));
return g;
};
const wasd = grid([
[null, keyEl("W", "KeyW"), null],
[keyEl("A", "KeyA"), keyEl("S", "KeyS"), keyEl("D", "KeyD")],
]);
const arrows = grid([
[null, keyEl("↑", "ArrowUp"), null],
[keyEl("←", "ArrowLeft"), keyEl("↓", "ArrowDown"), keyEl("→", "ArrowRight")],
]);
const extra = document.createElement("div");
Object.assign(extra.style, { display: "flex", gap: "2px", alignItems: "center" });
const shift = keyEl("⇧", "ShiftLeft", 24);
codes["ShiftRight"] = shift; // both shifts light the same key
extra.appendChild(shift);
extra.appendChild(keyEl("Spc", "Space", 26));
extra.appendChild(keyEl("C", "KeyC"));
wrap.append(wasd, arrows, extra);
const set = (code, on) => {
const el = codes[code];
if (!el) return;
el.style.background = on ? "#00e0ff" : "#2a2a2a";
el.style.color = on ? "#002b30" : "#aaa";
el.style.borderColor = on ? "#00e0ff" : "#444";
};
const reset = () => Object.keys(codes).forEach(c => set(c, false));
return { el: wrap, set, reset };
}
app.registerExtension({
name: "gaussianpack.previewgaussians",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
// Auto-refresh PLY dropdown list for the file selector node
if (nodeData.name === "LoadPLY" || nodeData.name === "LoadPLYOutput") {
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") {
// Spectate is now a camera_mode of PreviewGaussians; the dedicated
// PreviewGaussianSpectate node was removed. Kept as a const so the
// downstream "spectate" branches still read cleanly.
const _isSpectate = false;
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", "camera_mode",
"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";
// Allow the in-iframe Fullscreen button to fill the whole screen.
iframe.allowFullscreen = true;
iframe.setAttribute("allow", "fullscreen");
{
const cmW = (this.widgets || []).find(w => w.name === "camera_mode");
const wantSpectate = _isSpectate || (cmW && cmW.value === "spectate");
const _qs = wantSpectate ? "mode=spectate&" : "";
iframe.src = `/extensions/${EXTENSION_FOLDER}/viewer_gaussian.html?${_qs}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>';
// Footer = info panel (left, grows) + key-press indicator (right).
const keyPad = makeKeyPad();
infoPanel.style.flex = "1 1 auto";
const footer = document.createElement("div");
Object.assign(footer.style, { display: "flex", alignItems: "stretch", flexShrink: "0" });
footer.appendChild(infoPanel);
footer.appendChild(keyPad.el);
this.gaussianKeyPad = keyPad;
// Add iframe + footer + loading-bar overlay to container.
// loadBar last so its overlay sits above the iframe.
container.appendChild(iframe);
container.appendChild(footer);
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();
});
// camera_mode change → reload iframe with correct mode
if (!_isSpectate) {
const _cmW = (this.widgets || []).find(w => w.name === "camera_mode");
if (_cmW) {
const _origCmCb = _cmW.callback;
_cmW.callback = function(v) {
propVals["camera_mode"] = v;
const mqs = v === "spectate" ? "mode=spectate&" : "";
iframe.src = `/extensions/${EXTENSION_FOLDER}/viewer_gaussian.html?${mqs}v=` + Date.now();
iframeLoaded = false;
return _origCmCb ? _origCmCb.apply(this, arguments) : undefined;
};
}
}
// 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;
// Key-press indicator: light up the footer keypad.
if (event.data?.type === "KEY_STATE") {
this.gaussianKeyPad?.set(event.data.code, !!event.data.down);
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]);
// Download cache — skip re-fetch when same file is re-queued
let _cachedSplat = null; // { key, buffer }
// 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.
// When width/height are 0 (auto), keep the current node
// size and let the viewer fill whatever space is available.
if (intrinsics && intrinsics[0] && intrinsics[1]) {
const imageWidth = intrinsics[0][2] * 2; // cx * 2
const imageHeight = intrinsics[1][2] * 2; // cy * 2
if (imageWidth > 0 && imageHeight > 0) {
this.resizeToAspectRatio(imageWidth, imageHeight);
}
}
// Reload iframe if camera mode changed
const _cameraMode = message.camera_mode?.[0] || (_isSpectate ? "spectate" : "orbit");
const iframeHasSpectate = iframe.src && iframe.src.includes("mode=spectate");
const wantSpectate = _cameraMode === "spectate";
if (wantSpectate !== iframeHasSpectate) {
const mqs = wantSpectate ? "mode=spectate&" : "";
iframe.src = `/extensions/${EXTENSION_FOLDER}/viewer_gaussian.html?${mqs}v=` + Date.now();
iframeLoaded = false;
}
// 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);
// Freshness token: changes whenever the upstream file is
// rewritten under the same name, so a re-merged PLY isn't
// served stale from the in-memory or browser HTTP cache.
const tok = message.mtime?.[0] ?? 0;
let filepath, iframeFilename, loadLabel;
if (transportFormat === "spz") {
filepath = `/gaussianpack/spz?filename=${encodeURIComponent(filename)}&type=${qsType}&subfolder=${qsSub}&_=${tok}`;
iframeFilename = filename.replace(/\.ply$/i, ".spz");
loadLabel = "Transcoding + downloading SPZ...";
} else {
filepath = `/view?filename=${encodeURIComponent(filename)}&type=${qsType}&subfolder=${qsSub}&_=${tok}`;
iframeFilename = filename;
loadLabel = "Downloading PLY...";
}
const cacheKey = `${filename}|${plyType}|${plySubfolder}|${transportFormat}|${tok}`;
const sendToIframe = (arrayBuffer) => {
if (!iframe.contentWindow) return;
node._setLoadProgress?.(100, "Parsing splats...");
iframe.contentWindow.postMessage({
type: "LOAD_MESH_DATA",
data: arrayBuffer,
filename: iframeFilename,
extrinsics, intrinsics, renderer,
timestamp: Date.now()
}, "*", [arrayBuffer]);
};
const fetchAndSend = async () => {
if (!iframe.contentWindow) {
console.error("[GaussianPack] Iframe contentWindow not available");
return;
}
// Use cached data if same file
if (_cachedSplat && _cachedSplat.key === cacheKey) {
console.log("[GaussianPack] using cached splat data for", filename);
sendToIframe(_cachedSplat.buffer.slice(0));
return;
}
try {
const t0 = performance.now();
console.log("[GaussianPack] fetch start:", filepath);
node._showLoadBar?.(loadLabel);
const response = await fetch(filepath);
if (!response.ok) {
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;
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`);
}
}
}
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)`);
// Cache a copy, send the original (transferred)
_cachedSplat = { key: cacheKey, buffer: arrayBuffer.slice(0) };
sendToIframe(arrayBuffer);
} catch (error) {
console.error("[GaussianPack] Error fetching splat:", error);
infoPanel.innerHTML = `<div style="color: #ff6b6b;">Error loading splat: ${error.message}</div>`;
}
};
if (iframeLoaded) {
fetchAndSend();
} else {
setTimeout(fetchAndSend, 500);
}
}
};
return r;
};
}
// ==============================================================
// Preview Gaussian Dual — side-by-side / slider comparison
// ==============================================================
if (nodeData.name === "PreviewGaussianDual") {
console.log("[GaussianPack] Registering Preview Gaussian Dual node");
const _origOnConfigureDual = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function(info) {
const r = _origOnConfigureDual ? _origOnConfigureDual.apply(this, arguments) : undefined;
this.properties = this.properties || {};
const saved = this.properties["Widget Values"] || {};
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;
if (isCombo && optsValues && !w.options.values.includes(w.value)) {
w.value = w.options.default ?? w.options.values[0];
}
}
return r;
};
const onNodeCreatedDual = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
const r = onNodeCreatedDual ? onNodeCreatedDual.apply(this, arguments) : undefined;
const node = this;
// Widget persistence
const TRACKED_DUAL = new Set([
"layout", "fov_degrees", "camera_mode",
"renderer", "transport_format",
]);
this.properties = this.properties || {};
if (!this.properties["Widget Values"]) this.properties["Widget Values"] = {};
const propValsDual = this.properties["Widget Values"];
for (const w of (this.widgets || [])) {
if (!w || !TRACKED_DUAL.has(w.name)) continue;
if (propValsDual[w.name] === undefined) propValsDual[w.name] = w.value;
const origCb = w.callback;
w.callback = function(v) {
propValsDual[w.name] = v;
return origCb ? origCb.apply(this, arguments) : undefined;
};
}
const container = document.createElement("div");
container.style.cssText = "width:100%;height:100%;display:flex;flex-direction:column;background:#1a1a1a;overflow:hidden;position:relative;";
// Two independent loading bars — green for splat 1, blue for splat 2
function _makeLoadBar(topOffset, gradientColors, textColor) {
const bar = document.createElement("div");
bar.style.cssText = `position:absolute;top:${topOffset}px;left:0;right:0;height:4px;background:rgba(0,0,0,0.35);z-index:100;transition:opacity 0.3s;pointer-events:none;opacity:0;`;
const fill = document.createElement("div");
fill.style.cssText = `height:100%;width:0%;background:linear-gradient(90deg,${gradientColors});transition:width 0.15s linear;`;
bar.appendChild(fill);
const lbl = document.createElement("div");
lbl.style.cssText = `position:absolute;top:6px;left:8px;font:10px monospace;color:${textColor};text-shadow:0 0 4px rgba(0,0,0,0.8);pointer-events:none;`;
bar.appendChild(lbl);
return { bar, fill, lbl };
}
const lb1 = _makeLoadBar(0, "#3a8,#6c6", "#cfc");
const lb2 = _makeLoadBar(6, "#38a,#6ac", "#cef");
function showLoadBar(lb, label) { lb.fill.style.width = "0%"; lb.lbl.textContent = label || ""; lb.bar.style.opacity = "1"; }
function setLoadProgress(lb, pct, label) { lb.fill.style.width = Math.max(0,Math.min(100,pct||0)).toFixed(1)+"%"; if (label!=null) lb.lbl.textContent = label; }
function hideLoadBar(lb) { lb.bar.style.opacity = "0"; }
const iframe = document.createElement("iframe");
iframe.style.cssText = "width:100%;flex:1 1 0;min-height:0;border:none;background:#1a1a1a;";
iframe.allowFullscreen = true;
iframe.setAttribute("allow", "fullscreen");
const infoPanel = document.createElement("div");
infoPanel.style.cssText = "background:#1a1a1a;border-top:1px solid #444;padding:6px 12px;font:10px monospace;color:#ccc;line-height:1.3;flex-shrink:0;overflow:hidden;";
infoPanel.innerHTML = '<span style="color:#888;">Dual view info will appear after execution</span>';
// Footer = info panel (left) + key-press indicator (right).
const keyPad = makeKeyPad();
infoPanel.style.flex = "1 1 auto";
const footer = document.createElement("div");
Object.assign(footer.style, { display: "flex", alignItems: "stretch", flexShrink: "0" });
footer.appendChild(infoPanel);
footer.appendChild(keyPad.el);
this.gaussianKeyPad = keyPad;
container.appendChild(iframe);
container.appendChild(footer);
container.appendChild(lb1.bar);
container.appendChild(lb2.bar);
const widget = this.addDOMWidget("preview_gaussian_dual", "GAUSSIAN_DUAL_PREVIEW", container, { serialize: false });
let currentNodeSize = [768, 500];
widget.computeSize = () => currentNodeSize;
this.gaussianDualIframe = iframe;
this.gaussianDualInfoPanel = infoPanel;
this.setSize([768, 500]);
let iframeLoaded = false;
iframe.addEventListener('load', () => { iframeLoaded = true; });
// Download cache for both splats
let _cachedDual = { key1: null, buf1: null, key2: null, buf2: null };
// Camera state persistence
this.properties = this.properties || {};
window.addEventListener('message', (event) => {
if (event.source !== iframe.contentWindow) return;
if (event.data?.type === "KEY_STATE") {
this.gaussianKeyPad?.set(event.data.code, !!event.data.down);
return;
}
if (event.data?.type === "CAMERA_STATE" && event.data.state) {
node.properties["Camera Config"] = {
cameraType: "perspective",
state: JSON.stringify(event.data.state),
};
}
if (event.data?.type === "MESH_LOADED") {
hideLoadBar(lb1); hideLoadBar(lb2);
const saved = node.properties["Camera Config"];
if (saved?.state && iframe.contentWindow) {
try {
iframe.contentWindow.postMessage({
type: "RESTORE_CAMERA_STATE",
state: JSON.parse(saved.state),
}, "*");
} catch(_) {}
}
}
if (event.data?.type === "MESH_ERROR") {
lb1.fill.style.background = "#c44"; lb1.fill.style.width = "100%";
lb2.fill.style.background = "#c44"; lb2.fill.style.width = "100%";
}
});
const onExecutedDual = this.onExecuted;
this.onExecuted = function(message) {
onExecutedDual?.apply(this, arguments);
if (message?.error?.[0]) {
infoPanel.innerHTML = `<div style="color:#ff6b6b;">Error: ${message.error[0]}</div>`;
return;
}
if (!message?.ply_file_1?.[0] || !message?.ply_file_2?.[0]) return;
const layout = message.layout?.[0] || "side_by_side";
const cameraMode = message.camera_mode?.[0] || "orbit";
const renderer = message.renderer?.[0] || "playcanvas";
const transport = message.transport_format?.[0] || "ply";
const fov = message.fov_degrees?.[0] || 50;
// Set iframe src with layout + mode params
const modeQs = cameraMode === "spectate" ? "&mode=spectate" : "";
iframe.src = `/extensions/${EXTENSION_FOLDER}/viewer_gaussian_dual.html?layout=${layout}${modeQs}&v=${Date.now()}`;
iframeLoaded = false;
// Resize node for dual view
currentNodeSize = layout === "side_by_side" ? [768, 500] : [512, 580];
node.setSize(currentNodeSize);
node.setDirtyCanvas(true, true);
// Freshness tokens: change whenever the upstream file is
// rewritten under the same name, so a re-merged PLY isn't
// served stale from the in-memory or browser HTTP cache.
const tok1 = message.mtime_1?.[0] ?? 0;
const tok2 = message.mtime_2?.[0] ?? 0;
// Build URLs for both PLYs
function buildUrl(filename, type, subfolder, tok) {
const qsType = encodeURIComponent(type);
const qsSub = encodeURIComponent(subfolder);
if (transport === "spz") {
return `/gaussianpack/spz?filename=${encodeURIComponent(filename)}&type=${qsType}&subfolder=${qsSub}&_=${tok}`;
}
return `/view?filename=${encodeURIComponent(filename)}&type=${qsType}&subfolder=${qsSub}&_=${tok}`;
}
const url1 = buildUrl(message.ply_file_1[0], message.ply_type_1?.[0] || "output", message.ply_subfolder_1?.[0] || "", tok1);
const url2 = buildUrl(message.ply_file_2[0], message.ply_type_2?.[0] || "output", message.ply_subfolder_2?.[0] || "", tok2);
const fn1 = transport === "spz" ? message.ply_file_1[0].replace(/\.ply$/i, ".spz") : message.ply_file_1[0];
const fn2 = transport === "spz" ? message.ply_file_2[0].replace(/\.ply$/i, ".spz") : message.ply_file_2[0];
// Info panel
const n1 = message.num_gaussians_1?.[0] || 0;
const n2 = message.num_gaussians_2?.[0] || 0;
const s1 = message.file_size_mb_1?.[0] || '?';
const s2 = message.file_size_mb_2?.[0] || '?';
infoPanel.innerHTML = `
<div style="display:flex;gap:16px;">
<div><span style="color:#888;">G1:</span> <span style="color:#6cc;">${message.ply_file_1[0]}</span> (${s1} MB, ${n1.toLocaleString()} splats)</div>
<div><span style="color:#888;">G2:</span> <span style="color:#6cc;">${message.ply_file_2[0]}</span> (${s2} MB, ${n2.toLocaleString()} splats)</div>
</div>`;
// Streaming fetch with per-side progress bars
async function streamFetch(url, lb, label) {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status} for ${label}`);
const totalHdr = response.headers.get("content-length");
const total = totalHdr ? parseInt(totalHdr, 10) : 0;
const chunks = [];
let received = 0;
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);
setLoadProgress(lb, pct, `${label}: ${mb} / ${tmb} MB`);
} else {
setLoadProgress(lb, 50, `${label}: ${(received/(1024*1024)).toFixed(1)} MB`);
}
}
}
setLoadProgress(lb, 100, `${label}: done`);
const u8 = new Uint8Array(received);
let off = 0;
for (const c of chunks) { u8.set(c, off); off += c.byteLength; }
return u8.buffer;
}
const key1 = `${message.ply_file_1[0]}|${message.ply_type_1?.[0]||"output"}|${message.ply_subfolder_1?.[0]||""}|${transport}|${tok1}`;
const key2 = `${message.ply_file_2[0]}|${message.ply_type_2?.[0]||"output"}|${message.ply_subfolder_2?.[0]||""}|${transport}|${tok2}`;
const fetchAndSendDual = async () => {
if (!iframe.contentWindow) { setTimeout(fetchAndSendDual, 300); return; }
try {
let ab1, ab2;
if (_cachedDual.key1 === key1 && _cachedDual.buf1) {
console.log("[GaussianPack] using cached G1");
ab1 = _cachedDual.buf1.slice(0);
} else {
showLoadBar(lb1, "Downloading G1...");
ab1 = await streamFetch(url1, lb1, "G1");
_cachedDual.key1 = key1;
_cachedDual.buf1 = ab1.slice(0);
}
if (_cachedDual.key2 === key2 && _cachedDual.buf2) {
console.log("[GaussianPack] using cached G2");
ab2 = _cachedDual.buf2.slice(0);
} else {
showLoadBar(lb2, "Downloading G2...");
ab2 = await streamFetch(url2, lb2, "G2");
_cachedDual.key2 = key2;
_cachedDual.buf2 = ab2.slice(0);
}
iframe.contentWindow.postMessage({
type: "LOAD_DUAL_GAUSSIAN",
data1: ab1, data2: ab2,
filename1: fn1, filename2: fn2,
renderer: renderer,
fov: fov,
timestamp: Date.now(),
}, "*", [ab1, ab2]);
} catch (err) {
infoPanel.innerHTML = `<div style="color:#ff6b6b;">Error: ${err.message}</div>`;
lb1.fill.style.background = "#c44"; lb1.fill.style.width = "100%";
lb2.fill.style.background = "#c44"; lb2.fill.style.width = "100%";
}
};
iframe.addEventListener('load', () => { iframeLoaded = true; fetchAndSendDual(); }, { once: true });
};
return r;
};
}
}
});