From b60d7f5b8d061b563ff03541ecb62d567e1be6b6 Mon Sep 17 00:00:00 2001 From: PozzettiAndrea Date: Thu, 14 May 2026 15:07:22 +0200 Subject: [PATCH] Rename to ComfyUI-GSViewer; robust onConfigure migration * Bulk rename "ComfyUI-TurntableGSViewer" -> "ComfyUI-GSViewer" across package metadata, route prefix (/turntablegsviewer/spz -> /gsviewer/spz), logger name, extension id, and all console-log tags. * Extend onConfigure to defend against stale workflow schemas: rescues stranded camera-state JSON into properties["Camera Config"] and resets combo widgets whose value isn't in the option list. Fixes the validator-reject when an old-schema basic.json (camera state glued into widgets_values[3]) is loaded against today's renderer combo. --- README.md | 2 +- __init__.py | 2 +- comfyui_extension.json | 6 +- pyproject.toml | 8 +-- spz_route.py | 6 +- web/js/gaussian_preview.js | 121 ++++++++++++++++++++++++++----------- 6 files changed, 99 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 0635d99..adfa5a8 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ https://github.com/user-attachments/assets/4e21f1fd-9ba2-4ecc-b777-34f5dd9f7075 -# ComfyUI-TurntableGSViewer +# ComfyUI-GSViewer Tiny Gaussian-splat turntable viewer for ComfyUI. One node, `Preview Gaussians`, with a gsplat.js WebGL preview and camera-intrinsics widgets (FOV, image width, image height). diff --git a/__init__.py b/__init__.py index 7759d0a..7bff50f 100644 --- a/__init__.py +++ b/__init__.py @@ -1,6 +1,6 @@ # SPDX-License-Identifier: GPL-3.0-or-later -"""ComfyUI-TurntableGSViewer — tiny Gaussian-splat turntable viewer node.""" +"""ComfyUI-GSViewer — tiny Gaussian-splat turntable viewer node.""" from .preview_gaussian import PreviewGaussians from .spz_route import register_routes as _register_spz_route diff --git a/comfyui_extension.json b/comfyui_extension.json index 7c33609..de04d04 100644 --- a/comfyui_extension.json +++ b/comfyui_extension.json @@ -1,8 +1,8 @@ { - "name": "ComfyUI-TurntableGSViewer", - "display_name": "ComfyUI-TurntableGSViewer", + "name": "ComfyUI-GSViewer", + "display_name": "ComfyUI-GSViewer", "description": "Tiny Gaussian Splat turntable viewer node for ComfyUI.", "author": "PozzettiAndrea", - "repo": "https://github.com/PozzettiAndrea/ComfyUI-TurntableGSViewer", + "repo": "https://github.com/PozzettiAndrea/ComfyUI-GSViewer", "license": "GPL-3.0-or-later" } diff --git a/pyproject.toml b/pyproject.toml index bfa7ca9..701736c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,16 +1,16 @@ [project] -name = "ComfyUI-TurntableGSViewer" +name = "ComfyUI-GSViewer" description = "Tiny Gaussian Splat turntable viewer node for ComfyUI." version = "0.0.1" license = {file = "LICENSE"} [project.urls] -Repository = "https://github.com/PozzettiAndrea/ComfyUI-TurntableGSViewer" -Documentation = "https://github.com/PozzettiAndrea/ComfyUI-TurntableGSViewer" +Repository = "https://github.com/PozzettiAndrea/ComfyUI-GSViewer" +Documentation = "https://github.com/PozzettiAndrea/ComfyUI-GSViewer" [tool.comfy] PublisherId = "pozzettiandrea" -DisplayName = "ComfyUI-TurntableGSViewer" +DisplayName = "ComfyUI-GSViewer" Icon = "" includes = [] diff --git a/spz_route.py b/spz_route.py index 1436dac..309ee92 100644 --- a/spz_route.py +++ b/spz_route.py @@ -9,7 +9,7 @@ decode in their current shipped builds) — `spz-js` writes v2/gzipped which both renderers can read. Query: - /turntablegsviewer/spz?filename=.ply + /gsviewer/spz?filename=.ply &subfolder= &type=output @@ -29,7 +29,7 @@ from aiohttp import web import folder_paths -log = logging.getLogger("comfyui-turntablegsviewer") +log = logging.getLogger("comfyui-gsviewer") # Per-path lock — concurrent requests for the same PLY share the cost. _locks: dict[str, threading.Lock] = {} @@ -109,7 +109,7 @@ def register_routes() -> None: routes = PromptServer.instance.routes - @routes.get("/turntablegsviewer/spz") + @routes.get("/gsviewer/spz") async def get_spz(request: web.Request) -> web.StreamResponse: filename = request.query.get("filename", "") subfolder = request.query.get("subfolder", "") diff --git a/web/js/gaussian_preview.js b/web/js/gaussian_preview.js index e7f84e1..da5feca 100644 --- a/web/js/gaussian_preview.js +++ b/web/js/gaussian_preview.js @@ -1,5 +1,5 @@ /** - * ComfyUI-TurntableGSViewer - Gaussian Splat Preview Widget + * ComfyUI-GSViewer - Gaussian Splat Preview Widget * Interactive 3D Gaussian Splatting viewer using gsplat.js */ @@ -10,13 +10,13 @@ import { api } from "../../../scripts/api.js"; const EXTENSION_FOLDER = (() => { const url = import.meta.url; const match = url.match(/\/extensions\/([^/]+)\//); - return match ? match[1] : "ComfyUI-TurntableGSViewer"; + return match ? match[1] : "ComfyUI-GSViewer"; })(); -console.log("[TurntableGSViewer] Loading extension..."); +console.log("[GSViewer] Loading extension..."); app.registerExtension({ - name: "turntablegsviewer.previewgaussians", + name: "gsviewer.previewgaussians", async beforeRegisterNodeDef(nodeType, nodeData, app) { // Auto-refresh PLY dropdown list for the file selector node @@ -45,7 +45,7 @@ app.registerExtension({ app.graph.setDirtyCanvas(true, true); } } catch (e) { - console.warn("[TurntableGSViewer] Failed to refresh PLY list", e); + console.warn("[GSViewer] Failed to refresh PLY list", e); } }; @@ -66,33 +66,86 @@ app.registerExtension({ } if (nodeData.name === "PreviewGaussians") { - console.log("[TurntableGSViewer] Registering Preview Gaussians node"); + console.log("[GSViewer] Registering Preview Gaussians node"); - // After ComfyUI applies serialized widgets_values, reset any numeric - // widget whose value is a string. Earlier versions of this code - // persisted camera state into widgets_values, shifting fov/width/ - // height by one slot on reload. onConfigure runs AFTER configure() - // has stamped the saved (possibly bad) values onto the widgets, - // so this is where the migration has to live — onNodeCreated runs - // before configure() and would only see fresh defaults. + // 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, ""] — 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 "" 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) { const r = _origOnConfigure ? _origOnConfigure.apply(this, arguments) : undefined; 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("[GSViewer] 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("[GSViewer] 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("[TurntableGSViewer] resetting corrupted widget", + console.warn("[GSViewer] resetting corrupted widget", w.name, "(was string) ->", def); w.value = (def !== undefined) ? def : 0; } - // Also clamp out-of-range numerics (e.g. the shifted - // fov_degrees=512 from a saved-then-shifted workflow). + + // (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("[TurntableGSViewer] resetting out-of-range widget", + console.warn("[GSViewer] resetting out-of-range widget", w.name, w.value, "->", def); w.value = (def !== undefined) ? def : (min ?? 0); } @@ -197,7 +250,7 @@ app.registerExtension({ { type: "RESTORE_CAMERA_STATE", state }, "*", ); } catch (e) { - console.warn("[TurntableGSViewer] bad saved camera state:", e); + console.warn("[GSViewer] bad saved camera state:", e); } }; @@ -237,7 +290,7 @@ app.registerExtension({ node.setDirtyCanvas(true, true); app.graph.setDirtyCanvas(true, true); - console.log("[TurntableGSViewer] Resized node to:", nodeWidth, "x", nodeHeight, "(aspect ratio:", aspectRatio.toFixed(2), ")"); + console.log("[GSViewer] Resized node to:", nodeWidth, "x", nodeHeight, "(aspect ratio:", aspectRatio.toFixed(2), ")"); }; // Track iframe load state @@ -316,18 +369,18 @@ app.registerExtension({ if (response.ok) { const result = await response.json(); - console.log('[TurntableGSViewer] Screenshot saved:', result.name); + console.log('[GSViewer] Screenshot saved:', result.name); } else { throw new Error(`Upload failed: ${response.status}`); } } catch (error) { - console.error('[TurntableGSViewer] Error saving screenshot:', error); + console.error('[GSViewer] Error saving screenshot:', error); } } // Handle error messages from iframe else if (event.data.type === 'MESH_ERROR' && event.data.error) { - console.error('[TurntableGSViewer] Error from viewer:', event.data.error); + console.error('[GSViewer] Error from viewer:', event.data.error); if (infoPanel) { infoPanel.innerHTML = `
Error: ${event.data.error}
`; } @@ -340,7 +393,7 @@ app.registerExtension({ // Handle execution const onExecuted = this.onExecuted; this.onExecuted = function(message) { - console.log("[TurntableGSViewer] onExecuted called with:", message); + console.log("[GSViewer] onExecuted called with:", message); onExecuted?.apply(this, arguments); // Check for errors @@ -386,7 +439,7 @@ app.registerExtension({ // Wire URL + iframe filename based on transport_format. // "ply" → ComfyUI's /view streams the raw PLY. - // "spz" → /turntablegsviewer/spz lazy-transcodes + // "spz" → /gsviewer/spz lazy-transcodes // via vendored spz-js (~9× smaller). On // first call the server pays the transcode // cost; subsequent calls hit the cached @@ -397,7 +450,7 @@ app.registerExtension({ // honest). let filepath, iframeFilename, loadLabel; if (transportFormat === "spz") { - filepath = `/turntablegsviewer/spz?filename=${encodeURIComponent(filename)}&type=output&subfolder=`; + filepath = `/gsviewer/spz?filename=${encodeURIComponent(filename)}&type=output&subfolder=`; iframeFilename = filename.replace(/\.ply$/i, ".spz"); loadLabel = "Transcoding + downloading SPZ..."; } else { @@ -409,7 +462,7 @@ app.registerExtension({ // Function to fetch and send data to iframe const fetchAndSend = async () => { if (!iframe.contentWindow) { - console.error("[TurntableGSViewer] Iframe contentWindow not available"); + console.error("[GSViewer] Iframe contentWindow not available"); return; } @@ -417,11 +470,11 @@ app.registerExtension({ // Fetch the file from parent context (authenticated). // Stream so we can log download progress. const t0 = performance.now(); - console.log("[TurntableGSViewer] fetch start:", filepath); + console.log("[GSViewer] fetch start:", filepath); node._showLoadBar?.(loadLabel); const response = await fetch(filepath); if (!response.ok) { - // Bubble the server's body up — for /turntablegsviewer/spz + // Bubble the server's body up — for /gsviewer/spz // we put an actionable message there (e.g. "spz not installed"). let body = ""; try { body = (await response.text() || "").slice(0, 400); } catch (_) {} @@ -429,7 +482,7 @@ app.registerExtension({ } const totalHdr = response.headers.get("content-length"); const total = totalHdr ? parseInt(totalHdr, 10) : 0; - console.log(`[TurntableGSViewer] fetch response 200; content-length=${total || "unknown"}`); + console.log(`[GSViewer] fetch response 200; content-length=${total || "unknown"}`); const chunks = []; let received = 0; @@ -448,13 +501,13 @@ app.registerExtension({ node._setLoadProgress?.(pct, `Downloading: ${mb} / ${tmb} MB`); const ipct = Math.floor(pct); if (ipct !== lastPctLogged && (ipct % 5 === 0 || ipct === 100)) { - console.log(`[TurntableGSViewer] download ${ipct}% ${mb}/${tmb} MB`); + console.log(`[GSViewer] 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(`[TurntableGSViewer] downloaded ${(received/(1024*1024)).toFixed(1)} MB so far`); + console.log(`[GSViewer] downloaded ${(received/(1024*1024)).toFixed(1)} MB so far`); } } } @@ -466,12 +519,12 @@ app.registerExtension({ const arrayBuffer = u8.buffer; const dt = performance.now() - t0; const speed = received > 0 ? (received / (1024*1024)) / (dt / 1000) : 0; - console.log(`[TurntableGSViewer] fetch done: ${arrayBuffer.byteLength} bytes in ${dt.toFixed(0)} ms (${speed.toFixed(1)} MB/s)`); + console.log(`[GSViewer] 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("[TurntableGSViewer] posting LOAD_MESH_DATA to iframe, renderer:", renderer, "filename:", iframeFilename); + console.log("[GSViewer] posting LOAD_MESH_DATA to iframe, renderer:", renderer, "filename:", iframeFilename); node._setLoadProgress?.(100, "Parsing splats..."); iframe.contentWindow.postMessage({ type: "LOAD_MESH_DATA", @@ -483,7 +536,7 @@ app.registerExtension({ timestamp: Date.now() }, "*", [arrayBuffer]); } catch (error) { - console.error("[TurntableGSViewer] Error fetching splat:", error); + console.error("[GSViewer] Error fetching splat:", error); infoPanel.innerHTML = `
Error loading splat: ${error.message}
`; } };