Compare commits

..
Author SHA1 Message Date
Alexander KharinandClaude Fable 5 012ab737d6 Fix CI: make sharp early-return test independent of repo submodule state
test_sharp_early_return asserted on the real working copy, which only has
submodules/ml-sharpt materialized after a recursive clone - CI checks out
without submodules, so ensure_sharp_checkout correctly cloned and the
assertion failed. Build the already-materialized state in a temp dir
instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 15:55:10 +03:00
Alexander KharinandClaude Fable 5 2f9aa76478 Exclude fisheye out-of-circle regions from 4D lifting and rendering
For FISHEYE inputs, the [-1,1] uv square contains the corners beyond the
image circle (r > 1, view angles beyond fov/2). Those regions carry no
scene content, yet:
- _uv_to_dirs clamped r > 1 onto the rim, so MotionMaskFromDepth could
  flag garbage-depth corners as dynamic and TracksToTrajectories lifted
  corner tracks to junk 3D control trajectories;
- _projection_valid only checked the square, so points at angles beyond
  fov/2 that project diagonally (e.g. r=1.33 at u=v~0.94) were treated as
  in-image by the motion-mask warp check and SplitSplatsByMask;
- render_gaussians had the same square-only cull, painting behind-camera
  splats into the corners of FISHEYE renders (and mirror-projecting
  behind-camera points in PINHOLE renders - Z>0 cull added to match
  GS4D's _projection_valid).

Add _uv_in_fov helper, apply it in MotionMaskFromDepth (corner pixels can
never be flagged dynamic) and TracksToTrajectories (corner samples are
invalid, fully-out tracks dropped), extend _projection_valid and the
render cull with the r <= 1 circle test.

New smoke test 11 covers all three paths: flickering-corner depth stays
static while real in-circle motion is flagged, a corner track is dropped,
and a beyond-fov splat falls outside an all-ones mask (11/11 pass).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 15:36:22 +03:00
Alexander KharinandClaude Fable 5 918c882abd Add static-fisheye video -> 4D Gaussian workflow
fisheye_static_video_to_4d.json: turns a locked-off 180-degree fisheye video
into a 4D Gaussian scene rendered along a novel camera path. A static
camera needs no VGGT pose estimation: the trajectory is identity
(MotionMaskFromDepth interpolates it to T; TracksToTrajectories' optional
trajectory input already defaults to identity), per-frame radial depth
comes from the batched FisheyeDepthEstimator (DISTANCE_AWARE merge), and
the whole static background is one FisheyeToGaussian prediction of frame 0
split by the motion mask (outside = static world, inside = dynamic
canonical for BuildSplats4D). Renders a pinhole novel orbit plus a muted
fisheye replay render for A/B checking; saves the scene as .npz.

Built by notebooks/build_fisheye_static_4d.py (documents the node facts
the graph relies on). Embedded About note, groups, rev stamp; passes the
schema validator and link-integrity lint. README table + video-to-4D
section updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 11:48:45 +03:00
Alexander KharinandClaude Fable 5 79a1c2d6f3 Remove committed __pycache__, add .gitignore
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 18:49:39 +03:00
Alexander KharinandClaude Fable 5 d77af07189 Apply workflow improvements: runnable defaults, templates, dep trim, CI
- Bundle example inputs (example_inputs/: pinhole + fisheye JPEG, example
  trajectory .npy); install.py copies them into ComfyUI's input/ folder and
  every LoadImage/LoadTrajectory default now points at them, so the shipped
  workflows queue on a fresh install.
- Template browser: add same-name .jpg thumbnails for 10 workflows
  (workflows/ is already a recognized template dir); drop the stray
  workflows/__init__.py.
- Move superseded reference graphs to workflows/legacy/ (SD-checkpoint
  outpaint variant, manual multi-view fisheye depth) - out of the template
  browser, still documented.
- video_camera.json: remove dead-end BlurMaskFast (radius 0/0 no-op),
  easy-mathInt pad math and dangling VHS_VideoInfo; swap KJNodes
  GetImageRangeFromBatch for built-in ImageFromBatch. Pack deps cut from
  five to two (VideoHelperSuite + Florence2).
- New workflows/record_trajectory.json - produces the .npy trajectory that
  PC_enricher and wan_vace_ref_to_video consume.
- Stamp extra.camera_comfyui_rev=1 in all workflows for future migrations;
  prune 8 pre-existing stale link references found by a bidirectional
  integrity sweep.
- CI: .github/workflows/validate.yml runs the schema validator,
  installer-logic tests and 4D smoke suite (CPU torch) on PRs and main.
- README + docs/workflows_review.md updated accordingly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 18:49:14 +03:00
Alexander KharinandClaude Fable 5 6f8ed9d382 Rework all workflows: schema repairs, embedded docs, groups, README table
Migrate widgets_values stored with pre-2026 node schemas (28 nodes across
9 files) to the current INPUT_TYPES: DepthEstimatorNode (+median_blur),
FisheyeDepthEstimator (+mode/median_blur), PointCloudCleaner (new
screen-space schema, reset to defaults), CameraMotionNode (+mask options),
CameraInterpolationNode (+num_steps), PointcloudTrajectoryEnricher
(20->16 widgets). Positionally-shifted values loaded silently wrong before.

Fix broken references:
- outpainting_fisheye.json: patch rotations lived in a removed
  ReprojectImage widget slot that now lands on `inverse` (42 -> truthy);
  rebuilt with explicit TransformToMatrix (±42°) nodes + inverse flags,
  mirroring the flux variant
- outpainting_fisheye_flux.json: normalize inverse widgets stored as 0/45
- wan_vace_ref_to_video.json: UNET filename typo (wan2,1_vace14B ->
  wan2.1_vace_14B_fp16.safetensors)

Explain and format every workflow: embedded "About this workflow"
MarkdownNote (purpose, stages, what to set, required packs/models),
meaningful group boxes (incl. retitling six anonymous "Group" boxes in
Fisheye_depth_workflow), titles on user-editable nodes, pretty-printed
JSON. Rename the two spaced filenames (pointcloud_walker,
test_pointcloud_loading). Rewrite the README workflow table with an
extras/dependencies column.

Tooling (notebooks/): validate_workflows.py checks stored widgets against
current INPUT_TYPES (count + combo values); rework_workflows_2026_07.py is
the migration that produced this state. Analysis and proposed improvements
in docs/workflows_review.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 17:28:18 +03:00
Alexander KharinandClaude Fable 5 d85c2125d1 Make installation fully automatic via ComfyUI-Manager
Add install.py, which ComfyUI-Manager runs automatically after installing
the pack (manual users run `python install.py`). It idempotently and
non-fatally sets up every optional dependency that previously needed
manual steps:

- vggt: pip-installed from GitHub over https (not on PyPI, no ssh needed)
- SHARP: initializes the submodules/ml-sharpt submodule, or direct-clones
  apple/ml-sharp for non-git (registry zip) installs
- gsplat: pip-installed (CUDA kernels JIT-compile on first use)
- ComfyUI-Flux-Inpainting: cloned into custom_nodes/inpainting_flux when
  no copy exists, incl. its requirements

Refactor flux_fisheye_filling_nodes to locate the flux inpainting pack
under any of its common folder names (inpainting_flux,
ComfyUI-Flux-Inpainting[-main]) instead of requiring a manual rename.

Ship SHARP's pure-Python runtime deps (click, timm, plyfile, pillow-heif,
matplotlib, imageio[-ffmpeg]) in requirements.txt/pyproject so ImageToSplat
works out of the box. Fix the wrong `pip install vggt` advice (package is
not on PyPI) in README and node error messages, point them at install.py,
and update install.sh to reuse it. Bump version to 1.1.0.

Offline tests: notebooks/test_install_logic.py (pip/git stubbed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 13:41:27 +03:00
55 changed files with 17250 additions and 429 deletions
+34
View File
@@ -0,0 +1,34 @@
name: Validate workflows & smoke tests
# Guards against node-schema drift silently breaking the shipped workflows:
# validate_workflows.py compares every stored widgets_values against the
# current INPUT_TYPES (see docs/workflows_review.md).
on:
pull_request:
push:
branches:
- main
permissions:
contents: read
jobs:
validate:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install CPU test dependencies
run: |
pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu
pip install numpy pillow scipy tqdm
- name: Workflow schema validation
run: python notebooks/validate_workflows.py
- name: Installer logic tests
run: python notebooks/test_install_logic.py
- name: 4D node smoke tests
run: python notebooks/smoke_test_4d.py
+2
View File
@@ -0,0 +1,2 @@
__pycache__/
*.pyc
+23 -3
View File
@@ -180,6 +180,18 @@ def _uv_grid(height: int, width: int, device: torch.device) -> Tuple[torch.Tenso
return u, v
def _uv_in_fov(u: torch.Tensor, v: torch.Tensor, projection: str) -> torch.Tensor:
"""True where normalized uv lies inside the projection's actual image region.
For FISHEYE the [-1,1] square contains the corners beyond the image circle
(r > 1, i.e. view angles beyond fov/2); pixels there carry no scene content
(black corners / garbage depth) and must not be lifted, warped or tracked.
"""
if projection == "FISHEYE":
return (u * u + v * v) <= 1.0 + 1e-6
return torch.ones_like(u, dtype=torch.bool)
def _uv_to_dirs(u: torch.Tensor, v: torch.Tensor, projection: str, horizontal_fov: float) -> torch.Tensor:
"""Unit ray directions [...,3] in camera frame for normalized uv in [-1,1].
@@ -222,7 +234,9 @@ def _project_xyz(
def _projection_valid(
u: torch.Tensor, v: torch.Tensor, Z: torch.Tensor, projection: str
) -> torch.Tensor:
"""In-image validity for projected points; pinhole additionally requires Z>0."""
"""In-image validity for projected points; pinhole additionally requires Z>0,
fisheye requires the point inside the image circle (r <= 1), not just the
[-1,1] square — angles beyond fov/2 can otherwise land in the corners."""
valid = (
torch.isfinite(u)
& torch.isfinite(v)
@@ -233,6 +247,8 @@ def _projection_valid(
)
if projection == "PINHOLE":
valid = valid & (Z > 1e-6)
elif projection == "FISHEYE":
valid = valid & ((u * u + v * v) <= 1.0 + 1e-6)
return valid
@@ -480,6 +496,7 @@ class MotionMaskFromDepth:
u, v = _uv_grid(H, W, target_device)
dirs = _uv_to_dirs(u, v, input_projection, input_horizontal_fov) # [H,W,3]
in_fov = _uv_in_fov(u, v, input_projection) # excludes fisheye corners
gap = max(1, int(frame_gap))
dynamic = torch.zeros((T, H, W), device=target_device)
@@ -490,7 +507,7 @@ class MotionMaskFromDepth:
tr_t = poses[t, :3, 3]
# world = (cam - t) @ R (inverse of cam = world @ R.T + t)
world = (cam_pts - tr_t) @ R_t
has_depth = depth_t > 1e-6
has_depth = (depth_t > 1e-6) & in_fov
flagged = torch.zeros((H, W), dtype=torch.bool, device=target_device)
for t2 in (t + gap, t - gap):
@@ -693,7 +710,10 @@ class TracksToTrajectories:
# world = (cam - t) @ R per frame.
world = torch.bmm(cam - tr.unsqueeze(1), R)
in_bounds = (u >= -1.0) & (u <= 1.0) & (v >= -1.0) & (v <= 1.0)
in_bounds = (
(u >= -1.0) & (u <= 1.0) & (v >= -1.0) & (v <= 1.0)
& _uv_in_fov(u, v, input_projection)
)
valid = vis & in_bounds & (d > 1e-6) & torch.isfinite(world).all(dim=-1)
world = torch.where(valid.unsqueeze(-1), world, torch.zeros_like(world))
+8 -1
View File
@@ -527,7 +527,8 @@ def _extract_f_rest(data: Dict[str, np.ndarray]) -> Tuple[np.ndarray, int]:
def _ensure_sharp_available() -> None:
if not _SHARP_AVAILABLE:
raise ModuleNotFoundError(
f"ml-sharpt is unavailable. Ensure submodules/ml-sharpt is present and its dependencies are installed. "
f"ml-sharpt is unavailable. Run this pack's install.py (ComfyUI-Manager does this "
f"automatically) to fetch submodules/ml-sharpt and its dependencies (incl. gsplat). "
f"Import error: {_SHARP_IMPORT_ERROR}"
)
@@ -1302,6 +1303,12 @@ def render_gaussians(
u, v, depth = _xyz_to_equirect(X, Y, Z, camera_horizontal_fov)
valid = (u >= -1.0) & (u <= 1.0) & (v >= -1.0) & (v <= 1.0)
if camera_projection == "PINHOLE":
# behind-camera points otherwise mirror-project into the frame
valid = valid & (Z > 1e-6)
elif camera_projection == "FISHEYE":
# keep the image circle only: angles beyond fov/2 land in the corners
valid = valid & ((u * u + v * v) <= 1.0 + 1e-6)
if not valid.any():
return _empty_render(output_width, output_height, dev)
+56 -42
View File
@@ -38,7 +38,17 @@ A collection of ComfyUI custom nodes to handle diverse camera projections (pinho
### Option A — ComfyUI Manager (recommended)
The node pack is published to the [ComfyUI Registry](https://registry.comfy.org) as **`camera-comfyui`** (publisher `alexk`). In ComfyUI, open **Manager → Custom Nodes Manager**, search for **camera-comfyUI**, and click **Install**, then restart ComfyUI. The registry package bundles the SHARP submodule and installs the base Python requirements automatically; optional CUDA-specific extras (`gsplat`, `vggt`) still follow the manual steps below.
The node pack is published to the [ComfyUI Registry](https://registry.comfy.org) as **`camera-comfyui`** (publisher `alexk`). In ComfyUI, open **Manager → Custom Nodes Manager**, search for **camera-comfyUI**, and click **Install**, then restart ComfyUI.
Installation is fully automatic: ComfyUI-Manager installs `requirements.txt` and then runs this pack's `install.py`, which sets up everything the optional nodes need — no manual steps:
* **vggt** (`VideoPoseEstimator`) — pip-installed from GitHub over https (it is not on PyPI).
* **SHARP** (`ImageToSplat`, `VideoToFusedSplats`, …) — the `submodules/ml-sharpt` checkout is bundled in the registry package (and fetched via `git submodule`/clone for git installs), and its Python deps come from `requirements.txt`.
* **gsplat** — pip-installed; its CUDA kernels JIT-compile on first use.
* **ComfyUI-Flux-Inpainting** (`OutpaintAnyProjection`, `SplatTrajectoryEnricher`) — cloned automatically into `custom_nodes/inpainting_flux` (skipped if you already have the pack under any of its usual folder names).
* **Example inputs** — the sample image/trajectory files referenced by the bundled workflows are copied into ComfyUI's `input/` folder, so the templates run immediately.
Each step is optional and non-fatal: if one fails (e.g. no network), only the nodes that need it stay disabled — re-run `python install.py` inside the pack folder to retry.
> **Maintainers:** releases are automated — bumping `version` in `pyproject.toml` on `main` triggers `.github/workflows/publish_action.yml`, which publishes the new version to the registry (requires the `REGISTRY_ACCESS_TOKEN` repo secret).
@@ -56,35 +66,29 @@ The node pack is published to the [ComfyUI Registry](https://registry.comfy.org)
sudo apt-get update && sudo apt-get install build-essential ffmpeg libsm6 libxext6 -y
```
3. **Python Requirements**:
3. **Python Requirements + optional dependencies** — one command sets up everything (base requirements, vggt, the SHARP submodule, gsplat, and the `inpainting_flux` sibling pack):
```bash
pip install -r custom_nodes/camera-comfyUI/requirements.txt
cd custom_nodes/camera-comfyUI && python install.py
```
* *Optional:* `open3d` for GUI point cloud tools.
This is the same script ComfyUI-Manager runs automatically; it is idempotent, and every optional step is non-fatal.
**Optional dependencies** (only needed for specific nodes):
**What it covers** (for reference — no manual action needed):
* **gsplat** — CUDA-accelerated Gaussian splat rasterizer. Required by `SplatPolish` and used as the fast render backend for `RenderSplat` / `RenderSplats4D*`. Needs a CUDA GPU and a matching PyTorch build: `pip install gsplat`.
* **vggt** — camera pose + depth estimation (`VideoPoseEstimator`). Install with `pip install vggt` (or `pip install git+https://github.com/facebookresearch/vggt.git`), or clone [facebookresearch/vggt](https://github.com/facebookresearch/vggt) as a sibling folder in your ComfyUI root. The `facebook/VGGT-1B` weights (~5 GB) download via `huggingface_hub` on first use.
* **CoTracker3** — point tracking for `EstimateTracks`. No manual install: it is fetched automatically via `torch.hub` on first use.
* **SHARP** — image→splat prediction (`ImageToSplat`, `FisheyeToGaussian`, `VideoToFusedSplats`, `SplatTrajectoryEnricher`). Ships as the existing git submodule at `submodules/ml-sharpt` ([apple/ml-sharp](https://github.com/apple/ml-sharp)) — run `git submodule update --init` after cloning.
* **gsplat** — CUDA-accelerated Gaussian splat rasterizer. Required by `SplatPolish`, SHARP, and the fast render backend for `RenderSplat` / `RenderSplats4D*`. Kernels JIT-compile on first use (needs a CUDA GPU + matching PyTorch build).
* **vggt** — camera pose + depth estimation (`VideoPoseEstimator`). Not on PyPI — installed with `pip install git+https://github.com/facebookresearch/vggt.git`. A sibling clone of [facebookresearch/vggt](https://github.com/facebookresearch/vggt) in your ComfyUI root also works. The `facebook/VGGT-1B` weights (~5 GB) download via `huggingface_hub` on first use.
* **CoTracker3** — point tracking for `EstimateTracks`. Fetched automatically via `torch.hub` on first use.
* **SHARP** — image→splat prediction (`ImageToSplat`, `FisheyeToGaussian`, `VideoToFusedSplats`, `SplatTrajectoryEnricher`). Lives as the git submodule at `submodules/ml-sharpt` ([apple/ml-sharp](https://github.com/apple/ml-sharp)); `install.py` initializes it for you.
* **ComfyUI-Flux-Inpainting** — cloned into `custom_nodes/inpainting_flux` if missing. Any of the usual folder names (`inpainting_flux`, `ComfyUI-Flux-Inpainting`, `ComfyUI-Flux-Inpainting-main`) is detected — no renaming needed.
4. **Additional Nodes** (for certain workflows):
4. **Additional Nodes** (only for some example workflows):
* Clone the following repositories directly into your `custom_nodes` folder:
* [ComfyUI-Flux-Inpainting](https://github.com/rubi-du/ComfyUI-Flux-Inpainting)
* [ComfyUI-Image-Filters](https://github.com/spacepxl/ComfyUI-Image-Filters)
* **Important:** If the `ComfyUI-Flux-Inpainting` repository is cloned as `ComfyUI-Flux-Inpainting-main`, rename the folder to `inpainting_flux`:
```bash
mv custom_nodes/ComfyUI-Flux-Inpainting-main custom_nodes/inpainting_flux
```
* [ComfyUI-Image-Filters](https://github.com/spacepxl/ComfyUI-Image-Filters) — install via Manager or clone into `custom_nodes`.
5. **Flux Models** (Hugging Face):
5. **Flux Models** (Hugging Face, only for gated models):
```bash
pip install huggingface_hub
huggingface-cli login
```
@@ -205,6 +209,8 @@ Turn a monocular video into a navigable 4D (3D + time) Gaussian splat scene and
4. **Tracked dynamic 4D Gaussians** — `EstimateTracks` (CoTracker3) tracks a dense point grid across the video; `TracksToTrajectories` lifts the tracks to world-space 3D using depth + poses; `SplitSplatsByMask` separates dynamic splats from the static background; `BuildSplats4D` binds the dynamic canonical splats to track control points via kNN blending, producing a `GSPLAT4D` scene.
5. **Render a novel trajectory** — build any new camera path (e.g. `CameraInterpolationNode`, `TrajectoryCompose` to retarget relative to a source pose) and render with `RenderSplats4DVideo` (or single frames with `RenderSplats4DFrame`). Save/reload scenes with `SaveSplats4D` / `LoadSplats4D`.
**Static-camera fisheye variant** — for footage from a locked-off 180° fisheye camera, `workflows/fisheye_static_video_to_4d.json` skips pose estimation entirely (identity trajectory), uses the batched `FisheyeDepthEstimator` for per-frame radial depth and `FisheyeToGaussian` on frame 0 for the whole static world, then follows the same track → split → `BuildSplats4D` → render path (all 4D nodes accept the FISHEYE projection directly).
### Caveats
* **Z-depth vs ray depth**: depth estimators (including `VideoPoseEstimator`) output Z-depth; point-cloud and splat lifting nodes expect ray depth. Insert `ZDepthToRayDepthNode` where needed, or geometry will bow at wide FOVs.
@@ -216,23 +222,31 @@ Turn a monocular video into a navigable 4D (3D + time) Gaussian splat scene and
## Workflows
A set of JSON workflows illustrating typical use cases. Each workflow lives in `workflows/` and can be loaded directly in ComfyUI.
A set of JSON workflows illustrating typical use cases. Once the pack is installed they appear in ComfyUI under **Workflow → Browse Templates** (with thumbnails); the files live in `workflows/` and can also be loaded directly. Each workflow contains an embedded **“About this workflow”** note in the canvas explaining its stages, what to set, and what it needs — and references the bundled example inputs that `install.py` copies into your ComfyUI `input/` folder, so they run as-is on a fresh install.
| Workflow | Description |
| -------------------------------------- | -------------------------------------------------------------- |
| **demo\_camera\_workflow\.json** | Masked reprojection demo: pinhole → fisheye/equirect |
| **outpainting\_fisheye.json** | Text‐guided fisheye outpainting (built‐in inpaint node) |
| **outpainting\_fisheye\_flux.json** | Flux‐based outpainting with clear reprojection scheme |
| **Outpaint\_node\_test.json** | Test harness for the universal outpaint node |
| **Outpaint\_fisheye180.json** | 180° fisheye outpainting via `OutpaintAnyProjection` |
| **Fisheye\_depth\_workflow\.json** | Fisheye → metric depth → point cloud → PLY export |
| **Pointcloud.json** | Metric‐depth‐anything v2 → point cloud → camera view synthesis |
| **pointcloud\_inpaint.json** | Inpaint + backproject to 3D for dynamic camera motion videos |
| **Pointcloud\_walker.json** | GUI‐based camera control via Open3D |
| **sbs180\_workflow.json** | Generate stereo (side-by-side) wide-angle/fisheye/equirectangular stereo pairs from a high-res input |
| **video_camera.json** | Camera trajectory movement workflow using `wan-vace` for video inpainting. |
| **video_to_4d_world\.json** | Video → 4D world: VGGT poses/depth → motion masking → fused static splats + polish → tracked dynamic 4D Gaussians → novel-trajectory render. |
| **video_to_4d_walkable_world\.json** | Video → 4D WALKABLE world (test-friendly defaults): polished static splats enriched along a walk trajectory (`SplatTrajectoryEnricher`, Flux outpaint + SHARP) → 4D scene → walk-through render + `.ply`/`.npz` exports for free walking in external 3DGS viewers. |
*Extras* below means dependencies beyond this pack and Depth-Anything V2 (which auto-downloads); `inpainting_flux` is installed automatically by `install.py`.
| Workflow | Description | Extras |
| --- | --- | --- |
| **demo\_camera\_workflow\.json** | Minimal demo: rotate the camera and reproject pinhole → equirectangular, with coverage mask | — |
| **Outpaint\_node\_test.json** | One-patch smoke test of `OutpaintAnyProjection` | inpainting_flux |
| **Outpaint\_fisheye180.json** | Pinhole 90° → full 180° fisheye via five chained `OutpaintAnyProjection` passes + composite/upscale | inpainting_flux |
| **outpainting\_fisheye\_flux.json** | Manual version of the above: explicit Flux Inpainting + reprojection stages | inpainting_flux, RealESRGAN |
| **fisheye\_to\_pointcloud.json** | Fisheye 180° → metric depth → point cloud (`.ply`/`.npy`) | — |
| **PointCloud.json** | Single image → point cloud → cleaned novel-view render | Image-Filters (optional) |
| **pointcloud\_walker.json** | Image → point cloud → camera fly-through WEBM | — |
| **test\_pointcloud\_loading.json** | Reload a saved point cloud and orbit-render it | — |
| **record\_trajectory.json** | Record a camera trajectory `.npy` for LoadTrajectory (two poses → SE(3) interpolation → SaveTrajectory) | — |
| **pointcloud\_inpaint.json** | Enrich a cloud: Flux-inpaint disocclusions, lift them to 3D, merge, orbit render | inpainting_flux |
| **PC\_enricher.json** | One-node version of the above: `PointcloudTrajectoryEnricher` along a saved trajectory | inpainting_flux |
| **sbs180\_workflow.json** | Synthesize the second eye of a VR180 stereo pair from one fisheye view | inpainting_flux |
| **video\_camera.json** | Re-shoot a video with a new camera move; WAN VACE regenerates disocclusions, Florence2 auto-captions | VHS, Florence2; WAN 2.1 VACE + Video-Depth-Anything models |
| **wan\_vace\_ref\_to\_video.json** | Still fisheye image + recorded trajectory → WAN VACE camera-move video | WAN 2.1 VACE models; VHS (optional MP4 export) |
| **video_to_4d_world\.json** | Video → 4D world: VGGT poses/depth → motion masking → fused static splats + polish → tracked dynamic 4D Gaussians → novel-trajectory render. | — |
| **video_to_4d_walkable_world\.json** | Video → 4D WALKABLE world (test-friendly defaults): polished static splats enriched along a walk trajectory (`SplatTrajectoryEnricher`, Flux outpaint + SHARP) → 4D scene → walk-through render + `.ply`/`.npz` exports for free walking in external 3DGS viewers. | inpainting_flux |
| **fisheye\_static\_video\_to\_4d.json** | Static-camera 180° fisheye video → 4D Gaussian scene → novel-path render. No pose estimation needed: identity trajectory, batched fisheye depth, frame-0 splats split into static world + dynamic canonical. | VHS |
Superseded reference graphs live in `workflows/legacy/` (kept out of the template browser): **outpainting\_fisheye.json** (SD-inpaint-checkpoint variant of the flux outpaint) and **Fisheye\_depth\_workflow\.json** (the multi-view depth fusion that `FisheyeDepthEstimator` now performs internally).
---
@@ -247,9 +261,9 @@ Basic reprojection pipeline: apply masks, rotate pinhole camera, outpaint fishey
<img src="demo_images/Pinhole_camera_rotation.png" alt="Pinhole Rotation" width="45%" />
</div>
### 2. `outpainting_fisheye.json`
### 2. `legacy/outpainting_fisheye.json`
Simplest text‐guided fisheye outpainting built with the core inpaint node.
Simplest text‐guided fisheye outpainting built with the core inpaint node (superseded by the Flux variant).
### 3. `outpainting_fisheye_flux.json`
@@ -265,9 +279,9 @@ Flux Inpainting ensures sharper results and explicit reprojection stages.
<img src="demo_images/Fisheye_outpainted_flux_dev.png" alt="Flux Dev" width="60%" />
### 5. `Fisheye_depth_workflow.json`
### 5. `legacy/Fisheye_depth_workflow.json`
Convert fisheye images to metric depth and generate a PLY point cloud.
Convert fisheye images to metric depth and generate a PLY point cloud — the manual multi-view graph that `FisheyeDepthEstimator` now performs in one node (see `fisheye_to_pointcloud.json`).
<img src="demo_images/Depthmap.png" alt="Fisheye Depth→PointCloud" width="60%" />
@@ -277,7 +291,7 @@ Convert fisheye images to metric depth and generate a PLY point cloud.
Quick test for the universal outpaint node in arbitrary views and camera movement
### 7. `Pointcloud.json`
### 7. `PointCloud.json`
Depth→PointCloud pipeline with interactive camera movement and reprojection views.
@@ -297,9 +311,9 @@ Take a wide-angle (fisheye or equirectangular) high-resolution (e.g., 4096×4096
<img src="demo_images/equirect_stereo.gif" alt="Equirectangular Stereo Demo" width="80%" />
### 10. `Pointcloud_walker.json`
### 10. `pointcloud_walker.json`
Interactive Open3D-based GUI for walking and setting camera trajectory inside pointcloud.
Image → point cloud → camera fly-through rendered to WEBM (`CameraTrajectoryNode` + `CameraMotionNode`).
### 11. `video_camera.json`
+96
View File
@@ -0,0 +1,96 @@
# Workflow review — July 2026
Scope: all 17 `workflows/*.json`. Tooling used (kept in `notebooks/`):
* `validate_workflows.py` — checks every stored `widgets_values` array against
the *current* `INPUT_TYPES` of the node it targets (count + combo values).
Run it whenever a node's inputs change.
* `rework_workflows_2026_07.py` — the one-shot migration that produced the
current state of the files (documented below, idempotent-ish).
## What was wrong (now fixed)
ComfyUI applies `widgets_values` **positionally**. When a node gains/loses/
reorders widgets, old workflows load with silently shifted values — no error,
just wrong settings. The validator found 28 such cases across 9 files:
| Node | Old → new widgets | Files affected | Migration applied |
| --- | --- | --- | --- |
| `DepthEstimatorNode` | 2 → 3 (`median_blur_kernel` added) | 5 files, 12 nodes | appended default `1` |
| `FisheyeDepthEstimator` | 6 → 8 (`mode`, `median_blur_kernel` added) | 2 files | inserted `SOFTMERGE` (radius was already configured), appended `1` |
| `PointCloudCleaner` | 2 → 4 (screen-space `width`/`height` added; units changed) | `PointCloud.json` | reset to defaults `[1024, 1024, 1.0, 3]` — old world-unit values were meaningless in the new schema (**retune on GPU box**) |
| `CameraMotionNode` | 6 → 9 (`widen_mask`, `invert_mask`, `points_to_mask`) | 3 files | appended defaults `[0, false, false]` |
| `CameraInterpolationNode` | 0 → 1 (`num_steps`) | 3 files | set `2` (keyframes only — `CameraMotionNode`/`VideoCameraMotionSequence` interpolate frames themselves) |
| `PointcloudTrajectoryEnricher` | 20 → 16 (render/reproject-back options internalized) | `PC_enricher.json` | first 13 kept 1:1, new tail set to defaults |
Beyond widget drift:
* **`outpainting_fisheye.json` was structurally broken**: its seven
`ReprojectImage` nodes stored patch rotations (±42°) in a pre-2025 widget
slot that now lands on the `inverse` boolean (42 → truthy). Repaired by
adding two `TransformToMatrix` nodes (±42°) wired to `transform_matrix`
inputs and setting proper `inverse` flags — mirroring the structure of
`outpainting_fisheye_flux.json`.
* **`outpainting_fisheye_flux.json`** stored `45` in one `inverse` slot
(truthy-by-accident); normalized all seven to real booleans.
* **`wan_vace_ref_to_video.json`** pointed the UNETLoader at
`wan2,1_vace14B_fp16.safetensors` (comma typo + missing underscore) — no such
file can exist; fixed to `wan2.1_vace_14B_fp16.safetensors` (matches
`install.sh`'s download name).
* `Pointcloud walker.json` / `Test pointcloud_loading.json` renamed to
`pointcloud_walker.json` / `test_pointcloud_loading.json` (spaces break
shell ergonomics and URL linking).
* README workflow table was stale (claimed `pointcloud_walker` was an "Open3D
GUI", missed 5 workflows); rewritten with per-workflow extras columns.
Every workflow now carries an embedded **“About this workflow”** MarkdownNote
(purpose, stages, what to set, required packs/models), meaningful group boxes,
and titles on the nodes users are expected to edit.
## Improvements — applied 2026-07-16
Implemented by `notebooks/apply_improvements_2026_07.py` plus repo changes:
1. **Runnable defaults** ✅ — `example_inputs/` ships
`camera_example_pinhole.jpg`, `camera_example_fisheye.jpg` and
`ComfyUITrajectory_00001.npy`; `install.py` copies them into ComfyUI's
`input/` dir (no overwrite), and every LoadImage/LoadTrajectory default
points at them. Exception: `video_camera.json` still needs a user video
(none bundled — a clip would bloat the archive).
2. **Template browser** ✅ — `workflows/` is already an accepted template
directory name (per docs.comfy.org, alongside `example_workflows`), so no
rename was needed; added the missing same-name `.jpg` thumbnails (10
workflows, generated 512 px from `demo_images/`) and removed the stray
`workflows/__init__.py`. Legacy graphs moved to `workflows/legacy/`, which
keeps them out of the browser.
3. **`video_camera.json` dep trim** ✅ — removed the dead-end `BlurMaskFast`
(blur radius was 0/0 — a no-op even if wired), the `easy mathInt` pad
computation and the then-dangling `VHS_VideoInfo`; pad top/bottom now use
the node's stored values (set to `(W−H)/2`, note explains); swapped
KJNodes' `GetImageRangeFromBatch` for the built-in `ImageFromBatch`.
Remaining pack deps: VideoHelperSuite + Florence2 (was five packs).
4. **Fisheye-outpaint consolidation** ✅ — SD-checkpoint variant moved to
`workflows/legacy/outpainting_fisheye.json`.
5. **Depth consolidation** ✅ — `workflows/legacy/Fisheye_depth_workflow.json`.
6. **Trajectory recorder** ✅ — new `workflows/record_trajectory.json`
(TransformToMatrix ×2 → CameraInterpolationNode → SaveTrajectory), and the
bundled example trajectory covers the zero-setup path.
7. **CI guard** ✅ — `.github/workflows/validate.yml` runs the workflow
validator, installer-logic tests and the 4D smoke suite on CPU torch for
every PR and push to main.
8. **Schema versioning** ✅ — every workflow now carries
`extra.camera_comfyui_rev = 1`; bump on the next migration.
9. Bonus: a bidirectional link-integrity sweep found and pruned 8 stale link
references (pre-existing) plus one dangling `mask` input in
`outpainting_fisheye_flux.json`.
## Still open
* **Retune migrated defaults on the GPU box.** `PointCloudCleaner` (in
`PointCloud.json`) and the voxel-merge tail of `PointcloudTrajectoryEnricher`
(in `PC_enricher.json`) were reset to schema defaults; verify visual quality
and bake in good values.
* **Load-and-queue pass in real ComfyUI.** All checks here are static; open
each template once on the GPU box to confirm layout and execution.
* **Bundle a small example video** for `video_camera.json` if archive size
allows (or document a public sample clip URL in its note).
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

+44 -7
View File
@@ -4,16 +4,52 @@ import numpy as np
# reprojection helpers
from .reprojection_nodes import Projection, ReprojectImage, TransformToMatrix
# Try importing FluxInpainting and capture any ImportError
import importlib
import sys, os, logging
here = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
if here not in sys.path:
sys.path.append(here)
# Folder names the ComfyUI-Flux-Inpainting pack may live under in custom_nodes/
# (install.py clones it as "inpainting_flux"; keep both lists in sync).
_FLUX_PACK_CANDIDATES = (
"inpainting_flux",
"ComfyUI-Flux-Inpainting",
"ComfyUI-Flux-Inpainting-main",
"comfyui-flux-inpainting",
)
def _import_flux_inpainting():
"""Import FluxNF4Inpainting from the flux inpainting pack regardless of the
folder name it was installed under."""
custom_nodes_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
comfy_root = os.path.dirname(custom_nodes_dir)
if comfy_root not in sys.path:
sys.path.append(comfy_root)
last_error = None
for name in _FLUX_PACK_CANDIDATES:
if not os.path.isfile(os.path.join(custom_nodes_dir, name, "nodes.py")):
continue
try:
module = importlib.import_module(f"custom_nodes.{name}.nodes")
return module.FluxNF4Inpainting
except Exception as exc:
last_error = exc
if last_error is None:
last_error = ModuleNotFoundError(
"No flux inpainting pack found in custom_nodes (looked for "
f"{', '.join(_FLUX_PACK_CANDIDATES)}). It is installed automatically "
"by this pack's install.py (run by ComfyUI-Manager); to set it up "
f"manually, clone {'https://github.com/rubi-du/ComfyUI-Flux-Inpainting'} "
"into custom_nodes/inpainting_flux."
)
raise last_error
# Try importing FluxInpainting and capture any error
try:
from custom_nodes.inpainting_flux.nodes import FluxNF4Inpainting as FluxInpainting
FluxInpainting = _import_flux_inpainting()
_flux_import_error = None
except ImportError as e:
except Exception as e:
FluxInpainting = None
_flux_import_error = e
logging.error(f"[OutpaintAnyProjection] could not import FluxNF4Inpainting: {e}")
@@ -71,7 +107,8 @@ class OutpaintAnyProjection:
if _flux_import_error is not None:
raise RuntimeError(
f"FluxNF4Inpainting is not available: {_flux_import_error}\n"
"Please install or fix your inpainting_flux package."
"Run this pack's install.py (ComfyUI-Manager does this automatically) "
"or install/fix custom_nodes/inpainting_flux."
)
def normalize_mask(m: torch.Tensor):
+215
View File
@@ -0,0 +1,215 @@
"""Post-install setup for camera-comfyUI.
ComfyUI-Manager runs this script automatically after installing the node pack
(both git and registry installs), right after `pip install -r requirements.txt`.
Manual users can run it themselves: `python install.py` from this directory.
It makes the optional heavy dependencies work out of the box:
* vggt — pip-installed from GitHub (not on PyPI); needed by VideoPoseEstimator.
* SHARP — the submodules/ml-sharpt checkout; needed by ImageToSplat & co.
* gsplat — SHARP's rasterizer backend (JIT-compiles CUDA kernels on first use).
* inpainting_flux — the ComfyUI-Flux-Inpainting node pack, cloned as a sibling
custom node; needed by OutpaintAnyProjection / SplatTrajectoryEnricher.
Every step is idempotent and non-fatal: the node pack degrades gracefully at
runtime (see __init__.py), so a failed optional step only disables the nodes
that need it. The script therefore always exits 0 and reports what it skipped.
"""
import os
import shutil
import subprocess
import sys
NODE_DIR = os.path.dirname(os.path.abspath(__file__))
LOG_PREFIX = "[camera-comfyUI install]"
VGGT_GIT_URL = "https://github.com/facebookresearch/vggt.git"
SHARP_GIT_URL = "https://github.com/apple/ml-sharp"
FLUX_PACK_GIT_URL = "https://github.com/rubi-du/ComfyUI-Flux-Inpainting.git"
# Folder names under custom_nodes/ that count as "the flux inpainting pack is
# already installed" (must stay in sync with flux_fisheye_filling_nodes.py).
FLUX_PACK_CANDIDATES = (
"inpainting_flux",
"ComfyUI-Flux-Inpainting",
"ComfyUI-Flux-Inpainting-main",
"comfyui-flux-inpainting",
)
def _log(message: str) -> None:
print(f"{LOG_PREFIX} {message}", flush=True)
def _run(cmd, cwd=None) -> None:
_log("$ " + " ".join(cmd))
subprocess.check_call(cmd, cwd=cwd)
def _pip_install(*args: str) -> None:
_run([sys.executable, "-m", "pip", "install", *args])
def _git() -> str:
git = shutil.which("git")
if git is None:
raise RuntimeError(
"git executable not found on PATH; cannot fetch GitHub dependencies"
)
return git
def _importable(module_name: str) -> bool:
import importlib.util
try:
return importlib.util.find_spec(module_name) is not None
except (ImportError, ValueError):
return False
def ensure_base_requirements() -> None:
"""Install requirements.txt if it clearly has not been installed yet.
ComfyUI-Manager installs it before running this script, so this only fires
for manual `python install.py` users.
"""
if _importable("diffusers") and _importable("transformers"):
_log("base requirements already satisfied")
return
_pip_install("-r", os.path.join(NODE_DIR, "requirements.txt"))
def ensure_vggt() -> None:
"""VGGT (VideoPoseEstimator). Not on PyPI — install from GitHub over https."""
if _importable("vggt"):
_log("vggt already installed")
return
_pip_install(f"vggt @ git+{VGGT_GIT_URL}")
_log("vggt installed from GitHub")
def ensure_sharp_checkout() -> None:
"""Materialize the SHARP submodule (ImageToSplat / VideoToFusedSplats).
Registry archives already bundle it; git installs need `submodule update`
(ComfyUI-Manager does not clone recursively). If this directory is not a
git checkout at all, fall back to a direct clone.
"""
sharp_dir = os.path.join(NODE_DIR, "submodules", "ml-sharpt")
sharp_src = os.path.join(sharp_dir, "src", "sharp")
if os.path.isdir(sharp_src):
_log("SHARP checkout already present")
return
git = _git()
if os.path.exists(os.path.join(NODE_DIR, ".git")):
_run([git, "submodule", "update", "--init", "--recursive"], cwd=NODE_DIR)
if os.path.isdir(sharp_src):
_log("SHARP submodule initialized")
return
if os.path.isdir(sharp_dir) and os.listdir(sharp_dir):
raise RuntimeError(
f"{sharp_dir} exists but does not contain src/sharp; "
"remove it and re-run install.py"
)
_run([git, "clone", "--depth", "1", SHARP_GIT_URL, sharp_dir])
_log("SHARP cloned from GitHub")
def ensure_gsplat() -> None:
"""gsplat backs SHARP's import chain and the fast splat render path.
pip install is lightweight (CUDA kernels JIT-compile on first use), but it
can still fail on exotic setups — that only disables the SHARP/gsplat nodes.
"""
if _importable("gsplat"):
_log("gsplat already installed")
return
_pip_install("gsplat")
_log("gsplat installed")
def ensure_flux_inpainting_pack() -> None:
"""Clone ComfyUI-Flux-Inpainting next to this pack if no copy exists yet."""
custom_nodes_dir = os.path.dirname(NODE_DIR)
if os.path.basename(custom_nodes_dir).lower() != "custom_nodes":
_log(
"not installed under a ComfyUI custom_nodes directory; "
"skipping ComfyUI-Flux-Inpainting setup"
)
return
for name in FLUX_PACK_CANDIDATES:
if os.path.isdir(os.path.join(custom_nodes_dir, name)):
_log(f"flux inpainting pack already present ({name})")
return
git = _git()
target = os.path.join(custom_nodes_dir, "inpainting_flux")
_run([git, "clone", "--depth", "1", FLUX_PACK_GIT_URL, target])
pack_requirements = os.path.join(target, "requirements.txt")
if os.path.isfile(pack_requirements):
_pip_install("-r", pack_requirements)
_log("ComfyUI-Flux-Inpainting installed as custom_nodes/inpainting_flux")
def ensure_example_inputs() -> None:
"""Copy bundled example inputs into ComfyUI's input dir (no overwrite).
The shipped example workflows reference these files, so a fresh install
can queue them immediately.
"""
src_dir = os.path.join(NODE_DIR, "example_inputs")
if not os.path.isdir(src_dir):
_log("no example_inputs directory; skipping")
return
custom_nodes_dir = os.path.dirname(NODE_DIR)
if os.path.basename(custom_nodes_dir).lower() != "custom_nodes":
_log("not installed under a ComfyUI custom_nodes directory; "
"skipping example input setup")
return
input_dir = os.path.join(os.path.dirname(custom_nodes_dir), "input")
os.makedirs(input_dir, exist_ok=True)
copied = 0
for name in os.listdir(src_dir):
target = os.path.join(input_dir, name)
if not os.path.exists(target):
shutil.copy2(os.path.join(src_dir, name), target)
copied += 1
_log(f"example inputs ready ({copied} file(s) copied to {input_dir})")
STEPS = (
("base requirements", ensure_base_requirements),
("vggt (VideoPoseEstimator)", ensure_vggt),
("SHARP checkout (ImageToSplat)", ensure_sharp_checkout),
("gsplat (splat rasterizer)", ensure_gsplat),
("ComfyUI-Flux-Inpainting (OutpaintAnyProjection)", ensure_flux_inpainting_pack),
("example workflow inputs", ensure_example_inputs),
)
def main() -> int:
failures = []
for title, step in STEPS:
_log(f"--- {title} ---")
try:
step()
except Exception as exc: # keep going: each dependency is optional
failures.append((title, exc))
_log(f"WARNING: {title} failed: {exc}")
if failures:
_log("finished with warnings - the affected optional nodes stay disabled:")
for title, exc in failures:
_log(f" * {title}: {exc}")
_log("re-run `python install.py` after fixing the issue (network/git/pip)")
else:
_log("all optional dependencies are ready")
return 0
if __name__ == "__main__":
sys.exit(main())
+3 -2
View File
@@ -29,6 +29,9 @@ install_camera_node() {
git clone https://github.com/Alexankharin/camera-comfyUI.git \
ComfyUI/custom_nodes/camera-comfyUI
pip3 install -r ComfyUI/custom_nodes/camera-comfyUI/requirements.txt
# install.py sets up vggt, the SHARP submodule, gsplat and the
# inpainting_flux sibling pack (same script ComfyUI-Manager runs).
( cd ComfyUI/custom_nodes/camera-comfyUI && python3 install.py )
}
install_image_filters() {
@@ -133,7 +136,6 @@ case "$MODE" in
install_system_deps
clone_and_install_comfyui
install_camera_node
clone_flux_inpainting
install_image_filters
install_comfyui_manager
install_hf_hub
@@ -145,7 +147,6 @@ case "$MODE" in
install_system_deps
clone_and_install_comfyui
install_camera_node
clone_flux_inpainting
install_image_filters
install_comfyui_manager
install_hf_hub
+287
View File
@@ -0,0 +1,287 @@
"""Second July-2026 workflow pass: apply the improvements proposed in
docs/workflows_review.md.
1. Point every LoadImage at the bundled example inputs (example_inputs/ is
copied into ComfyUI's input dir by install.py), so workflows run on a
fresh install without hunting for files.
2. video_camera.json dependency trim: drop the dead-end BlurMaskFast
(Image-Filters), the easy-mathInt pad computation (Easy-Use) and the now
dangling VHS_VideoInfo; swap KJNodes' GetImageRangeFromBatch for the
built-in ImageFromBatch. Remaining pack deps: VHS + Florence2.
3. Create workflows/record_trajectory.json (TransformToMatrix x2 ->
CameraInterpolationNode -> SaveTrajectory) - the missing producer for the
trajectory files PC_enricher / wan_vace_ref_to_video consume.
4. Stamp `extra.camera_comfyui_rev = 1` in every workflow for future
migrations.
Run: python notebooks/apply_improvements_2026_07.py
"""
import glob
import json
import os
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
WF = os.path.join(REPO, "workflows")
PINHOLE = "camera_example_pinhole.jpg"
FISHEYE = "camera_example_fisheye.jpg"
# workflow file -> example image for its LoadImage node(s)
LOADIMAGE_DEFAULTS = {
"demo_camera_workflow.json": PINHOLE,
"Outpaint_node_test.json": PINHOLE,
"Outpaint_fisheye180.json": PINHOLE,
"outpainting_fisheye_flux.json": PINHOLE,
"legacy/outpainting_fisheye.json": PINHOLE,
"PointCloud.json": PINHOLE,
"pointcloud_walker.json": PINHOLE,
"pointcloud_inpaint.json": PINHOLE,
"fisheye_to_pointcloud.json": FISHEYE,
"legacy/Fisheye_depth_workflow.json": FISHEYE,
"PC_enricher.json": FISHEYE,
"sbs180_workflow.json": FISHEYE,
"wan_vace_ref_to_video.json": FISHEYE,
}
def load(rel):
with open(os.path.join(WF, rel), encoding="utf-8") as fh:
return json.load(fh)
def save(rel, data):
with open(os.path.join(WF, rel), "w", encoding="utf-8", newline="\n") as fh:
json.dump(data, fh, indent=2, ensure_ascii=False)
fh.write("\n")
def node(data, nid):
for n in data["nodes"]:
if n["id"] == nid:
return n
raise KeyError(nid)
def set_example_inputs():
for rel, image in LOADIMAGE_DEFAULTS.items():
d = load(rel)
changed = 0
for n in d["nodes"]:
if n["type"] == "LoadImage":
n["widgets_values"][0] = image
changed += 1
assert changed, f"{rel}: no LoadImage found"
save(rel, d)
print(f"[ok] {rel}: {changed} LoadImage -> {image}")
def remove_node(data, nid):
"""Remove a node and every link touching it; re-index dst slots."""
n = node(data, nid)
dead = set()
for inp in n.get("inputs") or []:
if inp.get("link") is not None:
dead.add(inp["link"])
for out in n.get("outputs") or []:
dead.update(out.get("links") or [])
data["nodes"] = [x for x in data["nodes"] if x["id"] != nid]
data["links"] = [l for l in data["links"] if l[0] not in dead]
for x in data["nodes"]:
for inp in x.get("inputs") or []:
if inp.get("link") in dead:
inp["link"] = None
for out in x.get("outputs") or []:
if out.get("links"):
out["links"] = [l for l in out["links"] if l not in dead]
def drop_input(data, nid, name):
"""Remove a (widget-converted) input socket and fix slot indices."""
n = node(data, nid)
inputs = n.get("inputs") or []
n["inputs"] = [i for i in inputs if i["name"] != name]
index = {i["name"]: k for k, i in enumerate(n["inputs"])}
for l in data["links"]:
if l[3] == nid:
# find by link id which input holds it
for i in n["inputs"]:
if i.get("link") == l[0]:
l[4] = index[i["name"]]
def bbox(data, ids, pad_top=60, pad=20):
xs, ys, xe, ye = [], [], [], []
for nid in ids:
n = node(data, nid)
p, s = n["pos"], n["size"]
xs.append(p[0]); ys.append(p[1])
xe.append(p[0] + s[0]); ye.append(p[1] + s[1])
x, y = min(xs) - pad, min(ys) - pad_top
return [x, y, max(xe) + pad - x, max(ye) + pad - y]
def fix_video_camera(rel="video_camera.json"):
d = load(rel)
# 1. dead-end mask blur (only Image-Filters usage; radius was 0/0 anyway)
remove_node(d, 60)
# 2. pad computation chain (Easy-Use mathInt x2 + VHS_VideoInfo feeding it);
# ImagePadForOutpaint falls back to its stored widgets (280/280)
for nid in (27, 26, 23):
remove_node(d, nid)
for name in ("top", "bottom"):
drop_input(d, 21, name)
n21 = node(d, 21)
n21["title"] = "Pad to square — set top/bottom to (W−H)/2"
# 3. KJNodes GetImageRangeFromBatch -> built-in ImageFromBatch (frame 0)
n63 = node(d, 63)
n63["type"] = "ImageFromBatch"
n63["properties"]["Node name for S&R"] = "ImageFromBatch"
n63["inputs"][0]["name"] = "image"
n63["widgets_values"] = [0, 1] # batch_index, length
n63["title"] = "First frame (for captioning)"
# regroup without the removed nodes
groups = [
("1. Load & pad video", [9, 21, 68]),
("2. Metric video depth", [19, 12]),
("3. Re-render with new camera", [16, 11, 10, 6]),
("4. Masks & composite", [39, 41, 75, 54, 74]),
("5. Auto-caption (Florence2)", [63, 62, 61]),
("6. WAN VACE re-generation", [29, 30, 37, 32, 33, 34, 28, 31, 36, 35]),
("7. Outputs", [4, 13, 14, 38]),
]
colors = ["#3f789e", "#a1309b", "#8A8", "#b58b2a", "#88A", "#b06634", "#535"]
d["groups"] = [{
"id": i + 1, "title": t, "bounding": bbox(d, ids),
"color": colors[i % len(colors)], "font_size": 24, "flags": {},
} for i, (t, ids) in enumerate(groups)]
# refresh the note (dependency list changed)
for n in d["nodes"]:
if n["type"] == "MarkdownNote" and n.get("title") == "About this workflow":
n["widgets_values"] = [(
"# Re-shoot a video with a new camera move\n\n"
"Re-renders an input video along a user-defined camera "
"trajectory and uses WAN 2.1 VACE to regenerate what the new "
"camera reveals:\n\n"
"1. Video is padded square (ImagePadForOutpaint — set "
"top/bottom to (width−height)/2 for your video) and "
"depth-estimated per frame (Video-Depth-Anything metric).\n"
"2. **VideoCameraMotionSequence** lifts each frame to a point "
"cloud and re-renders it along the SE(3)-interpolated "
"trajectory.\n"
"3. Disocclusion masks + re-rendered frames become VACE "
"control video/masks; **Florence2** auto-captions the clip as "
"the prompt; WAN 2.1 VACE 14B fills the gaps.\n\n"
"- **Set:** video path (VHS Load Video Path); the camera move "
"(two TransformToMatrix poses); pad amounts; override the "
"auto-caption in CLIPTextEncode if desired.\n"
"- **Requires (packs):** VideoHelperSuite, ComfyUI-Florence2.\n"
"- **Requires (models):** `metric_video_depth_anything_vitl"
".pth` (install.sh `depth`), WAN 2.1 VACE 14B + umt5-xxl + "
"WAN VAE (install.sh `vae`), Florence-2 (auto-download).\n"
"- **Outputs:** re-rendered composite WEBM, depth WEBM, final "
"VACE clip."
)]
save(rel, d)
print(f"[ok] {rel}: removed BlurMaskFast/mathInt/VideoInfo, "
f"ImageFromBatch swap, regrouped")
def make_record_trajectory(rel="record_trajectory.json"):
note = (
"# Record a camera trajectory\n\n"
"Produces the `.npy` trajectory file consumed by `PC_enricher.json` "
"and `wan_vace_ref_to_video.json` (LoadTrajectory): two poses are "
"SE(3)-interpolated into a smooth 20-step path and saved by "
"**SaveTrajectory** to your ComfyUI **output** directory.\n\n"
"- **Set:** the end pose (shift XYZ in scene units — metric if the "
"cloud came from metric depth — plus theta = pitch, phi = yaw) and "
"`num_steps`.\n"
"- **Then:** move the saved file from `output/` to `input/` so "
"LoadTrajectory can list it. A bundled example "
"(`ComfyUITrajectory_00001.npy`) is already installed by install.py.\n"
"- Chain more CameraInterpolationNode segments (or use "
"CameraTrajectoryNode on a point cloud) for multi-keyframe paths."
)
d = {
"id": "00000000-0000-0000-0000-000000000000",
"revision": 0,
"last_node_id": 5,
"last_link_id": 3,
"nodes": [
{
"id": 1, "type": "TransformToMatrix", "title": "Start pose (identity)",
"pos": [-500, 320], "size": [315, 154], "flags": {}, "order": 0,
"mode": 0, "inputs": [],
"outputs": [{"name": "transformation matrix", "type": "MAT_4X4",
"links": [1], "slot_index": 0}],
"properties": {"Node name for S&R": "TransformToMatrix"},
"widgets_values": [0.0, 0.0, 0.0, 0.0, 0.0],
},
{
"id": 2, "type": "TransformToMatrix", "title": "End pose (edit me)",
"pos": [-500, 540], "size": [315, 154], "flags": {}, "order": 1,
"mode": 0, "inputs": [],
"outputs": [{"name": "transformation matrix", "type": "MAT_4X4",
"links": [2], "slot_index": 0}],
"properties": {"Node name for S&R": "TransformToMatrix"},
"widgets_values": [0.0, 0.0, 0.3, 0.0, 30.0],
},
{
"id": 3, "type": "CameraInterpolationNode",
"title": "Interpolate 20 poses",
"pos": [-120, 430], "size": [226, 78], "flags": {}, "order": 2,
"mode": 0,
"inputs": [
{"name": "initial_matrix", "type": "MAT_4X4", "link": 1},
{"name": "final_matrix", "type": "MAT_4X4", "link": 2},
],
"outputs": [{"name": "trajectory", "type": "TENSOR",
"links": [3], "slot_index": 0}],
"properties": {"Node name for S&R": "CameraInterpolationNode"},
"widgets_values": [20],
},
{
"id": 4, "type": "SaveTrajectory", "title": "Save to output/*.npy",
"pos": [170, 430], "size": [315, 82], "flags": {}, "order": 3,
"mode": 0,
"inputs": [{"name": "trajectory", "type": "TENSOR", "link": 3}],
"outputs": [],
"properties": {"Node name for S&R": "SaveTrajectory"},
"widgets_values": ["ComfyUITrajectory"],
},
{
"id": 5, "type": "MarkdownNote", "title": "About this workflow",
"pos": [-1080, 320], "size": [520, 430], "flags": {}, "order": 4,
"mode": 0, "inputs": [], "outputs": [], "properties": {},
"widgets_values": [note], "color": "#432", "bgcolor": "#653",
},
],
"links": [
[1, 1, 0, 3, 0, "MAT_4X4"],
[2, 2, 0, 3, 1, "MAT_4X4"],
[3, 3, 0, 4, 0, "TENSOR"],
],
"groups": [],
"config": {},
"extra": {},
"version": 0.4,
}
save(rel, d)
print(f"[ok] created {rel}")
def stamp_revision():
for path in glob.glob(os.path.join(WF, "**", "*.json"), recursive=True):
rel = os.path.relpath(path, WF)
d = load(rel)
d.setdefault("extra", {})["camera_comfyui_rev"] = 1
save(rel, d)
print("[ok] stamped extra.camera_comfyui_rev = 1")
if __name__ == "__main__":
set_example_inputs()
fix_video_camera()
make_record_trajectory()
stamp_revision()
+294
View File
@@ -0,0 +1,294 @@
"""Build workflows/fisheye_static_video_to_4d.json.
Static-fisheye-camera variant of video_to_4d_world.json: because the camera
does not move, no VGGT pose estimation is needed — the trajectory is identity,
per-frame depth comes from the batched FisheyeDepthEstimator, and the whole
static background is a single FisheyeToGaussian prediction of frame 0 split by
the motion mask (outside = static world, inside = dynamic canonical).
Node facts this graph relies on (verified against current INPUT_TYPES):
- FisheyeDepthEstimator is batched: IMAGE [T,H,W,C] -> depthmap [T,H,W,1];
GS4D nodes squeeze the trailing channel (GS4D_nodes.py::167).
- MotionMaskFromDepth interpolates a [K,4,4] trajectory to T internally.
- TracksToTrajectories' trajectory input is optional and defaults to identity
("static camera" per its tooltip) — left unconnected on purpose.
- SplitSplatsByMask returns (inside_splats, outside_splats); its optional
camera_matrix defaults to identity, which is exactly the frame-0 camera here.
Run: python notebooks/build_fisheye_static_4d.py
"""
import json
import os
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OUT = os.path.join(REPO, "workflows", "fisheye_static_video_to_4d.json")
NODES = []
LINKS = []
_link_id = 0
def node(nid, ntype, title, pos, size, widgets, inputs=(), outputs=(), mode=0,
extra=None):
n = {
"id": nid, "type": ntype, "pos": list(pos), "size": list(size),
"flags": {}, "order": len(NODES), "mode": mode,
"inputs": [dict(i) for i in inputs],
"outputs": [
{"name": o[0], "type": o[1], "links": [], "slot_index": k}
for k, o in enumerate(outputs)
],
"properties": {"Node name for S&R": ntype},
"widgets_values": widgets,
}
if title:
n["title"] = title
if extra:
n.update(extra)
NODES.append(n)
return n
def link(src, src_slot, dst, dst_input, ltype):
global _link_id
_link_id += 1
sn = next(n for n in NODES if n["id"] == src)
dn = next(n for n in NODES if n["id"] == dst)
slot = next(i for i, inp in enumerate(dn["inputs"]) if inp["name"] == dst_input)
dn["inputs"][slot]["link"] = _link_id
sn["outputs"][src_slot]["links"].append(_link_id)
LINKS.append([_link_id, src, src_slot, dst, slot, ltype])
def inp(name, ltype):
return {"name": name, "type": ltype, "link": None}
NOTE = """# Static fisheye video → 4D Gaussian video
Turns a video shot on a **static 180° fisheye camera** (locked-off shot,
security cam, tripod) into a 4D (3D + time) Gaussian scene, then re-renders it
from a *moving* novel camera.
A static camera needs **no pose estimation** (no VGGT, unlike
`video_to_4d_world.json`): the trajectory is identity, and one frame already
sees the entire static background.
**Stages**
1. **FisheyeDepthEstimator** — per-frame metric *radial* depth on the whole
fisheye batch (DISTANCE_AWARE multi-view merge).
2. **MotionMaskFromDepth** (identity trajectory) — pixels whose depth changes
over time are dynamic.
3. **FisheyeToGaussian** on frame 0 → whole-scene splats;
**SplitSplatsByMask** (FISHEYE 180°) separates the dynamic canonical
(inside) from the static world (outside).
4. **EstimateTracks** (CoTracker3) + **TracksToTrajectories** (FISHEYE 180°,
identity poses — its default) lift 2D tracks to 3D control trajectories.
5. **BuildSplats4D** binds the canonical splats to the tracks → 4D scene with
the static background attached.
6. **RenderSplats4DVideo** renders a novel orbit (pinhole 90°). A second,
**muted** render replays the original static fisheye view for A/B
comparison — unmute it (Ctrl+M) to use.
**Set:** the video path + `frame_load_cap` (≤ 64 recommended); the novel-path
end pose; `threshold` in MotionMaskFromDepth (raise it if the mask flickers —
per-frame depth is not temporally consistent).
**Requires:** SHARP + gsplat (auto via install.py), CoTracker3 (torch.hub,
first-use download), Depth-Anything V2 (auto), VideoHelperSuite pack.
**Limitations:** the static world only knows what frame 0 saw — regions
occluded by moving objects at t=0 are holes if the novel camera peeks behind
them; strong depth flicker can leak static pixels into the dynamic set.
**Outputs:** novel-path WEBM, the 4D scene as `.npz` (SaveSplats4D — reload
with LoadSplats4D), optional replay WEBM."""
DA = "Depth-Anything-V2-Metric-Indoor-Base-hf"
# --------------------------------------------------------------------------- #
node(30, "MarkdownNote", "About this workflow", (-2000, 260), (560, 700), [NOTE],
extra={"color": "#432", "bgcolor": "#653"})
node(1, "VHS_LoadVideoPath", "Load fisheye video (static camera, <= 64 frames)",
(-1380, 300), (240, 262),
{
"video": "input/fisheye_video.mp4",
"force_rate": 0, "custom_width": 0, "custom_height": 0,
"frame_load_cap": 49, "skip_first_frames": 0, "select_every_nth": 1,
"format": "AnimateDiff",
"videopreview": {"hidden": False, "paused": False, "params": {
"filename": "input/fisheye_video.mp4", "type": "path",
"format": "video/mp4", "force_rate": 0, "custom_width": 0,
"custom_height": 0, "frame_load_cap": 49,
"skip_first_frames": 0, "select_every_nth": 1}},
},
inputs=[inp("meta_batch", "VHS_BatchManager"), inp("vae", "VAE")],
outputs=[("IMAGE", "IMAGE"), ("frame_count", "INT"),
("audio", "AUDIO"), ("video_info", "VHS_VIDEOINFO")])
node(2, "FisheyeDepthEstimator", "Per-frame radial depth (batched)",
(-1060, 300), (315, 246),
[DA, 1.0, 90.0, 518, 1024, "DISTANCE_AWARE", 25, 1],
inputs=[inp("image", "IMAGE")],
outputs=[("depthmap", "TENSOR"), ("mask", "MASK")])
node(3, "TransformToMatrix", "Static camera (identity pose)",
(-1060, 640), (315, 154), [0.0, 0.0, 0.0, 0.0, 0.0],
outputs=[("transformation matrix", "MAT_4X4")])
node(4, "CameraInterpolationNode", "Identity trajectory (interpolated to T)",
(-680, 680), (226, 78), [2],
inputs=[inp("initial_matrix", "MAT_4X4"), inp("final_matrix", "MAT_4X4")],
outputs=[("trajectory", "TENSOR")])
node(5, "MotionMaskFromDepth", "Dynamic-pixel mask (1 = moving)",
(-680, 300), (315, 202), ["FISHEYE", 180.0, 0.15, 4, 2, "auto"],
inputs=[inp("depth_seq", "TENSOR"), inp("trajectory", "TENSOR")],
outputs=[("motion_mask", "MASK")])
node(6, "ImageFromBatch", "Frame 0 (canonical view)",
(-680, 560), (226, 82), [0, 1],
inputs=[inp("image", "IMAGE")],
outputs=[("IMAGE", "IMAGE")])
node(7, "FisheyeToGaussian", "SHARP frame 0 -> whole-scene splats",
(-300, 460), (330, 290),
[180.0, 0, 0, "<download default>", "auto", 90.0, 0, "smart", 0.01, 5.0],
inputs=[inp("image", "IMAGE")],
outputs=[("splats", "GSPLAT")])
node(8, "EstimateTracks", "CoTracker3 (downloads on first use)",
(-300, 820), (300, 102), [20, "auto"],
inputs=[inp("frames", "IMAGE")],
outputs=[("tracks", "TENSOR"), ("visibility", "TENSOR")])
node(9, "SplitSplatsByMask", "Split: inside = dynamic, outside = static world",
(100, 300), (315, 174), ["FISHEYE", 180.0, 0.5, "auto"],
inputs=[inp("splats", "GSPLAT"), inp("mask", "MASK"),
inp("camera_matrix", "MAT_4X4")],
outputs=[("inside_splats", "GSPLAT"), ("outside_splats", "GSPLAT")])
node(10, "TracksToTrajectories", "Lift tracks to 3D (identity poses = default)",
(100, 700), (315, 190), ["FISHEYE", 180.0, 0.5, "auto"],
inputs=[inp("tracks", "TENSOR"), inp("visibility", "TENSOR"),
inp("depth_seq", "TENSOR"), inp("trajectory", "TENSOR")],
outputs=[("trajectories3d", "TENSOR"), ("track_valid", "TENSOR")])
node(11, "BuildSplats4D", "Bind canonical to tracks -> 4D scene",
(500, 440), (315, 190), [0, 4, 0.0, "auto"],
inputs=[inp("canonical", "GSPLAT"), inp("trajectories3d", "TENSOR"),
inp("static", "GSPLAT"), inp("times", "TENSOR"),
inp("track_valid", "TENSOR")],
outputs=[("splats4d", "GSPLAT4D")])
node(12, "TransformToMatrix", "Novel path: start (original camera)",
(500, 720), (315, 154), [0.0, 0.0, 0.0, 0.0, 0.0],
outputs=[("transformation matrix", "MAT_4X4")])
node(13, "TransformToMatrix", "Novel path: end (small orbit — edit me)",
(500, 920), (315, 154), [0.15, 0.0, 0.1, 0.0, -10.0],
outputs=[("transformation matrix", "MAT_4X4")])
node(14, "CameraInterpolationNode", "Novel camera path",
(880, 820), (226, 78), [2],
inputs=[inp("initial_matrix", "MAT_4X4"), inp("final_matrix", "MAT_4X4")],
outputs=[("trajectory", "TENSOR")])
node(15, "RenderSplats4DVideo", "Render 4D along novel path (pinhole 90°)",
(900, 300), (315, 266), [49, 0.0, 1.0, "PINHOLE", 90.0, 768, 768, "auto", 0, "auto"],
inputs=[inp("splats4d", "GSPLAT4D"), inp("trajectory", "TENSOR")],
outputs=[("images", "IMAGE"), ("masks", "MASK"), ("disparity", "TENSOR")])
node(16, "RenderSplats4DVideo", "MUTED: replay original fisheye view (A/B check)",
(900, 1060), (315, 266), [49, 0.0, 1.0, "FISHEYE", 180.0, 1024, 1024, "auto", 0, "auto"],
inputs=[inp("splats4d", "GSPLAT4D"), inp("trajectory", "TENSOR")],
outputs=[("images", "IMAGE"), ("masks", "MASK"), ("disparity", "TENSOR")],
mode=2)
node(17, "SaveWEBM", None, (1300, 300), (315, 437),
["4d_fisheye_novel", "vp9", 24, 32],
inputs=[inp("images", "IMAGE")])
node(18, "SaveSplats4D", "Save 4D scene (.npz)", (1300, 800), (315, 106),
["ComfyUISplat4D_fisheye", False],
inputs=[inp("splats4d", "GSPLAT4D")])
node(19, "SaveWEBM", "MUTED: replay output", (1300, 1060), (315, 437),
["4d_fisheye_replay", "vp9", 24, 32],
inputs=[inp("images", "IMAGE")], mode=2)
# --------------------------------------------------------------------------- #
link(1, 0, 2, "image", "IMAGE")
link(1, 0, 6, "image", "IMAGE")
link(1, 0, 8, "frames", "IMAGE")
link(2, 0, 5, "depth_seq", "TENSOR")
link(2, 0, 10, "depth_seq", "TENSOR")
link(3, 0, 4, "initial_matrix", "MAT_4X4")
link(3, 0, 4, "final_matrix", "MAT_4X4")
link(4, 0, 5, "trajectory", "TENSOR")
link(4, 0, 16, "trajectory", "TENSOR")
link(5, 0, 9, "mask", "MASK")
link(6, 0, 7, "image", "IMAGE")
link(7, 0, 9, "splats", "GSPLAT")
link(8, 0, 10, "tracks", "TENSOR")
link(8, 1, 10, "visibility", "TENSOR")
link(9, 0, 11, "canonical", "GSPLAT")
link(9, 1, 11, "static", "GSPLAT")
link(10, 0, 11, "trajectories3d", "TENSOR")
link(10, 1, 11, "track_valid", "TENSOR")
link(11, 0, 15, "splats4d", "GSPLAT4D")
link(11, 0, 16, "splats4d", "GSPLAT4D")
link(11, 0, 18, "splats4d", "GSPLAT4D")
link(12, 0, 14, "initial_matrix", "MAT_4X4")
link(13, 0, 14, "final_matrix", "MAT_4X4")
link(14, 0, 15, "trajectory", "TENSOR")
link(15, 0, 17, "images", "IMAGE")
link(16, 0, 19, "images", "IMAGE")
# NOTE: TracksToTrajectories.trajectory stays unconnected on purpose — its
# default is identity poses, i.e. exactly the static camera.
def bbox(ids, pad_top=60, pad=20):
ns = [n for n in NODES if n["id"] in ids]
x = min(n["pos"][0] for n in ns) - pad
y = min(n["pos"][1] for n in ns) - pad_top
x2 = max(n["pos"][0] + n["size"][0] for n in ns) + pad
y2 = max(n["pos"][1] + n["size"][1] for n in ns) + pad
return [x, y, x2 - x, y2 - y]
GROUPS = [
("1. Load fisheye video", [1]),
("2. Per-frame fisheye depth", [2]),
("3. Static camera trajectory", [3, 4]),
("4. Motion mask", [5]),
("5. Frame-0 splats & static/dynamic split", [6, 7, 9]),
("6. Dynamic tracks -> 3D", [8, 10]),
("7. 4D scene", [11, 18]),
("8. Render novel path (+ muted replay)", [12, 13, 14, 15, 16, 17, 19]),
]
COLORS = ["#3f789e", "#a1309b", "#8A8", "#b58b2a", "#88A", "#b06634", "#535",
"#3f789e"]
workflow = {
"id": "00000000-0000-0000-0000-000000000000",
"revision": 0,
"last_node_id": 30,
"last_link_id": _link_id,
"nodes": NODES,
"links": LINKS,
"groups": [{
"id": i + 1, "title": t, "bounding": bbox(ids),
"color": COLORS[i % len(COLORS)], "font_size": 24, "flags": {},
} for i, (t, ids) in enumerate(GROUPS)],
"config": {},
"extra": {"camera_comfyui_rev": 1},
"version": 0.4,
}
with open(OUT, "w", encoding="utf-8", newline="\n") as fh:
json.dump(workflow, fh, indent=2, ensure_ascii=False)
fh.write("\n")
print(f"wrote {OUT}: {len(NODES)} nodes, {len(LINKS)} links")
+699
View File
@@ -0,0 +1,699 @@
"""One-shot July 2026 workflow rework: schema repairs + documentation.
For every workflows/*.json this script:
1. migrates widgets_values stored with pre-2026 node schemas to the current
widget lists (see notebooks/validate_workflows.py for the detector);
2. fixes broken references (wan UNET filename typo, ReprojectImage rotation
widgets that no longer exist -> explicit TransformToMatrix nodes);
3. adds an embedded MarkdownNote explaining purpose/stages/requirements,
meaningful group boxes and node titles;
4. rewrites the JSON pretty-printed (indent 2).
Idempotent-ish: repairs are guarded by length/value checks, notes are only
added if no MarkdownNote titled 'About this workflow' exists.
Run: python notebooks/rework_workflows_2026_07.py
"""
import json
import os
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
WF = os.path.join(REPO, "workflows")
DA_MODEL = "Depth-Anything-V2-Metric-Indoor-Base-hf"
NOTE_TITLE = "About this workflow"
# --------------------------------------------------------------------------- #
# Generic helpers
# --------------------------------------------------------------------------- #
def load(name):
with open(os.path.join(WF, name), encoding="utf-8") as fh:
return json.load(fh)
def save(name, data):
# normalize pos/size dict-form (pre-2024 serialization) to arrays
for n in data.get("nodes", []):
for key in ("pos", "size"):
v = n.get(key)
if isinstance(v, dict):
n[key] = [v[k] for k in sorted(v, key=lambda s: int(s))]
with open(os.path.join(WF, name), "w", encoding="utf-8", newline="\n") as fh:
json.dump(data, fh, indent=2, ensure_ascii=False)
fh.write("\n")
def node(data, nid):
for n in data["nodes"]:
if n["id"] == nid:
return n
raise KeyError(f"node {nid} not found")
def xy(v):
if isinstance(v, dict):
return [float(v["0"]), float(v["1"])]
return [float(v[0]), float(v[1])]
def bbox(data, ids, pad_top=60, pad=20):
xs, ys, xe, ye = [], [], [], []
for nid in ids:
n = node(data, nid)
p, s = xy(n["pos"]), xy(n["size"])
xs.append(p[0]); ys.append(p[1])
xe.append(p[0] + s[0]); ye.append(p[1] + s[1])
x, y = min(xs) - pad, min(ys) - pad_top
return [x, y, max(xe) + pad - x, max(ye) + pad - y]
GROUP_COLORS = ["#3f789e", "#a1309b", "#8A8", "#b58b2a", "#88A", "#b06634", "#535"]
def set_groups(data, groups):
"""groups: list of (title, node_ids). Replaces the groups list."""
out = []
for i, (title, ids) in enumerate(groups):
out.append({
"id": i + 1,
"title": title,
"bounding": bbox(data, ids),
"color": GROUP_COLORS[i % len(GROUP_COLORS)],
"font_size": 24,
"flags": {},
})
data["groups"] = out
def retitle_groups(data, titles):
"""titles: list matching data['groups'] order."""
assert len(titles) == len(data.get("groups", [])), "group count mismatch"
for g, t in zip(data["groups"], titles):
g["title"] = t
def set_title(data, nid, title):
node(data, nid)["title"] = title
def next_node_id(data):
data["last_node_id"] = int(data.get("last_node_id", 0)) + 1
return data["last_node_id"]
def next_link_id(data):
data["last_link_id"] = int(data.get("last_link_id", 0)) + 1
return data["last_link_id"]
def add_note(data, markdown, pos=None, size=None):
for n in data["nodes"]:
if n["type"] in ("MarkdownNote", "Note") and n.get("title") == NOTE_TITLE:
n["widgets_values"] = [markdown]
return n["id"]
if pos is None:
xs = [xy(n["pos"])[0] for n in data["nodes"]]
ys = [xy(n["pos"])[1] for n in data["nodes"]]
pos = [min(xs) - 560, min(ys)]
if size is None:
lines = markdown.count("\n") + 1
size = [520, max(220, min(700, 26 * lines + 80))]
nid = next_node_id(data)
data["nodes"].append({
"id": nid,
"type": "MarkdownNote",
"title": NOTE_TITLE,
"pos": pos,
"size": size,
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {},
"widgets_values": [markdown],
"color": "#432",
"bgcolor": "#653",
})
return nid
def add_transform_node(data, pos, widgets):
nid = next_node_id(data)
data["nodes"].append({
"id": nid,
"type": "TransformToMatrix",
"pos": pos,
"size": [315, 154],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [{"name": "transformation matrix", "type": "MAT_4X4",
"links": [], "slot_index": 0}],
"properties": {"Node name for S&R": "TransformToMatrix"},
"widgets_values": widgets,
})
return nid
def link(data, src, dst, dst_input, ltype="MAT_4X4", src_slot=0):
"""Connect src node output slot to dst node's named input (added if absent)."""
dnode = node(data, dst)
inputs = dnode.setdefault("inputs", [])
slot = None
for i, inp in enumerate(inputs):
if inp["name"] == dst_input:
slot = i
break
if slot is None:
inputs.append({"name": dst_input, "type": ltype, "link": None})
slot = len(inputs) - 1
lid = next_link_id(data)
inputs[slot]["link"] = lid
snode = node(data, src)
snode["outputs"][src_slot].setdefault("links", None)
if snode["outputs"][src_slot]["links"] is None:
snode["outputs"][src_slot]["links"] = []
snode["outputs"][src_slot]["links"].append(lid)
data["links"].append([lid, src, src_slot, dst, slot, ltype])
return lid
# --------------------------------------------------------------------------- #
# Global widget-schema repairs (2025 -> 2026 node schemas)
# --------------------------------------------------------------------------- #
def repair_widgets(data):
changed = []
for n in data["nodes"]:
t, w = n["type"], n.get("widgets_values")
if not isinstance(w, list):
continue
if t == "DepthEstimatorNode" and len(w) == 2:
n["widgets_values"] = w + [1] # median_blur_kernel
elif t == "FisheyeDepthEstimator" and len(w) == 6:
# old: [model, scale, pfov, pres, fres, softmerge_radius]
n["widgets_values"] = w[:5] + ["SOFTMERGE", w[5], 1]
elif t == "PointCloudCleaner" and len(w) == 2:
# old (voxel_size, min_points) were world-unit; semantics changed
n["widgets_values"] = [1024, 1024, 1.0, 3]
elif t == "CameraMotionNode" and len(w) == 6:
n["widgets_values"] = w + [0, False, False]
elif t == "CameraInterpolationNode" and len(w) == 0:
n["widgets_values"] = [2] # keyframes; renderers interpolate frames
elif t == "PointcloudTrajectoryEnricher" and len(w) == 20:
# old tail: reproject-back proj/fov/w/h + legacy backend/voxel params
n["widgets_values"] = w[:13] + [0.07, 3, DA_MODEL]
else:
continue
changed.append(f"{t}#{n['id']}")
return changed
# --------------------------------------------------------------------------- #
# Per-file specs
# --------------------------------------------------------------------------- #
def do_demo_camera(name="demo_camera_workflow.json"):
d = load(name)
repair_widgets(d)
set_title(d, 1, "Rotate camera (pitch 60°)")
set_title(d, 2, "Pinhole 90° → Equirect 180°")
set_title(d, 7, "Reprojected image")
set_title(d, 4, "Coverage mask (white = hole)")
add_note(d, (
"# Camera reprojection demo\n\n"
"Minimal example of the two core nodes: **TransformToMatrix** rotates the "
"virtual camera (theta = 60° pitch) and **ReprojectImage** converts a 90° "
"pinhole image into a 180° equirectangular view.\n\n"
"Previews show the reprojected image and the coverage mask "
"(white = pixels the source image cannot see).\n\n"
"- **Set:** the image in LoadImage.\n"
"- **Requires:** nothing beyond this pack (no models).\n"
"- **Try:** switch `output_projection` to FISHEYE, or raise `feathering` "
"to soften the mask edge."
))
save(name, d)
def do_outpaint_node_test(name="Outpaint_node_test.json"):
d = load(name)
repair_widgets(d)
set_title(d, 11, "Outpaint one patch (yaw +45°)")
set_title(d, 3, "Result")
set_title(d, 5, "Holes still to fill")
add_note(d, (
"# OutpaintAnyProjection smoke test\n\n"
"Single-node sanity check: a 90° pinhole image is placed on a 180° fisheye "
"canvas and one 90° pinhole patch at yaw +45° is Flux-inpainted "
"(10 steps for speed).\n\n"
"Outputs: the partially outpainted canvas and the *remaining holes* mask — "
"chain more OutpaintAnyProjection nodes at other angles to fill it "
"(see `Outpaint_fisheye180.json`).\n\n"
"- **Set:** input image; prompt inside the node (empty = unconditional).\n"
"- **Requires:** `custom_nodes/inpainting_flux` "
"(installed automatically by this pack's install.py) — downloads "
"FLUX.1-Fill NF4 weights on first run."
))
save(name, d)
def do_fisheye_to_pointcloud(name="fisheye_to_pointcloud.json"):
d = load(name)
repair_widgets(d)
set_title(d, 155, "Metric depth (fisheye-aware)")
set_title(d, 160, "Depth → point cloud")
set_title(d, 161, "Save .ply / .npy")
set_groups(d, [
("1. Fisheye metric depth", [154, 155, 156, 157, 158, 159]),
("2. Unproject & save", [160, 161]),
])
add_note(d, (
"# Fisheye 180° → point cloud\n\n"
"Estimates metric depth directly on a 180° fisheye image — "
"**FisheyeDepthEstimator** internally splits it into pinhole views, runs "
"Depth-Anything V2 on each and merges the depths back (SOFTMERGE) — then "
"unprojects image + depth to a 3D point cloud and saves it.\n\n"
"Previews: colorized depth and the validity mask.\n\n"
"- **Set:** fisheye input image (e.g. produced by "
"`Outpaint_fisheye180.json`); filename in SavePointCloud.\n"
"- **Requires:** Depth-Anything V2 (auto-downloads from HuggingFace).\n"
"- **Next:** view the cloud with `test_pointcloud_loading.json` or "
"synthesize a second eye with `sbs180_workflow.json`."
))
save(name, d)
def do_pointcloud(name="PointCloud.json"):
d = load(name)
repair_widgets(d)
set_title(d, 6, "Move camera (dolly −0.1, yaw 20°)")
set_title(d, 31, "Render novel view")
set_title(d, 47, "Drop stretched/occluded points")
set_title(d, 43, "Novel-view depth → image")
set_groups(d, [
("1. Image → metric ray depth", [1, 38, 45, 42, 40]),
("2. Lift to 3D & move camera", [18, 6, 9, 47]),
("3. Novel view & previews", [31, 13, 12, 43, 44, 25, 26]),
])
add_note(d, (
"# Single image → point cloud → novel view\n\n"
"Lifts one pinhole image to a 3D point cloud via monocular metric depth, "
"moves the camera, cleans occlusion artifacts and re-renders from the new "
"viewpoint.\n\n"
"Stages: DepthEstimator → ZDepthToRayDepth → DepthToPointCloud → "
"TransformPointCloud (dolly −0.1, yaw 20°) → PointCloudCleaner → "
"ProjectPointCloud. MedianFilterImage smooths the re-render "
"(from ComfyUI-Image-Filters, optional).\n\n"
"- **Set:** input image; the camera move in TransformToMatrix.\n"
"- **Requires:** Depth-Anything V2 (auto-download); ComfyUI-Image-Filters "
"only for the median-filter preview.\n"
"- **Note:** PointCloudCleaner params were reset to defaults during the "
"2026-07 schema migration — retune voxel_size / min_points_per_voxel if "
"the render looks too sparse."
))
save(name, d)
def do_pointcloud_walker(name="Pointcloud walker.json"):
d = load(name)
repair_widgets(d)
set_title(d, 5, "Camera move (edit me)")
set_title(d, 17, "Build trajectory")
set_title(d, 14, "Render fly-through")
set_groups(d, [
("1. Image → point cloud", [1, 3, 20, 2]),
("2. Trajectory", [5, 4, 17]),
("3. Render & save", [14, 10]),
])
add_note(d, (
"# Point cloud walker (fly-through video)\n\n"
"Single image → metric depth → point cloud, then **CameraTrajectoryNode** "
"derives a camera path and **CameraMotionNode** renders a fly-through "
"saved as WEBM.\n\n"
"- **Set:** input image; the move in TransformToMatrix "
"(shift XYZ + theta/phi); frames-per-segment (`n_points`) in "
"CameraMotionNode.\n"
"- **Requires:** Depth-Anything V2 (auto-download).\n"
"- **Note:** the pinhole FOV here is 60° — keep DepthToPointCloud and "
"CameraMotionNode FOVs consistent with your input."
))
save(name, d)
def do_test_pointcloud_loading(name="Test pointcloud_loading.json"):
d = load(name)
repair_widgets(d)
set_title(d, 6, "Load saved cloud (.ply/.npy)")
set_title(d, 1, "Start pose (identity)")
set_title(d, 5, "End pose (dolly +0.1)")
set_title(d, 7, "Render motion")
add_note(d, (
"# Load a saved point cloud & orbit\n\n"
"Reloads a point cloud saved by SavePointCloud and renders a short camera "
"move between two poses (identity → 0.1 forward) as a WEBM.\n\n"
"- **Set:** the file in LoadPointCloud (dropdown lists the ComfyUI input "
"dir — run `PointCloud.json` or `fisheye_to_pointcloud.json` first); the "
"two TransformToMatrix poses.\n"
"- **Requires:** nothing beyond this pack."
))
save(name, d)
def do_pc_enricher(name="PC_enricher.json"):
d = load(name)
repair_widgets(d)
set_title(d, 3, "Enrich cloud along trajectory (Flux outpaint)")
set_title(d, 11, "Orbit preview of enriched cloud")
set_groups(d, [
("1. Fisheye → point cloud", [13, 14, 17, 16, 15]),
("2. Enrich along trajectory", [18, 3]),
("3. Save & preview", [4, 11, 12]),
])
add_note(d, (
"# Point cloud enricher (outpaint along a trajectory)\n\n"
"Fisheye image → metric depth → point cloud, then "
"**PointcloudTrajectoryEnricher** walks the loaded camera trajectory: at "
"each pose it renders the cloud, Flux-inpaints the disocclusion holes, "
"re-estimates depth, aligns it and merges the new points into the cloud. "
"The enriched cloud is saved and previewed as an orbit video.\n\n"
"This is the one-node version of `pointcloud_inpaint.json`.\n\n"
"- **Set:** input image; trajectory .npy (record one with "
"SaveTrajectory); prompt inside the enricher.\n"
"- **Requires:** inpainting_flux (auto-installed), Depth-Anything V2, "
"FLUX.1-Fill NF4 weights.\n"
"- **Note:** 2026-07 schema migration — the enricher's render/reproject "
"options are now internal; voxel merge params were reset to defaults."
))
save(name, d)
def do_fisheye_depth(name="Fisheye_depth_workflow.json"):
d = load(name)
repair_widgets(d)
retitle_groups(d, [
"View 1: center pinhole 90°",
"View 2: yaw +45°",
"View 3: yaw −45°",
"View 4: pitch +45°",
"View 5: pitch −45°",
"View 6: full-fisheye fallback",
])
# fusion chain + export were never grouped
d["groups"].append({
"id": 7,
"title": "Fuse views & export point cloud",
"bounding": bbox(d, [95, 146, 147, 148, 149, 105, 151, 153, 154]),
"color": "#b58b2a",
"font_size": 24,
"flags": {},
})
set_title(d, 149, "Final fuse (SRC keeps fused views)")
set_title(d, 151, "Fused depth → point cloud")
add_note(d, (
"# Multi-view fisheye depth — manual reference\n\n"
"Estimates consistent metric depth over a 180° fisheye image by "
"reprojecting it into five pinhole views (center, yaw ±45°, pitch ±45°), "
"running Depth-Anything V2 on each, reprojecting the depths back to the "
"fisheye and progressively fusing them (CombineDepthsNode + "
"DepthRenormalizer to align scales), plus a full-fisheye pass for the "
"rim. The fused depth is unprojected and saved as a point cloud.\n\n"
"⚠️ **This whole graph is now one node** — `FisheyeDepthEstimator` does "
"the same split-and-merge internally (see `fisheye_to_pointcloud.json`). "
"Kept as a transparent, tweakable reference implementation.\n\n"
"- **Set:** fisheye input image; SavePointCloud filename.\n"
"- **Requires:** Depth-Anything V2 (auto-download)."
))
save(name, d)
def do_outpaint_fisheye180(name="Outpaint_fisheye180.json"):
d = load(name)
repair_widgets(d)
retitle_groups(d, [
"2. Chained patch outpaints",
"1. Pinhole → fisheye canvas",
])
set_title(d, 2, "Outpaint yaw +45°")
set_title(d, 3, "Outpaint yaw −45°")
set_title(d, 4, "Outpaint pitch +45°")
set_title(d, 5, "Outpaint pitch −45°")
set_title(d, 9, "Full-frame pass (low-res rim fill)")
set_title(d, 15, "Upscale rim fill to 4096")
set_title(d, 12, "Composite sharp patches over rim")
add_note(d, (
"# Outpaint to a full 180° fisheye\n\n"
"Places a 90° pinhole image onto a 180° fisheye canvas (ReprojectImage), "
"then chains **five OutpaintAnyProjection passes** — yaw +45°, yaw −45°, "
"pitch +45°, pitch −45°, and a low-res full-frame pass for the rim. Each "
"pass consumes the previous pass's *remaining holes* mask. A PorterDuff "
"composite keeps the sharp high-res patches on top of the upscaled rim "
"fill.\n\n"
"- **Set:** input image; prompts inside each outpaint node (optional).\n"
"- **Requires:** inpainting_flux (auto-installed by install.py); "
"FLUX.1-Fill NF4 weights download on first run (~12 GB VRAM).\n"
"- **Output:** `Saved_fisheye` PNG — used as the input of "
"`fisheye_to_pointcloud.json`, `sbs180_workflow.json` and "
"`PC_enricher.json`."
))
save(name, d)
def do_outpainting_fisheye_sd(name="outpainting_fisheye.json"):
d = load(name)
repair_widgets(d)
# --- structural repair: pre-2025 ReprojectImage stored patch rotations as
# widgets; the current node takes a MAT_4X4 transform_matrix input and an
# inverse flag instead (mirrors outpainting_fisheye_flux.json).
fixes = {
42: ([90, 180, "PINHOLE", "FISHEYE", 4096, 4096, False, 0], None),
40: ([180, 90, "FISHEYE", "PINHOLE", 1024, 1024, False, 0], "+42"),
51: ([90, 180, "PINHOLE", "FISHEYE", 4096, 4096, True, 0], "+42"),
59: ([180, 90, "FISHEYE", "PINHOLE", 1024, 1024, False, 0], "-42"),
63: ([90, 180, "PINHOLE", "FISHEYE", 4096, 4096, True, 0], "-42"),
67: ([180, 180, "FISHEYE", "FISHEYE", 1024, 1024, False, 0], None),
72: ([180, 180, "FISHEYE", "FISHEYE", 4096, 4096, False, 0], None),
}
needs_repair = any(len(node(d, nid).get("widgets_values", [])) == 9
for nid in fixes)
if needs_repair:
m_pos = add_transform_node(d, [20, 480], [0, 0, 0, 0, 42])
m_neg = add_transform_node(d, [20, 1180], [0, 0, 0, 0, -42])
set_title(d, m_pos, "Patch rotation +42°")
set_title(d, m_neg, "Patch rotation −42°")
for nid, (widgets, mat) in fixes.items():
node(d, nid)["widgets_values"] = widgets
if mat is not None:
link(d, m_pos if mat == "+42" else m_neg, nid, "transform_matrix")
retitle_groups(d, [
"Patch 1: yaw +42° — extract & inpaint-encode",
"Patch 2: yaw −42° — extract & inpaint-encode",
])
set_title(d, 42, "Pinhole 90° → fisheye canvas")
set_title(d, 67, "Full-frame pass (low-res)")
add_note(d, (
"# Outpaint fisheye — SD-inpaint-checkpoint variant\n\n"
"Same pipeline as `outpainting_fisheye_flux.json`, but the holes are "
"filled with a classic SD inpainting checkpoint (VAEEncodeForInpaint + "
"KSampler) instead of Flux: pinhole 90° → 180° fisheye canvas, two ±42° "
"pinhole patches and a final full-frame pass are inpainted and "
"composited; RealESRGAN upscales the result.\n\n"
"- **Set:** input image; positive/negative prompts.\n"
"- **Requires (models):** `512-inpainting-ema.safetensors`, "
"`RealESRGAN_x4plus.pth`. No custom node packs.\n"
"- **Repaired 2026-07:** patch rotations were stored in a pre-2025 "
"ReprojectImage schema; they are now explicit TransformToMatrix (±42°) "
"nodes + `inverse` flags. Prefer the Flux variant for quality."
))
save(name, d)
def do_outpainting_fisheye_flux(name="outpainting_fisheye_flux.json"):
d = load(name)
repair_widgets(d)
for nid, inverse in ((42, False), (40, False), (51, True), (80, False),
(63, True), (67, False), (72, False)):
w = node(d, nid)["widgets_values"]
if len(w) == 8:
w[6] = inverse # was stored as 0/45/true mixtures
retitle_groups(d, [
"Patch 1: yaw +42° — extract & Flux inpaint",
"Patch 2: yaw −42° — extract & Flux inpaint",
])
set_title(d, 42, "Pinhole 90° → fisheye canvas")
set_title(d, 75, "Patch rotation +42°")
set_title(d, 76, "Patch rotation −42°")
set_title(d, 67, "Full-frame pass (low-res)")
set_title(d, 77, "Upscale full pass to 4096")
add_note(d, (
"# Outpaint fisheye — Flux variant\n\n"
"Pinhole 90° image → 180° fisheye canvas; **Flux Inpainting** fills two "
"90° pinhole patches (rotations ±42° from the TransformToMatrix nodes) "
"and one low-res full-frame fisheye pass; PorterDuff composites + "
"RealESRGAN upscale assemble the final 4096² fisheye "
"(saved as `fluxfish`).\n\n"
"This is the manual, step-visible version of what "
"**OutpaintAnyProjection** does in one node — see "
"`Outpaint_fisheye180.json`.\n\n"
"- **Set:** input image; prompts in the three Flux Inpainting nodes.\n"
"- **Requires:** inpainting_flux (auto-installed), FLUX.1-Fill NF4 "
"weights (first-run download), `RealESRGAN_x4plus.pth`."
))
save(name, d)
def do_sbs180(name="sbs180_workflow.json"):
d = load(name)
repair_widgets(d)
retitle_groups(d, [
"1. Fisheye metric depth",
"2. Point cloud & eye-baseline shift",
"3. Outpaint disocclusions & export equirect",
"Previews",
])
set_title(d, 33, "Eye baseline (shiftX 0.1)")
set_title(d, 37, "Right eye (equirect)")
set_title(d, 44, "Left eye (equirect)")
add_note(d, (
"# SBS VR180: synthesize the second eye\n\n"
"From one 180° fisheye view, synthesizes a stereo pair: fisheye metric "
"depth → point cloud → clean → shift the camera by the eye baseline "
"(TransformToMatrix shiftX = 0.1) → re-project to fisheye → four chained "
"OutpaintAnyProjection passes fill the disocclusions → both eyes are "
"exported as 180° equirectangular images.\n\n"
"- **Set:** fisheye input (e.g. from `Outpaint_fisheye180.json`); the "
"baseline (0.1 ≈ 6.5 cm when depth is metric); prompts optional.\n"
"- **Requires:** inpainting_flux (auto-installed), Depth-Anything V2.\n"
"- **Output:** `init_camera_equirect` + `shifted_camera_equirect` — "
"combine side-by-side for a VR180 player."
))
save(name, d)
def do_pointcloud_inpaint(name="pointcloud_inpaint.json"):
d = load(name)
repair_widgets(d)
set_title(d, 6, "Camera shift (dolly −0.1)")
set_title(d, 43, "Flux inpaint holes")
set_title(d, 54, "Align new depth to cloud")
set_title(d, 55, "Merge old + new points")
set_groups(d, [
("1. Image → point cloud", [1, 46, 18]),
("2. Novel view & hole mask", [6, 9, 10, 45, 27, 35, 48, 47]),
("3. Flux inpaint", [43, 26]),
("4. Lift inpainted region & merge", [49, 54, 50, 55]),
("5. Orbit render", [61, 67, 68, 69, 63]),
])
add_note(d, (
"# Iterative point-cloud inpainting\n\n"
"Enriches a single-image point cloud with generated content: move the "
"camera back → render the cloud (holes appear) → grow + invert the "
"coverage mask → **Flux-inpaint the holes** → re-estimate depth on the "
"inpainted image → **DepthRenormalizer** aligns it to the original "
"cloud's depth → lift the new pixels to 3D → **PointCloudUnion** merges "
"everything → orbit render of the enriched scene.\n\n"
"One-node alternative: PointcloudTrajectoryEnricher "
"(`PC_enricher.json`).\n\n"
"- **Set:** input image; camera shift; inpaint prompt.\n"
"- **Requires:** inpainting_flux (auto-installed), Depth-Anything V2."
))
save(name, d)
def do_video_camera(name="video_camera.json"):
d = load(name)
repair_widgets(d)
set_groups(d, [
("1. Load & pad video", [9, 23, 26, 27, 21, 68]),
("2. Metric video depth", [19, 12]),
("3. Re-render with new camera", [16, 11, 10, 6]),
("4. Masks & composite", [39, 41, 60, 75, 54, 74]),
("5. Auto-caption (Florence2)", [63, 62, 61]),
("6. WAN VACE re-generation", [29, 30, 37, 32, 33, 34, 28, 31, 36, 35]),
("7. Outputs", [4, 13, 14, 38]),
])
set_title(d, 11, "Final camera pose (edit me)")
set_title(d, 6, "Re-render along trajectory")
set_title(d, 28, "WAN VACE control")
add_note(d, (
"# Re-shoot a video with a new camera move\n\n"
"Re-renders an input video along a user-defined camera trajectory and "
"uses WAN 2.1 VACE to regenerate what the new camera reveals:\n\n"
"1. Video is padded square and depth-estimated per frame "
"(Video-Depth-Anything metric).\n"
"2. **VideoCameraMotionSequence** lifts each frame to a point cloud and "
"re-renders it along the SE(3)-interpolated trajectory.\n"
"3. Disocclusion masks + the re-rendered frames become VACE "
"control video/masks; **Florence2** auto-captions the clip as the "
"prompt; WAN 2.1 VACE 14B fills the gaps.\n\n"
"- **Set:** video path (VHS Load Video Path); the camera move "
"(two TransformToMatrix poses); override the auto-caption in "
"CLIPTextEncode if desired.\n"
"- **Requires (packs):** VideoHelperSuite, ComfyUI-Florence2, KJNodes "
"(GetImageRangeFromBatch), ComfyUI-Easy-Use (math), ComfyUI-Image-"
"Filters (BlurMaskFast).\n"
"- **Requires (models):** `metric_video_depth_anything_vitl.pth` "
"(install.sh `depth`), WAN 2.1 VACE 14B + umt5-xxl + WAN VAE "
"(install.sh `vae`), Florence-2 (auto-download).\n"
"- **Outputs:** re-rendered composite WEBM, depth WEBM, final VACE clip."
))
save(name, d)
def do_wan_vace_ref(name="wan_vace_ref_to_video.json"):
d = load(name)
repair_widgets(d)
# broken model filename: comma + missing underscore
n37 = node(d, 37)
if n37["widgets_values"][0] == "wan2,1_vace14B_fp16.safetensors":
n37["widgets_values"][0] = "wan2.1_vace_14B_fp16.safetensors"
set_title(d, 70, "Render control frames + masks")
set_title(d, 55, "WAN VACE control")
set_groups(d, [
("1. Fisheye image → point cloud", [52, 62, 83, 82, 81, 63, 84]),
("2. Camera-motion control video", [77, 70, 78, 80]),
("3. WAN VACE generation", [37, 54, 38, 6, 7, 39, 55, 3, 56, 8]),
("4. Save", [60, 85, 58]),
])
add_note(d, (
"# WAN VACE: still image + camera move → video\n\n"
"Turns a single 180° fisheye still into a camera-move video: metric "
"depth → point cloud → **CameraMotionNode** renders point-splat frames "
"and masks along a loaded trajectory; these become the VACE control "
"video/masks with the original image as the reference, and WAN 2.1 VACE "
"14B synthesizes the final clip from your prompt.\n\n"
"- **Set:** fisheye image; trajectory file (record one with "
"SaveTrajectory); the positive prompt.\n"
"- **Requires:** WAN 2.1 VACE models (install.sh `vae`), Depth-Anything "
"V2, VideoHelperSuite (only for the h264/MP4 export — SaveWEBM works "
"without it).\n"
"- **Fixed 2026-07:** the UNET filename contained a typo "
"(`wan2,1_vace14B` → `wan2.1_vace_14B_fp16.safetensors`)."
))
save(name, d)
def main():
for fn in (
do_demo_camera, do_outpaint_node_test, do_fisheye_to_pointcloud,
do_pointcloud, do_pointcloud_walker, do_test_pointcloud_loading,
do_pc_enricher, do_fisheye_depth, do_outpaint_fisheye180,
do_outpainting_fisheye_sd, do_outpainting_fisheye_flux, do_sbs180,
do_pointcloud_inpaint, do_video_camera, do_wan_vace_ref,
):
fn()
print(f"[ok] {fn.__name__}")
# video_to_4d_world / video_to_4d_walkable_world already have notes+groups;
# normalize formatting only.
for name in ("video_to_4d_world.json", "video_to_4d_walkable_world.json"):
save(name, load(name))
print(f"[ok] reformat {name}")
if __name__ == "__main__":
main()
+59
View File
@@ -18,6 +18,7 @@ with small synthetic data:
8. align_depth_scale (world_nodes, contract C4) + DepthEdgeFilter
9. FuseSplats weighted voxel fusion
10. SphereSplatSeed pano -> splat sphere -> render round-trip
11. Fisheye image-circle exclusion (motion mask / tracks / splat split)
"""
import math
@@ -445,6 +446,63 @@ def test_10_sphere_splat_seed():
# --------------------------------------------------------------------------- #
# Runner
# --------------------------------------------------------------------------- #
def test_11_fisheye_circle_exclusion():
"""Fisheye pixels/points outside the image circle (r > 1) must be ignored:
corners never become 'dynamic', corner tracks never lift to 3D, and points
at view angles beyond fov/2 are not matched against the mask."""
T, H, W = 6, 32, 32
fov = 180.0
# --- MotionMaskFromDepth: wildly flickering corners must stay static ----
depth = torch.full((T, H, W), 5.0)
depth[:, 14:18, 14:18] = torch.linspace(5.0, 2.0, T).view(T, 1, 1) # real motion
u = torch.linspace(-1, 1, W).view(1, W).expand(H, W)
v = torch.linspace(-1, 1, H).view(H, 1).expand(H, W)
corners = (u * u + v * v) > 1.0 + 1e-6
for t in range(T): # garbage depth flicker outside the circle
depth[t][corners] = 1.0 if t % 2 == 0 else 10.0
identity = torch.eye(4).unsqueeze(0).expand(T, 4, 4).contiguous()
(dyn,) = GS4D_nodes.MotionMaskFromDepth().motion_mask(
depth_seq=depth, trajectory=identity, input_projection="FISHEYE",
input_horizontal_fov=fov, threshold=0.10, frame_gap=2, dilate=0,
device="cpu",
)
assert dyn[:, corners].max().item() == 0.0, "fisheye corners were flagged dynamic"
assert dyn[:, 14:18, 14:18].max().item() == 1.0, "real in-circle motion missed"
# --- TracksToTrajectories: corner track must be dropped -----------------
tracks = torch.zeros(T, 2, 2)
tracks[:, 0, 0] = W / 2.0 # center track
tracks[:, 0, 1] = H / 2.0
tracks[:, 1, 0] = 0.0 # corner track (u=v=-1, r=1.414)
tracks[:, 1, 1] = 0.0
visibility = torch.ones(T, 2)
traj3d, track_ok = GS4D_nodes.TracksToTrajectories().tracks_to_trajectories(
tracks=tracks, visibility=visibility, depth_seq=depth,
input_projection="FISHEYE", input_horizontal_fov=fov,
min_visible_frac=0.5, device="cpu",
)
assert bool(track_ok[0]), "in-circle track unexpectedly invalid"
assert not bool(track_ok[1]), "out-of-circle corner track was lifted to 3D"
# --- SplitSplatsByMask: angle beyond fov/2 lands diagonally inside the
# [-1,1] square (r=1.33, u=v~0.94) but must not be matched to the mask ---
front = torch.tensor([[0.0, 0.0, 1.0]])
theta = math.radians(120.0)
behind_diag = torch.tensor([[
math.sin(theta) * math.cos(math.radians(45.0)),
math.sin(theta) * math.sin(math.radians(45.0)),
math.cos(theta),
]])
splats = make_splats(torch.cat([front, behind_diag], dim=0))
inside, outside = GS4D_nodes.SplitSplatsByMask().split_splats(
splats=splats, mask=torch.ones(H, W), projection="FISHEYE",
horizontal_fov=fov, threshold=0.5, device="cpu",
)
assert inside.xyz.shape[0] == 1, "expected only the in-fov splat inside"
assert outside.xyz.shape[0] == 1, "beyond-fov splat must fall outside"
TESTS = [
test_01_interpolate_se3,
test_02_render_gaussians_shapes_and_empty,
@@ -456,6 +514,7 @@ TESTS = [
test_08_align_depth_scale_and_depth_edge_filter,
test_09_fuse_splats,
test_10_sphere_splat_seed,
test_11_fisheye_circle_exclusion,
]
+199
View File
@@ -0,0 +1,199 @@
"""Offline tests for install.py logic and the flux-pack import resolution in
flux_fisheye_filling_nodes.py. Stubs pip/git so nothing is actually installed.
Run: python notebooks/test_install_logic.py
"""
import importlib.util
import os
import shutil
import sys
import tempfile
import types
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PASS = []
def ok(name):
PASS.append(name)
print(f"[ ok ] {name}")
def load_install_module():
spec = importlib.util.spec_from_file_location(
"camera_install", os.path.join(REPO, "install.py")
)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def stub_commands(mod):
"""Replace subprocess-based helpers with recorders."""
calls = []
mod._run = lambda cmd, cwd=None: calls.append((tuple(cmd), cwd))
mod._pip_install = lambda *args: calls.append((("pip",) + args, None))
mod._git = lambda: "git"
return calls
def test_sharp_early_return():
mod = load_install_module()
calls = stub_commands(mod)
# Build the "already materialized" state explicitly — the repo's own
# submodule may not be checked out (e.g. CI clones without --recursive).
with tempfile.TemporaryDirectory() as tmp:
mod.NODE_DIR = os.path.join(tmp, "camera-comfyUI")
os.makedirs(os.path.join(mod.NODE_DIR, "submodules", "ml-sharpt", "src", "sharp"))
mod.ensure_sharp_checkout()
assert calls == [], f"expected no commands, got {calls}"
ok("sharp checkout present -> no git calls")
def test_sharp_clone_fallback():
mod = load_install_module()
calls = stub_commands(mod)
with tempfile.TemporaryDirectory() as tmp:
mod.NODE_DIR = os.path.join(tmp, "custom_nodes", "camera-comfyUI")
os.makedirs(mod.NODE_DIR)
# No .git -> should go straight to direct clone
mod.ensure_sharp_checkout()
assert len(calls) == 1 and "clone" in calls[0][0], calls
assert mod.SHARP_GIT_URL in calls[0][0], calls
ok("sharp missing + no .git -> direct clone")
def test_vggt_uses_https_git():
mod = load_install_module()
calls = stub_commands(mod)
mod._importable = lambda name: False
mod.ensure_vggt()
assert calls == [(("pip", "vggt @ git+https://github.com/facebookresearch/vggt.git"), None)], calls
ok("vggt -> pip install from git+https (no ssh)")
def test_flux_pack_skip_outside_comfyui():
mod = load_install_module()
calls = stub_commands(mod)
with tempfile.TemporaryDirectory() as tmp:
mod.NODE_DIR = os.path.join(tmp, "somewhere", "camera-comfyUI")
os.makedirs(mod.NODE_DIR)
mod.ensure_flux_inpainting_pack()
assert calls == [], calls
ok("flux pack: skipped when parent is not custom_nodes")
def test_flux_pack_detects_existing_and_clones_when_missing():
mod = load_install_module()
for existing in ("inpainting_flux", "ComfyUI-Flux-Inpainting", "ComfyUI-Flux-Inpainting-main"):
calls = stub_commands(mod)
with tempfile.TemporaryDirectory() as tmp:
cn = os.path.join(tmp, "custom_nodes")
mod.NODE_DIR = os.path.join(cn, "camera-comfyUI")
os.makedirs(mod.NODE_DIR)
os.makedirs(os.path.join(cn, existing))
mod.ensure_flux_inpainting_pack()
assert calls == [], f"{existing}: {calls}"
ok("flux pack: all existing folder names detected, no clone")
calls = stub_commands(mod)
with tempfile.TemporaryDirectory() as tmp:
cn = os.path.join(tmp, "custom_nodes")
mod.NODE_DIR = os.path.join(cn, "camera-comfyUI")
os.makedirs(mod.NODE_DIR)
mod.ensure_flux_inpainting_pack()
assert len(calls) == 1 and "clone" in calls[0][0], calls
assert calls[0][0][-1] == os.path.join(cn, "inpainting_flux"), calls
ok("flux pack: missing -> cloned as custom_nodes/inpainting_flux")
def test_example_inputs_copied():
mod = load_install_module()
stub_commands(mod)
with tempfile.TemporaryDirectory() as tmp:
cn = os.path.join(tmp, "ComfyUI", "custom_nodes")
mod.NODE_DIR = os.path.join(cn, "camera-comfyUI")
src = os.path.join(mod.NODE_DIR, "example_inputs")
os.makedirs(src)
with open(os.path.join(src, "example.jpg"), "w") as f:
f.write("x")
input_dir = os.path.join(tmp, "ComfyUI", "input")
os.makedirs(input_dir)
with open(os.path.join(input_dir, "existing.jpg"), "w") as f:
f.write("keep me")
mod.ensure_example_inputs()
mod.ensure_example_inputs() # idempotent, no overwrite
assert os.path.isfile(os.path.join(input_dir, "example.jpg"))
assert open(os.path.join(input_dir, "existing.jpg")).read() == "keep me"
ok("example inputs copied to ComfyUI input dir, no overwrite")
def test_main_never_fails():
mod = load_install_module()
def boom():
raise RuntimeError("no network")
mod.STEPS = (("step-a", boom), ("step-b", boom))
assert mod.main() == 0
ok("main() returns 0 even when every step fails")
def test_flux_import_resolution():
"""Build a fake ComfyUI tree and check _import_flux_inpainting finds the
pack under a dashed folder name and honors its relative imports."""
if "PIL" not in sys.modules:
try:
import PIL # noqa: F401
except ImportError:
pil = types.ModuleType("PIL")
pil.Image = types.SimpleNamespace()
sys.modules["PIL"] = pil
sys.modules["PIL.Image"] = types.ModuleType("PIL.Image")
tmp = tempfile.mkdtemp()
try:
cn = os.path.join(tmp, "ComfyUI", "custom_nodes")
pack = os.path.join(cn, "campack")
os.makedirs(pack)
for fname in ("reprojection_nodes.py", "flux_fisheye_filling_nodes.py"):
shutil.copy(os.path.join(REPO, fname), pack)
open(os.path.join(pack, "__init__.py"), "w").close()
# Fake flux pack under a dashed (non-identifier) folder name with a
# relative import, mirroring the real repo layout.
flux = os.path.join(cn, "ComfyUI-Flux-Inpainting")
os.makedirs(os.path.join(flux, "modules"))
open(os.path.join(flux, "__init__.py"), "w").close()
open(os.path.join(flux, "modules", "__init__.py"), "w").close()
with open(os.path.join(flux, "modules", "load_util.py"), "w") as f:
f.write("MARKER = 'loaded'\n")
with open(os.path.join(flux, "nodes.py"), "w") as f:
f.write(
"from .modules.load_util import MARKER\n"
"class FluxNF4Inpainting:\n"
" marker = MARKER\n"
)
sys.path.insert(0, os.path.dirname(pack))
mod = importlib.import_module("campack.flux_fisheye_filling_nodes")
assert mod._flux_import_error is None, mod._flux_import_error
assert mod.FluxInpainting is not None
assert mod.FluxInpainting.marker == "loaded"
ok("flux import: dashed folder name resolved incl. relative imports")
finally:
shutil.rmtree(tmp, ignore_errors=True)
if __name__ == "__main__":
test_sharp_early_return()
test_sharp_clone_fallback()
test_vggt_uses_https_git()
test_flux_pack_skip_outside_comfyui()
test_flux_pack_detects_existing_and_clones_when_missing()
test_example_inputs_copied()
test_main_never_fails()
test_flux_import_resolution()
print(f"\n{len(PASS)} install-logic checks passed")
+133
View File
@@ -0,0 +1,133 @@
"""Validate workflows/*.json against the current node definitions.
For every camera-comfyUI node used in a workflow, compares the stored
widgets_values against the widget list derived from the node's INPUT_TYPES
(required + optional, in order, counting only widget-type inputs). A length
mismatch means the workflow predates a node-schema change and will load with
silently shifted/défault values.
Run: python notebooks/validate_workflows.py
Exit code 1 if any mismatch is found (missing node types are also reported).
"""
import glob
import json
import os
import sys
import tempfile
import types
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if REPO_ROOT not in sys.path:
sys.path.insert(0, REPO_ROOT)
_TMP_DIR = tempfile.mkdtemp(prefix="wf_validate_")
def _stub_get_save_image_path(filename_prefix, output_dir, *args, **kwargs):
os.makedirs(output_dir, exist_ok=True)
return output_dir, filename_prefix, 0, "", filename_prefix
_fp = types.ModuleType("folder_paths")
_fp.get_input_directory = lambda: _TMP_DIR
_fp.get_output_directory = lambda: _TMP_DIR
_fp.get_temp_directory = lambda: _TMP_DIR
_fp.get_save_image_path = _stub_get_save_image_path
_fp.get_annotated_filepath = lambda name: os.path.join(_TMP_DIR, name)
_fp.exists_annotated_filepath = lambda name: os.path.exists(os.path.join(_TMP_DIR, name))
_fp.get_filename_list = lambda folder: []
_fp.models_dir = _TMP_DIR
sys.modules["folder_paths"] = _fp
# Light stubs for heavy deps that some modules import at module level but that
# INPUT_TYPES itself does not need.
for name in ("transformers", "diffusers"):
if name not in sys.modules:
try:
__import__(name)
except ImportError:
stub = types.ModuleType(name)
stub.pipeline = lambda *a, **k: None
sys.modules[name] = stub
# Import the repo as a package so modules with relative imports load too.
import importlib.util # noqa: E402
_spec = importlib.util.spec_from_file_location(
"camcomfy",
os.path.join(REPO_ROOT, "__init__.py"),
submodule_search_locations=[REPO_ROOT],
)
_pkg = importlib.util.module_from_spec(_spec)
sys.modules["camcomfy"] = _pkg
_spec.loader.exec_module(_pkg)
NODE_CLASS_MAPPINGS = dict(_pkg.NODE_CLASS_MAPPINGS)
WIDGET_TYPES = {"INT", "FLOAT", "STRING", "BOOLEAN"}
def widget_specs(cls):
"""Ordered (name, combo_options|None) for a node's widget inputs."""
it = cls.INPUT_TYPES()
specs = []
for section in ("required", "optional"):
for name, spec in it.get(section, {}).items():
t = spec[0] if isinstance(spec, (tuple, list)) and spec else spec
if isinstance(t, (list, tuple)): # combo box (either sequence type)
specs.append((name, list(t)))
elif isinstance(t, str) and t in WIDGET_TYPES:
specs.append((name, None))
# ComfyUI appends a control_after_generate widget after seeds
if t == "INT" and name in ("seed", "noise_seed"):
specs.append((f"{name}:control_after_generate", None))
return specs
def main() -> int:
problems = 0
for path in sorted(glob.glob(os.path.join(REPO_ROOT, "workflows", "**", "*.json"),
recursive=True)):
data = json.load(open(path, encoding="utf-8"))
header_shown = False
def report(msg):
nonlocal header_shown, problems
if not header_shown:
print(f"\n=== {os.path.basename(path)}")
header_shown = True
print(f" {msg}")
problems += 1
for n in data.get("nodes", []):
t = n["type"]
if t not in NODE_CLASS_MAPPINGS:
continue # builtin or third-party node
specs = widget_specs(NODE_CLASS_MAPPINGS[t])
got = n.get("widgets_values") or []
if isinstance(got, dict):
continue # API-style dict widgets (third-party save format)
if len(got) != len(specs):
report(
f"N{n['id']} {t}: {len(got)} widget values, node now has "
f"{len(specs)} widgets {[s[0] for s in specs]}; "
f"stored={json.dumps(got)[:100]}"
)
continue
for (name, options), value in zip(specs, got):
# empty option lists are dynamic file dropdowns (input dir
# listing) — not verifiable outside a real ComfyUI install
if options and value not in options:
report(
f"N{n['id']} {t}: widget '{name}' has stale value "
f"{value!r}, valid options are {options}"
)
if problems:
print(f"\n{problems} mismatches found")
return 1
print("all workflow widget schemas match current node definitions")
return 0
if __name__ == "__main__":
sys.exit(main())
+4 -3
View File
@@ -174,9 +174,10 @@ def _import_vggt() -> Tuple[Any, Any]:
return VGGT, pose_encoding_to_extri_intri
except ImportError as exc:
raise ModuleNotFoundError(
"VGGT is not installed. Install it with `pip install vggt` (or "
"`pip install git+https://github.com/facebookresearch/vggt.git`), or clone "
f"https://github.com/facebookresearch/vggt into {vggt_clone_path!r}. "
"VGGT is not installed. Run this pack's install.py (ComfyUI-Manager does this "
"automatically), or install it manually with "
"`pip install git+https://github.com/facebookresearch/vggt.git` (it is not on PyPI), "
f"or clone https://github.com/facebookresearch/vggt into {vggt_clone_path!r}. "
"It also requires `huggingface_hub` to download the facebook/VGGT-1B weights."
) from exc
+11 -1
View File
@@ -1,13 +1,23 @@
[project]
name = "camera-comfyui"
description = "Custom ComfyUI nodes for camera projections (pinhole/fisheye/equirectangular), depth, point clouds, camera trajectories, and 3D/4D Gaussian splatting — including video-to-4D-world workflows."
version = "1.0.0"
version = "1.1.0"
license = { file = "LICENSE" }
# Keep in sync with requirements.txt. GitHub/CUDA-flavored extras (vggt,
# gsplat) and the ComfyUI-Flux-Inpainting sibling pack are installed by
# install.py, which ComfyUI-Manager runs automatically after install.
dependencies = [
"transformers==4.50.0",
"diffusers==0.33.1",
"open3d==0.19.0",
"protobuf",
"click",
"timm",
"plyfile",
"pillow-heif",
"matplotlib",
"imageio",
"imageio-ffmpeg",
]
[project.urls]
+11 -3
View File
@@ -2,9 +2,17 @@ transformers==4.50.0
diffusers==0.33.1
open3d==0.19.0
protobuf
# Runtime deps of the bundled SHARP submodule (submodules/ml-sharpt), so
# ImageToSplat & co. work out of the box. torch/torchvision/scipy/tqdm come
# with ComfyUI itself; gsplat and vggt are GitHub/CUDA-flavored and are
# handled by install.py (run automatically by ComfyUI-Manager).
click
timm
plyfile
pillow-heif
matplotlib
imageio
imageio-ffmpeg
# open3d is optional, see pointcloud_nodes.py
# Python version: 3.12.x (used by embedded python)
# All versions pinned to match embedded environment
# If using a different Python, adjust versions accordingly
# For full reproducibility, consider using a virtual environment
# and pip freeze > requirements.txt
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+289 -1
View File
@@ -1 +1,289 @@
{"id":"8c6e5ec1-4ff2-42d9-9408-fcd0a23d362a","revision":0,"last_node_id":11,"last_link_id":17,"nodes":[{"id":3,"type":"PreviewImage","pos":[-5.768195629119873,204.72561645507812],"size":[210,246],"flags":{},"order":2,"mode":0,"inputs":[{"localized_name":"images","name":"images","type":"IMAGE","link":16}],"outputs":[],"properties":{"Node name for S&R":"PreviewImage"},"widgets_values":[""]},{"id":4,"type":"MaskToImage","pos":[-30.901016235351562,532.864013671875],"size":[264.5999755859375,26],"flags":{},"order":3,"mode":0,"inputs":[{"localized_name":"mask","name":"mask","type":"MASK","link":17}],"outputs":[{"localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","links":[4]}],"properties":{"Node name for S&R":"MaskToImage"},"widgets_values":[]},{"id":2,"type":"LoadImage","pos":[-843.689208984375,354.9311828613281],"size":[315,314],"flags":{},"order":0,"mode":0,"inputs":[],"outputs":[{"localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","links":[15]},{"localized_name":"MASK","name":"MASK","type":"MASK","links":[]}],"properties":{"Node name for S&R":"LoadImage"},"widgets_values":["flux_dev_example.png","image",""]},{"id":5,"type":"PreviewImage","pos":[296.6977844238281,611.6702880859375],"size":[210,246],"flags":{},"order":4,"mode":0,"inputs":[{"localized_name":"images","name":"images","type":"IMAGE","link":4}],"outputs":[],"properties":{"Node name for S&R":"PreviewImage"},"widgets_values":[""]},{"id":11,"type":"OutpaintAnyProjection","pos":[-498.9301452636719,317.144287109375],"size":[400,492],"flags":{},"order":1,"mode":0,"inputs":[{"localized_name":"image","name":"image","type":"IMAGE","link":15},{"localized_name":"mask","name":"mask","shape":7,"type":"MASK","link":null}],"outputs":[{"localized_name":"final_image","name":"final_image","type":"IMAGE","links":[16]},{"localized_name":"needs_inpaint_mask","name":"needs_inpaint_mask","type":"MASK","links":[17]}],"properties":{"Node name for S&R":"OutpaintAnyProjection"},"widgets_values":["PINHOLE",90,"FISHEYE",180,4096,4096,"PINHOLE",90,1024,45,0,"",10,false,30,1,false]}],"links":[[4,4,0,5,0,"IMAGE"],[15,2,0,11,0,"IMAGE"],[16,11,0,3,0,"IMAGE"],[17,11,1,4,0,"MASK"]],"groups":[],"config":{},"extra":{"ds":{"scale":0.8390545288824094,"offset":[703.1897327574803,-308.43624510132975]}},"version":0.4}
{
"id": "8c6e5ec1-4ff2-42d9-9408-fcd0a23d362a",
"revision": 0,
"last_node_id": 12,
"last_link_id": 17,
"nodes": [
{
"id": 3,
"type": "PreviewImage",
"pos": [
-5.768195629119873,
204.72561645507812
],
"size": [
210,
246
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "images",
"name": "images",
"type": "IMAGE",
"link": 16
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PreviewImage"
},
"widgets_values": [
""
],
"title": "Result"
},
{
"id": 4,
"type": "MaskToImage",
"pos": [
-30.901016235351562,
532.864013671875
],
"size": [
264.5999755859375,
26
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"localized_name": "mask",
"name": "mask",
"type": "MASK",
"link": 17
}
],
"outputs": [
{
"localized_name": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"links": [
4
]
}
],
"properties": {
"Node name for S&R": "MaskToImage"
},
"widgets_values": []
},
{
"id": 2,
"type": "LoadImage",
"pos": [
-843.689208984375,
354.9311828613281
],
"size": [
315,
314
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"localized_name": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"links": [
15
]
},
{
"localized_name": "MASK",
"name": "MASK",
"type": "MASK",
"links": []
}
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": [
"camera_example_pinhole.jpg",
"image",
""
]
},
{
"id": 5,
"type": "PreviewImage",
"pos": [
296.6977844238281,
611.6702880859375
],
"size": [
210,
246
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"localized_name": "images",
"name": "images",
"type": "IMAGE",
"link": 4
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PreviewImage"
},
"widgets_values": [
""
],
"title": "Holes still to fill"
},
{
"id": 11,
"type": "OutpaintAnyProjection",
"pos": [
-498.9301452636719,
317.144287109375
],
"size": [
400,
492
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "image",
"name": "image",
"type": "IMAGE",
"link": 15
},
{
"localized_name": "mask",
"name": "mask",
"shape": 7,
"type": "MASK",
"link": null
}
],
"outputs": [
{
"localized_name": "final_image",
"name": "final_image",
"type": "IMAGE",
"links": [
16
]
},
{
"localized_name": "needs_inpaint_mask",
"name": "needs_inpaint_mask",
"type": "MASK",
"links": [
17
]
}
],
"properties": {
"Node name for S&R": "OutpaintAnyProjection"
},
"widgets_values": [
"PINHOLE",
90,
"FISHEYE",
180,
4096,
4096,
"PINHOLE",
90,
1024,
45,
0,
"",
10,
false,
30,
1,
false
],
"title": "Outpaint one patch (yaw +45°)"
},
{
"id": 12,
"type": "MarkdownNote",
"title": "About this workflow",
"pos": [
-1403.689208984375,
204.72561645507812
],
"size": [
520,
288
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {},
"widgets_values": [
"# OutpaintAnyProjection smoke test\n\nSingle-node sanity check: a 90° pinhole image is placed on a 180° fisheye canvas and one 90° pinhole patch at yaw +45° is Flux-inpainted (10 steps for speed).\n\nOutputs: the partially outpainted canvas and the *remaining holes* mask — chain more OutpaintAnyProjection nodes at other angles to fill it (see `Outpaint_fisheye180.json`).\n\n- **Set:** input image; prompt inside the node (empty = unconditional).\n- **Requires:** `custom_nodes/inpainting_flux` (installed automatically by this pack's install.py) — downloads FLUX.1-Fill NF4 weights on first run."
],
"color": "#432",
"bgcolor": "#653"
}
],
"links": [
[
4,
4,
0,
5,
0,
"IMAGE"
],
[
15,
2,
0,
11,
0,
"IMAGE"
],
[
16,
11,
0,
3,
0,
"IMAGE"
],
[
17,
11,
1,
4,
0,
"MASK"
]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 0.8390545288824094,
"offset": [
703.1897327574803,
-308.43624510132975
]
},
"camera_comfyui_rev": 1
},
"version": 0.4
}
+84 -16
View File
@@ -1,7 +1,7 @@
{
"id": "312a3a27-6189-4cd7-8c9f-5a62881c623e",
"revision": 0,
"last_node_id": 18,
"last_node_id": 19,
"last_link_id": 37,
"nodes": [
{
@@ -38,7 +38,7 @@
"Node name for S&R": "LoadImage"
},
"widgets_values": [
"Fisheye_outpainted_flux_dev.png",
"camera_example_fisheye.jpg",
"image"
]
},
@@ -213,7 +213,9 @@
90,
1024,
4096,
21
"SOFTMERGE",
21,
1
]
},
{
@@ -326,14 +328,11 @@
10,
7.5,
5,
"PINHOLE",
90,
1024,
1024,
"open",
9,
15
]
0.07,
3,
"Depth-Anything-V2-Metric-Indoor-Base-hf"
],
"title": "Enrich cloud along trajectory (Flux outpaint)"
},
{
"id": 18,
@@ -411,8 +410,36 @@
90,
1024,
1024,
2
]
2,
0,
false,
false
],
"title": "Orbit preview of enriched cloud"
},
{
"id": 19,
"type": "MarkdownNote",
"title": "About this workflow",
"pos": [
982.239013671875,
1182.49755859375
],
"size": [
520,
314
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {},
"widgets_values": [
"# Point cloud enricher (outpaint along a trajectory)\n\nFisheye image → metric depth → point cloud, then **PointcloudTrajectoryEnricher** walks the loaded camera trajectory: at each pose it renders the cloud, Flux-inpaints the disocclusion holes, re-estimates depth, aligns it and merges the new points into the cloud. The enriched cloud is saved and previewed as an orbit video.\n\nThis is the one-node version of `pointcloud_inpaint.json`.\n\n- **Set:** input image; trajectory .npy (record one with SaveTrajectory); prompt inside the enricher.\n- **Requires:** inpainting_flux (auto-installed), Depth-Anything V2, FLUX.1-Fill NF4 weights.\n- **Note:** 2026-07 schema migration — the enricher's render/reproject options are now internal; voxel merge params were reset to defaults."
],
"color": "#432",
"bgcolor": "#653"
}
],
"links": [
@@ -513,7 +540,47 @@
"TENSOR"
]
],
"groups": [],
"groups": [
{
"id": 1,
"title": "1. Fisheye → point cloud",
"bounding": [
1522.239013671875,
1122.49755859375,
1332.4192962646484,
589.1494140625
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
},
{
"id": 2,
"title": "2. Enrich along trajectory",
"bounding": [
1964.2667236328125,
1479.796630859375,
1073.373291015625,
614.0
],
"color": "#a1309b",
"font_size": 24,
"flags": {}
},
{
"id": 3,
"title": "3. Save & preview",
"bounding": [
2283.51123046875,
1545.8380126953125,
1130.08935546875,
726.26318359375
],
"color": "#8A8",
"font_size": 24,
"flags": {}
}
],
"config": {},
"extra": {
"ds": {
@@ -523,7 +590,8 @@
-1433.3959538925521
]
},
"frontendVersion": "1.19.9"
"frontendVersion": "1.19.9",
"camera_comfyui_rev": 1
},
"version": 0.4
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-2
View File
@@ -1,2 +0,0 @@
# This file marks the workflows directory as a Python package.
NODE_CLASS_MAPPINGS={}
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

+331 -1
View File
@@ -1 +1,331 @@
{"id":"de69fe58-2f01-4527-92b1-681a151b1ce3","revision":0,"last_node_id":7,"last_link_id":8,"nodes":[{"id":3,"type":"LoadImage","pos":[-897.7078247070312,314.2335205078125],"size":[315,314],"flags":{},"order":0,"mode":0,"inputs":[],"outputs":[{"localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","links":[2]},{"localized_name":"MASK","name":"MASK","type":"MASK","links":null}],"properties":{"Node name for S&R":"LoadImage"},"widgets_values":["example.png","image",""]},{"id":1,"type":"TransformToMatrix","pos":[-871.939697265625,694.5091552734375],"size":[315,154],"flags":{},"order":1,"mode":0,"inputs":[],"outputs":[{"localized_name":"MAT_4X4","name":"MAT_4X4","type":"MAT_4X4","links":[5]}],"properties":{"Node name for S&R":"TransformToMatrix"},"widgets_values":[0,0,0,60,0]},{"id":6,"type":"MaskToImage","pos":[-169.98599243164062,763.4010009765625],"size":[264.5999755859375,26],"flags":{},"order":4,"mode":0,"inputs":[{"localized_name":"mask","name":"mask","type":"MASK","link":6}],"outputs":[{"localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","links":[7]}],"properties":{"Node name for S&R":"MaskToImage"}},{"id":4,"type":"PreviewImage","pos":[299.262451171875,609.4740600585938],"size":[210,246],"flags":{},"order":5,"mode":0,"inputs":[{"localized_name":"images","name":"images","type":"IMAGE","link":7}],"outputs":[],"properties":{"Node name for S&R":"PreviewImage"},"widgets_values":[""]},{"id":7,"type":"PreviewImage","pos":[-14.518107414245605,366.5712585449219],"size":[210,246],"flags":{},"order":3,"mode":0,"inputs":[{"localized_name":"images","name":"images","type":"IMAGE","link":8}],"outputs":[],"properties":{"Node name for S&R":"PreviewImage"},"widgets_values":[""]},{"id":2,"type":"ReprojectImage","pos":[-468.4571838378906,358.2191467285156],"size":[315,266],"flags":{},"order":2,"mode":0,"inputs":[{"localized_name":"image","name":"image","type":"IMAGE","link":2},{"localized_name":"mask","name":"mask","shape":7,"type":"MASK","link":null},{"localized_name":"transform_matrix","name":"transform_matrix","shape":7,"type":"MAT_4X4","link":5}],"outputs":[{"localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","links":[8]},{"localized_name":"MASK","name":"MASK","type":"MASK","links":[6]}],"properties":{"Node name for S&R":"ReprojectImage"},"widgets_values":[90,180,"PINHOLE","EQUIRECTANGULAR",1024,1024,false,7]}],"links":[[2,3,0,2,0,"IMAGE"],[5,1,0,2,2,"MAT_4X4"],[6,2,1,6,0,"MASK"],[7,6,0,4,0,"IMAGE"],[8,2,0,7,0,"IMAGE"]],"groups":[],"config":{},"extra":{"ds":{"scale":0.8264462809917362,"offset":[806.6857406850668,-309.49182798364893]}},"version":0.4}
{
"id": "de69fe58-2f01-4527-92b1-681a151b1ce3",
"revision": 0,
"last_node_id": 8,
"last_link_id": 8,
"nodes": [
{
"id": 3,
"type": "LoadImage",
"pos": [
-897.7078247070312,
314.2335205078125
],
"size": [
315,
314
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"localized_name": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"links": [
2
]
},
{
"localized_name": "MASK",
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": [
"camera_example_pinhole.jpg",
"image",
""
]
},
{
"id": 1,
"type": "TransformToMatrix",
"pos": [
-871.939697265625,
694.5091552734375
],
"size": [
315,
154
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"localized_name": "MAT_4X4",
"name": "MAT_4X4",
"type": "MAT_4X4",
"links": [
5
]
}
],
"properties": {
"Node name for S&R": "TransformToMatrix"
},
"widgets_values": [
0,
0,
0,
60,
0
],
"title": "Rotate camera (pitch 60°)"
},
{
"id": 6,
"type": "MaskToImage",
"pos": [
-169.98599243164062,
763.4010009765625
],
"size": [
264.5999755859375,
26
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"localized_name": "mask",
"name": "mask",
"type": "MASK",
"link": 6
}
],
"outputs": [
{
"localized_name": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"links": [
7
]
}
],
"properties": {
"Node name for S&R": "MaskToImage"
}
},
{
"id": 4,
"type": "PreviewImage",
"pos": [
299.262451171875,
609.4740600585938
],
"size": [
210,
246
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"localized_name": "images",
"name": "images",
"type": "IMAGE",
"link": 7
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PreviewImage"
},
"widgets_values": [
""
],
"title": "Coverage mask (white = hole)"
},
{
"id": 7,
"type": "PreviewImage",
"pos": [
-14.518107414245605,
366.5712585449219
],
"size": [
210,
246
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"localized_name": "images",
"name": "images",
"type": "IMAGE",
"link": 8
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PreviewImage"
},
"widgets_values": [
""
],
"title": "Reprojected image"
},
{
"id": 2,
"type": "ReprojectImage",
"pos": [
-468.4571838378906,
358.2191467285156
],
"size": [
315,
266
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "image",
"name": "image",
"type": "IMAGE",
"link": 2
},
{
"localized_name": "mask",
"name": "mask",
"shape": 7,
"type": "MASK",
"link": null
},
{
"localized_name": "transform_matrix",
"name": "transform_matrix",
"shape": 7,
"type": "MAT_4X4",
"link": 5
}
],
"outputs": [
{
"localized_name": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"links": [
8
]
},
{
"localized_name": "MASK",
"name": "MASK",
"type": "MASK",
"links": [
6
]
}
],
"properties": {
"Node name for S&R": "ReprojectImage"
},
"widgets_values": [
90,
180,
"PINHOLE",
"EQUIRECTANGULAR",
1024,
1024,
false,
7
],
"title": "Pinhole 90° → Equirect 180°"
},
{
"id": 8,
"type": "MarkdownNote",
"title": "About this workflow",
"pos": [
-1457.7078247070312,
314.2335205078125
],
"size": [
520,
314
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {},
"widgets_values": [
"# Camera reprojection demo\n\nMinimal example of the two core nodes: **TransformToMatrix** rotates the virtual camera (theta = 60° pitch) and **ReprojectImage** converts a 90° pinhole image into a 180° equirectangular view.\n\nPreviews show the reprojected image and the coverage mask (white = pixels the source image cannot see).\n\n- **Set:** the image in LoadImage.\n- **Requires:** nothing beyond this pack (no models).\n- **Try:** switch `output_projection` to FISHEYE, or raise `feathering` to soften the mask edge."
],
"color": "#432",
"bgcolor": "#653"
}
],
"links": [
[
2,
3,
0,
2,
0,
"IMAGE"
],
[
5,
1,
0,
2,
2,
"MAT_4X4"
],
[
6,
2,
1,
6,
0,
"MASK"
],
[
7,
6,
0,
4,
0,
"IMAGE"
],
[
8,
2,
0,
7,
0,
"IMAGE"
]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 0.8264462809917362,
"offset": [
806.6857406850668,
-309.49182798364893
]
},
"camera_comfyui_rev": 1
},
"version": 0.4
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+469 -1
View File
@@ -1 +1,469 @@
{"id":"8e48e6c6-735b-4131-9b4d-5847d2948c18","revision":0,"last_node_id":161,"last_link_id":330,"nodes":[{"id":156,"type":"DepthToImageNode","pos":[101.64265441894531,607.9051513671875],"size":[315,58],"flags":{},"order":2,"mode":0,"inputs":[{"localized_name":"depth","name":"depth","type":"TENSOR","link":323}],"outputs":[{"localized_name":"depth image","name":"depth image","type":"IMAGE","links":[322]}],"properties":{"Node name for S&R":"DepthToImageNode"},"widgets_values":[false]},{"id":157,"type":"PreviewImage","pos":[449.5372314453125,592.8480224609375],"size":[210,246],"flags":{},"order":5,"mode":0,"inputs":[{"localized_name":"images","name":"images","type":"IMAGE","link":322}],"outputs":[],"properties":{"Node name for S&R":"PreviewImage"},"widgets_values":[""]},{"id":159,"type":"PreviewImage","pos":[128.622802734375,835.9542236328125],"size":[210,246],"flags":{},"order":6,"mode":0,"inputs":[{"localized_name":"images","name":"images","type":"IMAGE","link":326}],"outputs":[],"properties":{"Node name for S&R":"PreviewImage"},"widgets_values":[""]},{"id":158,"type":"MaskToImage","pos":[105.04651641845703,715.2989501953125],"size":[264.5999755859375,26],"flags":{},"order":3,"mode":0,"inputs":[{"localized_name":"mask","name":"mask","type":"MASK","link":325}],"outputs":[{"localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","links":[326]}],"properties":{"Node name for S&R":"MaskToImage"}},{"id":154,"type":"LoadImage","pos":[-601.5926513671875,610.8336181640625],"size":[315,314],"flags":{},"order":0,"mode":0,"inputs":[],"outputs":[{"localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","links":[324,327]},{"localized_name":"MASK","name":"MASK","type":"MASK","links":null}],"properties":{"Node name for S&R":"LoadImage"},"widgets_values":["Saved_fisheye_00001_.png","image",""]},{"id":155,"type":"FisheyeDepthEstimator","pos":[-258.0684814453125,592.8478393554688],"size":[315,198],"flags":{},"order":1,"mode":0,"inputs":[{"localized_name":"image","name":"image","type":"IMAGE","link":324}],"outputs":[{"localized_name":"depthmap","name":"depthmap","type":"TENSOR","links":[323,329]},{"localized_name":"mask","name":"mask","type":"MASK","links":[325,328]}],"properties":{"Node name for S&R":"FisheyeDepthEstimator"},"widgets_values":["Depth-Anything-V2-Metric-Indoor-Base-hf",1,90,1024,4096,25]},{"id":160,"type":"DepthToPointCloud","pos":[398.363525390625,895.5885620117188],"size":[315,170],"flags":{},"order":4,"mode":0,"inputs":[{"localized_name":"image","name":"image","type":"IMAGE","link":327},{"localized_name":"depthmap","name":"depthmap","shape":7,"type":"TENSOR","link":329},{"localized_name":"mask","name":"mask","shape":7,"type":"MASK","link":328}],"outputs":[{"localized_name":"pointcloud","name":"pointcloud","type":"TENSOR","links":[330]}],"properties":{"Node name for S&R":"DepthToPointCloud"},"widgets_values":["FISHEYE",180,1,false]},{"id":161,"type":"SavePointCloud","pos":[785.9862670898438,881.0266723632812],"size":[315,82],"flags":{},"order":7,"mode":0,"inputs":[{"localized_name":"pointcloud","name":"pointcloud","type":"TENSOR","link":330}],"outputs":[],"properties":{"Node name for S&R":"SavePointCloud"},"widgets_values":["kitchen","npy"]}],"links":[[322,156,0,157,0,"IMAGE"],[323,155,0,156,0,"TENSOR"],[324,154,0,155,0,"IMAGE"],[325,155,1,158,0,"MASK"],[326,158,0,159,0,"IMAGE"],[327,154,0,160,0,"IMAGE"],[328,155,1,160,2,"MASK"],[329,155,0,160,1,"TENSOR"],[330,160,0,161,0,"TENSOR"]],"groups":[],"config":{},"extra":{"ds":{"scale":0.9229599817706441,"offset":[115.11465258583665,-662.5990021779805]},"frontendVersion":"1.18.9"},"version":0.4}
{
"id": "8e48e6c6-735b-4131-9b4d-5847d2948c18",
"revision": 0,
"last_node_id": 162,
"last_link_id": 330,
"nodes": [
{
"id": 156,
"type": "DepthToImageNode",
"pos": [
101.64265441894531,
607.9051513671875
],
"size": [
315,
58
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "depth",
"name": "depth",
"type": "TENSOR",
"link": 323
}
],
"outputs": [
{
"localized_name": "depth image",
"name": "depth image",
"type": "IMAGE",
"links": [
322
]
}
],
"properties": {
"Node name for S&R": "DepthToImageNode"
},
"widgets_values": [
false
]
},
{
"id": 157,
"type": "PreviewImage",
"pos": [
449.5372314453125,
592.8480224609375
],
"size": [
210,
246
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"localized_name": "images",
"name": "images",
"type": "IMAGE",
"link": 322
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PreviewImage"
},
"widgets_values": [
""
]
},
{
"id": 159,
"type": "PreviewImage",
"pos": [
128.622802734375,
835.9542236328125
],
"size": [
210,
246
],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"localized_name": "images",
"name": "images",
"type": "IMAGE",
"link": 326
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PreviewImage"
},
"widgets_values": [
""
]
},
{
"id": 158,
"type": "MaskToImage",
"pos": [
105.04651641845703,
715.2989501953125
],
"size": [
264.5999755859375,
26
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"localized_name": "mask",
"name": "mask",
"type": "MASK",
"link": 325
}
],
"outputs": [
{
"localized_name": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"links": [
326
]
}
],
"properties": {
"Node name for S&R": "MaskToImage"
}
},
{
"id": 154,
"type": "LoadImage",
"pos": [
-601.5926513671875,
610.8336181640625
],
"size": [
315,
314
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"localized_name": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"links": [
324,
327
]
},
{
"localized_name": "MASK",
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": [
"camera_example_fisheye.jpg",
"image",
""
]
},
{
"id": 155,
"type": "FisheyeDepthEstimator",
"pos": [
-258.0684814453125,
592.8478393554688
],
"size": [
315,
198
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "image",
"name": "image",
"type": "IMAGE",
"link": 324
}
],
"outputs": [
{
"localized_name": "depthmap",
"name": "depthmap",
"type": "TENSOR",
"links": [
323,
329
]
},
{
"localized_name": "mask",
"name": "mask",
"type": "MASK",
"links": [
325,
328
]
}
],
"properties": {
"Node name for S&R": "FisheyeDepthEstimator"
},
"widgets_values": [
"Depth-Anything-V2-Metric-Indoor-Base-hf",
1,
90,
1024,
4096,
"SOFTMERGE",
25,
1
],
"title": "Metric depth (fisheye-aware)"
},
{
"id": 160,
"type": "DepthToPointCloud",
"pos": [
398.363525390625,
895.5885620117188
],
"size": [
315,
170
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"localized_name": "image",
"name": "image",
"type": "IMAGE",
"link": 327
},
{
"localized_name": "depthmap",
"name": "depthmap",
"shape": 7,
"type": "TENSOR",
"link": 329
},
{
"localized_name": "mask",
"name": "mask",
"shape": 7,
"type": "MASK",
"link": 328
}
],
"outputs": [
{
"localized_name": "pointcloud",
"name": "pointcloud",
"type": "TENSOR",
"links": [
330
]
}
],
"properties": {
"Node name for S&R": "DepthToPointCloud"
},
"widgets_values": [
"FISHEYE",
180,
1,
false
],
"title": "Depth → point cloud"
},
{
"id": 161,
"type": "SavePointCloud",
"pos": [
785.9862670898438,
881.0266723632812
],
"size": [
315,
82
],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{
"localized_name": "pointcloud",
"name": "pointcloud",
"type": "TENSOR",
"link": 330
}
],
"outputs": [],
"properties": {
"Node name for S&R": "SavePointCloud"
},
"widgets_values": [
"kitchen",
"npy"
],
"title": "Save .ply / .npy"
},
{
"id": 162,
"type": "MarkdownNote",
"title": "About this workflow",
"pos": [
-1161.5926513671875,
592.8478393554688
],
"size": [
520,
314
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {},
"widgets_values": [
"# Fisheye 180° → point cloud\n\nEstimates metric depth directly on a 180° fisheye image — **FisheyeDepthEstimator** internally splits it into pinhole views, runs Depth-Anything V2 on each and merges the depths back (SOFTMERGE) — then unprojects image + depth to a 3D point cloud and saves it.\n\nPreviews: colorized depth and the validity mask.\n\n- **Set:** fisheye input image (e.g. produced by `Outpaint_fisheye180.json`); filename in SavePointCloud.\n- **Requires:** Depth-Anything V2 (auto-downloads from HuggingFace).\n- **Next:** view the cloud with `test_pointcloud_loading.json` or synthesize a second eye with `sbs180_workflow.json`."
],
"color": "#432",
"bgcolor": "#653"
}
],
"links": [
[
322,
156,
0,
157,
0,
"IMAGE"
],
[
323,
155,
0,
156,
0,
"TENSOR"
],
[
324,
154,
0,
155,
0,
"IMAGE"
],
[
325,
155,
1,
158,
0,
"MASK"
],
[
326,
158,
0,
159,
0,
"IMAGE"
],
[
327,
154,
0,
160,
0,
"IMAGE"
],
[
328,
155,
1,
160,
2,
"MASK"
],
[
329,
155,
0,
160,
1,
"TENSOR"
],
[
330,
160,
0,
161,
0,
"TENSOR"
]
],
"groups": [
{
"id": 1,
"title": "1. Fisheye metric depth",
"bounding": [
-621.5926513671875,
532.8478393554688,
1301.1298828125,
569.1063842773438
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
},
{
"id": 2,
"title": "2. Unproject & save",
"bounding": [
378.363525390625,
821.0266723632812,
742.6227416992188,
264.5618896484375
],
"color": "#a1309b",
"font_size": 24,
"flags": {}
}
],
"config": {},
"extra": {
"ds": {
"scale": 0.9229599817706441,
"offset": [
115.11465258583665,
-662.5990021779805
]
},
"frontendVersion": "1.18.9",
"camera_comfyui_rev": 1
},
"version": 0.4
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

File diff suppressed because one or more lines are too long
+781
View File
@@ -0,0 +1,781 @@
{
"id": "016c13e5-c6a7-4b2e-adfc-18e9e969f21e",
"revision": 0,
"last_node_id": 21,
"last_link_id": 32,
"nodes": [
{
"id": 10,
"type": "SaveWEBM",
"pos": [
3352.103515625,
1535.6951904296875
],
"size": [
315,
437
],
"flags": {},
"order": 8,
"mode": 0,
"inputs": [
{
"localized_name": "images",
"name": "images",
"type": "IMAGE",
"link": 24
},
{
"localized_name": "filename_prefix",
"name": "filename_prefix",
"type": "STRING",
"widget": {
"name": "filename_prefix"
},
"link": null
},
{
"localized_name": "codec",
"name": "codec",
"type": "COMBO",
"widget": {
"name": "codec"
},
"link": null
},
{
"localized_name": "fps",
"name": "fps",
"type": "FLOAT",
"widget": {
"name": "fps"
},
"link": null
},
{
"localized_name": "crf",
"name": "crf",
"type": "FLOAT",
"widget": {
"name": "crf"
},
"link": null
}
],
"outputs": [],
"properties": {},
"widgets_values": [
"ComfyUI",
"vp9",
24,
32
]
},
{
"id": 4,
"type": "TransformPointCloud",
"pos": [
2458.775390625,
1405.860595703125
],
"size": [
329.20001220703125,
46
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"localized_name": "pointcloud",
"name": "pointcloud",
"type": "TENSOR",
"link": 4
},
{
"localized_name": "transform_matrix",
"name": "transform_matrix",
"type": "MAT_4X4",
"link": 5
}
],
"outputs": [
{
"localized_name": "transformed pointcloud",
"name": "transformed pointcloud",
"type": "TENSOR",
"links": [
23
]
}
],
"properties": {
"Node name for S&R": "TransformPointCloud"
},
"widgets_values": []
},
{
"id": 5,
"type": "TransformToMatrix",
"pos": [
2296.978759765625,
1742.214599609375
],
"size": [
315,
154
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "shiftX",
"name": "shiftX",
"type": "FLOAT",
"widget": {
"name": "shiftX"
},
"link": null
},
{
"localized_name": "shiftY",
"name": "shiftY",
"type": "FLOAT",
"widget": {
"name": "shiftY"
},
"link": null
},
{
"localized_name": "shiftZ",
"name": "shiftZ",
"type": "FLOAT",
"widget": {
"name": "shiftZ"
},
"link": null
},
{
"localized_name": "theta",
"name": "theta",
"type": "FLOAT",
"widget": {
"name": "theta"
},
"link": null
},
{
"localized_name": "phi",
"name": "phi",
"type": "FLOAT",
"widget": {
"name": "phi"
},
"link": null
}
],
"outputs": [
{
"localized_name": "transformation matrix",
"name": "transformation matrix",
"type": "MAT_4X4",
"links": [
5,
25
]
}
],
"properties": {
"Node name for S&R": "TransformToMatrix"
},
"widgets_values": [
0,
0,
0,
0,
0
],
"title": "Camera move (edit me)"
},
{
"id": 2,
"type": "DepthToPointCloud",
"pos": [
2099.81640625,
1385.9761962890625
],
"size": [
315,
170
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"localized_name": "image",
"name": "image",
"type": "IMAGE",
"link": 1
},
{
"localized_name": "depthmap",
"name": "depthmap",
"shape": 7,
"type": "TENSOR",
"link": 32
},
{
"localized_name": "mask",
"name": "mask",
"shape": 7,
"type": "MASK",
"link": null
},
{
"localized_name": "input_projection",
"name": "input_projection",
"type": "COMBO",
"widget": {
"name": "input_projection"
},
"link": null
},
{
"localized_name": "input_horizontal_fov",
"name": "input_horizontal_fov",
"type": "FLOAT",
"widget": {
"name": "input_horizontal_fov"
},
"link": null
},
{
"localized_name": "depth_scale",
"name": "depth_scale",
"type": "FLOAT",
"widget": {
"name": "depth_scale"
},
"link": null
},
{
"localized_name": "invert_depth",
"name": "invert_depth",
"type": "BOOLEAN",
"widget": {
"name": "invert_depth"
},
"link": null
}
],
"outputs": [
{
"localized_name": "pointcloud",
"name": "pointcloud",
"type": "TENSOR",
"links": [
4,
26
]
}
],
"properties": {
"Node name for S&R": "DepthToPointCloud"
},
"widgets_values": [
"PINHOLE",
60,
1,
false
]
},
{
"id": 14,
"type": "CameraMotionNode",
"pos": [
2984.30224609375,
1486.6483154296875
],
"size": [
315,
198
],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{
"localized_name": "pointcloud",
"name": "pointcloud",
"type": "TENSOR",
"link": 23
},
{
"localized_name": "trajectory",
"name": "trajectory",
"type": "TENSOR",
"link": 27
},
{
"localized_name": "n_points",
"name": "n_points",
"type": "INT",
"widget": {
"name": "n_points"
},
"link": null
},
{
"localized_name": "output_projection",
"name": "output_projection",
"type": "COMBO",
"widget": {
"name": "output_projection"
},
"link": null
},
{
"localized_name": "output_horizontal_fov",
"name": "output_horizontal_fov",
"type": "FLOAT",
"widget": {
"name": "output_horizontal_fov"
},
"link": null
},
{
"localized_name": "output_width",
"name": "output_width",
"type": "INT",
"widget": {
"name": "output_width"
},
"link": null
},
{
"localized_name": "output_height",
"name": "output_height",
"type": "INT",
"widget": {
"name": "output_height"
},
"link": null
},
{
"localized_name": "point_size",
"name": "point_size",
"type": "INT",
"widget": {
"name": "point_size"
},
"link": null
}
],
"outputs": [
{
"localized_name": "motion_frames",
"name": "motion_frames",
"type": "IMAGE",
"links": [
24
]
}
],
"properties": {
"Node name for S&R": "CameraMotionNode"
},
"widgets_values": [
10,
"PINHOLE",
60,
1024,
1024,
2,
0,
false,
false
],
"title": "Render fly-through"
},
{
"id": 17,
"type": "CameraTrajectoryNode",
"pos": [
2722.265380859375,
1772.097412109375
],
"size": [
317.4000244140625,
46
],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"localized_name": "pointcloud",
"name": "pointcloud",
"type": "TENSOR",
"link": 26
},
{
"localized_name": "initial_matrix",
"name": "initial_matrix",
"type": "MAT_4X4",
"link": 25
}
],
"outputs": [
{
"localized_name": "trajectory",
"name": "trajectory",
"type": "TENSOR",
"links": [
27
]
}
],
"properties": {
"Node name for S&R": "CameraTrajectoryNode"
},
"widgets_values": [],
"title": "Build trajectory"
},
{
"id": 3,
"type": "DepthEstimatorNode",
"pos": [
1570.1402587890625,
1859.879638671875
],
"size": [
315,
82
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "image",
"name": "image",
"type": "IMAGE",
"link": 3
},
{
"localized_name": "model_name",
"name": "model_name",
"type": "STRING",
"widget": {
"name": "model_name"
},
"link": null
},
{
"localized_name": "depth_scale",
"name": "depth_scale",
"type": "FLOAT",
"widget": {
"name": "depth_scale"
},
"link": null
}
],
"outputs": [
{
"localized_name": "depth tensor",
"name": "depth tensor",
"type": "TENSOR",
"links": [
31
]
}
],
"properties": {
"Node name for S&R": "DepthEstimatorNode"
},
"widgets_values": [
"Depth-Anything-V2-Metric-Indoor-Base-hf",
1.0000000000000002,
1
]
},
{
"id": 20,
"type": "ZDepthToRayDepthNode",
"pos": [
1969.0958251953125,
1782.2877197265625
],
"size": [
210,
58
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"localized_name": "depth",
"name": "depth",
"type": "TENSOR",
"link": 31
},
{
"localized_name": "fov",
"name": "fov",
"type": "FLOAT",
"widget": {
"name": "fov"
},
"link": null
}
],
"outputs": [
{
"localized_name": "ray depth",
"name": "ray depth",
"type": "TENSOR",
"links": [
32
]
}
],
"properties": {
"Node name for S&R": "ZDepthToRayDepthNode"
},
"widgets_values": [
90
]
},
{
"id": 1,
"type": "LoadImage",
"pos": [
1575.78955078125,
1419.3006591796875
],
"size": [
315,
314
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "image",
"name": "image",
"type": "COMBO",
"widget": {
"name": "image"
},
"link": null
},
{
"localized_name": "choose file to upload",
"name": "upload",
"type": "IMAGEUPLOAD",
"widget": {
"name": "upload"
},
"link": null
}
],
"outputs": [
{
"localized_name": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"links": [
1,
3
]
},
{
"localized_name": "MASK",
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": [
"camera_example_pinhole.jpg",
"image"
]
},
{
"id": 21,
"type": "MarkdownNote",
"title": "About this workflow",
"pos": [
1010.1402587890625,
1385.9761962890625
],
"size": [
520,
262
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {},
"widgets_values": [
"# Point cloud walker (fly-through video)\n\nSingle image → metric depth → point cloud, then **CameraTrajectoryNode** derives a camera path and **CameraMotionNode** renders a fly-through saved as WEBM.\n\n- **Set:** input image; the move in TransformToMatrix (shift XYZ + theta/phi); frames-per-segment (`n_points`) in CameraMotionNode.\n- **Requires:** Depth-Anything V2 (auto-download).\n- **Note:** the pinhole FOV here is 60° — keep DepthToPointCloud and CameraMotionNode FOVs consistent with your input."
],
"color": "#432",
"bgcolor": "#653"
}
],
"links": [
[
1,
1,
0,
2,
0,
"IMAGE"
],
[
3,
1,
0,
3,
0,
"IMAGE"
],
[
4,
2,
0,
4,
0,
"TENSOR"
],
[
5,
5,
0,
4,
1,
"MAT_4X4"
],
[
23,
4,
0,
14,
0,
"TENSOR"
],
[
24,
14,
0,
10,
0,
"IMAGE"
],
[
25,
5,
0,
17,
1,
"MAT_4X4"
],
[
26,
2,
0,
17,
0,
"TENSOR"
],
[
27,
17,
0,
14,
1,
"TENSOR"
],
[
31,
3,
0,
20,
0,
"TENSOR"
],
[
32,
20,
0,
2,
1,
"TENSOR"
]
],
"groups": [
{
"id": 1,
"title": "1. Image → point cloud",
"bounding": [
1550.1402587890625,
1325.9761962890625,
884.6761474609375,
635.9034423828125
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
},
{
"id": 2,
"title": "2. Trajectory",
"bounding": [
2276.978759765625,
1345.860595703125,
782.6866455078125,
570.35400390625
],
"color": "#a1309b",
"font_size": 24,
"flags": {}
},
{
"id": 3,
"title": "3. Render & save",
"bounding": [
2964.30224609375,
1426.6483154296875,
722.80126953125,
566.046875
],
"color": "#8A8",
"font_size": 24,
"flags": {}
}
],
"config": {},
"extra": {
"ds": {
"scale": 1.083470594338842,
"offset": [
-1328.4875281402603,
-1380.0248413907814
]
},
"frontendVersion": "1.18.9",
"camera_comfyui_rev": 1
},
"version": 0.4
}
+212
View File
@@ -0,0 +1,212 @@
{
"id": "00000000-0000-0000-0000-000000000000",
"revision": 0,
"last_node_id": 5,
"last_link_id": 3,
"nodes": [
{
"id": 1,
"type": "TransformToMatrix",
"title": "Start pose (identity)",
"pos": [
-500,
320
],
"size": [
315,
154
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "transformation matrix",
"type": "MAT_4X4",
"links": [
1
],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "TransformToMatrix"
},
"widgets_values": [
0.0,
0.0,
0.0,
0.0,
0.0
]
},
{
"id": 2,
"type": "TransformToMatrix",
"title": "End pose (edit me)",
"pos": [
-500,
540
],
"size": [
315,
154
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "transformation matrix",
"type": "MAT_4X4",
"links": [
2
],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "TransformToMatrix"
},
"widgets_values": [
0.0,
0.0,
0.3,
0.0,
30.0
]
},
{
"id": 3,
"type": "CameraInterpolationNode",
"title": "Interpolate 20 poses",
"pos": [
-120,
430
],
"size": [
226,
78
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "initial_matrix",
"type": "MAT_4X4",
"link": 1
},
{
"name": "final_matrix",
"type": "MAT_4X4",
"link": 2
}
],
"outputs": [
{
"name": "trajectory",
"type": "TENSOR",
"links": [
3
],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "CameraInterpolationNode"
},
"widgets_values": [
20
]
},
{
"id": 4,
"type": "SaveTrajectory",
"title": "Save to output/*.npy",
"pos": [
170,
430
],
"size": [
315,
82
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "trajectory",
"type": "TENSOR",
"link": 3
}
],
"outputs": [],
"properties": {
"Node name for S&R": "SaveTrajectory"
},
"widgets_values": [
"ComfyUITrajectory"
]
},
{
"id": 5,
"type": "MarkdownNote",
"title": "About this workflow",
"pos": [
-1080,
320
],
"size": [
520,
430
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {},
"widgets_values": [
"# Record a camera trajectory\n\nProduces the `.npy` trajectory file consumed by `PC_enricher.json` and `wan_vace_ref_to_video.json` (LoadTrajectory): two poses are SE(3)-interpolated into a smooth 20-step path and saved by **SaveTrajectory** to your ComfyUI **output** directory.\n\n- **Set:** the end pose (shift XYZ in scene units — metric if the cloud came from metric depth — plus theta = pitch, phi = yaw) and `num_steps`.\n- **Then:** move the saved file from `output/` to `input/` so LoadTrajectory can list it. A bundled example (`ComfyUITrajectory_00001.npy`) is already installed by install.py.\n- Chain more CameraInterpolationNode segments (or use CameraTrajectoryNode on a point cloud) for multi-keyframe paths."
],
"color": "#432",
"bgcolor": "#653"
}
],
"links": [
[
1,
1,
0,
3,
0,
"MAT_4X4"
],
[
2,
2,
0,
3,
1,
"MAT_4X4"
],
[
3,
3,
0,
4,
0,
"TENSOR"
]
],
"groups": [],
"config": {},
"extra": {
"camera_comfyui_rev": 1
},
"version": 0.4
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

+39 -11
View File
@@ -1,7 +1,7 @@
{
"id": "4e89c8a4-594b-4d15-a089-da088966adee",
"revision": 0,
"last_node_id": 44,
"last_node_id": 45,
"last_link_id": 87,
"nodes": [
{
@@ -687,7 +687,8 @@
0,
0,
0
]
],
"title": "Eye baseline (shiftX 0.1)"
},
{
"id": 31,
@@ -871,7 +872,8 @@
"properties": {},
"widgets_values": [
"shifted_camera_equirect"
]
],
"title": "Right eye (equirect)"
},
{
"id": 27,
@@ -908,7 +910,7 @@
"Node name for S&R": "LoadImage"
},
"widgets_values": [
"Fisheye_outpainted_flux_dev.png",
"camera_example_fisheye.jpg",
"image"
]
},
@@ -998,7 +1000,32 @@
"properties": {},
"widgets_values": [
"init_camera_equirect"
]
],
"title": "Left eye (equirect)"
},
{
"id": 45,
"type": "MarkdownNote",
"title": "About this workflow",
"pos": [
929.3206787109375,
1152.5965576171875
],
"size": [
520,
262
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {},
"widgets_values": [
"# SBS VR180: synthesize the second eye\n\nFrom one 180° fisheye view, synthesizes a stereo pair: fisheye metric depth → point cloud → clean → shift the camera by the eye baseline (TransformToMatrix shiftX = 0.1) → re-project to fisheye → four chained OutpaintAnyProjection passes fill the disocclusions → both eyes are exported as 180° equirectangular images.\n\n- **Set:** fisheye input (e.g. from `Outpaint_fisheye180.json`); the baseline (0.1 ≈ 6.5 cm when depth is metric); prompts optional.\n- **Requires:** inpainting_flux (auto-installed), Depth-Anything V2.\n- **Output:** `init_camera_equirect` + `shifted_camera_equirect` — combine side-by-side for a VR180 player."
],
"color": "#432",
"bgcolor": "#653"
}
],
"links": [
@@ -1230,7 +1257,7 @@
"groups": [
{
"id": 1,
"title": "Metric depth estimation",
"title": "1. Fisheye metric depth",
"bounding": [
1474.8399658203125,
1625.7159423828125,
@@ -1243,7 +1270,7 @@
},
{
"id": 2,
"title": "Pointcloud manipulation",
"title": "2. Point cloud & eye-baseline shift",
"bounding": [
2231.516845703125,
1746.5635986328125,
@@ -1256,7 +1283,7 @@
},
{
"id": 4,
"title": "Outpaint and project to equirect",
"title": "3. Outpaint disocclusions & export equirect",
"bounding": [
3721.45654296875,
1723.2581787109375,
@@ -1269,7 +1296,7 @@
},
{
"id": 5,
"title": "Visualisations",
"title": "Previews",
"bounding": [
2852.595703125,
1071.784912109375,
@@ -1290,7 +1317,8 @@
-1811.182955009025
]
},
"frontendVersion": "1.20.4"
"frontendVersion": "1.20.4",
"camera_comfyui_rev": 1
},
"version": 0.4
}
}
@@ -1,7 +1,7 @@
{
"id": "dd56c0bf-7405-406e-924f-42b2feacb73f",
"revision": 0,
"last_node_id": 8,
"last_node_id": 9,
"last_link_id": 9,
"nodes": [
{
@@ -87,7 +87,8 @@
0,
false,
false
]
],
"title": "Render motion"
},
{
"id": 6,
@@ -118,7 +119,8 @@
},
"widgets_values": [
"ComfyUIPointCloud_00001.ply"
]
],
"title": "Load saved cloud (.ply/.npy)"
},
{
"id": 1,
@@ -153,7 +155,8 @@
0,
0,
0
]
],
"title": "Start pose (identity)"
},
{
"id": 5,
@@ -188,7 +191,8 @@
0,
0,
0
]
],
"title": "End pose (dolly +0.1)"
},
{
"id": 8,
@@ -228,7 +232,33 @@
"properties": {
"Node name for S&R": "CameraInterpolationNode"
},
"widgets_values": []
"widgets_values": [
2
]
},
{
"id": 9,
"type": "MarkdownNote",
"title": "About this workflow",
"pos": [
-1097.2319946289062,
1294.427734375
],
"size": [
520,
236
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {},
"widgets_values": [
"# Load a saved point cloud & orbit\n\nReloads a point cloud saved by SavePointCloud and renders a short camera move between two poses (identity → 0.1 forward) as a WEBM.\n\n- **Set:** the file in LoadPointCloud (dropdown lists the ComfyUI input dir — run `PointCloud.json` or `fisheye_to_pointcloud.json` first); the two TransformToMatrix poses.\n- **Requires:** nothing beyond this pack."
],
"color": "#432",
"bgcolor": "#653"
}
],
"links": [
@@ -283,7 +313,8 @@
-1186.3099424359816
]
},
"frontendVersion": "1.21.7"
"frontendVersion": "1.21.7",
"camera_comfyui_rev": 1
},
"version": 0.4
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

+138 -309
View File
@@ -1,7 +1,7 @@
{
"id": "6a6957a6-60ed-4027-9a58-b0b7a511139f",
"revision": 0,
"last_node_id": 75,
"last_node_id": 76,
"last_link_id": 151,
"nodes": [
{
@@ -44,7 +44,9 @@
"ver": "bc358f82634eb51b60207e1a8ddd5b29aaaaa2ae",
"Node name for S&R": "CameraInterpolationNode"
},
"widgets_values": []
"widgets_values": [
2
]
},
{
"id": 30,
@@ -205,142 +207,6 @@
"default"
]
},
{
"id": 26,
"type": "easy mathInt",
"pos": [
-358.4687805175781,
47.47260284423828
],
"size": [
270,
106
],
"flags": {},
"order": 13,
"mode": 0,
"inputs": [
{
"name": "a",
"type": "INT",
"widget": {
"name": "a"
},
"link": 28
},
{
"name": "b",
"type": "INT",
"widget": {
"name": "b"
},
"link": 29
}
],
"outputs": [
{
"name": "INT",
"type": "INT",
"links": [
30
]
}
],
"properties": {
"cnr_id": "comfyui-easy-use",
"ver": "1.3.0",
"Node name for S&R": "easy mathInt"
},
"widgets_values": [
0,
0,
"subtract"
]
},
{
"id": 23,
"type": "VHS_VideoInfo",
"pos": [
-582.7188720703125,
224.42620849609375
],
"size": [
225.59765625,
206
],
"flags": {},
"order": 11,
"mode": 0,
"inputs": [
{
"name": "video_info",
"type": "VHS_VIDEOINFO",
"link": 25
}
],
"outputs": [
{
"name": "source_fps🟨",
"type": "FLOAT",
"links": []
},
{
"name": "source_frame_count🟨",
"type": "INT",
"links": []
},
{
"name": "source_duration🟨",
"type": "FLOAT",
"links": null
},
{
"name": "source_width🟨",
"type": "INT",
"links": [
28
]
},
{
"name": "source_height🟨",
"type": "INT",
"links": [
29
]
},
{
"name": "loaded_fps🟦",
"type": "FLOAT",
"links": null
},
{
"name": "loaded_frame_count🟦",
"type": "INT",
"links": null
},
{
"name": "loaded_duration🟦",
"type": "FLOAT",
"links": null
},
{
"name": "loaded_width🟦",
"type": "INT",
"links": null
},
{
"name": "loaded_height🟦",
"type": "INT",
"links": null
}
],
"properties": {
"cnr_id": "comfyui-videohelpersuite",
"ver": "a7ce59e381934733bfae03b1be029756d6ce936d",
"Node name for S&R": "VHS_VideoInfo"
},
"widgets_values": {}
},
{
"id": 28,
"type": "WanVaceToVideo",
@@ -432,7 +298,8 @@
81,
1,
1
]
],
"title": "WAN VACE control"
},
{
"id": 35,
@@ -566,7 +433,7 @@
},
{
"id": 63,
"type": "GetImageRangeFromBatch",
"type": "ImageFromBatch",
"pos": [
583.0528564453125,
-1016.3375854492188
@@ -580,7 +447,7 @@
"mode": 0,
"inputs": [
{
"name": "images",
"name": "image",
"shape": 7,
"type": "IMAGE",
"link": 116
@@ -609,12 +476,13 @@
"properties": {
"cnr_id": "comfyui-kjnodes",
"ver": "d57154c3a808b8a3f232ed293eaa2d000867c884",
"Node name for S&R": "GetImageRangeFromBatch"
"Node name for S&R": "ImageFromBatch"
},
"widgets_values": [
0,
1
]
],
"title": "First frame (for captioning)"
},
{
"id": 62,
@@ -755,51 +623,6 @@
32
]
},
{
"id": 27,
"type": "easy mathInt",
"pos": [
-29.064062118530273,
56.160377502441406
],
"size": [
270,
106
],
"flags": {},
"order": 15,
"mode": 0,
"inputs": [
{
"name": "a",
"type": "INT",
"widget": {
"name": "a"
},
"link": 30
}
],
"outputs": [
{
"name": "INT",
"type": "INT",
"links": [
31,
32
]
}
],
"properties": {
"cnr_id": "comfyui-easy-use",
"ver": "1.3.0",
"Node name for S&R": "easy mathInt"
},
"widgets_values": [
0,
2,
"divide"
]
},
{
"id": 19,
"type": "ImageScale",
@@ -862,22 +685,6 @@
"name": "image",
"type": "IMAGE",
"link": 22
},
{
"name": "top",
"type": "INT",
"widget": {
"name": "top"
},
"link": 31
},
{
"name": "bottom",
"type": "INT",
"widget": {
"name": "bottom"
},
"link": 32
}
],
"outputs": [
@@ -907,7 +714,8 @@
0,
280,
1
]
],
"title": "Pad to square — set top/bottom to (W−H)/2"
},
{
"id": 68,
@@ -1018,7 +826,8 @@
1.0000000000000002,
0,
-16.666717529296875
]
],
"title": "Final camera pose (edit me)"
},
{
"id": 61,
@@ -1138,9 +947,7 @@
{
"name": "video_info",
"type": "VHS_VIDEOINFO",
"links": [
25
]
"links": []
}
],
"properties": {
@@ -1221,45 +1028,6 @@
60
]
},
{
"id": 60,
"type": "BlurMaskFast",
"pos": [
1376.188720703125,
442.5711669921875
],
"size": [
270,
82
],
"flags": {},
"order": 25,
"mode": 0,
"inputs": [
{
"name": "masks",
"type": "MASK",
"link": 111
}
],
"outputs": [
{
"name": "MASK",
"type": "MASK",
"links": []
}
],
"properties": {
"cnr_id": "ComfyUI-Image-Filters",
"ver": "5abb3c2395739e0b94c2061bbc49bd1349de97c4",
"Node name for S&R": "BlurMaskFast",
"aux_id": "spacepxl/ComfyUI-Image-Filters"
},
"widgets_values": [
0,
0
]
},
{
"id": 14,
"type": "SaveWEBM",
@@ -1371,7 +1139,8 @@
3,
1,
1
]
],
"title": "Re-render along trajectory"
},
{
"id": 4,
@@ -1433,7 +1202,6 @@
"name": "MASK",
"type": "MASK",
"links": [
111,
144,
145,
149
@@ -1715,6 +1483,30 @@
],
"color": "#232",
"bgcolor": "#353"
},
{
"id": 76,
"type": "MarkdownNote",
"title": "About this workflow",
"pos": [
-1439.115966796875,
-1016.3375854492188
],
"size": [
520,
392
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {},
"widgets_values": [
"# Re-shoot a video with a new camera move\n\nRe-renders an input video along a user-defined camera trajectory and uses WAN 2.1 VACE to regenerate what the new camera reveals:\n\n1. Video is padded square (ImagePadForOutpaint — set top/bottom to (width−height)/2 for your video) and depth-estimated per frame (Video-Depth-Anything metric).\n2. **VideoCameraMotionSequence** lifts each frame to a point cloud and re-renders it along the SE(3)-interpolated trajectory.\n3. Disocclusion masks + re-rendered frames become VACE control video/masks; **Florence2** auto-captions the clip as the prompt; WAN 2.1 VACE 14B fills the gaps.\n\n- **Set:** video path (VHS Load Video Path); the camera move (two TransformToMatrix poses); pad amounts; override the auto-caption in CLIPTextEncode if desired.\n- **Requires (packs):** VideoHelperSuite, ComfyUI-Florence2.\n- **Requires (models):** `metric_video_depth_anything_vitl.pth` (install.sh `depth`), WAN 2.1 VACE 14B + umt5-xxl + WAN VAE (install.sh `vae`), Florence-2 (auto-download).\n- **Outputs:** re-rendered composite WEBM, depth WEBM, final VACE clip."
],
"color": "#432",
"bgcolor": "#653"
}
],
"links": [
@@ -1798,54 +1590,6 @@
0,
"IMAGE"
],
[
25,
9,
3,
23,
0,
"VHS_VIDEOINFO"
],
[
28,
23,
3,
26,
0,
"INT"
],
[
29,
23,
4,
26,
1,
"INT"
],
[
30,
26,
0,
27,
0,
"INT"
],
[
31,
27,
0,
21,
1,
"INT"
],
[
32,
27,
0,
21,
2,
"INT"
],
[
35,
33,
@@ -1974,14 +1718,6 @@
1,
"VAE"
],
[
111,
41,
0,
60,
0,
"MASK"
],
[
113,
63,
@@ -2111,7 +1847,99 @@
"IMAGE"
]
],
"groups": [],
"groups": [
{
"id": 1,
"title": "1. Load & pad video",
"bounding": [
-899.115966796875,
135.00233459472656,
1236.9286651611328,
882.2797393798828
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
},
{
"id": 2,
"title": "2. Metric video depth",
"bounding": [
-376.9869384765625,
436.6513671875,
645.8890285491943,
255.26275634765625
],
"color": "#a1309b",
"font_size": 24,
"flags": {}
},
{
"id": 3,
"title": "3. Re-render with new camera",
"bounding": [
-310.3587646484375,
426.8964538574219,
978.3455200195312,
817.5969543457031
],
"color": "#8A8",
"font_size": 24,
"flags": {}
},
{
"id": 4,
"title": "4. Masks & composite",
"bounding": [
701.5014038085938,
437.3684387207031,
313.2791748046875,
562.5854187011719
],
"color": "#b58b2a",
"font_size": 24,
"flags": {}
},
{
"id": 5,
"title": "5. Auto-caption (Florence2)",
"bounding": [
402.0513000488281,
-1076.3375854492188,
915.5072937011719,
674.8928833007812
],
"color": "#88A",
"font_size": 24,
"flags": {}
},
{
"id": 6,
"title": "6. WAN VACE re-generation",
"bounding": [
-252.87193298339844,
-580.986328125,
2209.4490814208984,
931.6179580688477
],
"color": "#b06634",
"font_size": 24,
"flags": {}
},
{
"id": 7,
"title": "7. Outputs",
"bounding": [
782.4984130859375,
-267.39794921875,
1570.5733642578125,
1506.2070922851562
],
"color": "#535",
"font_size": 24,
"flags": {}
}
],
"config": {},
"extra": {
"ds": {
@@ -2125,7 +1953,8 @@
"VHS_latentpreview": false,
"VHS_latentpreviewrate": 0,
"VHS_MetadataImage": true,
"VHS_KeepIntermediate": true
"VHS_KeepIntermediate": true,
"camera_comfyui_rev": 1
},
"version": 0.4
}
}
+2 -1
View File
@@ -1605,7 +1605,8 @@
450
]
},
"frontendVersion": "1.23.4"
"frontendVersion": "1.23.4",
"camera_comfyui_rev": 1
},
"version": 0.4
}
+2 -1
View File
@@ -1509,7 +1509,8 @@
450
]
},
"frontendVersion": "1.23.4"
"frontendVersion": "1.23.4",
"camera_comfyui_rev": 1
},
"version": 0.4
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

+88 -8
View File
@@ -1,7 +1,7 @@
{
"id": "0898f6a6-2814-4ccd-968a-a2405ee177e7",
"revision": 0,
"last_node_id": 85,
"last_node_id": 86,
"last_link_id": 152,
"nodes": [
{
@@ -495,7 +495,8 @@
81,
1,
1
]
],
"title": "WAN VACE control"
},
{
"id": 63,
@@ -706,7 +707,7 @@
"widget_ue_connectable": {}
},
"widgets_values": [
"Fisheye_outpainted_flux_dev.png",
"camera_example_fisheye.jpg",
"image"
]
},
@@ -780,7 +781,7 @@
"widget_ue_connectable": {}
},
"widgets_values": [
"wan2,1_vace14B_fp16.safetensors",
"wan2.1_vace_14B_fp16.safetensors",
"default"
]
},
@@ -1051,7 +1052,8 @@
0,
true,
false
]
],
"title": "Render control frames + masks"
},
{
"id": 85,
@@ -1082,6 +1084,30 @@
24,
32
]
},
{
"id": 86,
"type": "MarkdownNote",
"title": "About this workflow",
"pos": [
-1085.614501953125,
34.52389907836914
],
"size": [
520,
262
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {},
"widgets_values": [
"# WAN VACE: still image + camera move → video\n\nTurns a single 180° fisheye still into a camera-move video: metric depth → point cloud → **CameraMotionNode** renders point-splat frames and masks along a loaded trajectory; these become the VACE control video/masks with the original image as the reference, and WAN 2.1 VACE 14B synthesizes the final clip from your prompt.\n\n- **Set:** fisheye image; trajectory file (record one with SaveTrajectory); the positive prompt.\n- **Requires:** WAN 2.1 VACE models (install.sh `vae`), Depth-Anything V2, VideoHelperSuite (only for the h264/MP4 export — SaveWEBM works without it).\n- **Fixed 2026-07:** the UNET filename contained a typo (`wan2,1_vace14B` → `wan2.1_vace_14B_fp16.safetensors`)."
],
"color": "#432",
"bgcolor": "#653"
}
],
"links": [
@@ -1318,7 +1344,60 @@
"IMAGE"
]
],
"groups": [],
"groups": [
{
"id": 1,
"title": "1. Fisheye image → point cloud",
"bounding": [
-545.614501953125,
357.1524963378906,
1213.47607421875,
760.8647155761719
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
},
{
"id": 2,
"title": "2. Camera-motion control video",
"bounding": [
-28.916632652282715,
970.2586669921875,
1234.2812566757202,
456.984619140625
],
"color": "#a1309b",
"font_size": 24,
"flags": {}
},
{
"id": 3,
"title": "3. WAN VACE generation",
"bounding": [
-35.783010482788086,
-25.47610092163086,
1676.566946029663,
968.7900657653809
],
"color": "#8A8",
"font_size": 24,
"flags": {}
},
{
"id": 4,
"title": "4. Save",
"bounding": [
1361.3507080078125,
107.86778259277344,
998.1239013671875,
914.3678741455078
],
"color": "#b58b2a",
"font_size": 24,
"flags": {}
}
],
"config": {},
"extra": {
"ds": {
@@ -1334,7 +1413,8 @@
"VHS_latentpreview": false,
"VHS_latentpreviewrate": 0,
"VHS_MetadataImage": true,
"VHS_KeepIntermediate": true
"VHS_KeepIntermediate": true,
"camera_comfyui_rev": 1
},
"version": 0.4
}
}
+2 -1
View File
@@ -71,7 +71,8 @@ def _load_outpaint_node_class():
raise RuntimeError(
"OutpaintAnyProjection could not be imported from flux_fisheye_filling_nodes. "
"It requires the inpainting_flux custom node package (Flux NF4 inpainting, "
"diffusers). Install/fix custom_nodes/inpainting_flux and its dependencies. "
"diffusers), which this pack's install.py sets up automatically (ComfyUI-Manager "
"runs it on install). Run install.py or fix custom_nodes/inpainting_flux. "
f"Import error: {exc}"
) from exc