The section never held nodes -- every entry is a whole pack repo. Read
literally, `[node_reqs]` says "the requirements of my node", which is pip
packages, and that section genuinely exists one file over as [dependencies]
in comfy-env.toml. `[node_packs]` names the unit and sits parallel to the
existing [cuda_packages].
Requires comfy-env >= 0.4.27, which renames the key with no shim: the root
file has a closed schema, so an old comfy-env rejects [node_packs] by name
and a new one rejects [node_reqs] by name. Neither fails silently.
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.
- README: add SAM3-style 3-option install (Manager / Git URL / manual) and a
"report problems" note; keep the live gallery link + demo video.
- assets/icon.png (400x400) cropped from the apple-splat preview screenshot.
- assets/gallery-preview.png: screenshot of the dev/linux-cpu test gallery
(4/4 passing). README image points at the dev raw URL; the gallery link still
points at the live gh-pages results.
- pyproject.toml: set Icon (dev raw URL) and add the comfy-env install warning
to the description.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`gsplat.rasterization` defaults to packed=True. In packed mode the
underlying rasterize_to_pixels flattens valid splats to (nnz, 2), so
image_dims = means2d.shape[:-2] = () and the bg-shape assertion
expects exactly (channels,) = (3,) — NOT (C, 3). The previous
`background.unsqueeze(0)` gave (1, 3) and tripped:
AssertionError: torch.Size([1, 3]) (_wrapper.py:598)
Drop the unsqueeze; pass bg as a 1D (3,) tensor and let gsplat
broadcast it. (If we ever flip to packed=False the bg shape would
need to be (C, 3) instead — the comment in the source flags both.)
Also add debug prints across the pipeline:
- ply load: gaussian count, world-space bounds, scale + opacity ranges
- render(): extrinsics/intrinsics shapes + first translation + first K
- _render_gsplat: input tensor shapes + per-view VRAM probe
(allocated + reserved) on first and last view
- _render_torch: z-cull diagnostics per view
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- _request_vram_eviction helper (vendored from PanoramaDepthMerge): asks
the parent ComfyUI process via comfy_worker.call_parent IPC to evict
sibling-worker patchers (HYWM2 DiT, MoGe2, etc.) before rendering.
Call it from render() with peak_bytes = N_gauss × H × W × 1e-3.
- _render_gsplat now loops per-view instead of batching all viewmats
through gsplat.rasterization at once. The fused multi-camera path
allocates a tile-intersection workspace ≈ N_gauss × V × tiles, which
OOMs at V=12, N=5.5M, 1024² on a 3090. Per-view shrinks workspace
by 1/V at negligible per-call overhead.
- torch.no_grad() on both _render_gsplat and _render_torch (pure
inference — no need for autograd buffers).
- X/Y frustum cull in _render_torch after the radius calc: drop
gaussians whose 3σ ellipse lies entirely outside the image bounds.
gsplat does this internally; the torch fallback didn't.
- comfy-env.toml: add safetensors for the SPZ route (cosmetic
worker-startup warning otherwise).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New node renders a 3DGS PLY from given camera poses to an IMAGE batch.
Tiered renderer: try gsplat (CUDA, reference, ~10-50 ms/frame at 1M
gauss) -> fall back to pure-torch EWA splatter (slower but runs on
any torch backend: CUDA, ROCm, MPS, CPU).
Inputs:
ply_path STRING (forceInput) 3DGS PLY path
extrinsics EXTRINSICS [N, 4, 4] w2c
intrinsics INTRINSICS [N, 3, 3] pixel-K or normalized-K
(auto-sniffed via fx<2 rule)
image_width INT default 1024
image_height INT default 1024
Optional:
near_plane FLOAT default 0.01
background COMBO black/white
force_backend COMBO auto/gsplat/torch
Output: IMAGE [N, H, W, 3] in [0, 1] on CPU.
Cross-platform strategy:
* gsplat declared in nodes/comfy-env.toml [cuda] packages. cuda-wheels
has a recipe (gsplat.yml) that publishes prebuilt wheels for
cu12.x/torch2.x/cp31x. On non-NVIDIA installs (AMD ROCm, Apple
Silicon Metal, CPU-only) cuda-wheels skips the package silently.
* Node detects gsplat at runtime via try/import. ImportError -> fall
back to the pure-torch EWA splatter, which works on any torch
backend without extra deps. The fallback is naive vectorized
(no tile optimization), so ~1-10 s/frame depending on gaussian
count + resolution. Correct, just not real-time.
PLY parsing follows the standard 3DGS layout (graphdeco-inria
convention): x/y/z, rot_0..3 (wxyz), scale_0..2 (log space), opacity
(logit space), f_dc_0..2 (sh band 0), optional f_rest_* (higher SH
bands).
comfy-env.toml is new for this pack. Declares plyfile/aiohttp/numpy/
pillow + NanoGS git dep for the existing pack nodes that need them.
Wraps the whole pack in a worker subprocess. Existing iframe-based
PreviewGaussians' route registration becomes a no-op in the worker
(routes can't be registered to the host server from a subprocess),
which is a known trade-off.
Register in nodes/__init__.py alongside the existing entries.
Three standard comfy-test scaffolding workflows, lifted verbatim from
ComfyUI-TRELLIS2 (identical across TRELLIS2/CADabra/HYWM2/GeometryPack
— no per-repo customization needed; they reference
PozzettiAndrea/comfy-test/.github/workflows/* and GitHub injects the
repo context):
- pr-gate.yml PR + push-to-main + manual: runs comfy-test gate,
publishes results to gh-pages, screenshots the
gallery index at 1200x700 to gh-pages/gallery-preview.png
- publish.yml pyproject.toml changes -> publish to Comfy registry
- run-tests.yml push-to-dev -> runs the test matrix from comfy-test.toml
Closes the gap that made the README's gallery thumbnail 404 — it
points at https://pozzettiandrea.github.io/ComfyUI-GaussianPack/
gallery-preview.png, which now gets generated on each pr-gate run.
User wants GaussianPack fully self-contained — no comfy-env in the
runtime path. Three swaps + one orphan deletion:
1. prestartup_script.py: `comfy_env.copy_files(src, dst)` -> stdlib
shutil.copy2 loop. Same semantics (top-level files, non-clobbering),
6 lines. Fixes the NameError that snuck in earlier when the
`from comfy_env import copy_files` line was removed without
replacement; in the test workspace `apple.ply` actually lands in
`input/` now.
2. __init__.py: `comfy_env.register_nodes()` -> `from .nodes import
NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS`. That's literally
what register_nodes() boils down to when no nodes/comfy-env.toml is
present (the no-isolation branch). Hyphenated parent package names
are fine for relative imports — verified earlier in this session
with a Foo-Bar fixture via importlib.import_module.
3. nodes/__init__.py: docstring update — drop the "calls
comfy_env.register_nodes()" mention now that the root does the
relative import directly.
4. comfy-env-root.toml: deleted. The [node_reqs] section only had
meaning for comfy-env's isolation discovery; with no comfy-env in
play it was dead metadata.
`comfy-test.toml` stays (that's a different tool — comfy-test, not
comfy-env). `requirements.txt` stays (numpy + plyfile + NanoGS git).
`grep -rn comfy_env` across the repo returns 0 hits. Import smoke
test in the worker pixi env resolves all 5 nodes
(PreviewGaussians, GaussianMerge, LoadPLY, GaussianAnalysis,
GaussianExport).
Same pattern as ComfyUI-TRELLIS2: centered clickable thumbnail +
"View Live Test Gallery →" link, pointing at
https://pozzettiandrea.github.io/ComfyUI-GaussianPack/
Assumes the standard gh-pages publish path that comfy-test writes to
on push (gallery-preview.png lives at the page root). If no run has
been published yet, the image 404s harmlessly and the text link still
works.
Previous design scanned input/ AND output/ and stored prefixed combo
entries like 'input/apple.ply'. When comfy-test ran the workflow, the
saved value 'input/apple.ply' didn't match anything the node could
resolve, and VALIDATE_INPUTS rejected the prompt:
Custom validation failed for node: ply_file -
LoadPLY: 'input/apple.ply' is not a known PLY in input/ or output/
Refactor to mirror ComfyUI's stock LoadImage node:
- INPUT_TYPES lists ONLY input/, bare filenames (no folder prefix)
- IS_CHANGED uses folder_paths.get_annotated_filepath(name)
- VALIDATE_INPUTS uses folder_paths.exists_annotated_filepath(name)
- load() returns folder_paths.get_annotated_filepath(name)
`get_annotated_filepath` understands `[input]/name`, `[temp]/name`,
and `[output]/name` annotations transparently, so power users can
type those into the combo if they need output/ access — same as
LoadImage. The default UX is "pick from input/".
Output-side PLYs from upstream nodes (GaussianMerge, GaussianExport,
LiToExportPLY) are wired directly into PreviewGaussians via the
STRING output — no LoadPLY needed.
workflows/merge_splats.json: rewrite widgets_values from
"input/apple.ply" -> "apple.ply" to match the new bare-filename
convention.
User asked for the test config to mirror TRELLIS2's. Changes:
- levels: ["syntax", "install", "registration", "instantiation"]
-> ["syntax", "install", "execution"]
Drops the two intermediate levels (execution implies them) and
adds `execution` so the actual workflow runs. Without `execution`
the test was passing vacuously without ever loading the workflow
JSON.
- publish: true -> 'true' (string, matching TRELLIS2's spelling)
- [test.platforms]: dropped macos_desktop / windows_desktop keys
to match TRELLIS2's key set. Kept CPU rows enabled and GPU rows
disabled (inverse of TRELLIS2 since GaussianPack is pure-Python).
[test.workflows] unchanged: cpu = ["merge_splats"], gpu = [].
comfy-test's instantiation level was passing with 0 nodes instead of
the 5 we register, because nodes/__init__.py crashed at module-load:
nodes/__init__.py:17 _register_spz_route()
nodes/spz_route.py from server import PromptServer
ComfyUI/server.py:8 import nodes
ComfyUI/nodes.py:26 import comfy.diffusers_load
comfy/sd.py:7 from comfy import model_management
comfy/model_management.py:254 total_vram = get_total_memory(get_torch_device())
RuntimeError: Cannot access accelerator device when none is available.
ComfyUI's model_management.py evaluates `torch.device(torch.cuda.
current_device())` at module-import time. comfy-test's test
subprocess monkey-patches torch.cuda.{is_available,device_count,
current_device} but `torch.device(0)` still crashes on CPU-only.
Our `except ImportError` clause missed the RuntimeError, so the
exception escaped and nodes/__init__.py never completed →
register_nodes() returned 0 → test "passes" vacuously with
`All 0 node(s) instantiated successfully!`.
Fix: catch `Exception` instead of `ImportError`. Real ComfyUI
startup is unaffected — by the time custom_nodes load, `server` is
already in sys.modules so the import is a no-op lookup with no
transitive imports.
Verified locally:
[comfy-env] Importing nodes (root)...
WARNING SPZ route not registered (RuntimeError: Cannot access accelerator device...)
[comfy-env] Imported nodes root: 5 nodes
[comfy-env] Registered 5 total nodes
MAPPINGS: ['PreviewGaussians', 'GaussianMerge', 'LoadPLY', 'GaussianAnalysis', 'GaussianExport']
Next `cds test gaussianpack` should now print
`All 5 node(s) instantiated successfully!`.
The widget's tooltip was just `"kNN candidate count for the merge
graph."` — technically correct but didn't tell the user whether
higher means better or worse, what to set it to, or what it costs.
Three lines now explain:
- What k does (search breadth for candidate merge pairs).
- The trade-off (higher = better selector choice, O(N*k) memory).
- Pragmatic defaults: 16 = sweet spot, 8 = fast draft, 24-32 on
specular-heavy scenes, 32+ wasted.
No behavior change.
User feedback: the path → (filename, subfolder, kind) resolution
should read folder_paths directly rather than relying on
COMFYUI_INPUT_FOLDER / COMFYUI_OUTPUT_FOLDER captured in common.py
at module import.
Folded the two-base scan into `folder_paths.get_directory_by_type(t)`
(folder_paths.py:214) over ("output", "input", "temp") in priority
order. Three benefits:
- Picks up runtime config: --input-directory CLI flag, mid-session
setattr on folder_paths, etc. The previous version froze at first
import of common.py.
- Adds "temp" coverage for free.
- Single source of truth — common.py no longer needs to mirror
folder_paths constants for this codepath.
The COMFYUI_INPUT_FOLDER / COMFYUI_OUTPUT_FOLDER definitions in
common.py stay (the other nodes still import them for their own
defaults); only preview_gaussian.py stops using them.
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.
## GaussianAnalysis (viewer/Gaussian Analysis)
Header-only PLY inspector — instant even for hundreds-of-MB scenes.
Reports:
- splat count + file size + bytes/splat
- PLY encoding (ascii vs binary little/big endian)
- SH degree (derived from f_rest_* count: 0/9/24/45 -> 0/1/2/3)
- precision (float32 all / uint8 all / mixed)
- feature flags: has f_dc_*, has RGB uchar, has opacity, has scale_*,
has rot_*
- flavor heuristic: "raw 3DGS PLY (SH degree N)" / "SH-stripped"
/ "RGB-baked" / "compressed / packed"
OUTPUT_NODE: the report is also dumped into the node body via the `ui`
payload, while a STRING is returned so it can be chained into a Note
or printed downstream.
Sanity-tested against the bundled assets/apple.ply (200,000 splats,
47.30 MB, raw 3DGS SH=3, float32 all).
## GaussianExport (viewer/Gaussian Export)
PLY -> PLY (or PLY -> SPZ) reformatter. Three modes:
ply passthrough copy (just rename).
ply_no_sh strip f_rest_* (view-dependent SH coefficients). Keeps
the DC term (f_dc_0/1/2 = base color) so the splat still
looks right; loses specular highlights. ~3-5x smaller.
Pure Python via plyfile.
spz SPZ v2 (gzipped) via the vendored ply_to_spz.mjs (same
Node subprocess spz_route.py uses for the in-flight
viewer transcode). ~9x smaller; SH quantizes to 4 bits.
Requires Node on PATH.
Output is the absolute path to the produced file, so the node chains
into PreviewGaussians / GaussianMerge directly.
Typical use:
LoadPLY -> GaussianMerge(target=200k) -> GaussianExport(ply_no_sh)
-> PreviewGaussians
That gets you ~50 MB scenes from ~225 MB raw outputs without losing
view-independent appearance.
Mirrors LiTo's prestartup pattern: on each ComfyUI startup, copies
files from this repo's `assets/` directory (currently apple.ply, ~50MB)
into the host ComfyUI's `input/` directory.
Uses `comfy_env.copy_files`, which is non-clobbering — pre-existing
files in input/ are left alone, so user edits and uploads survive
restarts. Skipped if assets/ is missing (e.g. fresh shallow clone).
GaussianPack only needs three pure-Python deps (no CUDA, no conda-side
wheels), so the comfy-env pixi spec was overkill. requirements.txt at
the root is the standard custom-node convention and ComfyUI-Manager
auto-installs it on first launch.
Translation:
[dependencies]
numpy = ">=1.24" -> numpy>=1.24
plyfile = "*" -> plyfile
[pypi-dependencies]
nanogs = { git = "..." } -> git+https://github.com/saliteta/NanoGS.git
Without `nodes/comfy-env.toml`, comfy_env.register_nodes() falls back
to its main-process branch (imports nodes/__init__.py normally — no
subprocess isolation), which is exactly what we want for these pure-
Python nodes.
Match LiTo's project layout. Net file moves:
spz_route.py -> nodes/spz_route.py
install.py -> root (was briefly in nodes/)
server_tools/ -> web/server_tools/ (Node JS subprocess
tool — non-python
asset, sits next to
browser-served web/)
Root and nodes/ now mirror ComfyUI-LiTo:
Root: __init__.py, install.py, pyproject.toml, comfy-env-root.toml,
comfy-test.toml, README.md, LICENSE, web/, scripts/, nodes/
nodes/: __init__.py, comfy-env.toml, common.py, preview_gaussian.py,
merge_gaussians.py, load_ply.py, spz_route.py
## Root __init__.py — LiTo-style
import logging
log = logging.getLogger("comfyui-gaussianpack")
from comfy_env import register_nodes
NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS = register_nodes()
WEB_DIRECTORY = "./web"
Delegates node discovery to `comfy_env.register_nodes()`, which scans
`nodes/__init__.py` for the mappings. WEB_DIRECTORY stays at root
(points at top-level web/) so ComfyUI's static handler doesn't have to
walk into nodes/.
## nodes/__init__.py — clean child package
Imports PreviewGaussians, GaussianMerge, LoadPLY from sibling modules,
calls register_routes() for the SPZ HTTP route, exposes the two
mapping dicts. Dropped `WEB_DIRECTORY` (it belongs at root).
## nodes/comfy-env.toml — pixi env declaration
numpy + plyfile + nanogs (git pip). Same content as before — was
previously at root, where comfy-env's discovery wouldn't pick it up
for isolation purposes. Now lives where comfy-env expects.
## spz_route.py — path fix
NODE_SCRIPT was `THIS_DIR / "server_tools" / "ply_to_spz.mjs"` when the
file was at root. Now at nodes/ with server_tools moved to web/, so:
NODE_SCRIPT = THIS_DIR.parent / "web" / "server_tools" / "ply_to_spz.mjs"
Smoke test (worker pixi env):
PYTHONPATH=.../ComfyUI:. python -c "import nodes; print(nodes.NODE_CLASS_MAPPINGS)"
-> {'PreviewGaussians': ..., 'GaussianMerge': ..., 'LoadPLY': ...}
User asked to "copy the Load PLY node from HallettVisual/comfyui-
GaussianViewer". After cloning + reading the source, that repo doesn't
ship a LoadPLY node — every PLY input there is forceInput-only
(GaussianViewer / GeomPackRenderGaussian / ExtrinsicsToPose). So
writing one from scratch in the spirit of the request.
`load_ply.py` scans both ComfyUI's input/ AND output/ directories
recursively for .ply files; the combo entries are prefixed with the
folder kind ("input/foo.ply", "output/lito_output.ply") to disambiguate
collisions. LiTo's LiToExportPLY writes to output/, so its files appear
in the dropdown directly — no manual copying.
Implements:
- INPUT_TYPES re-scans on each schema evaluation (no stale lists).
- IS_CHANGED keys on the picked file's mtime so re-runs catch edits.
- VALIDATE_INPUTS rejects entries that disappeared between schema
fetch and execute.
Output is a STRING `ply_path` (absolute), so it slots directly into
PreviewGaussians / GaussianMerge's `ply_path` forceInput. Lets users
preview an arbitrary PLY without re-running LiTo, or pipe a
hand-edited PLY into GaussianMerge.