Compare commits

...
24 Commits
Author SHA1 Message Date
KOFiblto e596c6de5a docs(changelog): finalize v2.5.0 release notes with dock, lora caching, and settings improvements 2026-10-10 15:57:33 +02:00
KOFiblto 387b5ceb0b perf(lora_loader): non-blocking async thumbnailing, bicubic fast-webp encoding, viewport lazy-loading, and bypass setting 2026-10-08 22:58:41 +02:00
KOFiblto 5369980d57 feat(dock): open live latent in core lightbox inspecting view with real-time sampling updates 2026-10-07 22:29:45 +02:00
KOFiblto 61011eea96 feat(settings): convert dock dimensions and queue height to continuous sliders and optimize layout observer 2026-10-05 21:56:46 +02:00
KOFiblto d706f0eb00 Fix bottom toolbar alignment, default dock size 280px, queue visible height setting, footer injection, and banner spacing 2026-10-05 18:41:54 +02:00
KOFiblto 77637e0626 Remove Sampling badge and text from live latent preview dock 2026-10-05 18:21:18 +02:00
KOFiblto 9c9917961c perf(lora_loader): add disk-cached WebP thumbnail generation and lazy loading for visual lora tiles 2026-10-05 17:56:39 +02:00
KOFiblto a32bb7f913 fix(settings,cli,colors): node management class grouping, footer isolation, startup total format, and beforeRegisterNodeDef error 2026-10-05 17:14:36 +02:00
KOFiblto 4c6493fe63 fix(queue): export persistent_queue_manager alias for startup banner 2026-10-05 17:00:21 +02:00
KOFiblto c12beff2d2 feat(cli): add system status lines to startup banner 2026-10-05 16:59:04 +02:00
KOFiblto 5e2872d5f8 feat(cli): clean unified startup banner in Saturn colors and remove noisy internal debug prints 2026-10-05 16:52:00 +02:00
KOFiblto 5a8f23ea2b feat(dock,settings): exact aspect contain sizing and flush alignment, remove Stereo3DLive, and detect disabled workflow nodes 2026-10-05 16:43:29 +02:00
KOFiblto 12492185d2 feat(pause,preview,nodes): pause before queue pop, flush-right portrait dock, and selective node disabling in settings 2026-10-05 16:19:43 +02:00
KOFiblto c99af98e09 feat(preview): add floating island live latent dock with dynamic minimap tracking and aspect ratio shape 2026-10-05 16:10:32 +02:00
KOFiblto a8a8946edc fix(ci): replace cv2 with PIL and add graceful fallback when onnxruntime is unavailable 2026-10-01 18:13:52 +02:00
KOFiblto 16d2ed916d fix(stereo,queue): add 1s live countdown with mid-render interrupt, fix slider NaN bug, and update restored queue create_time 2026-10-01 18:07:03 +02:00
KOFiblto 31047dc9da feat(stereo): implement SaturnStereo3D and SaturnStereo3DLive with 500ms debounced live view and native lightbox modal, fix menubar dropdown injection 2026-09-30 22:41:01 +02:00
KOFiblto e15f716563 feat(3d): implement Stereonix-inspired Stereoscopic 3D & Cross-Eye Generator node 2026-09-30 21:16:27 +02:00
KOFiblto a6fbfe06e0 docs: establish strict version-named branching workflow and initialize v2.5.0 development track 2026-09-29 20:42:04 +02:00
KOFiblto 11a8d9d218 docs: restore collapsible details accordions in README for compact catalog viewing 2026-09-29 20:33:49 +02:00
KOFiblto ebc9903bf2 docs: replace html details with standard markdown headings for registry rendering and sanitize example names 2026-09-29 20:26:33 +02:00
KOFiblto 7c57ccaae4 fix(security): strictly confine folder paths and file deletion to ComfyUI input/output directories 2026-09-24 09:13:49 +02:00
KOFiblto 3653d2883f chore: bump version to 2.4.1 (latest development) 2026-09-24 00:55:49 +02:00
KOFiblto 9c5870f938 chore(release): v2.4.0 stable release 2026-09-24 00:55:18 +02:00
28 changed files with 3053 additions and 209 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
# Optional Civitai API Key for auto-downloading LoRA preview thumbnails
CIVITAI_API_KEY=
# Optional TMDB API Key / Read Access Token for celebrity image preview search
# Optional TMDB API Key / Read Access Token for actor / media preview image search
TMDB_API_KEY=
# Feature toggles
+5
View File
@@ -23,3 +23,8 @@ user/
.vscode/
.DS_Store
Thumbs.db
# Downloaded models and checkpoints
models/
*.onnx
*.onnx.tmp
+29 -20
View File
@@ -17,9 +17,9 @@ For Git workflow, branch protection, and Pull Request guidelines, see **[`CONTRI
| **`WALKTHROUGH.md`** | Step-by-step user tutorials, example workflows, visual load guides, and batch prompting patterns. | [WALKTHROUGH.md](./WALKTHROUGH.md) |
| **`CHANGELOG.md`** | Strict Keep-A-Changelog semantic version release history across all updates. | [CHANGELOG.md](./CHANGELOG.md) |
| **`SECURITY.md`** | Security policies, supported versions, and private vulnerability disclosure instructions. | [SECURITY.md](./SECURITY.md) |
| **`SECURITY_AUDIT_PLAYBOOK.md`** | Authoritative ComfyUI custom node security rulebook, PR #3200 review precedents, threat model, and coding standards. | [SECURITY_AUDIT_PLAYBOOK.md](./SECURITY_AUDIT_PLAYBOOK.md) |
| **`CLAUDE.md`** | AI agent instruction pointer. | [CLAUDE.md](./CLAUDE.md) |
| **`GEMINI.md`** | AI agent instruction pointer. | [GEMINI.md](./GEMINI.md) |
| **`AGENTS.md`** | AI agent instruction pointer. | [AGENTS.md](./AGENTS.md) |
| **`bug_report.md`** | GitHub issue template for reporting reproducible defects. | [bug_report.md](./.github/ISSUE_TEMPLATE/bug_report.md) |
| **`feature_request.md`** | GitHub issue template for proposing new nodes or UI features. | [feature_request.md](./.github/ISSUE_TEMPLATE/feature_request.md) |
@@ -129,8 +129,8 @@ When implementing a new custom node, you **must complete all 5 steps**:
- Previews & Decisions: Violet (`color: "#7c3aed"`, `bgcolor: "#6d28d9"`)
### Step 2.4: Documentation (`README.md` & `WALKTHROUGH.md`)
- [ ] Add a collapsible `<details>` section in [`README.md`](./README.md) under `## 📦 Individual Nodes Reference`:
- Node summary tag: `<summary><b>🍃 🏷️ Display Name</b> (<code>ClassName</code>)</summary>`
- [ ] Add a clean heading section in [`README.md`](./README.md) under `## 📦 Individual Nodes Reference`:
- Heading: `### 🪐 🏷️ Display Name — \`ClassName\``
- `#### Overview`
- `#### Inputs & Widgets`
- `#### Outputs`
@@ -238,31 +238,40 @@ If legacy files need to be migrated in future multi-user releases or version upg
---
## 6. Git, Branching & Commit Conventions
## 6. Git, Branching, Versioning & Release Strategy (MANDATORY)
### 6.1 Strict Branch Naming Classification
Never create misleading or overly narrow branch names when multiple components, fixes, and features are bundled together!
- **Atomic Feature Branches** (`feature/<feature-name>`):
- Used **ONLY** when implementing a single, isolated new feature or node.
- Examples: `feature/prompt-iterator-privacy`, `feature/visual-loaders-pathing`
- **Atomic Bug Fix Branches** (`fix/<bug-name>`):
- Used **ONLY** for targeted bug fixes.
- Examples: `fix/preview-latent-insets`, `fix/tray-icon-persistence`
- **Comprehensive Integration & Release Branches** (`release/vX.Y.Z-<summary>` or `dev/<summary>`):
- **MANDATORY**: Whenever bundling multiple features, fixes, rebrands, or architectural updates into a combined testing or release candidate, use a `release/...` or `dev/...` branch name.
- **STRICTLY PROHIBITED**: Never disguise a multi-feature/fix release bundle under a single isolated `feature/<narrow-name>`.
- Examples: `release/v2.2.0-leafflow-update`, `dev/v2.2.0-full-integration`
### 6.1 Version-Named Branching Workflow
From version `v2.5.0` onward, all development work in this repository strictly adheres to a version-scoped branching model:
1. **Active Work Occurs ONLY in the Current Version Branch**:
- All active development, bug fixes, UI improvements, and code changes **MUST** occur on a dedicated branch named directly after the target version prefixed with `v` (e.g. `v2.5.0`).
- Git and GitHub fully support dots in branch names (`v2.5.0`, `v2.6.0`, `v3.0.0`).
- **STRICTLY PROHIBITED**: Never commit or push directly to `main` during active development.
2. **Strict User-Approval Gate for Merges & Releases**:
- Merging to `main` is **STRICTLY GATED** by explicit user approval.
- **NEVER** merge a version branch into `main` unless the user explicitly commands: *"merge to main"* or *"make a release"*.
3. **Release & Tagging Execution Steps (Upon User Command)**:
When explicit permission to release is granted:
- **Merge**: Merge the version branch (e.g. `v2.5.0`) into `main`.
- **Tag**: Create an annotated Git tag prefixed with `v` (e.g. `git tag -a v2.5.0 -m "v2.5.0 ..."`).
- *Rule*: **ALWAYS** use the `v` prefix (`v2.5.0`, `v2.6.0`). **NEVER** create bare tags without `v` (like `2.5.0`).
- **GitHub Release**: Publish or update the GitHub Release attached to that `vX.Y.Z` tag.
- *Rule*: Every release **MUST** include a clear, descriptive title (e.g. `v2.5.0 - <Feature/Theme Summary> 🪐`) and a concise markdown bullet-point explanation of what changed and what was fixed (matching the standard of `v2.3.2` and `v2.4.0`), never just bare automated compare links.
- **Next Version Transition**:
- Immediately checkout a new branch from `main` named after the next target version (e.g. `git checkout -b v2.6.0` or `git checkout -b v3.0.0`).
- Update version identifiers in `pyproject.toml`, `__init__.py`, `CHANGELOG.md`, and frontend info to the new development version.
- Continue all subsequent work exclusively on the new version branch.
### 6.2 Excluded Files & Privacy
Never commit sensitive keys, runtime states, or caches:
Never commit sensitive keys, personal paths, runtime states, or caches:
- `.env`, `.env.local`
- `persistent_queue.json`, `lora_usage.json`, `failed_scrapes.json`, `image_prompts_cache.json`
- `user/` directory
- Personal user names, machine paths (`C:\Users\...`), or private model names.
### 6.3 Commit Messages
Use atomic, descriptive English commit headers:
- `Feat: ...` (New features or nodes)
- `Fix: ...` (Bug fixes, UI sync repairs)
- `Fix: ...` (Bug fixes, UI repairs)
- `Docs: ...` (Documentation, README, Walkthrough updates)
- `Refactor: ...` (Code restructuring or rebrands)
- `Release: ...` (Consolidated version releases)
- `Refactor: ...` (Code restructuring or cleanup)
- `Release: ...` (Consolidated version release merge)
+62 -1
View File
@@ -5,6 +5,67 @@ All notable changes to `ComfyUI-SaturnNodes` will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.5.0] - 2026-10-10 [Stable]
### Added
- **Floating Latent Live Preview Dock & Core Lightbox Live Inspection**:
- Floating live latent preview dock with dynamic aspect ratio preservation and contain-scaling, eliminating letterbox bars and layout jitter.
- Minimap tracking and alignment alongside canvas navigation controls.
- **Native Core Lightbox Live Inspecting View**: Clicking the floating latent dock opens the live generation directly in ComfyUI's native inspecting lightbox view, updating live in real time as each diffusion sampling step arrives.
- Minimalist design: removed redundant "Sampling..." badge overlays for distraction-free monitoring.
- **High-Performance LoRA Loader Engine & Viewport WebP Lazy-Loading**:
- **Disk-Cached WebP Generation**: High-resolution preview images are downscaled via bicubic fast-WebP encoding and cached in `ComfyUI/user/default/SaturnNodes/cache/lora_thumbs/` (~35KB per card).
- **Non-Blocking Background Worker**: Thumbnail processing runs asynchronously on worker threads with `threading.Event`, eliminating canvas UI thread lockups during heavy directory indexing.
- **Viewport IntersectionObserver**: Implemented native `IntersectionObserver` on visual LoRA cards so thumbnails only load as they scroll into view.
- Added an explicit settings toggle to bypass thumbnail generation if desired.
- **Stereoscopic 3D / Cross-Eye Generator (`SaturnStereo3D`)**:
- Transform 2D images into stereoscopic 3D pairs for cross-eye viewing, parallel 3D viewers, VR displays, and red-cyan anaglyphs.
- Hybrid depth execution: accepts any input `depth_map` (zero downloads, 1ms execution) or automatically falls back to built-in Depth Anything V2 Small ONNX model cached locally, with mathematical gradient fallback when ONNX is absent.
- Stereonix-inspired controls: `depth_intensity`, `depth_gamma`, `invert_depth`, `convergence_plane`, `image_zoom`, `pan_horizontal`, `pan_vertical`.
- Framing and focus controls: adjustable `gap_spacing`, `outer_border` padding, and high-contrast `alignment_dots`.
- **Stereoscopic 3D Live Viewer (`SaturnStereo3DLive`)**:
- Interactive live viewer node featuring a real-time **1s countdown timer** on parameter adjustment.
- **Mid-Render Interruption**: Modifying sliders during generation immediately sends `/interrupt` to abort obsolete rendering and restarts the 1s countdown.
- **In-Memory Depth Map Caching**: Cached depth predictions allow near-instant (~5ms) re-rendering when tweaking intensity, gamma, zoom, pan, and spacing on the same input image.
- **Queue Execution Controls & Mid-Generation Pause**:
- Added execution interception hook allowing the queue to pause before popping the next item from the queue.
- Restored queue prompt `create_time` timestamps are refreshed upon restart to prevent deprioritization in history.
- **Modernized Continuous Sliders Settings UI**:
- Converted `Visible Queue Items (Height)` and `Live Latent Dock Size` from rigid dropdowns into smooth continuous sliders.
- Cleaned settings categories and isolated context menus to prevent bleeding into top PrimeVue application menubars.
- **CLI Terminal Branding & Clean Telemetry**:
- Clean unified startup banner styled in Saturn amber theme.
- Cleaned noisy internal debug logging, displaying concise status lines for node count, persistence status, and storage paths.
### Fixed
- **Persistent Queue Restored Creation Timestamp**:
- Fixed an issue where unfinished prompts resumed after a server restart sorted to the bottom of the history and assets list by updating `create_time` to the current timestamp on recovery.
- **Live Viewer Widget Value Corruption**:
- Fixed an issue where sliding widgets on `SaturnStereo3DLive` set them to `NaN` or `0` due to arrow-function callback scoping, preventing "wrong type" and "missing connection" validation errors.
- **Top Application Menubar Injection**:
- Fixed an issue where "Copy prompt" appeared in the main application menu (between File and Edit) on PrimeVue Frontend V2 by strictly excluding `.comfy-command-menu`, `.comfy-menu`, `.p-menubar`, and top-level menuitems.
- **CI & Environment Resilience**:
- Replaced OpenCV dependency with PIL and ensured graceful fallback when `onnxruntime` is not installed.
- **Security & Path Confinement**:
- Hardened directory traversal guards against UNC paths and relative escapes, passing 119 unit tests.
---
## [2.4.0] - 2026-09-24 [Stable]
### Fixed
- **Frontend V1 Sizing & Layout Stability**:
- Eliminated rapid runaway downward expansion loop in `VisualLoraLoader` and `ImageLoader` on LiteGraph classic (Frontend V1).
- Cleaned duplicate function stubs and unified `computeSize()` and `computeLayoutSize()`.
- **Core Stability & Logic**:
- Implemented true cycle permutation for `Random (Cycle)` in `PromptQueueIterator`.
- Upgraded comma parsing in `MultiTextReplacer` with `csv.reader` to preserve quoted phrases.
- Removed legacy node mappings (`LeafFlowDecision`, `LeafFlowTextSplit`).
- Added robust exception handling for unterminated regex in text splitting.
- Optimized 60fps canvas draw loops by decoupling badge geometry enforcement from `onDrawForeground`.
---
## [2.3.2] - 2026-09-22
### Changed & Rebranded
@@ -175,7 +236,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Visual LoRA Loader**: Folder-filtered LoRA selector with formatted pretty names, usage tracking, and Civitai SHA256 API preview thumbnail downloading.
- **Visual Image Loader**: Folder image picker with PNG parameters & EXIF user comments metadata parsing.
- **Auto Watcher**: Folder watcher with non-blocking check (`wait_for_image = False`) and blocking poll (`wait_for_image = True`).
- **Undo Placeholder**: Prompt placeholder restoration tool for LoRA/celebrity names.
- **Undo Placeholder**: Prompt placeholder restoration tool for LoRA/subject names.
- **Recent Outputs**: Output image loader by recent step index for assets/sidebar integration.
- **Live Latent Preview**: Real-time sampler latent preview canvas node renderer.
- **Settings Panel**: Native ComfyUI Settings menu controls for API keys and UI toggles.
+9 -7
View File
@@ -22,12 +22,14 @@ Before opening a new issue:
## 🛠️ Contribution Workflow (Feature Branches & PRs)
### Branch and Pull Request Policy
Direct pushes to the `main` branch are restricted. All changes, bug fixes, and new features must be developed on a dedicated feature or fix branch and merged via Pull Request.
Direct pushes to the `main` branch are strictly prohibited. Active development is organized into dedicated version branches (e.g. `v2.5.0`, `v2.6.0`). Community features or fixes should branch off the active version branch or submit Pull Requests targeting it.
All development follows a **Feature Branch $\rightarrow$ Pull Request** workflow:
Merging into `main` and creating releases is strictly gated by repository maintainer explicit authorization. Every release is tagged with `v` prefix (`vX.Y.Z`) and accompanied by release notes detailing all changes.
All development follows a **Branch $\rightarrow$ Pull Request** workflow:
```
1. Fork/Branch Repository --> 2. Create Feature Branch --> 3. Commit Changes --> 4. Open Pull Request
1. Fork/Branch Repository --> 2. Create Working Branch --> 3. Commit Changes --> 4. Open Pull Request
```
### Step 1: Fork and Clone
@@ -37,7 +39,7 @@ git clone https://github.com/YOUR_USERNAME/ComfyUI-SaturnNodes.git
cd ComfyUI-SaturnNodes
```
### Step 2: Create a Feature Branch
### Step 2: Create a Working Branch
Create a descriptive branch for your change:
```bash
git checkout -b feature/short-description
@@ -67,8 +69,8 @@ Push your branch to your fork on GitHub and open a Pull Request:
```bash
git push origin feature/short-description
```
1. Open [Pull Requests](https://github.com/KOFiblto/ComfyUI-LeafFlow/pulls) $\rightarrow$ Click **New pull request**.
2. Select `base: main` $\leftarrow$ `compare: feature/short-description`.
1. Open [Pull Requests](https://github.com/KOFiblto/ComfyUI-SaturnNodes/pulls) $\rightarrow$ Click **New pull request**.
2. Select the active version branch (e.g. `v2.5.0`) or `main` as base $\leftarrow$ `compare: feature/short-description`.
3. Provide a brief summary of what changed and how you verified your fix.
4. Wait for code review approval and automated status check validation!
@@ -79,4 +81,4 @@ git push origin feature/short-description
- Be respectful and constructive when commenting on issues and reviewing pull requests.
- The official mascot of ComfyUI is a very cute anime girl with massive fennec ears, a big fluffy tail, long blonde wavy hair, and blue eyes. Treat her with respect!
Thank you for helping make `ComfyUI-LeafFlow` better for everyone!
Thank you for helping make `ComfyUI-SaturnNodes` better for everyone!
+43 -7
View File
@@ -1,12 +1,13 @@
# 🪐 ComfyUI-SaturnNodes
A unified workflow control and productivity suite for **ComfyUI**. Built for **ComfyUI Frontend V2 (Nodes 2.0 / Vue UI)** (fully supported and actively tested) with backwards compatibility for the **Classic ComfyUI Frontend (Nodes 1.0 / LiteGraph)** (supported as-is, but untested—no guarantees).
A unified workflow control and productivity suite for **ComfyUI**. Both **Frontend V1 (LiteGraph)** and **Frontend V2 (Vue UI)** are supported, though `VisualLoraLoader` breaks on V1 for now (will be fixed).
---
## 🚀 Key Capabilities & Overview
- **Live Latent Preview**: Watch generations materialize step-by-step directly inside your graph canvas nodes in real time.
- **Stereoscopic 3D & Cross-Eye Generator**: Convert 2D artwork and renders into 3D stereograms, parallel views, and red-cyan anaglyphs with automatic Depth Anything V2 fallback.
- **Batch Prompting & Iteration**: Queue multiple prompts automatically with delimiter splits, regex blocks, and persistent queue state tracking.
- **Visual Thumbnail Browsing**: Browse LoRAs and image folders with auto-scraped Civitai & TMDB previews, ranking badges, and multi-selection.
- **Real-Time Queue & Pause Controls**: Pause, resume, or cleanly cancel execution mid-generation, with automatic crash recovery for interrupted queues.
@@ -28,7 +29,7 @@ Loads a LoRA by folder path using raw filename matching or formatted names with
#### Inputs & Widgets
- **`model`** (`MODEL`): Input model.
- **`clip`** (`CLIP`): Input CLIP.
- **`folder`** (`STRING`): Subfolder filter (e.g. `Celebrities`, `PonyV6`, `*`).
- **`folder`** (`STRING`): Subfolder filter (e.g. `Characters`, `Styles/Anime`, `*`).
- **`lora_name`** (`COMBO`): Dropdown listing available LoRA files in the target folder.
- **`strength_model`** (`FLOAT`): Model weight strength (default `1.0`).
- **`strength_clip`** (`FLOAT`): CLIP weight strength (default `1.0`).
@@ -45,7 +46,7 @@ Loads a LoRA by folder path using raw filename matching or formatted names with
<summary><b>🪐 ✨ LoRA Loader (Pretty)</b> (<code>FolderLoraLoaderPretty</code>)</summary>
#### Overview
Loads a LoRA using formatted pretty names (e.g. `Ana De Armas V1` instead of `krea2_Ana-De-Armas_v1.safetensors`).
Loads a LoRA using formatted pretty names (e.g. `Cyberpunk Character V1` instead of `char_Cyberpunk-Character_v1.safetensors`).
#### Inputs & Widgets
- **`model`** (`MODEL`): Input model.
@@ -81,6 +82,9 @@ Visual thumbnail browser for LoRAs with Civitai SHA256 search & TMDB auto-scrapi
- **`MODEL`**: Patched model.
- **`CLIP`**: Patched CLIP.
- **`lora_name`** (`STRING`): Comma-separated list of active LoRA names.
> [!NOTE]
> Frontend V1 and V2 are supported, though `VisualLoraLoader` breaks on Frontend V1 for now (will be fixed). Frontend V2 (Vue UI) is recommended for visual LoRA browsing.
</details>
<details>
@@ -106,7 +110,7 @@ Visual thumbnail browser for image folders with instant preview selection, indep
<summary><b>🪐 📂 Load Image From Folder</b> (<code>LoadImageFromFolder</code>)</summary>
#### Overview
Monitors a folder for incoming images, loads the target image into a PyTorch tensor, with an optional toggle to delete the image after loading.
Monitors a folder for incoming images, loads the target image into a PyTorch tensor, with an optional toggle to delete the image after loading. Path access and deletion are strictly confined to ComfyUI `input` and `output` directories.
#### Inputs & Widgets
- **`folder`** (`STRING`): Watch directory path (default `input/watch`).
@@ -114,7 +118,7 @@ Monitors a folder for incoming images, loads the target image into a PyTorch ten
- **`rescan_interval`** (`INT`): Polling frequency in seconds.
- **`sort_by`** (`COMBO`): Sort by `date_modified`, `date_created`, or `name`.
- **`regex_filter`** (`STRING`): Regex pattern to filter filenames.
- **`delete_image`** (`BOOLEAN`, *Default: false*): If enabled, deletes the image file from disk after loading.
- **`delete_image`** (`BOOLEAN`, *Default: false*): If enabled, deletes the image file from disk after loading (strictly inside `input` or `output`).
#### Outputs
- **`image`** (`IMAGE`): Loaded image tensor.
@@ -318,6 +322,36 @@ Safely executes a local script or executable (`.bat`, `.cmd`, `.ps1`, `.exe` on
- **`passthrough`** (`*`): Passes input `trigger` data through untouched to downstream nodes.
</details>
<details>
<summary><b>🪐 👓 Stereoscopic 3D / Cross-Eye Generator</b> (<code>SaturnStereo3D</code>)</summary>
#### Overview
Inspired by Stereonix, this node transforms any 2D image into stereoscopic 3D pairs for cross-eye viewing, parallel 3D viewers, VR/3D displays, and red-cyan anaglyph 3D glasses using depth-based horizontal disparity mapping (DIBR). Features automatic on-demand Depth Anything V2 Small fallback, crop/zoom/pan controls, outer border framing, empty gap spacing, and focus alignment guide dots.
#### Inputs & Widgets
- **`image`** (`IMAGE`): The source 2D image.
- **`mode`** (`COMBO`): 3D viewing format (`Cross-Eye`, `Parallel (Wall-Eyed)`, `Red-Cyan Anaglyph`, `Side-by-Side (Parallel)`, `Top-Bottom (Over-Under)`, `Left Eye Only`, `Right Eye Only`).
- **`depth_intensity`** (`FLOAT` slider, `0.0` - `100.0`, default `25.0`): Controls how much the left and right eye perspectives shift apart horizontally.
- **`depth_gamma`** (`FLOAT` slider, `0.1` - `3.0`, default `0.50`): Adjusts the depth curve for realistic falloff.
- **`invert_depth`** (`BOOLEAN`, default `False`): Flips near and far depth planes.
- **`convergence_plane`** (`FLOAT` slider, `0.0` - `1.0`, default `0.50`): Focal depth plane where horizontal shift is zero (the screen surface). Depths closer pop out; depths farther sink in.
- **`image_zoom`** (`FLOAT` slider, `10.0` - `300.0`, default `100.0`): Zoom into areas of interest before generating 3D disparity.
- **`pan_horizontal`** (`FLOAT` slider, `-100.0` - `100.0`, default `0.0`): Horizontal pan offset.
- **`pan_vertical`** (`FLOAT` slider, `-100.0` - `100.0`, default `0.0`): Vertical pan offset.
- **`gap_spacing`** (`INT`, default `20`): Empty black space between the left and right eye panels.
- **`outer_border`** (`INT`, default `0`): Black border padding around the canvas (allows shrinking images for easier cross-eye viewing on large monitors).
- **`alignment_dots`** (`BOOLEAN`, default `True`): Renders focus guide dots centered above each eye view to easily lock into 3D cross-eye view.
- **`fill_method`** (`COMBO`): Hole-filling method (`Smooth (Bilinear Grid)` vs `Nearest Neighbor (Stereonix)`).
- **`preview_zoom`** (`COMBO`): Scale preview for comfortable canvas cross-eye viewing (`Fit`, `25%`, `33%`, `50%`, `75%`, `100%`).
- **`depth_map`** (`IMAGE`, *Optional Input*): Optional depth map from any node. If not connected, automatically downloads and runs Depth Anything V2 Small ONNX model internally.
#### Outputs
- **`stereogram`** (`IMAGE`): The composited 3D stereogram image.
- **`left_eye`** (`IMAGE`): The warped left eye viewpoint.
- **`right_eye`** (`IMAGE`): The warped right eye viewpoint.
- **`depth_map`** (`IMAGE`): The normalized depth map used for disparity calculation.
</details>
---
<a id="comfyui-settings-menu-reference"></a>
@@ -333,8 +367,8 @@ Configure options directly under ComfyUI Settings (⚙ gear icon):
- **`Custom Node Body Color`** (`color`, *Default: `#472a15`*): Body background color picker for SaturnNodes when custom colors are enabled.
- **`Civitai API Key`** (`text`): Optional key for Civitai SHA256 model preview search (`Authorization: Bearer <key>`).
- **`Enable Civitai Auto-Scraping`** (`boolean`, *Default: true*): Automatically download model preview images from Civitai via SHA256 file hashes. (Note: Local SHA256 hash searching always works).
- **`TMDB Access Token`** (`text`): Optional key or v4 Read Access Token (`eyJ...`) for celebrity preview search.
- **`Enable TMDB Auto-Scraping`** (`boolean`, *Default: false*): Automatically download celebrity preview images from TMDB.
- **`TMDB Access Token`** (`text`): Optional key or v4 Read Access Token (`eyJ...`) for actor / media preview search.
- **`Enable TMDB Auto-Scraping`** (`boolean`, *Default: false*): Automatically download media preview images from TMDB.
- **`Enable LoRA Usage Tracking`** (`boolean`, *Default: true*): Toggle LoRA usage counting and visual rank badges (🔥, Gold, Silver, Bronze) in the picker.
- **`Reset Failed Scrapes Cache`** (*Button: `🗑️ Clear Scrapes Cache`*): Clears failed scrape history so Civitai/TMDB can retry downloading missing preview images.
</details>
@@ -408,6 +442,8 @@ Configure options directly under ComfyUI Settings (⚙ gear icon):
## 🔒 Security & Privacy
For the comprehensive engineering standard, threat model, and review precedents enforced by the ComfyUI-Manager maintainers, see the full **[ComfyUI Custom Node Security & Compliance Rulebook](SECURITY_AUDIT_PLAYBOOK.md)**.
- **Local Runner Sandbox & Operator Authorization Gate:** The `Run Local File` node enforces single-use interactive operator authorization (`⚡ Authorize Run (5 min)`), strictly confines execution to `ComfyUI/scripts/`, never uses `shell=True`, terminates whole process trees on timeout or cancellation, and **never persists authorization inside workflow JSON or image metadata**.
- **CSRF Defense & Fetch Metadata Validation:** All administrative endpoints (`/saturnnodes/settings`, `/saturnnodes/power/*`, `/saturnnodes/local_runner/*`, `/saturnnodes/scrapes/clear` and backward-compatible `/leafflow/*` aliases) enforce loopback checks, `Sec-Fetch-Site` header checks (blocking cross-site requests from visited web pages), and cryptographic per-session `X-SaturnNodes-CSRF-Token` headers.
- **Two-Step Ephemeral Ticket Handshake for Power Actions:** Process restart and shutdown controls are disabled by default (`ALLOW_PROCESS_MANAGEMENT=false`), require CSRF authentication, and execute only after a single-use 30-second ephemeral ticket confirmation handshake.
+435
View File
@@ -0,0 +1,435 @@
# 🛡️ ComfyUI Custom Node Security, Compliance & Architecture Rulebook
*The Authoritative Engineering Standard & Audit Playbook Derived from ComfyUI-Manager PR #3200 Review Precedents*
---
## 📑 Table of Contents
1. [Core Threat Model & The Golden Rule](#1-core-threat-model--the-golden-rule)
2. [Case Study: ComfyUI-Manager PR #3200 Review Precedents](#2-case-study-comfyui-manager-pr-3200-review-precedents)
3. [The 8 Core Security Rules (With Code Examples)](#3-the-8-core-security-rules)
- [Rule 1: Filesystem Confinement & Traversal Prevention](#rule-1-filesystem-confinement--traversal-prevention)
- [Rule 2: Destructive File Actions & Deletion Guards](#rule-2-destructive-file-actions--deletion-guards)
- [Rule 3: Process Execution & The Workflow Serialization Trap](#rule-3-process-execution--the-workflow-serialization-trap)
- [Rule 4: Dynamic Code Evaluation & Deserialization](#rule-4-dynamic-code-evaluation--deserialization)
- [Rule 5: Web Endpoints & The Same-Machine Browser Fallacy (CSRF)](#rule-5-web-endpoints--the-same-machine-browser-fallacy-csrf)
- [Rule 6: Two-Step Ephemeral Ticket Handshakes for Power Actions](#rule-6-two-step-ephemeral-ticket-handshakes-for-power-actions)
- [Rule 7: Outbound Network Requests & SSRF Prevention](#rule-7-outbound-network-requests--ssrf-prevention)
- [Rule 8: Configuration Storage, Isolation & Injection Defense](#rule-8-configuration-storage-isolation--injection-defense)
4. [Ecosystem & Registry Packaging Compliance](#4-ecosystem--registry-packaging-compliance)
- [Rule 9: Zero Runtime Package Installations (`install.py` Prohibition)](#rule-9-zero-runtime-package-installations-installpy-prohibition)
- [Rule 10: PyTorch Exclusion from Dependencies](#rule-10-pytorch-exclusion-from-dependencies)
- [Rule 11: Headless & Cloud Container Resilience](#rule-11-headless--cloud-container-resilience)
- [Rule 12: Core Monkey-Patching Isolation](#rule-12-core-monkey-patching-isolation)
5. [Frontend Hygiene & Canvas Safety](#5-frontend-hygiene--canvas-safety)
- [Rule 13: Dual Frontend Architecture (V1 LiteGraph & V2 Vue UI)](#rule-13-dual-frontend-architecture-v1-litegraph--v2-vue-ui)
- [Rule 14: Canvas Resize Loops (`computeSize` & `node.size`)](#rule-14-canvas-resize-loops-computesize--nodesize)
- [Rule 15: Non-Serialized UI Widgets (`{ serialize: false }`)](#rule-15-non-serialized-ui-widgets--serialize-false-)
6. [Auditor & Maintainer Quick Conformance Matrix](#6-auditor--maintainer-quick-conformance-matrix)
---
## 1. Core Threat Model & The Golden Rule
### 1.1 The Golden Rule
> **"Treat every input, widget values included, as attacker-controlled, because it can come from a shared workflow or a direct `/prompt` call."**
> — *ltdrdata (ComfyUI-Manager Lead Maintainer, Comfy-Org)*
### 1.2 The 4 Attack Vectors Unique to ComfyUI
1. **Shared Workflow Vectors (.json & PNG Metadata):**
ComfyUI workflows are freely shared across community hubs (Civitai, Reddit, Discord, GitHub) as raw JSON files or embedded inside PNG image generation metadata. Loading a poisoned image instantly sets all node widgets on the canvas.
2. **The Same-Machine Browser Exploit (Drive-by Localhost CSRF):**
Any website an operator visits in their regular browser can execute background JavaScript (`fetch('http://localhost:8188/...')`). Because this request originates from the operator's local computer, primitive IP loopback checks (`127.0.0.1`) pass automatically unless strict fetch metadata and CSRF headers are validated.
3. **Direct Headless API Injection (`POST /prompt`):**
Attackers or automated scripts can bypass the graphical interface completely by sending malicious graph specifications directly to the server's `/prompt` endpoint.
4. **Host User Privileges:**
ComfyUI processes run with the full security privileges of the logged-in operating system user. Any sandbox breakout, unchecked script runner, or arbitrary file deletion results in immediate compromise of personal files or host systems.
---
## 2. Case Study: ComfyUI-Manager PR #3200 Review Precedents
During the official registration of **`ComfyUI-SaturnNodes`** in [Comfy-Org/ComfyUI-Manager#3200](https://github.com/Comfy-Org/ComfyUI-Manager/pull/3200), lead maintainer `@ltdrdata` conducted a three-phase security review. This exchange set definitive precedents for all modern custom nodes:
### Round 1: Unauthenticated Power Routes & Arbitrary File Reads
* **Maintainer Finding:** Exposing network routes that restart/shutdown the server, stream files by absolute path, or write to `.env` without access controls is strictly forbidden.
* **Precedent:** File reads must be confined to fixed base directories. Power management and settings routes must be local-only and strictly constrained.
### Round 2: The Same-Machine Fallacy & The Workflow Serialization Trap
* **Maintainer Finding:**
1. *Same-Machine Check Failure:* A basic IP loopback check (`request.remote == "127.0.0.1"`) is insufficient. Any open browser tab makes requests from `localhost`, allowing malicious web pages to trigger restarts, shutdowns, or settings changes.
2. *Escape Hatches Forbidden:* Settings toggles such as `ALLOW_ANY_SCRIPT_PATH` that bypass folder confinement must be completely removed.
3. *In-Workflow Consent Fallacy:* A `security_consent` boolean widget in `INPUT_TYPES` defaults to `False` in code, but a shared malicious workflow arrives with `security_consent: True` already serialized. Execution consent must never travel with the graph.
* **Precedent:**
- Routes must validate `Sec-Fetch-Site` (blocking `cross-site`) and verify cryptographic CSRF tokens.
- Process execution must require interactive, in-memory human authorization on the canvas node via an ephemeral RAM token.
### Round 3: Folder Deletion Sandbox Bypass
* **Maintainer Finding:** `sanitize_folder_path` allowed relative paths (`..`) and absolute paths. In a folder watcher node with an optional `delete_image` flag, an attacker could supply `folder: "C:/Windows"` or `../../` and delete arbitrary user files.
* **Precedent:** All path-handling functions must reject absolute paths, reject `..`, and verify canonical containment against approved ComfyUI directories using `os.path.commonpath`.
---
## 3. The 8 Core Security Rules
### Rule 1: Filesystem Confinement & Traversal Prevention
Nodes must never read, write, or list files outside designated ComfyUI directories (`input/`, `output/`, `temp/`, and `models/`).
#### ❌ Non-Compliant Patterns
```python
# VULNERABLE: Accepts arbitrary absolute paths or directory traversal
def load_image(self, folder, filename):
filepath = os.path.join(folder, filename)
with open(filepath, "rb") as f: # Attacker loads C:\Users\user\.ssh\id_rsa
return f.read()
```
#### ✅ Compliant Pattern
```python
import os
import re
def sanitize_folder_path(folder_input: str, allowed_base_dir: str) -> str:
"""
Strictly confines a folder to an allowed base directory.
Rejects absolute paths, drive letters, UNC shares, and parent traversal.
"""
if not folder_input or not folder_input.strip():
return allowed_base_dir
raw_path = folder_input.strip()
# Reject Windows drive letters (C:) and UNC network paths (// or \\)
if re.match(r'^[A-Za-z]:', raw_path) or raw_path.startswith(("\\\\", "//")):
raise ValueError(f"Absolute or network paths are forbidden: {raw_path}")
# Reject root slashes and directory traversal
if raw_path.startswith(("/", "\\")) or ".." in raw_path.replace("\\", "/").split("/"):
raise ValueError(f"Directory traversal sequences are forbidden: {raw_path}")
# Canonicalize and verify commonpath boundary
canonical_base = os.path.realpath(allowed_base_dir)
target_path = os.path.realpath(os.path.join(canonical_base, raw_path))
if os.path.commonpath([canonical_base, target_path]) != canonical_base:
raise ValueError(f"Path escapes allowed sandbox: {target_path}")
return target_path
```
---
### Rule 2: Destructive File Actions & Deletion Guards
Any node that deletes or overwrites files (`os.remove`, `os.unlink`, `shutil.rmtree`) must verify containment immediately prior to invocation.
#### ❌ Non-Compliant Patterns
```python
# VULNERABLE: Trusting earlier sanitization or allowing unverified deletion
def cleanup_cache(self, user_supplied_path):
if os.path.exists(user_supplied_path):
os.remove(user_supplied_path) # Arbitrary file deletion
```
#### ✅ Compliant Pattern
```python
def safe_delete_image(target_file_path: str, allowed_folders: list[str]) -> bool:
"""
Verifies target file is strictly inside input/output before deletion.
"""
real_target = os.path.realpath(target_file_path)
if not os.path.isfile(real_target):
return False
is_confined = any(
os.path.commonpath([os.path.realpath(base), real_target]) == os.path.realpath(base)
for base in allowed_folders
)
if not is_confined:
raise PermissionError(f"Security Violation: Refusing to delete file outside sandbox: {real_target}")
os.remove(real_target)
return True
```
---
### Rule 3: Process Execution & The Workflow Serialization Trap
Executing external binaries or scripts is high risk. You must adhere to the following principles:
1. **The Workflow Serialization Trap:** Never include an authorization checkbox (e.g. `"security_consent": ("BOOLEAN", {"default": False})`) in `INPUT_TYPES`. Attackers distribute workflows with `"security_consent": True` already serialized.
2. **Ephemeral RAM Authorization:** Authorization must occur via an in-memory session token generated when the user physically clicks a canvas UI button (e.g. `[⚡ Authorize Run]`).
3. **Execution Confinement:** Scripts must reside strictly within a dedicated directory (e.g., `ComfyUI/scripts/`).
4. **Never Use `shell=True`:** Always tokenize arguments (`shlex.split` or list) and execute with `shell=False`.
#### ❌ Non-Compliant Patterns
```python
# VULNERABLE: shell=True, arbitrary paths, and consent stored in workflow JSON
class VulnerableRunner:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"command": ("STRING", {"default": "python script.py"}),
"user_consent": ("BOOLEAN", {"default": False}), # Poisoned via JSON!
}
}
def run(self, command, user_consent):
if user_consent:
subprocess.Popen(command, shell=True) # Remote Code Execution
```
#### ✅ Compliant Pattern
```python
import subprocess
import shlex
import time
import os
# Stored strictly in volatile Python memory (RAM), never serialized to disk
ACTIVE_RUN_AUTHORIZATIONS: dict[str, float] = {}
def authorize_node_execution(node_id: str, ttl_seconds: int = 300) -> str:
"""Invoked only by interactive UI button via authenticated CSRF route."""
token = os.urandom(16).hex()
ACTIVE_RUN_AUTHORIZATIONS[node_id] = time.time() + ttl_seconds
return token
def execute_confined_script(script_name: str, args_list: list[str], node_id: str, scripts_dir: str):
# 1. Verify in-memory authorization
expiry = ACTIVE_RUN_AUTHORIZATIONS.pop(node_id, 0)
if time.time() > expiry:
raise PermissionError("Execution denied: Interactive operator authorization required on canvas.")
# 2. Confine target executable to scripts/ directory
real_scripts_base = os.path.realpath(scripts_dir)
target_script = os.path.realpath(os.path.join(real_scripts_base, script_name))
if os.path.commonpath([real_scripts_base, target_script]) != real_scripts_base:
raise ValueError("Script escape detected.")
if not os.path.isfile(target_script):
raise FileNotFoundError(f"Script not found: {script_name}")
# 3. Safe execution with shell=False
cmd = ["python", target_script] + args_list
proc = subprocess.Popen(
cmd,
shell=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)
return proc.communicate(timeout=60)
```
---
### Rule 4: Dynamic Code Evaluation & Deserialization
Never pass widget string inputs into `eval()`, `exec()`, or dynamic `importlib` calls.
#### ❌ Non-Compliant Patterns
```python
# VULNERABLE: Direct eval allows __import__('os').system('...')
def calculate_aspect(self, formula_string):
return eval(formula_string)
```
#### ✅ Compliant Pattern
```python
import ast
import operator
SAFE_OPERATORS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
}
def safe_eval_math(expression: str) -> float:
"""Parses mathematical expression using an Abstract Syntax Tree (AST)."""
node = ast.parse(expression, mode='eval').body
def _eval(node):
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
return node.value
if isinstance(node, ast.BinOp) and type(node.op) in SAFE_OPERATORS:
return SAFE_OPERATORS[type(node.op)](_eval(node.left), _eval(node.right))
raise ValueError(f"Unsafe expression: {expression}")
return float(_eval(node))
```
---
### Rule 5: Web Endpoints & The Same-Machine Browser Fallacy (CSRF)
Because any malicious web page the operator visits in another tab can target `http://127.0.0.1:8188`, endpoints that mutate state or trigger actions must defend against cross-site requests.
#### Required Multi-Layer Defense:
1. **Loopback IP Verification:** Peer address must be `127.0.0.1`, `::1`, or `localhost`.
2. **Fetch Metadata Inspection:** `Sec-Fetch-Site` header must **not** be `cross-site`.
3. **Cryptographic CSRF Header:** Require a per-session secret token in a custom header (e.g. `X-SaturnNodes-CSRF-Token`). Browsers forbid cross-origin pages from sending custom headers without CORS preflight approval.
#### ✅ Compliant Pattern
```python
import secrets
from aiohttp import web
SESSION_CSRF_TOKEN = secrets.token_hex(32)
def is_authenticated_local_request(request: web.Request) -> bool:
"""
Blocks remote network callers AND malicious cross-site browser requests.
"""
# 1. IP Loopback Check
peername = request.transport.get_extra_info('peername')
client_ip = peername[0] if peername else ""
if client_ip not in ("127.0.0.1", "::1", "localhost"):
return False
# 2. Browser Fetch Metadata Defense (blocks drive-by background fetches)
sec_fetch_site = request.headers.get("Sec-Fetch-Site", "")
if sec_fetch_site in ("cross-site",):
return False
# 3. Custom Header & Secret Verification (triggers CORS preflight rejection)
token = request.headers.get("X-SaturnNodes-CSRF-Token", "")
if not token or not secrets.compare_digest(token, SESSION_CSRF_TOKEN):
return False
return True
```
---
### Rule 6: Two-Step Ephemeral Ticket Handshakes for Power Actions
High-impact administrative actions (restarting ComfyUI, shutting down the host, mass cache purges) must **never** trigger from a single `POST` endpoint.
#### Handshake Protocol:
1. **Disabled by Default:** Master setting `ALLOW_PROCESS_MANAGEMENT = False`.
2. **Step 1 (`POST /power/request_token`):** Server validates local CSRF session, generates a single-use token in RAM with a 30-second TTL, and returns it to the client.
3. **Step 2 (`POST /power/confirm_action`):** Client provides the single-use token. Server validates, burns the token, and schedules the action.
---
### Rule 7: Outbound Network Requests & SSRF Prevention
If your custom node scrapes images, models, or metadata from the internet (Civitai, HuggingFace, TMDB):
1. **Opt-In Only:** Background scraping must be disabled by default in settings.
2. **Protocol Confinement:** Allow only `http://` and `https://`. Reject `file://`, `ftp://`, and `gopher://`.
3. **Private IP & Cloud Metadata Blocking (SSRF):** Reject requests resolving to:
- Loopback: `127.0.0.0/8`, `::1`
- Private LAN: `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`
- Link-Local / Cloud Metadata: `169.254.169.254`
#### ✅ Compliant Pattern
```python
import ipaddress
import socket
from urllib.parse import urlparse
def is_safe_external_url(url: str) -> bool:
try:
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
return False
hostname = parsed.hostname
if not hostname:
return False
# Resolve IP and check for private / loopback / metadata ranges
ip_addr = socket.gethostbyname(hostname)
ip = ipaddress.ip_address(ip_addr)
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
return False
return True
except Exception:
return False
```
---
### Rule 8: Configuration Storage, Isolation & Injection Defense
All user runtime state, custom settings, and caches must be stored cleanly inside ComfyUI's user directory:
`ComfyUI/user/default/<YourCustomNodeName>/`
- **Newline Injection Defense:** When writing to `.env` or config files, strip `\r` and `\n` from both keys and values to prevent setting forgery.
- **Atomic Disk Writes:** Write data to a temporary file (`.tmp`) first, then commit via `os.replace` to prevent corrupted files on unexpected shutdowns.
- **Log Masking:** Strip or mask API keys (`sk-***`, token hashes) in console output.
---
## 4. Ecosystem & Registry Packaging Compliance
To pass automated and manual review in the official ComfyUI Registry and ComfyUI-Manager:
### Rule 9: Zero Runtime Package Installations (`install.py` Prohibition)
- **Prohibited:** Creating an `install.py` script that invokes `subprocess.run(["pip", "install", ...])` during ComfyUI startup.
- **Mandated:** Declare all dependencies cleanly in `requirements.txt` and `pyproject.toml`.
### Rule 10: PyTorch Exclusion from Dependencies
- **Prohibited:** Listing `torch` or `torchvision` in your custom node's `requirements.txt` or `pyproject.toml`.
- **Reason:** ComfyUI manages its own PyTorch/CUDA environment. Specifying `torch` causes pip to downgrade or corrupt the user's CUDA acceleration binaries.
### Rule 11: Headless & Cloud Container Resilience
- If using desktop GUI dependencies (e.g. `pystray` system tray icons, Tkinter windows), wrap imports in `try/except ImportError` and provide silent headless fallbacks so cloud instances (RunPod, Colab, Docker) do not crash on launch.
### Rule 12: Core Monkey-Patching Isolation
- If overriding core ComfyUI objects (such as `PromptQueue`), the monkey-patch must be conditionally executed **only** if the corresponding feature flag is enabled in settings. If the user disables the feature, core classes must remain untouched.
---
## 5. Frontend Hygiene & Canvas Safety
### Rule 13: Dual Frontend Architecture (V1 LiteGraph & V2 Vue UI)
- All custom UI extensions must verify compatibility with both the classic LiteGraph canvas and the modern ComfyUI Vue V2 interface.
- In hooks (`nodeCreated`, `beforeRegisterNodeDef`), verify both `node.comfyClass` and `node.type`.
### Rule 14: Canvas Resize Loops (`computeSize` & `node.size`)
- Never compute widget heights dynamically based on `this.size[1]` inside `computeSize()`. In LiteGraph, this creates an infinite resize loop where the node expands downwards continuously on every draw frame.
### Rule 15: Non-Serialized UI Widgets (`{ serialize: false }`)
- Dynamic preview widgets, action buttons, ranking badges, and status labels must be configured with:
```javascript
widget.serialize = false;
// or widget.options = { serialize: false };
```
This prevents transient UI states from cluttering the user's saved workflow JSON.
---
## 6. Auditor & Maintainer Quick Conformance Matrix
Use this matrix when auditing your own or third-party ComfyUI custom nodes:
| Domain | Mandatory Security Requirement | Verification Method |
| :--- | :--- | :--- |
| **Filesystem** | Absolute paths (`C:`, `/`, `\\`) rejected | Test with `C:\Windows\System32` & `/etc/passwd` |
| **Filesystem** | Directory traversal (`..`) rejected | Test with `../../` |
| **Filesystem** | Canonical path confinement via `commonpath` | Verify `commonpath([base, target]) == base` |
| **Deletion** | Deletions strictly confined to `input/` and `output/` | Verify boundary check immediately before `os.remove` |
| **Execution** | `shell=False` enforced on all subprocesses | Inspect all `subprocess.Popen` / `subprocess.run` calls |
| **Execution** | Scripts confined strictly to `ComfyUI/scripts/` | Verify realpath check against scripts directory |
| **Execution** | Execution consent **absent** from `INPUT_TYPES` | Verify no authorization widgets in workflow JSON |
| **Execution** | In-memory interactive operator authorization | Confirm single-use RAM token generated via UI button |
| **Network** | Loopback checks on administrative routes | Test request from non-local IP (expects 403) |
| **Network** | CSRF defense: `Sec-Fetch-Site` + custom token header | Test request with `Sec-Fetch-Site: cross-site` |
| **Network** | Two-step ephemeral ticket for restart/shutdown | Verify token generation + confirmation handshake |
| **Network** | SSRF: Private IPs & cloud metadata blocked | Test with `127.0.0.1`, `192.168.1.1`, `169.254.169.254` |
| **Network** | Scraping features opt-in (disabled by default) | Verify settings default to `false` |
| **Packaging** | No `install.py` / dynamic pip execution | Verify absence of `install.py` |
| **Packaging** | `torch` excluded from `requirements.txt` | Verify dependencies list |
| **Data** | Data stored in `user/default/<pack>/` | Inspect state and cache file paths |
| **Frontend** | UI widgets marked `{ serialize: false }` | Inspect exported workflow JSON for UI clutter |
---
*Maintained under the ComfyUI-SaturnNodes security framework. Conforms strictly to Comfy-Org registry audit policies.*
+189 -10
View File
@@ -4,9 +4,19 @@ import subprocess
from aiohttp import web
from server import PromptServer
__version__ = "2.3.2"
__version__ = "2.5.0"
from .nodes.queue_control import setup_queue_control_routes, tray_manager
from .nodes.queue_control import (
setup_queue_control_routes,
tray_manager,
persistent_queue_manager,
is_persistent_queue_enabled
)
from .nodes.tray_icon import is_tray_icon_enabled
from .nodes.assets_restore import (
assets_restore_manager,
is_assets_restore_enabled
)
from .nodes.lora_loader import (
FolderLoraLoader,
FolderLoraLoaderPretty,
@@ -25,6 +35,7 @@ from .nodes.prompt_counter import PromptCounter
from .nodes.text_replacer import MultiTextReplacer
from .nodes.text_split import SaturnTextSplit
from .nodes.local_runner import RunLocalFileNode, setup_local_runner_routes
from .nodes.stereogram import SaturnStereo3D
from .nodes.utils import (
get_saturnnodes_user_dir,
get_leafflow_user_dir,
@@ -52,6 +63,7 @@ NODE_CLASS_MAPPINGS = {
"MultiTextReplacer": MultiTextReplacer,
"SaturnTextSplit": SaturnTextSplit,
"RunLocalFileNode": RunLocalFileNode,
"SaturnStereo3D": SaturnStereo3D,
}
NODE_DISPLAY_NAME_MAPPINGS = {
@@ -70,9 +82,87 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"PromptCounter": "🪐 📝 Prompt Counter",
"MultiTextReplacer": "🪐 🔤 Multi Text Replacer",
"SaturnTextSplit": "🪐 ✂️ Text Split",
"RunLocalFileNode": "🪐 ⚡ Run Local File"
"RunLocalFileNode": "🪐 ⚡ Run Local File",
"SaturnStereo3D": "🪐 👓 Stereoscopic 3D Generator",
}
ALL_SATURN_NODE_CLASSES = dict(NODE_CLASS_MAPPINGS)
ALL_SATURN_NODE_DISPLAY_NAMES = dict(NODE_DISPLAY_NAME_MAPPINGS)
ALL_SATURN_NODE_KEYS = list(NODE_DISPLAY_NAME_MAPPINGS.keys())
def get_saturn_nodes_info():
"""Returns sorted node metadata auto-generated from NODE_CLASS_MAPPINGS and NODE_DISPLAY_NAME_MAPPINGS."""
import re
info_list = []
category_order = ["Loaders", "Automation", "Previews", "3D & Previews", "Utils"]
for key in ALL_SATURN_NODE_KEYS:
cls = ALL_SATURN_NODE_CLASSES.get(key)
cat = getattr(cls, "CATEGORY", "🪐 SaturnNodes/Utils") if cls else "🪐 SaturnNodes/Utils"
subcat = cat.split("/")[-1].strip() if "/" in cat else "Utils"
disp = ALL_SATURN_NODE_DISPLAY_NAMES.get(key, key)
clean_name = re.sub(r'^[^a-zA-Z0-9\(]+', '', disp).strip()
if not clean_name:
clean_name = key
info_list.append({
"key": key,
"category": subcat,
"clean_name": clean_name,
"name": f"{subcat} / {clean_name}",
"desc": disp
})
def sort_key(item):
cat = item["category"]
cat_idx = category_order.index(cat) if cat in category_order else 999
return (cat_idx, item["name"])
info_list.sort(key=sort_key)
return info_list
def load_disabled_nodes():
"""Loads set of user-disabled SaturnNodes from user settings directory."""
try:
import json
user_dir = get_saturnnodes_user_dir()
path = os.path.join(user_dir, "disabled_nodes.json")
if os.path.isfile(path):
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, list):
return set(data)
except Exception as e:
print(f"[SaturnNodes] Notice: Unable to load disabled_nodes.json: {e}")
return set()
def save_disabled_nodes(disabled_set):
"""Saves set of user-disabled SaturnNodes atomically."""
import json
user_dir = get_saturnnodes_user_dir()
os.makedirs(user_dir, exist_ok=True)
path = os.path.join(user_dir, "disabled_nodes.json")
temp_path = path + ".tmp"
with open(temp_path, "w", encoding="utf-8") as f:
json.dump(sorted(list(disabled_set)), f, indent=2)
os.replace(temp_path, path)
# Filter disabled nodes (all enabled by default when disabled_nodes.json is empty)
_active_disabled_nodes = load_disabled_nodes()
if _active_disabled_nodes:
_alias_map = {
"FolderLoraLoaderPretty": "FolderLoraLoaderVisualPrettyV2",
"VisualImageLoader": "ImageLoaderVisualPrettyV2",
"TextAspectRatioFinder": "AspectRatioFinder",
"TextLoraFinder": "LoraTextFinder"
}
for _node_name in _active_disabled_nodes:
NODE_CLASS_MAPPINGS.pop(_node_name, None)
NODE_DISPLAY_NAME_MAPPINGS.pop(_node_name, None)
_alias = _alias_map.get(_node_name)
if _alias:
NODE_CLASS_MAPPINGS.pop(_alias, None)
WEB_DIRECTORY = "./web"
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
@@ -86,16 +176,79 @@ ENV_FILE = os.path.join(USER_DIR, ".env")
routes = server.routes
try:
can_encode_saturn = True
def print_startup_banner():
# Enable ANSI escape sequences on Windows console if available
if os.name == "nt":
try:
import ctypes
kernel32 = ctypes.windll.kernel32
kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7)
except Exception:
pass
# Saturn Brand Colors (Amber, Gold, Warm Sand, White)
AMBER = "\033[1;38;2;245;158;11m"
GOLD = "\033[38;2;251;191;36m"
SAND = "\033[38;2;222;203;178m"
WHITE = "\033[97m"
RESET = "\033[0m"
active_count = len(NODE_DISPLAY_NAME_MAPPINGS)
total_count = len(ALL_SATURN_NODE_KEYS)
disabled_count = len(_active_disabled_nodes) if _active_disabled_nodes else 0
total_str = f"{active_count}/{total_count} nodes loaded"
if disabled_count > 0:
total_str += f" ({disabled_count} disabled in settings)"
print()
print(f"{AMBER}[ComfyUI-SaturnNodes]{RESET} {SAND}Version:{RESET} {WHITE}{__version__}{RESET}")
print(f" {GOLD}*{RESET} {SAND}Loaded:{RESET} {WHITE}Visual LoRA & Image Loaders, LoRA Folder Loader{RESET}")
print(f" {GOLD}*{RESET} {SAND}Loaded:{RESET} {WHITE}Live Latent Preview & Floating Dock{RESET}")
print(f" {GOLD}*{RESET} {SAND}Loaded:{RESET} {WHITE}Stereoscopic 3D Generator{RESET}")
print(f" {GOLD}*{RESET} {SAND}Loaded:{RESET} {WHITE}Prompt Queue Iterator, Prompt Counter, Text Tools{RESET}")
print(f" {GOLD}*{RESET} {SAND}Loaded:{RESET} {WHITE}Queue Pause, Persistent Queue, Local Runner{RESET}")
print()
# System status lines
try:
"🪐".encode(sys.stdout.encoding or "ascii")
if is_tray_icon_enabled() and getattr(tray_manager, "_is_running", False):
tray_str = "System tray icon started."
elif not is_tray_icon_enabled():
tray_str = "System tray icon disabled in settings."
else:
tray_str = "System tray icon not started."
except Exception:
can_encode_saturn = False
saturn_symbol = "🪐 " if can_encode_saturn else ""
print(f"[ComfyUI-SaturnNodes] {saturn_symbol}Loaded {len(NODE_CLASS_MAPPINGS)} nodes & visual endpoints successfully (v{__version__}).")
tray_str = "System tray icon disabled in settings."
try:
if is_assets_restore_enabled():
r_count = getattr(assets_restore_manager, "restored_count", 0)
assets_str = f"Restored {r_count} recent image(s) into Assets pane."
else:
assets_str = "No images restored into Assets pane (disabled in settings)."
except Exception:
assets_str = "Assets pane restore ready."
try:
if is_persistent_queue_enabled():
p_items = getattr(persistent_queue_manager, "persistent_items", [])
q_count = len(p_items) if p_items else 0
queue_str = f"Loaded {q_count} saved queue item(s)."
else:
queue_str = "No saved queue items loaded (disabled in settings)."
except Exception:
queue_str = "Persistent queue manager ready."
print(f" {GOLD}*{RESET} {WHITE}{tray_str}{RESET}")
print(f" {GOLD}*{RESET} {WHITE}{assets_str}{RESET}")
print(f" {GOLD}*{RESET} {WHITE}{queue_str}{RESET}")
print(f"{AMBER}Total: {total_str}{RESET}")
print()
try:
print_startup_banner()
except Exception:
print(f"[ComfyUI-SaturnNodes] Loaded {len(NODE_CLASS_MAPPINGS)} nodes & visual endpoints successfully (v{__version__}).")
print(f"\n[ComfyUI-SaturnNodes] Version: {__version__} - Total: {len(NODE_DISPLAY_NAME_MAPPINGS)}/{len(ALL_SATURN_NODE_KEYS)} nodes loaded\n")
@routes.get("/saturnnodes/auth/token")
async def get_csrf_token_endpoint(request):
@@ -355,6 +508,32 @@ async def save_settings(request):
return web.json_response({"status": "ok"})
@routes.get("/saturnnodes/disabled_nodes")
async def get_disabled_nodes_endpoint(request):
if not is_local_request(request):
return web.json_response({"error": "Forbidden: Local access only"}, status=403)
return web.json_response({
"disabled_nodes": sorted(list(load_disabled_nodes())),
"all_nodes": ALL_SATURN_NODE_KEYS,
"nodes_info": get_saturn_nodes_info()
})
@routes.post("/saturnnodes/disabled_nodes")
async def save_disabled_nodes_endpoint(request):
if not is_authenticated_local_request(request):
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
try:
data = await request.json()
disabled_list = data.get("disabled_nodes", [])
if isinstance(disabled_list, list):
valid_keys = set(ALL_SATURN_NODE_KEYS)
cleaned = {str(k) for k in disabled_list if str(k) in valid_keys}
save_disabled_nodes(cleaned)
return web.json_response({"status": "ok", "disabled_nodes": sorted(list(cleaned))})
return web.json_response({"error": "Invalid format"}, status=400)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
@routes.post("/saturnnodes/scrapes/clear")
async def clear_scrapes_endpoint(request):
if not is_authenticated_local_request(request):
+1 -1
View File
@@ -229,9 +229,9 @@ class AssetsRestoreManager:
"total_scanned_images": len(images),
"restored_count": restored_count
}
self.restored_count = restored_count
if restored_count > 0:
print(f"[SaturnNodes] Restored {restored_count} recent image(s) into Assets / History pane.")
if server:
try:
server.queue_updated()
+5 -2
View File
@@ -5,7 +5,7 @@ import numpy as np
import re
from PIL import Image, ImageOps
import comfy.model_management
from .utils import sanitize_folder_path
from .utils import sanitize_folder_path, is_safe_path, get_allowed_folder_watch_directories
def get_current_prompt_id():
try:
@@ -70,6 +70,9 @@ class LoadImageFromFolder:
img_tensor = torch.from_numpy(image_array)[None,]
if delete_image:
if not is_safe_path(filepath, allowed_bases=get_allowed_folder_watch_directories()):
print(f"[SaturnNodes Security] Refusing to delete '{filepath}': file is outside ComfyUI input/output directories.")
return img_tensor
try:
os.remove(filepath)
except Exception as e:
@@ -148,7 +151,7 @@ class LoadImageFromFolder:
return files[idx]
def watch(self, folder, wait_if_folder_is_empty, rescan_interval, sort_by, regex_filter, delete_image=False, unique_id="default", **kwargs):
folder = sanitize_folder_path(folder, default_dir="input/watch")
folder = sanitize_folder_path(folder, default_dir="watch")
if wait_if_folder_is_empty:
while True:
+53 -4
View File
@@ -4,6 +4,7 @@ import fnmatch
import json
import time
import hashlib
import asyncio
import folder_paths
import urllib.request
import urllib.parse
@@ -390,6 +391,45 @@ async def get_loras_endpoint(request):
"mtime": mtime_by_path
})
THUMBNAILS_DIR = os.path.join(USER_DIR, "thumbnails")
def get_or_create_thumbnail(img_path, max_size=256):
"""
Returns path to a high-speed cached WebP thumbnail of img_path with max dimension max_size.
Cached on disk and invalidated if source file mtime changes.
"""
try:
from PIL import Image
mtime = os.path.getmtime(img_path)
hash_src = f"{img_path}_{mtime}_{max_size}".encode("utf-8")
cache_key = hashlib.md5(hash_src).hexdigest()
os.makedirs(THUMBNAILS_DIR, exist_ok=True)
thumb_path = os.path.join(THUMBNAILS_DIR, f"thumb_{cache_key}.webp")
if os.path.exists(thumb_path) and os.path.getsize(thumb_path) > 0:
return thumb_path
with Image.open(img_path) as img:
w, h = img.size
if w <= max_size and h <= max_size:
return img_path
if img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info):
img = img.convert("RGBA")
else:
img = img.convert("RGB")
# BICUBIC produces crisp, smooth, high-quality thumbnails from 4K/6MP sources
# while executing significantly faster than LANCZOS to avoid CPU contention.
img.thumbnail((max_size, max_size), Image.Resampling.BICUBIC)
temp_path = thumb_path + ".tmp"
# method=1 gives high quality WebP at ~10x the speed of method=4, eliminating CPU spikes
img.save(temp_path, format="WEBP", quality=85, method=1)
os.replace(temp_path, thumb_path)
return thumb_path
except Exception:
return img_path
@routes.get("/folder_lora_loader/get_preview")
async def get_preview_endpoint(request):
if not is_local_request(request):
@@ -398,16 +438,25 @@ async def get_preview_endpoint(request):
lora_name = request.query.get("lora", "")
folder = request.query.get("folder", "")
pretty = request.query.get("pretty", "true").lower() == "true"
full = request.query.get("full", "false").lower() == "true"
if not system_path and lora_name:
mapping = get_filtered_loras_mapping(folder, pretty=pretty)
system_path = mapping.get(lora_name, lora_name)
if system_path and system_path != "[ NONE ]":
img_path = find_preview_image(system_path)
if img_path and os.path.exists(img_path) and os.path.isfile(img_path):
return web.FileResponse(img_path)
if full:
return web.FileResponse(img_path)
try:
thumb_size = int(request.query.get("size", 256))
thumb_size = max(64, min(1024, thumb_size))
except Exception:
thumb_size = 256
serve_path = await asyncio.to_thread(get_or_create_thumbnail, img_path, max_size=thumb_size)
return web.FileResponse(serve_path)
return web.Response(status=404)
class FolderLoraLoader:
+27 -18
View File
@@ -69,29 +69,29 @@ class PauseQueueManager:
def patched_get(*args, **kwargs):
while True:
# 1. If queue is paused, wait BEFORE popping any item from the queue!
# This guarantees that ComfyUI never sets currently_running, never sends
# execution_start, and never starts the elapsed timer while waiting.
if self.paused:
if not self.is_waiting:
self.is_waiting = True
print("[PauseQueue] Workflow paused after this run: Current workflow finished, queue paused.")
print("[PauseQueue] Workflow paused: Queue is paused, waiting before next workflow.")
self.notify_clients()
while self.paused:
self.event.wait(0.2)
self.is_waiting = False
self.notify_clients()
item = original_get(*args, **kwargs)
# 2. Use a short timeout (0.5s) when polling for items so that if the user
# enables pause while the queue is idle, we don't remain stuck inside
# a blocking original_get() that would prematurely pop an incoming workflow.
kwargs_copy = dict(kwargs)
if "timeout" not in kwargs_copy and len(args) == 0:
kwargs_copy["timeout"] = 0.5
if self.paused:
if not self.is_waiting:
self.is_waiting = True
print("[PauseQueue] Workflow paused after this run: Current workflow finished, queue paused.")
self.notify_clients()
while self.paused:
self.event.wait(0.2)
self.is_waiting = False
self.notify_clients()
return item
item = original_get(*args, **kwargs_copy)
if item is not None:
return item
queue.get = patched_get
@@ -116,7 +116,6 @@ class PauseQueueManager:
print("[SaturnNodes] Queue control: Skipped patching send_sync (not found on server).")
self._patched = True
print("[SaturnNodes] Queue control: Pause manager initialized successfully.")
def is_currently_executing(self):
try:
@@ -153,6 +152,14 @@ class PauseQueueManager:
self.notify_clients()
try:
server = getattr(PromptServer, "instance", None)
if server and hasattr(server, "prompt_queue") and hasattr(server.prompt_queue, "not_empty"):
with server.prompt_queue.mutex:
server.prompt_queue.not_empty.notify_all()
except Exception:
pass
def set_mode(self, mode):
self.mode = mode
print(f"[PauseQueue] Selected pause mode: '{self.mode}'")
@@ -198,7 +205,6 @@ class PersistentQueueManager:
for x in self.persistent_items:
if "batch_info" in x and "prompt_id" in x:
self.batch_meta[str(x["prompt_id"])] = x["batch_info"]
print(f"[PersistentQueue] Loaded {len(self.persistent_items)} saved queue item(s).")
else:
self.persistent_items = []
except Exception as e:
@@ -361,7 +367,6 @@ class PersistentQueueManager:
server.send_sync = patched_send_sync
self._patched = True
print("[SaturnNodes] Queue control: Persistent queue manager initialized successfully.")
def restore_queue(self, active_client_id=None):
if self.has_claimed_once:
@@ -404,8 +409,11 @@ class PersistentQueueManager:
self.remove_item(pid)
continue
if active_client_id and len(item_list) > 3 and isinstance(item_list[3], dict):
item_list[3]["client_id"] = active_client_id
if len(item_list) > 3 and isinstance(item_list[3], dict):
if active_client_id:
item_list[3]["client_id"] = active_client_id
# Update create_time to current timestamp so resumed jobs sort as newest in history & assets
item_list[3]["create_time"] = int(time.time() * 1000)
if isinstance(item_list[0], (int, float)):
max_number = max(max_number, int(item_list[0]))
@@ -597,6 +605,7 @@ def _consume_power_ticket(ticket, expected_action=None):
pause_manager = PauseQueueManager()
persistent_manager = PersistentQueueManager()
persistent_queue_manager = persistent_manager
tray_manager = TrayIconManager(pause_manager)
power_manager = PowerControlManager(pause_manager)
+469
View File
@@ -0,0 +1,469 @@
import os
import urllib.request
import torch
import torch.nn.functional as F
import numpy as np
try:
import folder_paths
except ImportError:
folder_paths = None
# Global ONNX runtime session cache & predicted depth cache
_DEPTH_ORT_SESSION = None
_PREDICTED_DEPTH_CACHE = {}
def get_depth_model_path():
"""Returns local path to Depth Anything V2 Small ONNX model."""
if folder_paths and hasattr(folder_paths, "models_dir") and folder_paths.models_dir:
base_dir = os.path.join(folder_paths.models_dir, "depth")
else:
base_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "models")
os.makedirs(base_dir, exist_ok=True)
return os.path.join(base_dir, "depth-anything-v2-small-518.onnx")
def ensure_depth_model():
"""Downloads Depth Anything V2 Small ONNX model if not already present."""
target_path = get_depth_model_path()
if os.path.exists(target_path) and os.path.getsize(target_path) > 10 * 1024 * 1024:
return target_path
temp_path = target_path + ".tmp"
urls = [
"https://huggingface.co/onnx-community/depth-anything-v2-small/resolve/main/onnx/model.onnx",
"https://nqrlabs.com/Stereonix/models/depth-anything-v2-small-518.onnx"
]
print("[SaturnNodes 3D] Downloading Depth Anything V2 Small model (~94 MB)...")
for url in urls:
try:
req = urllib.request.Request(url, headers={"User-Agent": "ComfyUI-SaturnNodes"})
with urllib.request.urlopen(req, timeout=60) as resp, open(temp_path, "wb") as f:
total_size = int(resp.headers.get("Content-Length", 0))
downloaded = 0
last_pct = 0
while True:
chunk = resp.read(1024 * 1024 * 2)
if not chunk:
break
f.write(chunk)
downloaded += len(chunk)
if total_size > 0:
pct = int((downloaded / total_size) * 100)
if pct >= last_pct + 25 or pct == 100:
print(f"[SaturnNodes 3D] Model download: {pct}% ({downloaded // (1024*1024)}MB / {total_size // (1024*1024)}MB)")
last_pct = pct
if os.path.exists(temp_path) and os.path.getsize(temp_path) > 10 * 1024 * 1024:
os.replace(temp_path, target_path)
print(f"[SaturnNodes 3D] Successfully downloaded Depth Anything V2 to {target_path}")
return target_path
except Exception as e:
print(f"[SaturnNodes 3D] Download error from {url}: {e}")
if os.path.exists(temp_path):
try:
os.remove(temp_path)
except OSError:
pass
raise RuntimeError("Failed to download Depth Anything V2 Small ONNX model.")
def get_ort_session():
"""Retrieves or loads the ONNX runtime session for depth estimation."""
global _DEPTH_ORT_SESSION
if _DEPTH_ORT_SESSION is not None:
return _DEPTH_ORT_SESSION
try:
import onnxruntime as ort
except ImportError:
raise RuntimeError("onnxruntime is required for built-in depth estimation. Install via: pip install onnxruntime")
model_path = ensure_depth_model()
available_providers = ort.get_available_providers()
providers = ["CUDAExecutionProvider", "CPUExecutionProvider"] if "CUDAExecutionProvider" in available_providers else ["CPUExecutionProvider"]
_DEPTH_ORT_SESSION = ort.InferenceSession(model_path, providers=providers)
return _DEPTH_ORT_SESSION
def predict_depth_map(image_np):
"""
Runs Depth Anything V2 Small on a single [H, W, 3] float32 image (0.0 to 1.0).
Returns [H, W] normalized float32 depth map (0.0=far, 1.0=near).
If onnxruntime or the model is unavailable, gracefully falls back to a pseudo-depth map.
"""
H, W, _ = image_np.shape
try:
session = get_ort_session()
if session is None:
raise RuntimeError("ONNX runtime session could not be initialized.")
target_size = 518
scale = min(target_size / W, target_size / H)
scaled_w = max(1, int(round(W * scale)))
scaled_h = max(1, int(round(H * scale)))
offset_x = (target_size - scaled_w) // 2
offset_y = (target_size - scaled_h) // 2
# Letterbox to 518x518 with black padding using PIL
from PIL import Image
uint_img = (image_np * 255.0).clip(0, 255).astype(np.uint8)
scaled_pil = Image.fromarray(uint_img).resize((scaled_w, scaled_h), Image.Resampling.BILINEAR)
scaled_img = np.array(scaled_pil, dtype=np.uint8)
canvas = np.zeros((target_size, target_size, 3), dtype=np.uint8)
canvas[offset_y:offset_y + scaled_h, offset_x:offset_x + scaled_w] = scaled_img
# Normalize with ImageNet constants
mean = np.array([0.485, 0.456, 0.406], dtype=np.float32).reshape(1, 1, 3)
std = np.array([0.229, 0.224, 0.225], dtype=np.float32).reshape(1, 1, 3)
norm = ((canvas.astype(np.float32) / 255.0) - mean) / std
input_tensor = np.transpose(norm, (2, 0, 1))[np.newaxis, ...].astype(np.float32)
# Run inference
input_name = session.get_inputs()[0].name
raw_depth = session.run(None, {input_name: input_tensor})[0][0] # [518, 518]
# Crop out the letterbox padding and resize back to original (W, H)
valid_depth = raw_depth[offset_y:offset_y + scaled_h, offset_x:offset_x + scaled_w]
depth_pil = Image.fromarray(valid_depth.astype(np.float32), mode="F").resize((W, H), Image.Resampling.BILINEAR)
depth_full = np.array(depth_pil, dtype=np.float32)
# Normalize with p2 and p98 histogram percentiles
p2 = np.percentile(depth_full, 2)
p98 = np.percentile(depth_full, 98)
denom = max(float(p98 - p2), 1e-6)
depth_norm = np.clip((depth_full - p2) / denom, 0.0, 1.0).astype(np.float32)
return depth_norm
except Exception as e:
print(f"[SaturnNodes 3D] Notice: Automatic depth model unavailable ({e}). Using pseudo-depth gradient fallback.")
y_gradient = np.linspace(0.2, 1.0, H, dtype=np.float32).reshape(H, 1)
luminance = np.dot(image_np[..., :3], [0.299, 0.587, 0.114])
fallback = 0.7 * y_gradient + 0.3 * luminance
denom = max(float(fallback.max() - fallback.min()), 1e-6)
return ((fallback - fallback.min()) / denom).astype(np.float32)
def draw_alignment_dot(target_tensor, cx, cy, radius=5):
"""Draws a high-contrast white dot with black outline at (cx, cy) on [H, W, 3] tensor."""
H, W, _ = target_tensor.shape
y_min = max(0, cy - radius - 2)
y_max = min(H, cy + radius + 3)
x_min = max(0, cx - radius - 2)
x_max = min(W, cx + radius + 3)
if y_max <= y_min or x_max <= x_min:
return
yy, xx = torch.meshgrid(
torch.arange(y_min, y_max, device=target_tensor.device),
torch.arange(x_min, x_max, device=target_tensor.device),
indexing="ij"
)
dist_sq = (xx - cx) ** 2 + (yy - cy) ** 2
inner = dist_sq <= (radius ** 2)
outer = (dist_sq <= ((radius + 1.5) ** 2)) & (~inner)
target_tensor[yy[outer], xx[outer]] = 0.0
target_tensor[yy[inner], xx[inner]] = 1.0
class SaturnStereo3D:
"""
🪐 Stereoscopic 3D / Cross-Eye Generator
Generates side-by-side 3D stereograms, parallel views, and red-cyan anaglyphs
from 2D images using depth-based horizontal disparity mapping.
Includes built-in Depth Anything V2 Small auto-inference, zoom & pan cropping,
spacing/border padding, and alignment guide dots.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"mode": ([
"Cross-Eye",
"Parallel (Wall-Eyed)",
"Red-Cyan Anaglyph",
"Side-by-Side (Parallel)",
"Top-Bottom (Over-Under)",
"Left Eye Only",
"Right Eye Only"
], {"default": "Cross-Eye"}),
"depth_intensity": ("FLOAT", {
"default": 25.0, "min": 0.0, "max": 100.0, "step": 1.0,
"display": "slider",
"tooltip": "Controls how much the left and right perspectives shift apart."
}),
"depth_gamma": ("FLOAT", {
"default": 0.50, "min": 0.1, "max": 3.0, "step": 0.05,
"display": "slider",
"tooltip": "Adjust depth curve for realistic 3D falloff (Stereonix default is 0.50)."
}),
"invert_depth": ("BOOLEAN", {
"default": False,
"tooltip": "Flip which parts appear closer or further away."
}),
"convergence_plane": ("FLOAT", {
"default": 0.50, "min": 0.0, "max": 1.0, "step": 0.02,
"display": "slider",
"tooltip": "Depth level where parallax is zero (screen surface). Above pops out, below sinks in."
}),
"image_zoom": ("FLOAT", {
"default": 100.0, "min": 10.0, "max": 300.0, "step": 1.0,
"display": "slider",
"tooltip": "Zoom into areas of interest before generating 3D disparity."
}),
"pan_horizontal": ("FLOAT", {
"default": 0.0, "min": -100.0, "max": 100.0, "step": 1.0,
"display": "slider",
"tooltip": "Horizontal pan offset (-100 to 100)."
}),
"pan_vertical": ("FLOAT", {
"default": 0.0, "min": -100.0, "max": 100.0, "step": 1.0,
"display": "slider",
"tooltip": "Vertical pan offset (-100 to 100)."
}),
"gap_spacing": ("INT", {
"default": 20, "min": 0, "max": 1000, "step": 2,
"display": "number",
"tooltip": "Empty black space between the left and right images."
}),
"outer_border": ("INT", {
"default": 0, "min": 0, "max": 1000, "step": 2,
"display": "number",
"tooltip": "Black border padding around the canvas (allows shrinking images for easier cross-eye viewing)."
}),
"alignment_dots": ("BOOLEAN", {
"default": True,
"tooltip": "Place focus guide dots above each eye view to easily lock into 3D cross-eye view."
}),
"fill_method": ([
"Smooth (Bilinear Grid)",
"Nearest Neighbor (Stereonix)"
], {
"default": "Smooth (Bilinear Grid)",
"tooltip": "Hole-filling interpolation for occlusion gaps."
}),
"preview_zoom": ([
"Fit",
"25%",
"33%",
"50%",
"75%",
"100%"
], {
"default": "50%",
"tooltip": "Scale preview for easier cross-eye viewing on canvas (does not affect full output)."
}),
},
"optional": {
"depth_map": ("IMAGE", {
"tooltip": "Optional depth map. If disconnected, automatically runs Depth Anything V2."
}),
}
}
RETURN_TYPES = ("IMAGE", "IMAGE", "IMAGE", "IMAGE")
RETURN_NAMES = ("stereogram", "left_eye", "right_eye", "depth_map")
FUNCTION = "generate"
CATEGORY = "🪐 SaturnNodes/3D & Previews"
DESCRIPTION = (
"Creates side-by-side cross-eye stereograms, parallel 3D views, and red-cyan anaglyphs "
"from 2D images. Inspired by Stereonix, it includes automatic Depth Anything V2 Small fallback, "
"custom depth gamma/inversion, zoom & pan cropping, adjustable gap/border spacing, and focus alignment dots."
)
def generate(
self,
image,
mode="Cross-Eye",
depth_intensity=25.0,
depth_gamma=0.50,
invert_depth=False,
convergence_plane=0.50,
image_zoom=100.0,
pan_horizontal=0.0,
pan_vertical=0.0,
gap_spacing=20,
outer_border=0,
alignment_dots=True,
fill_method="Smooth (Bilinear Grid)",
preview_zoom="50%",
depth_map=None,
**kwargs
):
device = image.device
B, H, W, C = image.shape
# 1. Obtain or generate depth map
depth_tensors = []
if depth_map is not None:
# Handle incoming depth map tensor
d_map = depth_map.to(device=device, dtype=torch.float32)
if d_map.shape[1:3] != (H, W):
d_perm = d_map.permute(0, 3, 1, 2) if d_map.dim() == 4 else d_map.unsqueeze(1)
d_resized = F.interpolate(d_perm, size=(H, W), mode="bilinear", align_corners=False)
d_map = d_resized.permute(0, 2, 3, 1) if d_map.dim() == 4 else d_resized.squeeze(1)
if d_map.dim() == 4:
d_tensor = d_map[..., 0] # take single channel
else:
d_tensor = d_map
# Normalize 0 to 1
for b in range(B):
b_depth = d_tensor[b if b < d_tensor.shape[0] else 0]
min_v = b_depth.min()
max_v = b_depth.max()
denom = max(float(max_v - min_v), 1e-6)
depth_norm = torch.clamp((b_depth - min_v) / denom, 0.0, 1.0)
depth_tensors.append(depth_norm)
else:
# Automatic Depth Estimation via Depth Anything V2 Small ONNX (cached for instant slider responsiveness)
cpu_img = image.detach().cpu().numpy()
for b in range(B):
# Fast signature based on shape, sum, and corner samples
img_slice = cpu_img[b]
cache_key = (
img_slice.shape,
round(float(img_slice.sum()), 2),
round(float(img_slice[0, 0, 0]), 4),
round(float(img_slice[-1, -1, -1]), 4)
)
if cache_key in _PREDICTED_DEPTH_CACHE:
d_np = _PREDICTED_DEPTH_CACHE[cache_key]
else:
d_np = predict_depth_map(img_slice)
if len(_PREDICTED_DEPTH_CACHE) > 16:
_PREDICTED_DEPTH_CACHE.clear()
_PREDICTED_DEPTH_CACHE[cache_key] = d_np
depth_tensors.append(torch.from_numpy(d_np).to(device=device, dtype=torch.float32))
depth_batch = torch.stack(depth_tensors, dim=0) # [B, H, W]
# 2. Apply Invert & Depth Gamma
if invert_depth:
depth_batch = 1.0 - depth_batch
if abs(depth_gamma - 1.0) > 1e-4:
depth_batch = torch.pow(torch.clamp(depth_batch, 1e-6, 1.0), depth_gamma)
# 3. Apply Zoom & Pan cropping if requested
zoom_factor = max(0.1, float(image_zoom) / 100.0)
pan_x_norm = float(pan_horizontal) / 100.0
pan_y_norm = float(pan_vertical) / 100.0
grid_y = torch.linspace(-1, 1, H, device=device).view(1, H, 1, 1).expand(B, H, W, 1)
grid_x = torch.linspace(-1, 1, W, device=device).view(1, 1, W, 1).expand(B, H, W, 1)
if abs(zoom_factor - 1.0) > 1e-4 or abs(pan_x_norm) > 1e-4 or abs(pan_y_norm) > 1e-4:
crop_x = (grid_x - pan_x_norm) / zoom_factor
crop_y = (grid_y - pan_y_norm) / zoom_factor
crop_grid = torch.cat([crop_x, crop_y], dim=-1)
img_perm = image.permute(0, 3, 1, 2)
d_perm = depth_batch.unsqueeze(1)
image_cropped = F.grid_sample(img_perm, crop_grid, mode="bilinear", padding_mode="zeros", align_corners=True).permute(0, 2, 3, 1)
depth_cropped = F.grid_sample(d_perm, crop_grid, mode="bilinear", padding_mode="border", align_corners=True).squeeze(1)
else:
image_cropped = image
depth_cropped = depth_batch
# 4. Compute Parallax Shift (Horizontal Disparity)
# Shift magnitude scales with image width: at intensity 100, maximum shift is 3.5% of width
max_shift = (float(depth_intensity) / 100.0) * (W * 0.035)
# Shift relative to convergence plane
shift_px = (depth_cropped - float(convergence_plane)) * max_shift
shift_norm = (shift_px * (2.0 / W)).unsqueeze(-1) # [B, H, W, 1]
# 5. Generate Left and Right eye views
grid_left = torch.cat([grid_x - shift_norm, grid_y], dim=-1)
grid_right = torch.cat([grid_x + shift_norm, grid_y], dim=-1)
img_in = image_cropped.permute(0, 3, 1, 2)
if fill_method == "Nearest Neighbor (Stereonix)":
# Backward warp with nearest-neighbor lookup matching classic Stereonix
left_eye = F.grid_sample(img_in, grid_left, mode="nearest", padding_mode="border", align_corners=True).permute(0, 2, 3, 1)
right_eye = F.grid_sample(img_in, grid_right, mode="nearest", padding_mode="border", align_corners=True).permute(0, 2, 3, 1)
else:
# Smooth bilinear grid sampling
left_eye = F.grid_sample(img_in, grid_left, mode="bilinear", padding_mode="border", align_corners=True).permute(0, 2, 3, 1)
right_eye = F.grid_sample(img_in, grid_right, mode="bilinear", padding_mode="border", align_corners=True).permute(0, 2, 3, 1)
# 6. Composite according to Selected Mode
if mode == "Left Eye Only":
stereogram = left_eye
elif mode == "Right Eye Only":
stereogram = right_eye
elif mode == "Red-Cyan Anaglyph":
# Red channel from Left Eye, Cyan (Green + Blue) channels from Right Eye
stereogram = torch.zeros_like(left_eye)
stereogram[..., 0] = left_eye[..., 0]
stereogram[..., 1] = right_eye[..., 1]
stereogram[..., 2] = right_eye[..., 2]
elif mode == "Top-Bottom (Over-Under)":
gap_h = max(0, int(gap_spacing))
border = max(0, int(outer_border))
top_panel = left_eye
bot_panel = right_eye
if gap_h > 0:
gap_row = torch.zeros((B, gap_h, W, C), device=device, dtype=torch.float32)
stacked = torch.cat([top_panel, gap_row, bot_panel], dim=1)
else:
stacked = torch.cat([top_panel, bot_panel], dim=1)
if border > 0:
H_st, W_st = stacked.shape[1], stacked.shape[2]
stereogram = torch.zeros((B, H_st + border * 2, W_st + border * 2, C), device=device, dtype=torch.float32)
stereogram[:, border:border + H_st, border:border + W_st, :] = stacked
else:
stereogram = stacked
else:
# Side-by-Side: Cross-Eye vs Parallel
# In Cross-Eye: Left panel is Right-Eye, Right panel is Left-Eye
# In Parallel / Side-by-Side: Left panel is Left-Eye, Right panel is Right-Eye
if mode == "Cross-Eye":
panel_left = right_eye
panel_right = left_eye
else:
panel_left = left_eye
panel_right = right_eye
gap_w = max(0, int(gap_spacing))
border = max(0, int(outer_border))
if gap_w > 0:
gap_col = torch.zeros((B, H, gap_w, C), device=device, dtype=torch.float32)
side_by_side = torch.cat([panel_left, gap_col, panel_right], dim=2)
else:
side_by_side = torch.cat([panel_left, panel_right], dim=2)
# Apply outer border framing
if border > 0:
H_sbs, W_sbs = side_by_side.shape[1], side_by_side.shape[2]
stereogram = torch.zeros((B, H_sbs + border * 2, W_sbs + border * 2, C), device=device, dtype=torch.float32)
stereogram[:, border:border + H_sbs, border:border + W_sbs, :] = side_by_side
else:
stereogram = side_by_side
# Add alignment guide dots
if alignment_dots and (mode in ("Cross-Eye", "Parallel (Wall-Eyed)", "Side-by-Side (Parallel)")):
# Center coordinates above left and right panels
cx_left = border + (W // 2)
cx_right = border + W + gap_w + (W // 2)
cy_dot = border // 2 if border >= 18 else min(16, H // 12)
dot_radius = max(4, min(8, W // 100))
for b in range(B):
draw_alignment_dot(stereogram[b], cx_left, cy_dot, radius=dot_radius)
draw_alignment_dot(stereogram[b], cx_right, cy_dot, radius=dot_radius)
# 7. Convert Depth to 3-channel image for output
depth_output = depth_cropped.unsqueeze(-1).repeat(1, 1, 1, 3)
return (stereogram, left_eye, right_eye, depth_output)
-1
View File
@@ -195,7 +195,6 @@ class TrayIconManager:
def _run():
try:
self._is_running = True
print("[SaturnNodes] System tray icon started.")
self._icon.run()
except Exception as e:
print(f"[SaturnNodes] System tray icon error: {e}")
+131 -55
View File
@@ -180,26 +180,43 @@ def is_safe_path(target_path, allowed_bases=None):
def sanitize_image_loader_folder(folder_input, default_to_output=True):
"""
Resolves folder input strictly within ComfyUI input/output/temp directories.
If folder_input attempts to escape or points outside allowed bases, falls back safely.
Rejects directory traversal ('..') and unapproved absolute paths, strictly verifying confinement via commonpath.
"""
allowed_bases = get_allowed_image_directories()
input_dir = folder_paths.get_input_directory()
output_dir = folder_paths.get_output_directory()
default_dir = output_dir if default_to_output else input_dir
input_dir = folder_paths.get_input_directory() if hasattr(folder_paths, "get_input_directory") else "."
output_dir = folder_paths.get_output_directory() if hasattr(folder_paths, "get_output_directory") else "."
default_dir = os.path.abspath(os.path.realpath(output_dir if default_to_output else input_dir))
if not folder_input or not str(folder_input).strip():
return default_dir
clean = str(folder_input).strip().rstrip("\\/")
# 1. Reject directory traversal sequences ('..')
norm_slashes = clean.replace("\\", "/")
if any(p == ".." for p in norm_slashes.split("/")):
print(f"[SaturnNodes Security] Blocked directory traversal in image folder path '{clean}'. Confining to ComfyUI directories.")
return default_dir
# 2. Reject absolute paths (drive letters C:\, POSIX root /, UNC \\server)
is_absolute = (
os.path.isabs(clean)
or bool(re.match(r'^[A-Za-z]:', clean))
or clean.startswith(("//", "\\\\"))
)
if is_absolute:
if not is_safe_path(clean, allowed_bases):
print(f"[SaturnNodes Security] Blocked absolute image folder path '{clean}'. Confining to ComfyUI directories.")
return default_dir
return os.path.abspath(os.path.realpath(clean))
# 3. Handle relative 'input' or 'output' prefixes
if clean.lower() == "input" or clean.lower().startswith("input/") or clean.lower().startswith("input\\"):
sub = clean[5:].lstrip("\\/")
resolved = os.path.join(input_dir, sub) if sub else input_dir
elif clean.lower() == "output" or clean.lower().startswith("output/") or clean.lower().startswith("output\\"):
sub = clean[6:].lstrip("\\/")
resolved = os.path.join(output_dir, sub) if sub else output_dir
elif os.path.isabs(clean):
resolved = clean
else:
cand_out = os.path.join(output_dir, clean)
cand_inp = os.path.join(input_dir, clean)
@@ -209,7 +226,7 @@ def sanitize_image_loader_folder(folder_input, default_to_output=True):
resolved = cand_out
if is_safe_path(resolved, allowed_bases):
return os.path.normpath(resolved)
return os.path.abspath(os.path.realpath(resolved))
return default_dir
@@ -262,74 +279,133 @@ def parse_pretty_name_with_version(filepath):
return f"{pretty_name} {version_candidate.upper()}"
return pretty_name
def get_allowed_folder_watch_directories():
"""
Returns approved base directories for folder watching and recent output scanning:
strictly ComfyUI input and output directories.
"""
allowed = []
try:
inp = folder_paths.get_input_directory()
if inp:
allowed.append(os.path.abspath(os.path.realpath(inp)))
except Exception:
pass
try:
out = folder_paths.get_output_directory()
if out:
allowed.append(os.path.abspath(os.path.realpath(out)))
except Exception:
pass
return allowed
def sanitize_folder_path(folder_input, default_dir=None):
"""
Confines folder paths strictly within ComfyUI's input and output directories.
Rejects absolute paths and '..' directory traversal sequences.
Resolves canonical realpath and verifies commonpath containment.
"""
allowed_bases = get_allowed_folder_watch_directories()
input_dir = folder_paths.get_input_directory() if hasattr(folder_paths, "get_input_directory") else None
safe_fallback = os.path.join(input_dir, "watch") if input_dir else "watch"
if default_dir:
if os.path.isabs(default_dir):
try:
real_def = os.path.abspath(os.path.realpath(default_dir))
for base in allowed_bases:
if os.path.commonpath([base, real_def]) == base:
safe_fallback = real_def
break
except Exception:
pass
else:
safe_fallback = default_dir
if folder_input is None:
folder_input = ""
clean_folder = str(folder_input).strip()
# Strip any trailing wildcards (e.g. "krea2\*" -> "krea2", "krea2/*" -> "krea2")
# Strip any trailing wildcards (e.g. "subfolder\*" -> "subfolder", "subfolder/*" -> "subfolder")
clean_folder = re.sub(r'[\*\?]+$', '', clean_folder).rstrip("\\/")
if not clean_folder and default_dir:
clean_folder = default_dir
if not clean_folder:
return ""
clean_folder = safe_fallback
# 1. If absolute path, verify directly with case-insensitive check
# 1. Reject absolute paths (drive letters C:\, POSIX root /, UNC \\server)
is_absolute = (
os.path.isabs(clean_folder)
or bool(re.match(r'^[A-Za-z]:', clean_folder))
or clean_folder.startswith(("//", "\\\\"))
)
# 2. Reject directory traversal sequences ('..')
normalized_slashes = clean_folder.replace("\\", "/")
parts = normalized_slashes.split("/")
has_traversal = any(p == ".." for p in parts)
if is_absolute or has_traversal:
is_safe_default = False
if is_absolute and safe_fallback and clean_folder == safe_fallback:
try:
real_clean = os.path.abspath(os.path.realpath(clean_folder))
for base in allowed_bases:
if os.path.commonpath([base, real_clean]) == base:
is_safe_default = True
break
except Exception:
pass
if not is_safe_default:
print(f"[SaturnNodes Security] Blocked unsafe folder path '{clean_folder}' (absolute={is_absolute}, traversal={has_traversal}). Confining to ComfyUI input/output directories.")
clean_folder = safe_fallback
# 3. If clean_folder is an already-confined absolute path (e.g. from internal safe_fallback)
if os.path.isabs(clean_folder):
norm_path = os.path.normpath(clean_folder)
if os.path.exists(norm_path):
return norm_path
parent_dir = os.path.dirname(norm_path)
base_name = os.path.basename(norm_path)
if os.path.exists(parent_dir):
try:
for entry in os.listdir(parent_dir):
if entry.lower() == base_name.lower():
return os.path.join(parent_dir, entry)
except Exception:
pass
return norm_path
try:
real_cand = os.path.abspath(os.path.realpath(clean_folder))
for base in allowed_bases:
if os.path.commonpath([base, real_cand]) == base:
return real_cand
except Exception:
pass
return os.path.abspath(os.path.realpath(safe_fallback))
# 2. If relative path, check output directory, input directory, then base path
# 4. For relative paths, evaluate candidates strictly within allowed_bases
candidates = []
try:
output_dir = folder_paths.get_output_directory()
if output_dir:
candidates.append(os.path.normpath(os.path.join(output_dir, clean_folder)))
except Exception:
pass
for base in allowed_bases:
target = os.path.abspath(os.path.realpath(os.path.join(base, clean_folder)))
try:
if os.path.commonpath([base, target]) == base:
candidates.append((target, os.path.exists(target)))
except Exception:
continue
try:
input_dir = folder_paths.get_input_directory()
if input_dir:
candidates.append(os.path.normpath(os.path.join(input_dir, clean_folder)))
except Exception:
pass
# Return existing candidate if present on disk
for target, exists in candidates:
if exists:
return target
try:
if folder_paths.base_path:
candidates.append(os.path.normpath(os.path.join(folder_paths.base_path, clean_folder)))
except Exception:
pass
for cand in candidates:
if os.path.exists(cand):
return cand
for cand in candidates:
parent_dir = os.path.dirname(cand)
base_name = os.path.basename(cand)
# Case-insensitive resolution within candidate parent folders
for target, _ in candidates:
parent_dir = os.path.dirname(target)
base_name = os.path.basename(target)
if os.path.exists(parent_dir):
try:
for entry in os.listdir(parent_dir):
if entry.lower() == base_name.lower():
return os.path.join(parent_dir, entry)
match_path = os.path.join(parent_dir, entry)
real_match = os.path.abspath(os.path.realpath(match_path))
for base in allowed_bases:
if os.path.commonpath([base, real_match]) == base:
return real_match
except Exception:
pass
return candidates[0] if candidates else os.path.normpath(os.path.join(folder_paths.base_path, clean_folder))
if candidates:
return candidates[0][0]
default_input = folder_paths.get_input_directory() if hasattr(folder_paths, "get_input_directory") else "."
return os.path.abspath(os.path.realpath(os.path.join(default_input, "watch")))
def format_lora_output_name(resolved_path, display_name, output_format="Parsed Name", custom_regex=""):
if not resolved_path or resolved_path in ["[ NONE ]", "[ RANDOM ]"]:
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "comfyui-saturnnodes"
description = "Essential workflow controls, mid-generation pausing, real-time queue persistence, folder watcher, and visual model loaders for ComfyUI."
version = "2.3.2"
version = "2.5.0"
readme = "README.md"
license = "MIT"
dependencies = ["pillow", "numpy", "piexif"]
+2
View File
@@ -2,3 +2,5 @@ Pillow
piexif
numpy
# pystray # Optional: install manually if desktop system tray icon integration is enabled
# onnxruntime # Optional: install manually for built-in Depth Anything V2 estimation in 3D stereogram nodes
+10 -5
View File
@@ -12,8 +12,12 @@ from nodes.auto_watcher import LoadImageFromFolder
class TestAutoWatcher(unittest.TestCase):
def setUp(self):
import folder_paths
self.node = LoadImageFromFolder()
self.temp_dir = tempfile.mkdtemp()
inp_dir = folder_paths.get_input_directory()
os.makedirs(inp_dir, exist_ok=True)
self.temp_dir = tempfile.mkdtemp(dir=inp_dir)
self.rel_dir = os.path.relpath(self.temp_dir, inp_dir)
def tearDown(self):
shutil.rmtree(self.temp_dir, ignore_errors=True)
@@ -44,8 +48,9 @@ class TestAutoWatcher(unittest.TestCase):
def test_empty_folder_returns_dummy(self):
empty_dir = os.path.join(self.temp_dir, "empty")
os.makedirs(empty_dir, exist_ok=True)
empty_rel = os.path.join(self.rel_dir, "empty")
img_tensor, has_image = self.node.watch(
folder=empty_dir,
folder=empty_rel,
wait_if_folder_is_empty=False,
rescan_interval=1,
sort_by="date_modified",
@@ -62,15 +67,15 @@ class TestAutoWatcher(unittest.TestCase):
Image.new("RGB", (64, 64), color="green").save(f2)
# Run 1 -> loads img_a
t1, has1 = self.node.watch(folder=self.temp_dir, wait_if_folder_is_empty=False, rescan_interval=1, sort_by="name", regex_filter=".*", delete_image=False, unique_id="cycle_test")
t1, has1 = self.node.watch(folder=self.rel_dir, wait_if_folder_is_empty=False, rescan_interval=1, sort_by="name", regex_filter=".*", delete_image=False, unique_id="cycle_test")
self.assertTrue(has1)
# Run 2 -> loads img_b (cycled!)
t2, has2 = self.node.watch(folder=self.temp_dir, wait_if_folder_is_empty=False, rescan_interval=1, sort_by="name", regex_filter=".*", delete_image=False, unique_id="cycle_test")
t2, has2 = self.node.watch(folder=self.rel_dir, wait_if_folder_is_empty=False, rescan_interval=1, sort_by="name", regex_filter=".*", delete_image=False, unique_id="cycle_test")
self.assertTrue(has2)
# Run 3 -> loops back to img_a
t3, has3 = self.node.watch(folder=self.temp_dir, wait_if_folder_is_empty=False, rescan_interval=1, sort_by="name", regex_filter=".*", delete_image=False, unique_id="cycle_test")
t3, has3 = self.node.watch(folder=self.rel_dir, wait_if_folder_is_empty=False, rescan_interval=1, sort_by="name", regex_filter=".*", delete_image=False, unique_id="cycle_test")
self.assertTrue(has3)
def test_load_and_remove_image_with_delete_toggle(self):
+11 -9
View File
@@ -13,8 +13,12 @@ from nodes.load_recent import LoadRecentOutputs
class TestLoadRecent(unittest.TestCase):
def setUp(self):
import folder_paths
self.node = LoadRecentOutputs()
self.temp_dir = tempfile.mkdtemp()
out_dir = folder_paths.get_output_directory()
os.makedirs(out_dir, exist_ok=True)
self.temp_dir = tempfile.mkdtemp(dir=out_dir)
self.rel_dir = os.path.relpath(self.temp_dir, out_dir)
def tearDown(self):
shutil.rmtree(self.temp_dir, ignore_errors=True)
@@ -26,16 +30,14 @@ class TestLoadRecent(unittest.TestCase):
img.save(f1)
img.save(f2)
with patch("nodes.load_recent.folder_paths.get_output_directory", return_value=self.temp_dir):
out_tensor, = self.node.load_single_image(output_folder=self.temp_dir, amount=2, index=0)
self.assertEqual(out_tensor.shape[0], 1)
self.assertEqual(out_tensor.shape[1], 64)
self.assertEqual(out_tensor.shape[2], 64)
out_tensor, = self.node.load_single_image(output_folder=self.rel_dir, amount=2, index=0)
self.assertEqual(out_tensor.shape[0], 1)
self.assertEqual(out_tensor.shape[1], 64)
self.assertEqual(out_tensor.shape[2], 64)
def test_load_recent_empty_directory_returns_dummy(self):
with patch("nodes.load_recent.folder_paths.get_output_directory", return_value=self.temp_dir):
out_tensor, = self.node.load_single_image(output_folder=self.temp_dir, amount=5, index=0)
self.assertEqual(out_tensor.shape, (1, 512, 512, 3))
out_tensor, = self.node.load_single_image(output_folder=self.rel_dir, amount=5, index=0)
self.assertEqual(out_tensor.shape, (1, 512, 512, 3))
if __name__ == "__main__":
unittest.main()
+4 -4
View File
@@ -36,9 +36,9 @@ class TestLoraFinder(unittest.TestCase):
@patch("nodes.lora_finder.comfy.sd.load_lora_for_models")
def test_find_and_load_lora_match(self, mock_load_lora, mock_load_torch, mock_exists, mock_get_path, mock_mapping):
mock_mapping.return_value = {
"Ana De Armas": "celebrities/krea2_ana-de-armas_v1.safetensors"
"Cyberpunk Character": "characters/char_cyberpunk-character_v1.safetensors"
}
mock_get_path.return_value = "/models/loras/celebrities/krea2_ana-de-armas_v1.safetensors"
mock_get_path.return_value = "/models/loras/characters/char_cyberpunk-character_v1.safetensors"
mock_exists.return_value = True
mock_load_torch.return_value = {}
mock_load_lora.return_value = ("PATCHED_MODEL", "PATCHED_CLIP")
@@ -46,13 +46,13 @@ class TestLoraFinder(unittest.TestCase):
model, clip, loras = self.finder.find_and_load_loras(
model="MODEL",
clip="CLIP",
text="A photo of Ana De Armas in evening dress",
text="A digital painting of a Cyberpunk Character at neon midnight",
search_for="Parsed Name",
output_format="Parsed Name"
)
self.assertEqual(model, "PATCHED_MODEL")
self.assertEqual(clip, "PATCHED_CLIP")
self.assertIn("Ana De Armas", loras)
self.assertIn("Cyberpunk Character", loras)
if __name__ == "__main__":
unittest.main()
+108
View File
@@ -0,0 +1,108 @@
import os
import sys
import unittest
import torch
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import test_helper
from nodes.stereogram import SaturnStereo3D
class TestSaturnStereo3D(unittest.TestCase):
def setUp(self):
self.node = SaturnStereo3D()
self.img = torch.rand((1, 128, 128, 3), dtype=torch.float32)
self.depth = torch.linspace(0, 1, 128).view(1, 1, 128).expand(1, 128, 128).unsqueeze(-1).repeat(1, 1, 1, 3)
def test_cross_eye_mode_dimensions(self):
# Image is 128x128. With gap=10, border=5:
# Side by side: 128 + 10 + 128 = 266 width.
# With border=5 on each side: Width = 266 + 10 = 276. Height = 128 + 10 = 138.
res = self.node.generate(
self.img,
depth_map=self.depth,
mode="Cross-Eye",
depth_intensity=30.0,
gap_spacing=10,
outer_border=5,
alignment_dots=True
)
stereogram, left_eye, right_eye, depth_out = res
self.assertEqual(stereogram.shape, (1, 138, 276, 3))
self.assertEqual(left_eye.shape, (1, 128, 128, 3))
self.assertEqual(right_eye.shape, (1, 128, 128, 3))
self.assertEqual(depth_out.shape, (1, 128, 128, 3))
def test_parallel_mode(self):
res = self.node.generate(
self.img,
depth_map=self.depth,
mode="Parallel (Wall-Eyed)",
depth_intensity=25.0,
gap_spacing=0,
outer_border=0,
alignment_dots=False
)
stereogram, _, _, _ = res
self.assertEqual(stereogram.shape, (1, 128, 256, 3))
def test_anaglyph_mode(self):
res = self.node.generate(
self.img,
depth_map=self.depth,
mode="Red-Cyan Anaglyph",
depth_intensity=25.0
)
stereogram, left_eye, right_eye, _ = res
self.assertEqual(stereogram.shape, (1, 128, 128, 3))
np.testing.assert_allclose(stereogram[..., 0].cpu().numpy(), left_eye[..., 0].cpu().numpy(), atol=1e-5)
np.testing.assert_allclose(stereogram[..., 1].cpu().numpy(), right_eye[..., 1].cpu().numpy(), atol=1e-5)
np.testing.assert_allclose(stereogram[..., 2].cpu().numpy(), right_eye[..., 2].cpu().numpy(), atol=1e-5)
def test_top_bottom_mode(self):
res = self.node.generate(
self.img,
depth_map=self.depth,
mode="Top-Bottom (Over-Under)",
gap_spacing=6,
outer_border=0
)
stereogram, _, _, _ = res
self.assertEqual(stereogram.shape, (1, 262, 128, 3))
def test_single_eye_modes(self):
res_left = self.node.generate(self.img, depth_map=self.depth, mode="Left Eye Only")
res_right = self.node.generate(self.img, depth_map=self.depth, mode="Right Eye Only")
self.assertEqual(res_left[0].shape, (1, 128, 128, 3))
self.assertEqual(res_right[0].shape, (1, 128, 128, 3))
def test_zoom_and_pan(self):
res = self.node.generate(
self.img,
depth_map=self.depth,
image_zoom=150.0,
pan_horizontal=20.0,
pan_vertical=-10.0
)
stereogram = res[0]
self.assertIsNotNone(stereogram)
self.assertEqual(stereogram.dim(), 4)
def test_auto_depth_fallback(self):
from unittest.mock import patch
with patch("nodes.stereogram.get_ort_session", side_effect=RuntimeError("onnxruntime unavailable")):
res = self.node.generate(
self.img,
depth_map=None,
mode="Cross-Eye",
depth_intensity=20.0
)
stereogram, _, _, depth_out = res
self.assertEqual(stereogram.shape[0], 1)
self.assertEqual(depth_out.shape, (1, 128, 128, 3))
if __name__ == "__main__":
unittest.main()
+61 -14
View File
@@ -105,8 +105,8 @@ class TestUtils(unittest.TestCase):
shutil.rmtree(base1, ignore_errors=True)
def test_parse_pretty_name(self):
name = parse_pretty_name("krea2_ana-de-armas_v1.safetensors")
self.assertEqual(name, "Ana De Armas")
name = parse_pretty_name("char_cyberpunk-character_v1.safetensors")
self.assertEqual(name, "Cyberpunk Character")
def test_parse_pretty_name_special_keywords(self):
name = parse_pretty_name("tag_cyberpunk-nsfw-v2.safetensors")
@@ -114,28 +114,63 @@ class TestUtils(unittest.TestCase):
self.assertIn("V2", name)
def test_parse_pretty_name_with_version(self):
name_v = parse_pretty_name_with_version("krea2_ana-de-armas_v1.safetensors")
self.assertEqual(name_v, "Ana De Armas V1")
name_v = parse_pretty_name_with_version("char_cyberpunk-character_v1.safetensors")
self.assertEqual(name_v, "Cyberpunk Character V1")
def test_format_lora_output_name_modes(self):
rel_path = os.path.join("celebrities", "ana_de_armas.safetensors")
rel_path = os.path.join("characters", "cyberpunk_character.safetensors")
parsed = format_lora_output_name(rel_path, "Ana De Armas", "Parsed Name")
self.assertEqual(parsed, "Ana De Armas")
parsed = format_lora_output_name(rel_path, "Cyberpunk Character", "Parsed Name")
self.assertEqual(parsed, "Cyberpunk Character")
filename = format_lora_output_name(rel_path, "Ana De Armas", "Filename")
self.assertEqual(filename, "ana_de_armas.safetensors")
filename = format_lora_output_name(rel_path, "Cyberpunk Character", "Filename")
self.assertEqual(filename, "cyberpunk_character.safetensors")
no_ext = format_lora_output_name(rel_path, "Ana De Armas", "Filename without extension")
self.assertEqual(no_ext, "ana_de_armas")
no_ext = format_lora_output_name(rel_path, "Cyberpunk Character", "Filename without extension")
self.assertEqual(no_ext, "cyberpunk_character")
custom_reg = format_lora_output_name(rel_path, "Ana De Armas", "Custom Regex", custom_regex=r"ana_([a-z]+)")
self.assertEqual(custom_reg, "ana_de")
custom_reg = format_lora_output_name(rel_path, "Cyberpunk Character", "Custom Regex", custom_regex=r"cyberpunk_([a-z]+)")
self.assertEqual(custom_reg, "cyberpunk_character")
def test_sanitize_folder_path_wildcard_stripping(self):
clean = sanitize_folder_path("input/watch/*")
clean = sanitize_folder_path("watch/*")
self.assertNotIn("*", clean)
def test_sanitize_folder_path_blocks_absolute_paths(self):
import folder_paths
inp = os.path.abspath(os.path.realpath(folder_paths.get_input_directory()))
out = os.path.abspath(os.path.realpath(folder_paths.get_output_directory()))
# Test Windows drive letters and root paths
for bad_path in ["C:\\Windows\\System32", "D:\\SecretData", "/etc/passwd", "\\\\server\\share"]:
res = sanitize_folder_path(bad_path)
res_norm = os.path.abspath(os.path.realpath(res))
# Must be confined strictly within input or output directory
is_confined = (os.path.commonpath([inp, res_norm]) == inp) or (os.path.commonpath([out, res_norm]) == out)
self.assertTrue(is_confined, f"Path '{bad_path}' escaped confinement! Got: '{res_norm}'")
def test_sanitize_folder_path_blocks_directory_traversal(self):
import folder_paths
inp = os.path.abspath(os.path.realpath(folder_paths.get_input_directory()))
out = os.path.abspath(os.path.realpath(folder_paths.get_output_directory()))
for bad_traversal in ["../../etc", "watch/../../secret", "..\\..\\Windows", "sub/../../../root"]:
res = sanitize_folder_path(bad_traversal)
res_norm = os.path.abspath(os.path.realpath(res))
is_confined = (os.path.commonpath([inp, res_norm]) == inp) or (os.path.commonpath([out, res_norm]) == out)
self.assertTrue(is_confined, f"Traversal '{bad_traversal}' escaped confinement! Got: '{res_norm}'")
def test_sanitize_folder_path_allows_safe_subfolders(self):
import folder_paths
inp = os.path.abspath(os.path.realpath(folder_paths.get_input_directory()))
out = os.path.abspath(os.path.realpath(folder_paths.get_output_directory()))
for safe_sub in ["watch", "my_images", "subfolder/nested"]:
res = sanitize_folder_path(safe_sub)
res_norm = os.path.abspath(os.path.realpath(res))
is_confined = (os.path.commonpath([inp, res_norm]) == inp) or (os.path.commonpath([out, res_norm]) == out)
self.assertTrue(is_confined, f"Safe subfolder '{safe_sub}' failed confinement! Got: '{res_norm}'")
def test_is_safe_external_image_url(self):
from nodes.lora_loader import is_safe_external_image_url
self.assertTrue(is_safe_external_image_url("https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/test.jpg"))
@@ -155,5 +190,17 @@ class TestUtils(unittest.TestCase):
self.assertEqual(parse_pretty_name("[ NONE ]"), "")
self.assertEqual(parse_pretty_name("[ RANDOM ]"), "")
def test_sanitize_image_loader_folder_confinement(self):
from nodes.utils import sanitize_image_loader_folder
import folder_paths
inp = os.path.abspath(os.path.realpath(folder_paths.get_input_directory()))
out = os.path.abspath(os.path.realpath(folder_paths.get_output_directory()))
for bad in ["C:\\Windows", "/etc", "../../escape", "output/../../secret", "\\\\server\\share"]:
res = sanitize_image_loader_folder(bad)
res_norm = os.path.abspath(os.path.realpath(res))
is_confined = (os.path.commonpath([inp, res_norm]) == inp) or (os.path.commonpath([out, res_norm]) == out)
self.assertTrue(is_confined, f"Image folder '{bad}' escaped confinement: '{res_norm}'")
if __name__ == "__main__":
unittest.main()
+887
View File
@@ -0,0 +1,887 @@
import { app } from "/scripts/app.js";
import { api } from "/scripts/api.js";
import { PreviewManager } from "./js/preview_manager.js";
/**
* 🪐 SaturnNodes — Floating Live Latent Preview Dock
*
* Features:
* 1. Integrates a crisp vector SVG button directly into the bottom-right floating island (Canvas Toolbar).
* 2. Dynamic positioning: Dynamically tracks the minimap state.
* - Sits at the absolute lowest point possible right above the toolbar when minimap is disabled (~54px).
* - Automatically slides up to sit right above the minimap when minimap is enabled (~260px).
* - Pinned fully to the right (8px).
* 3. Takes the exact shape/aspect ratio of the latent being generated.
* 4. Full pixel-quality rendering on high-DPI canvas without compression or blur.
* 5. Configurable settings (enable/disable, max size, auto-hide when idle).
*/
const STORAGE_KEY_VISIBLE = "SaturnNodes.LivePreview.FloatingDockVisible";
class FloatingLatentDock {
constructor() {
this.dockEl = null;
this.canvasEl = null;
this.btnEl = null;
this.aspectBadge = null;
this.statusBadge = null;
this.currentImg = null;
this.isVisible = this.loadInitialVisibility();
this.isGenerating = false;
this.latestSamplerId = null;
// Inspecting Lightbox state (matching ComfyUI core MediaLightbox)
this.lightboxEl = null;
this.lightboxImg = null;
this.lightboxBadge = null;
this.lightboxPrevActive = null;
this._lightboxKeyDown = null;
this.init();
}
loadInitialVisibility() {
if (typeof localStorage !== "undefined") {
try {
const stored = localStorage.getItem(STORAGE_KEY_VISIBLE);
if (stored !== null) return stored === "true";
} catch (_) {}
}
return true; // Default visible
}
saveVisibility(val) {
this.isVisible = val;
if (typeof localStorage !== "undefined") {
try {
localStorage.setItem(STORAGE_KEY_VISIBLE, String(val));
} catch (_) {}
}
this.updateVisibility();
}
getSetting(id, defaultVal) {
if (app?.ui?.settings) {
const val = app.ui.settings.getSettingValue(id);
if (val !== undefined && val !== null) return val;
}
return defaultVal;
}
getMaxWidth() {
const val = this.getSetting("SaturnNodes.👁️ Live Preview.02a_FloatingDockMaxWidth", "280px");
const parsed = parseInt(String(val), 10);
return isNaN(parsed) || parsed < 50 ? 280 : parsed;
}
getMaxHeight() {
const val = this.getSetting("SaturnNodes.👁️ Live Preview.02b_FloatingDockMaxHeight", "280px");
const parsed = parseInt(String(val), 10);
return isNaN(parsed) || parsed < 50 ? 280 : parsed;
}
init() {
this.injectStyles();
this.buildDockElement();
this.registerWithPreviewManager();
this.setupExecutionListeners();
this.setupPositionObserver();
this.injectToolbarButtonLoop();
}
injectStyles() {
if (document.getElementById("saturn-floating-dock-styles")) return;
const style = document.createElement("style");
style.id = "saturn-floating-dock-styles";
style.textContent = `
#saturn-latent-floating-dock {
position: fixed;
right: 8px;
bottom: 54px;
z-index: 1050;
display: flex;
flex-direction: column;
box-sizing: border-box;
background: #09090b;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 8px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.65), 0 0 1px rgba(255, 255, 255, 0.2);
overflow: hidden;
pointer-events: auto;
user-select: none;
transition: bottom 0.2s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease, transform 0.2s ease;
transform-origin: bottom right;
}
#saturn-latent-floating-dock.saturn-dock-hidden {
opacity: 0;
pointer-events: none;
transform: scale(0.92);
visibility: hidden;
}
.saturn-dock-header {
position: absolute;
top: 0;
left: 0;
right: 0;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 6px;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.75) 0%, rgba(0, 0, 0, 0) 100%);
gap: 4px;
font-family: Inter, system-ui, -apple-system, sans-serif;
font-size: 10px;
color: #decbb2;
font-weight: 500;
line-height: 1;
overflow: hidden;
z-index: 10;
pointer-events: none;
opacity: 0.85;
transition: opacity 0.15s ease;
}
#saturn-latent-floating-dock:hover .saturn-dock-header {
opacity: 1;
}
.saturn-dock-title-group {
display: flex;
align-items: center;
gap: 4px;
overflow: hidden;
min-width: 0;
}
.saturn-dock-icon {
color: #f59e0b;
display: flex;
align-items: center;
flex-shrink: 0;
}
.saturn-dock-badge {
font-size: 9px;
padding: 1px 4px;
border-radius: 3px;
background: rgba(0, 0, 0, 0.55);
border: 1px solid rgba(255, 255, 255, 0.15);
color: #e4e4e7;
font-variant-numeric: tabular-nums;
white-space: nowrap;
flex-shrink: 0;
}
.saturn-dock-badge.generating {
background: rgba(245, 158, 11, 0.3);
color: #fcd34d;
border: 1px solid rgba(245, 158, 11, 0.45);
}
.saturn-dock-btn {
pointer-events: auto;
background: rgba(0, 0, 0, 0.5);
border: 1px solid rgba(255, 255, 255, 0.15);
color: #a1a1aa;
cursor: pointer;
padding: 2px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: color 0.15s, background 0.15s;
}
.saturn-dock-inspect-btn:hover {
color: #38bdf8;
background: rgba(56, 189, 248, 0.25);
}
.saturn-dock-close-btn:hover {
color: #f43f5e;
background: rgba(244, 63, 94, 0.25);
}
.saturn-dock-body {
position: relative;
width: 100%;
height: 100%;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
background: #09090b;
overflow: hidden;
}
.saturn-dock-canvas {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
image-rendering: -webkit-optimize-contrast;
image-rendering: crisp-edges;
cursor: pointer;
transition: filter 0.15s ease;
}
.saturn-dock-canvas:hover {
filter: brightness(1.08);
}
.saturn-dock-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
padding: 16px;
color: #52525b;
font-family: Inter, system-ui, sans-serif;
font-size: 11px;
text-align: center;
width: 160px;
height: 110px;
cursor: pointer;
}
@keyframes saturn-lightbox-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
#saturn-latent-lightbox {
animation: saturn-lightbox-fade-in 0.15s ease-out;
}
/* Toolbar button active highlight */
.saturn-floating-dock-btn.saturn-dock-btn-active {
color: #f59e0b !important;
background: rgba(245, 158, 11, 0.15) !important;
border-color: rgba(245, 158, 11, 0.35) !important;
}
`;
document.head.appendChild(style);
}
buildDockElement() {
if (document.getElementById("saturn-latent-floating-dock")) return;
this.dockEl = document.createElement("div");
this.dockEl.id = "saturn-latent-floating-dock";
// Header
const header = document.createElement("div");
header.className = "saturn-dock-header";
const titleGroup = document.createElement("div");
titleGroup.className = "saturn-dock-title-group";
const iconSpan = document.createElement("span");
iconSpan.className = "saturn-dock-icon";
iconSpan.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/>
<circle cx="12" cy="12" r="3"/>
<path d="M7 12h.01M17 12h.01"/>
</svg>
`;
const titleText = document.createElement("span");
titleText.textContent = "Live Latent";
this.titleTextEl = titleText;
this.statusBadge = null;
this.aspectBadge = document.createElement("span");
this.aspectBadge.className = "saturn-dock-badge";
this.aspectBadge.textContent = "--";
titleGroup.appendChild(iconSpan);
titleGroup.appendChild(titleText);
titleGroup.appendChild(this.aspectBadge);
const actionsGroup = document.createElement("div");
actionsGroup.className = "saturn-dock-actions-group";
actionsGroup.style.cssText = "display: flex; align-items: center; gap: 4px; pointer-events: auto;";
const inspectBtn = document.createElement("button");
inspectBtn.className = "saturn-dock-inspect-btn saturn-dock-btn";
inspectBtn.title = "Inspect Latent in Lightbox";
inspectBtn.type = "button";
inspectBtn.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
<line x1="11" y1="8" x2="11" y2="14"/>
<line x1="8" y1="11" x2="14" y2="11"/>
</svg>
`;
inspectBtn.onclick = (e) => {
e.stopPropagation();
this.openLightbox();
};
const closeBtn = document.createElement("button");
closeBtn.className = "saturn-dock-close-btn saturn-dock-btn";
closeBtn.title = "Hide Floating Latent Dock";
closeBtn.type = "button";
closeBtn.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6 6 18M6 6l12 12"/>
</svg>
`;
closeBtn.onclick = (e) => {
e.stopPropagation();
this.saveVisibility(false);
};
actionsGroup.appendChild(inspectBtn);
actionsGroup.appendChild(closeBtn);
header.appendChild(titleGroup);
header.appendChild(actionsGroup);
// Body with canvas
const body = document.createElement("div");
body.className = "saturn-dock-body";
this.canvasEl = document.createElement("canvas");
this.canvasEl.className = "saturn-dock-canvas";
this.canvasEl.style.display = "none";
this.canvasEl.title = "Click to inspect in Lightbox";
this.canvasEl.onclick = (e) => {
e.stopPropagation();
this.openLightbox();
};
this.placeholderEl = document.createElement("div");
this.placeholderEl.className = "saturn-dock-placeholder";
this.placeholderEl.title = "Click to inspect in Lightbox";
this.placeholderEl.onclick = (e) => {
e.stopPropagation();
this.openLightbox();
};
this.placeholderEl.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" style="opacity: 0.5;">
<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/>
<circle cx="9" cy="9" r="2"/>
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>
</svg>
<span>Live Latent Preview</span>
`;
body.appendChild(this.canvasEl);
body.appendChild(this.placeholderEl);
this.dockEl.appendChild(header);
this.dockEl.appendChild(body);
document.body.appendChild(this.dockEl);
this.updateVisibility();
this.updatePosition();
}
openLightbox() {
if (this.lightboxEl) {
this.updateLightbox();
return;
}
const prevActive = document.activeElement;
// Container matching ComfyUI core MediaLightbox dialog:
// role="dialog" aria-modal="true" aria-label="Gallery" data-mask tabindex="-1"
const overlay = document.createElement("div");
overlay.id = "saturn-latent-lightbox";
overlay.setAttribute("role", "dialog");
overlay.setAttribute("aria-modal", "true");
overlay.setAttribute("aria-label", "Gallery");
overlay.setAttribute("data-mask", "");
overlay.setAttribute("tabindex", "-1");
overlay.className = "comfy-media-lightbox fixed inset-0 z-9999 flex items-center justify-center bg-black/90 outline-none select-none";
overlay.style.cssText = `
position: fixed;
inset: 0;
z-index: 99999;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.9);
outline: none;
user-select: none;
`;
// Close button: ComfyUI round icon button
const closeBtn = document.createElement("button");
closeBtn.type = "button";
closeBtn.setAttribute("aria-label", "Close");
closeBtn.title = "Close Lightbox (Esc)";
closeBtn.className = "absolute top-4 right-4 z-10 rounded-full cursor-pointer";
closeBtn.style.cssText = `
position: absolute;
top: 16px;
right: 16px;
z-index: 10;
width: 36px;
height: 36px;
border-radius: 9999px;
background: rgba(255, 255, 255, 0.12);
color: #ffffff;
border: 1px solid rgba(255, 255, 255, 0.15);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s ease, transform 0.15s ease;
`;
closeBtn.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6 6 18M6 6l12 12"/>
</svg>
`;
closeBtn.onmouseenter = () => {
closeBtn.style.background = "rgba(255, 255, 255, 0.25)";
closeBtn.style.transform = "scale(1.05)";
};
closeBtn.onmouseleave = () => {
closeBtn.style.background = "rgba(255, 255, 255, 0.12)";
closeBtn.style.transform = "scale(1)";
};
closeBtn.onclick = (e) => {
e.stopPropagation();
this.closeLightbox();
};
// Center Image Wrapper
const imgWrap = document.createElement("div");
imgWrap.className = "relative flex max-h-[90vh] max-w-[90vw] items-center justify-center";
imgWrap.style.cssText = `
position: relative;
display: flex;
max-height: 90vh;
max-width: 90vw;
align-items: center;
justify-content: center;
pointer-events: auto;
`;
const lightboxImg = document.createElement("img");
lightboxImg.className = "size-auto max-h-[90vh] max-w-[90vw] object-contain shadow-2xl rounded-sm pointer-events-auto";
lightboxImg.alt = "Live Latent Preview";
lightboxImg.style.cssText = `
max-height: 90vh;
max-width: 90vw;
width: auto;
height: auto;
object-fit: contain;
border-radius: 4px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.8);
pointer-events: auto;
`;
// Dimensions / Status badge at bottom center matching ComfyUI pill style
const badge = document.createElement("div");
badge.className = "absolute bottom-4 left-1/2 -translate-x-1/2 z-10 px-3 py-1 rounded-full text-xs font-mono text-zinc-300 bg-black/60 border border-white/10 backdrop-blur-sm pointer-events-none";
badge.style.cssText = `
position: absolute;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
padding: 4px 12px;
border-radius: 9999px;
font-size: 11px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
color: #d4d4d8;
background: rgba(0, 0, 0, 0.65);
border: 1px solid rgba(255, 255, 255, 0.12);
backdrop-filter: blur(8px);
pointer-events: none;
display: flex;
align-items: center;
gap: 6px;
`;
imgWrap.appendChild(lightboxImg);
overlay.appendChild(closeBtn);
overlay.appendChild(imgWrap);
overlay.appendChild(badge);
this.lightboxEl = overlay;
this.lightboxImg = lightboxImg;
this.lightboxBadge = badge;
this.lightboxPrevActive = prevActive;
// Mask mousedown/mouseup logic (exact ComfyUI core MediaLightbox behavior)
let maskTarget = null;
overlay.onmousedown = (e) => {
maskTarget = e.target;
};
overlay.onmouseup = (e) => {
if (maskTarget === e.target && e.target.hasAttribute("data-mask")) {
this.closeLightbox();
}
};
// Keydown listener for Escape
this._lightboxKeyDown = (e) => {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
this.closeLightbox();
}
};
window.addEventListener("keydown", this._lightboxKeyDown, true);
document.body.appendChild(overlay);
overlay.focus();
this.updateLightbox();
}
updateLightbox() {
if (!this.lightboxEl || !this.lightboxImg) return;
const img = this.currentImg || PreviewManager.latestImage;
let nw = 0;
let nh = 0;
if (img && (img.src || img.naturalWidth)) {
if (this.lightboxImg.src !== img.src) {
this.lightboxImg.src = img.src;
}
nw = img.naturalWidth || img.width || 0;
nh = img.naturalHeight || img.height || 0;
this.lightboxImg.style.display = "block";
} else if (this.canvasEl && this.canvasEl.width > 0) {
try {
const dataUrl = this.canvasEl.toDataURL("image/png");
if (this.lightboxImg.src !== dataUrl) {
this.lightboxImg.src = dataUrl;
}
nw = this.canvasEl.width;
nh = this.canvasEl.height;
this.lightboxImg.style.display = "block";
} catch (_) {}
} else {
this.lightboxImg.style.display = "none";
}
if (this.lightboxBadge) {
const statusText = this.isGenerating ? "● Sampling" : "● Live Latent";
const dimsText = (nw && nh) ? `${nw}×${nh}` : "";
this.lightboxBadge.innerHTML = `
<span style="color: ${this.isGenerating ? '#f59e0b' : '#10b981'}; font-weight: 600;">${statusText}</span>
${dimsText ? `<span style="opacity: 0.5;">|</span><span>${dimsText}</span>` : ""}
`;
}
}
closeLightbox() {
if (this._lightboxKeyDown) {
window.removeEventListener("keydown", this._lightboxKeyDown, true);
this._lightboxKeyDown = null;
}
if (this.lightboxEl) {
this.lightboxEl.remove();
this.lightboxEl = null;
this.lightboxImg = null;
this.lightboxBadge = null;
}
if (this.lightboxPrevActive && typeof this.lightboxPrevActive.focus === "function") {
try {
this.lightboxPrevActive.focus();
} catch (_) {}
this.lightboxPrevActive = null;
}
}
registerWithPreviewManager() {
const listener = {
onNewPreview: (img, samplerId) => {
this.onNewPreview(img, samplerId);
}
};
PreviewManager.registerNode(listener);
if (PreviewManager.latestImage) {
this.onNewPreview(PreviewManager.latestImage, PreviewManager.latestSamplerId);
}
}
setupExecutionListeners() {
if (!api) return;
api.addEventListener("execution_start", () => {
this.isGenerating = true;
if (this.statusBadge) {
this.statusBadge.textContent = "Sampling";
this.statusBadge.classList.add("generating");
}
if (this.getSetting("SaturnNodes.👁️ Live Preview.03_AutoHideWhenIdle", false) && this.isVisible) {
this.dockEl?.classList.remove("saturn-dock-hidden");
}
if (this.lightboxEl) {
this.updateLightbox();
}
});
const handleExecutionEnd = () => {
this.isGenerating = false;
if (this.statusBadge) {
this.statusBadge.textContent = "Done";
this.statusBadge.classList.remove("generating");
}
if (this.getSetting("SaturnNodes.👁️ Live Preview.03_AutoHideWhenIdle", false)) {
this.dockEl?.classList.add("saturn-dock-hidden");
}
if (this.lightboxEl) {
this.updateLightbox();
}
};
api.addEventListener("status", (e) => {
const queueRemaining = e.detail?.exec_info?.queue_remaining ?? 0;
if (queueRemaining === 0) {
handleExecutionEnd();
}
});
api.addEventListener("execution_interrupted", handleExecutionEnd);
api.addEventListener("execution_error", handleExecutionEnd);
}
onNewPreview(img, samplerId) {
if (!img || !this.canvasEl) return;
this.currentImg = img;
this.latestSamplerId = samplerId;
const nw = img.naturalWidth || img.width;
const nh = img.naturalHeight || img.height;
if (!nw || !nh) return;
const maxW = this.getMaxWidth();
const maxH = this.getMaxHeight();
const scale = Math.min(maxW / nw, maxH / nh);
const tw = Math.max(1, Math.round(nw * scale));
const th = Math.max(1, Math.round(nh * scale));
// Full pixel quality rendering on native resolution
this.canvasEl.width = nw;
this.canvasEl.height = nh;
this.canvasEl.style.width = `${tw}px`;
this.canvasEl.style.height = `${th}px`;
this.canvasEl.style.display = "block";
if (this.dockEl) {
this.dockEl.style.width = `${tw}px`;
this.dockEl.style.height = `${th}px`;
this.dockEl.style.minWidth = `${tw}px`;
this.dockEl.style.maxWidth = `${tw}px`;
this.dockEl.style.minHeight = `${th}px`;
this.dockEl.style.maxHeight = `${th}px`;
}
if (this.titleTextEl) {
this.titleTextEl.style.display = tw < 140 ? "none" : "inline";
}
if (this.statusBadge) {
this.statusBadge.style.display = tw < 170 ? "none" : "inline";
}
if (this.aspectBadge) {
this.aspectBadge.textContent = `${nw}×${nh}`;
this.aspectBadge.style.display = tw < 100 ? "none" : "inline";
}
if (this.placeholderEl) {
this.placeholderEl.style.display = "none";
}
const ctx = this.canvasEl.getContext("2d");
if (ctx) {
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
ctx.drawImage(img, 0, 0, nw, nh);
}
if (this.lightboxEl) {
this.updateLightbox();
}
this.requestPositionUpdate();
}
updateVisibility() {
const isMasterEnabled = this.getSetting("SaturnNodes.👁️ Live Preview.01_EnableFloatingDock", true);
const shouldShow = isMasterEnabled && this.isVisible;
if (this.dockEl) {
if (shouldShow) {
this.dockEl.classList.remove("saturn-dock-hidden");
this.updatePosition();
} else {
this.dockEl.classList.add("saturn-dock-hidden");
}
}
if (this.btnEl) {
if (isMasterEnabled) {
this.btnEl.style.display = "inline-flex";
} else {
this.btnEl.style.display = "none";
}
if (this.isVisible && isMasterEnabled) {
this.btnEl.classList.add("saturn-dock-btn-active");
} else {
this.btnEl.classList.remove("saturn-dock-btn-active");
}
}
}
updatePosition() {
if (!this.dockEl) return;
// 1. Detect if Minimap is currently present and visible in DOM
const minimap = document.querySelector(".minimap-main-container, .litegraph-minimap");
const isMinimapVisible = !!(
minimap &&
minimap.offsetParent !== null &&
window.getComputedStyle(minimap).display !== "none" &&
window.getComputedStyle(minimap).visibility !== "hidden" &&
minimap.getBoundingClientRect().height > 20
);
// 2. Detect Bottom-Right Canvas Toolbar specifically
let toolbar = null;
if (this.btnEl && this.btnEl.isConnected && this.btnEl.parentElement) {
toolbar = this.btnEl.parentElement;
} else {
// Find toolbar located in the bottom portion of viewport
const candidates = document.querySelectorAll(
'[role="toolbar"][aria-label*="canvasToolbar" i], [role="toolbar"][aria-label*="Canvas Toolbar" i], [role="toolbar"], .graph-canvas-menu, .canvas-toolbar'
);
for (const cand of candidates) {
const r = cand.getBoundingClientRect();
// Strictly bottom half of viewport (never top menu)
if (r.top > window.innerHeight / 2 && r.height > 10) {
toolbar = cand;
break;
}
}
}
let bottomGap = 54; // clean fallback above bottom edge
if (isMinimapVisible) {
const mRect = minimap.getBoundingClientRect();
if (mRect.top > 0 && mRect.top < window.innerHeight) {
// Place dock flush 8px above the top of the minimap
bottomGap = Math.round(window.innerHeight - mRect.top + 8);
}
} else if (toolbar) {
const tRect = toolbar.getBoundingClientRect();
// Ensure toolbar is actually in the bottom half of viewport
if (tRect.top > window.innerHeight / 2 && tRect.top < window.innerHeight) {
// Place dock flush 8px above the top of the bottom toolbar
bottomGap = Math.round(window.innerHeight - tRect.top + 8);
}
}
// Safety clamp: bottomGap must never push dock beyond upper half of screen
bottomGap = Math.max(54, Math.min(bottomGap, Math.round(window.innerHeight * 0.75)));
this.dockEl.style.bottom = `${bottomGap}px`;
this.dockEl.style.right = "8px";
}
requestPositionUpdate() {
if (!this.dockEl || this.dockEl.classList.contains("saturn-dock-hidden") || !this.isVisible) return;
if (this._posUpdatePending) return;
this._posUpdatePending = true;
requestAnimationFrame(() => {
this._posUpdatePending = false;
this.updatePosition();
});
}
setupPositionObserver() {
// Track window resize (debounced via rAF)
window.addEventListener("resize", () => this.requestPositionUpdate(), { passive: true });
// Observe DOM structural changes only (minimap toggle / toolbar mount) without watching every subtree attribute
const observer = new MutationObserver(() => {
this.requestPositionUpdate();
});
observer.observe(document.body, {
childList: true,
subtree: false
});
}
injectToolbarButtonLoop() {
const tryInject = () => {
if (this.btnEl && document.body.contains(this.btnEl)) return;
const toolbar = document.querySelector(
'[role="toolbar"][aria-label*="canvasToolbar" i], [role="toolbar"][aria-label*="Canvas Toolbar" i], .graph-canvas-menu, .canvas-toolbar'
);
if (!toolbar) return;
this.createToolbarButton();
// Find Minimap button or FitView button in the toolbar to place alongside it
const minimapBtn = toolbar.querySelector('button[aria-label*="minimap" i], button[aria-label*="Minimap" i]');
if (minimapBtn && minimapBtn.parentElement === toolbar) {
toolbar.insertBefore(this.btnEl, minimapBtn.nextSibling);
} else {
toolbar.appendChild(this.btnEl);
}
this.updateVisibility();
this.updatePosition();
};
tryInject();
setInterval(tryInject, 1000);
}
createToolbarButton() {
if (this.btnEl) return;
this.btnEl = document.createElement("button");
this.btnEl.id = "saturn-floating-dock-btn";
this.btnEl.type = "button";
this.btnEl.className = "saturn-floating-dock-btn size-8 bg-comfy-menu-bg p-0 hover:bg-interface-button-hover-surface! rounded-sm border border-transparent cursor-pointer flex items-center justify-center text-text-primary transition-colors";
this.btnEl.title = "Toggle Live Latent Dock (SaturnNodes)";
this.btnEl.setAttribute("aria-label", "Toggle Live Latent Dock");
// Crisp vector SVG icon (No emoji)
this.btnEl.innerHTML = `
<svg class="w-4 h-4 text-neutral-300" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/>
<circle cx="12" cy="12" r="3"/>
<path d="M7 12h.01M17 12h.01"/>
</svg>
`;
this.btnEl.onclick = (e) => {
e.stopPropagation();
this.saveVisibility(!this.isVisible);
};
}
}
// Register extension
app.registerExtension({
name: "ComfyUI.SaturnNodes.FloatingLatentDock",
async setup() {
// Initialize floating dock on client startup
new FloatingLatentDock();
}
});
+32 -15
View File
@@ -537,17 +537,34 @@ function injectContextMenuCopy(contextMenu) {
if (contextMenu.querySelector(".leafflow-contextmenu-copy")) return;
if (!isCopyEnabled("LeafFlow.3 - 📋 Prompt Actions.02_EnableContextMenuCopyPrompt")) return;
let downloadLi = contextMenu.querySelector(
'li[aria-label="Download"], li[aria-label*="ownload" i], [data-pc-section="item"][aria-label*="ownload" i]'
);
if (!downloadLi) {
const items = contextMenu.querySelectorAll('li, [data-pc-section="item"]');
for (const item of items) {
const text = item.textContent?.trim().toLowerCase() || "";
if (text.includes("download") || item.querySelector('.icon-[lucide--download], [class*="download" i]')) {
downloadLi = item;
break;
}
// Guard: strictly ignore top menu, menubar, command menus, and main application menus!
if (
contextMenu.closest?.(".comfy-command-menu, .comfy-menu, .p-menubar, [data-pc-name='menubar'], [data-pc-name='tieredmenu']") ||
contextMenu.classList?.contains("comfy-command-menu") ||
contextMenu.classList?.contains("p-tieredmenu") ||
contextMenu.querySelector?.(".p-menubar-root-list, .p-tieredmenu-root-list, li[aria-label='File'], li[aria-label='New'], li[aria-label='Edit'], li[aria-label='View']")
) {
return;
}
let downloadLi = null;
const candidates = contextMenu.querySelectorAll('li[role="menuitem"], [data-pc-section="item"]');
for (const item of candidates) {
const label = (item.getAttribute("aria-label") || "").trim().toLowerCase();
const text = (item.textContent || "").trim().toLowerCase();
// Strictly ignore workflow export / save actions
if (label.includes("export") || text.includes("export") || label.includes("save as") || text.includes("save as")) {
continue;
}
if (label === "download" || label === "download image" || text === "download" || text === "download image") {
downloadLi = item;
break;
}
if (!downloadLi && (label.includes("download") || text.includes("download"))) {
downloadLi = item;
}
}
if (!downloadLi || !downloadLi.parentElement) return;
@@ -672,7 +689,7 @@ function scanAndInject() {
);
downloadBtns.forEach(injectCopyPromptNextToDownload);
const menus = document.querySelectorAll('.p-contextmenu, .p-menu, .p-tieredmenu, [data-pc-name="contextmenu"], [data-pc-name="menu"], [data-pc-name="tieredmenu"]');
const menus = document.querySelectorAll('.p-contextmenu, [data-pc-name="contextmenu"]');
menus.forEach(injectContextMenuCopy);
const olderOverlays = document.querySelectorAll(
@@ -694,7 +711,7 @@ document.addEventListener("pointerover", (e) => {
const cardDl = card.querySelector('button[aria-label="Download"], button[aria-label*="ownload" i]');
if (cardDl) injectCopyPromptNextToDownload(cardDl);
}
const menu = e.target.closest?.('.p-contextmenu, .p-menu, .p-tieredmenu, [data-pc-name="contextmenu"], [data-pc-name="menu"], [data-pc-name="tieredmenu"]');
const menu = e.target.closest?.('.p-contextmenu, [data-pc-name="contextmenu"]');
if (menu) {
injectContextMenuCopy(menu);
}
@@ -710,7 +727,7 @@ const observer = new MutationObserver((mutations) => {
if (node.matches?.('button[aria-label="Download"], button[aria-label*="ownload" i]')) {
injectCopyPromptNextToDownload(node);
} else if (node.matches?.('.p-contextmenu, .p-menu, .p-tieredmenu, [data-pc-name="contextmenu"], [data-pc-name="menu"], [data-pc-name="tieredmenu"]')) {
} else if (node.matches?.('.p-contextmenu, [data-pc-name="contextmenu"]')) {
injectContextMenuCopy(node);
} else if (node.matches?.('[data-testid="asset-card-actions"], .asset-card-overlay, .asset-item-overlay')) {
injectHoverCopyAction(node);
@@ -718,7 +735,7 @@ const observer = new MutationObserver((mutations) => {
const dlBtns = node.querySelectorAll('button[aria-label="Download"], button[aria-label*="ownload" i]');
dlBtns.forEach(injectCopyPromptNextToDownload);
const ctxMenus = node.querySelectorAll('.p-contextmenu, .p-menu, .p-tieredmenu, [data-pc-name="contextmenu"], [data-pc-name="menu"], [data-pc-name="tieredmenu"]');
const ctxMenus = node.querySelectorAll('.p-contextmenu, [data-pc-name="contextmenu"]');
ctxMenus.forEach(injectContextMenuCopy);
const overlays = node.querySelectorAll('[data-testid="asset-card-actions"], .asset-card-overlay, .asset-item-overlay');
+22 -2
View File
@@ -584,6 +584,22 @@ app.registerExtension({
const data = await response.json();
viewContainer.innerHTML = "";
if (viewContainer._tileObserver) {
try { viewContainer._tileObserver.disconnect(); } catch (_) {}
}
const tileObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
if (img.dataset.src) {
img.src = img.dataset.src;
img.removeAttribute("data-src");
}
observer.unobserve(img);
}
});
}, { root: viewContainer, rootMargin: "150px" });
viewContainer._tileObserver = tileObserver;
// Calculate Top 3 Ranks by versionless pretty name
const getTop3Ranks = () => {
@@ -689,7 +705,10 @@ app.registerExtension({
}
const img = document.createElement("img");
img.src = `/folder_lora_loader/get_preview?lora=${encodeURIComponent(loraName)}&folder=${encodeURIComponent(folder)}&pretty=true`;
img.loading = "lazy";
const bypassThumbs = app.ui.settings?.getSettingValue?.("SaturnNodes.🖼️ Visual Loaders.07_BypassThumbnailDownscaling", false);
img.dataset.src = `/folder_lora_loader/get_preview?lora=${encodeURIComponent(loraName)}&folder=${encodeURIComponent(folder)}&pretty=true` + (bypassThumbs ? "&full=true" : "&size=200");
tileObserver.observe(img);
img.onerror = () => {
img.remove();
@@ -1496,7 +1515,8 @@ app.registerExtension({
const img = document.createElement("img");
img.loading = "lazy";
img.dataset.src = `/folder_lora_loader/get_preview?system_path=${encodeURIComponent(item.systemPath)}`;
const bypassThumbs = app.ui.settings?.getSettingValue?.("SaturnNodes.🖼️ Visual Loaders.07_BypassThumbnailDownscaling", false);
img.dataset.src = `/folder_lora_loader/get_preview?system_path=${encodeURIComponent(item.systemPath)}` + (bypassThumbs ? "&full=true" : "&size=200");
imageObserver.observe(img);
img.onerror = () => {
+25
View File
@@ -0,0 +1,25 @@
import { app } from "/scripts/app.js";
/**
* 🪐 SaturnNodes — Stereoscopic 3D Extension
*
* Configures the static 3D stereogram generator node (SaturnStereo3D).
*/
app.registerExtension({
name: "ComfyUI.SaturnNodes.SaturnStereo3D",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "SaturnStereo3D") return;
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
if (onNodeCreated) onNodeCreated.apply(this, arguments);
if (!this.size || this.size[0] < 340) {
this.size = [360, 500];
}
};
}
});
+16 -13
View File
@@ -30,6 +30,7 @@ export const SATURN_NODE_TYPES = new Set([
// Queue & Previews
"PreviewLatentLive",
"SaturnStereo3D",
"PauseQueueNode",
"PersistentQueueNode"
]);
@@ -126,20 +127,22 @@ export function updateAllSaturnNodeColors() {
app.registerExtension({
name: "ComfyUI.SaturnNodes.Colors",
beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData && nodeData.name && SATURN_NODE_TYPES.has(nodeData.name)) {
if (isSaturnColorsEnabled()) {
const theme = getSaturnTheme();
nodeType.color = theme.color;
nodeType.bgcolor = theme.bgcolor;
nodeType.prototype.color = theme.color;
nodeType.prototype.bgcolor = theme.bgcolor;
} else {
nodeType.color = undefined;
nodeType.bgcolor = undefined;
nodeType.prototype.color = undefined;
nodeType.prototype.bgcolor = undefined;
try {
if (nodeData && nodeData.name && SATURN_NODE_TYPES.has(nodeData.name)) {
if (isSaturnColorsEnabled()) {
const theme = getSaturnTheme();
if (nodeType) {
nodeType.color = theme.color;
nodeType.bgcolor = theme.bgcolor;
}
} else {
if (nodeType) {
nodeType.color = undefined;
nodeType.bgcolor = undefined;
}
}
}
}
} catch (_) {}
},
async nodeCreated(node) {
applySaturnColorToNode(node);
+415 -19
View File
@@ -15,6 +15,118 @@ async function postSaturnNodesSettings(bodyObj) {
}
}
let _disabledNodesCache = null;
let _nodesInfoCache = null;
async function fetchDisabledNodes() {
try {
const res = await authenticatedFetch("/saturnnodes/disabled_nodes");
if (res && res.ok) {
const data = await res.json();
_disabledNodesCache = new Set(data.disabled_nodes || []);
if (Array.isArray(data.nodes_info) && data.nodes_info.length > 0) {
_nodesInfoCache = data.nodes_info;
}
return _disabledNodesCache;
}
} catch (e) {
console.warn("[SaturnNodes Settings] Failed to fetch disabled nodes:", e);
}
return new Set();
}
async function updateNodeDisabledState(nodeKey, isEnabled) {
try {
if (!_disabledNodesCache) {
await fetchDisabledNodes();
}
if (!_disabledNodesCache) {
_disabledNodesCache = new Set();
}
if (isEnabled) {
_disabledNodesCache.delete(nodeKey);
} else {
_disabledNodesCache.add(nodeKey);
}
await authenticatedFetch("/saturnnodes/disabled_nodes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ disabled_nodes: Array.from(_disabledNodesCache) })
});
if (app.extensionManager?.toast?.add) {
app.extensionManager.toast.add({
severity: "info",
summary: "🪐 SaturnNodes",
detail: `Node availability updated. Reload server or refresh to apply.`,
life: 4000
});
}
} catch (e) {
console.warn(`[SaturnNodes Settings] Failed to update disabled state for ${nodeKey}:`, e);
}
}
const ALL_SATURN_KEYS_SET = new Set([
"FolderLoraLoader",
"FolderLoraLoaderPretty",
"VisualLoraLoader",
"VisualImageLoader",
"LoadImageFromFolder",
"LoadRecentOutputs",
"PreviewLatentLive",
"SaturnDecision",
"TextAspectRatioFinder",
"PreviewImageSizeAspectRatio",
"TextLoraFinder",
"PromptQueueIterator",
"PromptCounter",
"MultiTextReplacer",
"SaturnTextSplit",
"RunLocalFileNode",
"SaturnStereo3D"
]);
function checkLoadedWorkflowForDisabledNodes(graphData) {
if (!graphData || !Array.isArray(graphData.nodes)) return;
fetchDisabledNodes().then(disabledSet => {
if (!disabledSet || disabledSet.size === 0) return;
const found = [];
for (const node of graphData.nodes) {
const nodeType = node?.type;
if (nodeType && disabledSet.has(nodeType) && ALL_SATURN_KEYS_SET.has(nodeType)) {
if (!found.includes(nodeType)) {
found.push(nodeType);
}
}
}
if (found.length > 0) {
const namesList = found.join(", ");
if (app.extensionManager?.toast?.add) {
app.extensionManager.toast.add({
severity: "warn",
summary: "🪐 SaturnNodes: Disabled Nodes in Workflow",
detail: `This workflow uses disabled SaturnNodes: ${namesList}. Re-enable them in Settings -> SaturnNodes -> Node Management to run.`,
life: 10000
});
} else {
console.warn(`[SaturnNodes] Loaded workflow contains disabled nodes: ${namesList}`);
}
}
});
}
// Hook ComfyUI workflow loading to alert if disabled nodes are present in the workflow
try {
const origLoadGraphData = app.loadGraphData;
if (typeof origLoadGraphData === "function") {
app.loadGraphData = function (graphData, ...args) {
checkLoadedWorkflowForDisabledNodes(graphData);
return origLoadGraphData.apply(this, [graphData, ...args]);
};
}
} catch (_) {}
// Inject CSS to ensure settings buttons look distinct and dropdown menus retain proper styling
if (typeof document !== "undefined") {
const styleId = "saturnnodes-settings-button-styles";
@@ -650,7 +762,9 @@ function getInitialSetting(saturnId, defaultVal) {
"🖼️ Assets Restore": "6 - 🖼️ Assets Restore",
"🩺 Diagnostics": "7 - 🩺 Diagnostics",
"🎨 Batch Queue": "8 - 🎨 Batch Queue",
"🛡️ Security": "9 - 🛡️ Security"
"🛡️ Security": "9 - 🛡️ Security",
"👁️ Live Preview": "10 - 👁️ Live Preview",
"🧩 Node Management": "11 - 🧩 Node Management"
};
for (const [plain, num] of Object.entries(categoryMap)) {
@@ -771,7 +885,7 @@ app.registerExtension({
name: "TMDB Access Token",
type: "text",
defaultValue: getInitialSetting("SaturnNodes.🖼️ Visual Loaders.03_TMDBApiKey", ""),
tooltip: "Optional. Accepts TMDB v3 API keys or TMDB v4 Read Access Tokens (eyJ...). Used for celebrity poster and preview image lookup. Whitespace is automatically stripped.",
tooltip: "Optional. Accepts TMDB v3 API keys or TMDB v4 Read Access Tokens (eyJ...). Used for actor and media preview image lookup. Whitespace is automatically stripped.",
onChange(value) {
const cleanKey = (value || "").trim();
postSaturnNodesSettings({ tmdb_api_key: cleanKey });
@@ -784,7 +898,7 @@ app.registerExtension({
name: "Enable TMDB Auto-Scraping",
type: "boolean",
defaultValue: getInitialSetting("SaturnNodes.🖼️ Visual Loaders.04_EnableTMDBScraping", false),
tooltip: "Toggles automated downloading of celebrity preview thumbnails from TMDB. Default is disabled.",
tooltip: "Toggles automated downloading of media preview thumbnails from TMDB. Default is disabled.",
onChange(value) {
postSaturnNodesSettings({ enable_tmdb_scraping: value ? "true" : "false" });
}
@@ -839,6 +953,110 @@ app.registerExtension({
}
});
// 1.7 Bypass Thumbnail Downscaling (default: false)
app.ui.settings.addSetting({
id: "SaturnNodes.🖼️ Visual Loaders.07_BypassThumbnailDownscaling",
name: "Disable Thumbnail Downscaling (Serve Raw)",
type: "boolean",
defaultValue: getInitialSetting("SaturnNodes.🖼️ Visual Loaders.07_BypassThumbnailDownscaling", false),
tooltip: "When enabled, serves full-resolution original preview images directly without generating downscaled WebP thumbnails. Default is disabled (downscaled WebP thumbnails enabled for faster UI rendering).",
onChange(value) {
if (typeof localStorage !== "undefined") {
try {
localStorage.setItem("Comfy.Settings.SaturnNodes.🖼️ Visual Loaders.07_BypassThumbnailDownscaling", JSON.stringify(Boolean(value)));
} catch (_) {}
}
}
});
// =========================================================================
// GROUP: 👁️ Live Preview
// =========================================================================
// 10.1 Enable Floating Latent Dock (default: true)
app.ui.settings.addSetting({
id: "SaturnNodes.👁️ Live Preview.01_EnableFloatingDock",
name: "Enable Floating Latent Dock",
type: "boolean",
defaultValue: getInitialSetting("SaturnNodes.👁️ Live Preview.01_EnableFloatingDock", true),
tooltip: "Adds a floating picture-in-picture Live Latent preview dock to the bottom-right corner and a toggle button to the canvas toolbar.",
onChange(value) {
if (typeof localStorage !== "undefined") {
try {
localStorage.setItem("Comfy.Settings.SaturnNodes.👁️ Live Preview.01_EnableFloatingDock", JSON.stringify(Boolean(value)));
} catch (_) {}
}
const btn = document.getElementById("saturn-floating-dock-btn");
const dock = document.getElementById("saturn-latent-floating-dock");
if (btn) btn.style.display = value ? "inline-flex" : "none";
if (dock) {
if (value) {
dock.classList.remove("saturn-dock-hidden");
} else {
dock.classList.add("saturn-dock-hidden");
}
}
}
});
// 10.2a Floating Dock Max Width (default: 280)
const rawMaxWidth = getInitialSetting("SaturnNodes.👁️ Live Preview.02a_FloatingDockMaxWidth", 280);
const initialMaxWidth = parseInt(String(rawMaxWidth).match(/\d+/)?.[0] || rawMaxWidth, 10) || 280;
app.ui.settings.addSetting({
id: "SaturnNodes.👁️ Live Preview.02a_FloatingDockMaxWidth",
name: "Floating Dock Max Width",
type: "slider",
attrs: { min: 80, max: 1200, step: 10 },
defaultValue: initialMaxWidth,
tooltip: "Maximum width limit (in pixels) for the floating live preview dock (80px to 1200px). The dock automatically matches the exact latent aspect ratio without distortion.",
onChange(value) {
const num = parseInt(value, 10) || 280;
if (typeof localStorage !== "undefined") {
try {
localStorage.setItem("Comfy.Settings.SaturnNodes.👁️ Live Preview.02a_FloatingDockMaxWidth", JSON.stringify(num));
} catch (_) {}
}
}
});
// 10.2b Floating Dock Max Height (default: 280)
const rawMaxHeight = getInitialSetting("SaturnNodes.👁️ Live Preview.02b_FloatingDockMaxHeight", 280);
const initialMaxHeight = parseInt(String(rawMaxHeight).match(/\d+/)?.[0] || rawMaxHeight, 10) || 280;
app.ui.settings.addSetting({
id: "SaturnNodes.👁️ Live Preview.02b_FloatingDockMaxHeight",
name: "Floating Dock Max Height",
type: "slider",
attrs: { min: 80, max: 1200, step: 10 },
defaultValue: initialMaxHeight,
tooltip: "Maximum height limit (in pixels) for the floating live preview dock (80px to 1200px). The dock automatically matches the exact latent aspect ratio without distortion.",
onChange(value) {
const num = parseInt(value, 10) || 280;
if (typeof localStorage !== "undefined") {
try {
localStorage.setItem("Comfy.Settings.SaturnNodes.👁️ Live Preview.02b_FloatingDockMaxHeight", JSON.stringify(num));
} catch (_) {}
}
}
});
// 10.3 Auto-Hide Dock When Idle (default: false)
app.ui.settings.addSetting({
id: "SaturnNodes.👁️ Live Preview.03_AutoHideWhenIdle",
name: "Auto-Hide Dock When Idle",
type: "boolean",
defaultValue: getInitialSetting("SaturnNodes.👁️ Live Preview.03_AutoHideWhenIdle", false),
tooltip: "When enabled, the floating dock automatically hides when generation is idle and appears when sampling begins.",
onChange(value) {
if (typeof localStorage !== "undefined") {
try {
localStorage.setItem("Comfy.Settings.SaturnNodes.👁️ Live Preview.03_AutoHideWhenIdle", JSON.stringify(Boolean(value)));
} catch (_) {}
}
}
});
// =========================================================================
// GROUP: 🔄 Prompt Iterator
// =========================================================================
@@ -1017,6 +1235,67 @@ app.registerExtension({
}
});
// 4.8 Visible Queue Items (Height Limit)
const applyQueueOverlayHeight = (val) => {
let style = document.getElementById("saturn-queue-overlay-height");
let px = 0;
if (typeof val === "number") {
px = val;
} else if (val && val !== "Native") {
const match = String(val).match(/\d+/);
if (match) px = parseInt(match[0], 10);
}
if (!px || px <= 0) {
if (style) style.remove();
return;
}
if (!style) {
style = document.createElement("style");
style.id = "saturn-queue-overlay-height";
document.head.appendChild(style);
}
style.textContent = `
[data-testid="queue-progress-overlay"] {
max-height: ${px}px !important;
}
.comfy-list {
max-height: ${px}px !important;
overflow-y: auto !important;
}
`;
};
const rawQueueHeight = getInitialSetting("SaturnNodes.⏸️ Pause Controls.08_QueueOverlayMaxHeight", 0);
let initialQueueHeight = 0;
if (typeof rawQueueHeight === "number") {
initialQueueHeight = rawQueueHeight;
} else if (rawQueueHeight && rawQueueHeight !== "Native") {
const match = String(rawQueueHeight).match(/\d+/);
if (match) initialQueueHeight = parseInt(match[0], 10);
}
applyQueueOverlayHeight(initialQueueHeight);
app.ui.settings.addSetting({
id: "SaturnNodes.⏸️ Pause Controls.08_QueueOverlayMaxHeight",
name: "Visible Queue Items (Height)",
type: "slider",
attrs: { min: 0, max: 1200, step: 10 },
defaultValue: initialQueueHeight,
tooltip: "Limits the visible height of the queue overlay panel in pixels (e.g. 360 ≈ 10 items, 500 ≈ 15 items) with a scrollbar. Set to 0 for Native (unconstrained ComfyUI default height).",
onChange(value) {
const num = parseInt(value, 10) || 0;
if (typeof localStorage !== "undefined") {
try {
localStorage.setItem("Comfy.Settings.SaturnNodes.⏸️ Pause Controls.08_QueueOverlayMaxHeight", JSON.stringify(num));
} catch (_) {}
}
applyQueueOverlayHeight(num);
}
});
// =========================================================================
// GROUP: 💾 Persistent Queue
// =========================================================================
@@ -1169,6 +1448,70 @@ app.registerExtension({
}
});
// =========================================================================
// GROUP: 🧩 Node Management
// =========================================================================
await fetchDisabledNodes();
const defaultSaturnNodeDefinitions = [
// Loaders
{ key: "FolderLoraLoader", name: "Loaders / LoRA Loader (Folder)", desc: "🪐 📁 LoRA Loader (Folder)" },
{ key: "FolderLoraLoaderPretty", name: "Loaders / LoRA Loader (Pretty)", desc: "🪐 ✨ LoRA Loader (Pretty)" },
{ key: "LoadRecentOutputs", name: "Loaders / Recent Outputs", desc: "🪐 ⏱️ Recent Outputs" },
{ key: "TextLoraFinder", name: "Loaders / Text LoRA Finder & Loader", desc: "🪐 🔎 Text LoRA Finder & Loader" },
{ key: "VisualImageLoader", name: "Loaders / Visual Image Loader", desc: "🪐 📷 Visual Image Loader" },
{ key: "VisualLoraLoader", name: "Loaders / Visual LoRA Loader", desc: "🪐 🖼️ Visual LoRA Loader" },
// Automation
{ key: "LoadImageFromFolder", name: "Automation / Load Image From Folder", desc: "🪐 📂 Load Image From Folder" },
{ key: "RunLocalFileNode", name: "Automation / Run Local File", desc: "🪐 ⚡ Run Local File" },
// Previews
{ key: "PreviewLatentLive", name: "Previews / Live Latent Preview", desc: "🪐 👁️ Live Latent Preview" },
// 3D & Previews
{ key: "SaturnStereo3D", name: "3D & Previews / Stereoscopic 3D Generator", desc: "🪐 👓 Stereoscopic 3D Generator" },
// Utils
{ key: "MultiTextReplacer", name: "Utils / Multi Text Replacer", desc: "🪐 🔤 Multi Text Replacer" },
{ key: "PreviewImageSizeAspectRatio", name: "Utils / Preview Image Size & Aspect Ratio", desc: "🪐 📐 Preview Image Size & Aspect Ratio" },
{ key: "PromptCounter", name: "Utils / Prompt Counter", desc: "🪐 📝 Prompt Counter" },
{ key: "PromptQueueIterator", name: "Utils / Prompt Queue Iterator", desc: "🪐 🔄 Prompt Queue Iterator" },
{ key: "SaturnDecision", name: "Utils / Saturn Decision", desc: "🪐 ⏸️ Saturn Decision" },
{ key: "TextAspectRatioFinder", name: "Utils / Text Aspect Ratio Finder", desc: "🪐 📐 Text Aspect Ratio Finder" },
{ key: "SaturnTextSplit", name: "Utils / Text Split", desc: "🪐 ✂️ Text Split" }
];
const saturnNodeDefinitions = (_nodesInfoCache && Array.isArray(_nodesInfoCache) && _nodesInfoCache.length > 0)
? _nodesInfoCache
: defaultSaturnNodeDefinitions;
saturnNodeDefinitions.forEach((node, index) => {
const numStr = String(index + 1).padStart(2, "0");
const settingId = `SaturnNodes.🧩 Node Management.${numStr}_${node.key}`;
const legacySettingId = `SaturnNodes.🧩 Node Management.${numStr}_Enable_${node.key}`;
const isInitialDisabled = _disabledNodesCache ? _disabledNodesCache.has(node.key) : false;
const defaultValue = isInitialDisabled ? false : getInitialSetting(settingId, getInitialSetting(legacySettingId, true));
app.ui.settings.addSetting({
id: settingId,
name: node.name,
type: "boolean",
defaultValue: defaultValue,
tooltip: `Enables or disables ${node.desc}. When disabled, the node is hidden from the node search menu (requires server restart to take effect).`,
onChange(value) {
if (typeof localStorage !== "undefined") {
try {
localStorage.setItem(`Comfy.Settings.${settingId}`, JSON.stringify(Boolean(value)));
localStorage.setItem(`Comfy.Settings.${legacySettingId}`, JSON.stringify(Boolean(value)));
} catch (_) {}
}
updateNodeDisabledState(node.key, Boolean(value));
}
});
});
// =========================================================================
// Automatically inject footer linking to GitHub, author profile, and README guide
setupSettingsFooterObserver();
@@ -1177,23 +1520,28 @@ app.registerExtension({
/**
* Injects a clean footer linking to GitHub, author profile, and settings reference
* at the bottom of the SaturnNodes settings panel.
* strictly at the bottom of the SaturnNodes settings panel, excluding it from search.
*/
function setupSettingsFooterObserver() {
function injectFooterIfMissing() {
const saturnItem = document.querySelector('[data-setting-id^="SaturnNodes.🛡️ Security"]') ||
document.querySelector('[data-setting-id^="SaturnNodes.9 - 🛡️ Security"]') ||
document.querySelector('[data-setting-id^="SaturnNodes."]');
if (!saturnItem || !saturnItem.parentElement) return;
function getSearchTerm() {
const dialog = document.querySelector('.p-dialog[role="dialog"]') ||
document.querySelector('.comfy-settings-dialog') ||
document.querySelector('[data-testid="settings-dialog"]');
if (!dialog) return "";
const searchInput = dialog.querySelector('input[type="search"]') ||
dialog.querySelector('input[placeholder*="Search" i]') ||
dialog.querySelector('input.p-inputtext');
return searchInput ? searchInput.value.trim() : "";
}
const container = saturnItem.parentElement;
if (container.querySelector(".saturnnodes-settings-footer")) return;
function createSettingsFooter() {
const githubSvg = `<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" style="display:inline-block; vertical-align:text-bottom; margin-right:4px;"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"></path></svg>`;
const footer = document.createElement("div");
footer.className = "saturnnodes-settings-footer";
footer.style.cssText = `
margin-top: 48px !important;
padding: 20px 10px 10px;
margin-top: 40px !important;
padding: 20px 10px 12px;
border-top: 1px solid rgba(255, 255, 255, 0.12);
display: flex;
align-items: center;
@@ -1206,13 +1554,11 @@ function setupSettingsFooterObserver() {
box-sizing: border-box;
`;
const githubSvg = `<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" style="display:inline-block; vertical-align:text-bottom; margin-right:4px;"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"></path></svg>`;
footer.innerHTML = `
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 16px;">🪐</span>
<span style="font-weight: 600; color: #e4e4e7;">ComfyUI-SaturnNodes</span>
<span style="font-size: 11px; padding: 2px 7px; border-radius: 4px; background: rgba(180, 140, 95, 0.18); color: #decbb2; border: 1px solid rgba(180, 140, 95, 0.35);">v2.3.2</span>
<span style="font-size: 11px; padding: 2px 7px; border-radius: 4px; background: rgba(180, 140, 95, 0.18); color: #decbb2; border: 1px solid rgba(180, 140, 95, 0.35);">v2.5.0</span>
</div>
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap;">
<a href="https://github.com/KOFiblto/ComfyUI-SaturnNodes" target="_blank" rel="noopener noreferrer" style="color: #decbb2; text-decoration: underline; text-decoration-color: rgba(222, 203, 178, 0.4); text-underline-offset: 3px; font-weight: 500; font-size: 12px; display: inline-flex; align-items: center; gap: 4px; padding: 4px 10px; border-radius: 6px; background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.12); transition: all 0.2s ease;" onmouseover="this.style.color='#ffffff'; this.style.background='rgba(255,255,255,0.12)'; this.style.borderColor='rgba(255,255,255,0.25)';" onmouseout="this.style.color='#decbb2'; this.style.background='rgba(255,255,255,0.05)'; this.style.borderColor='rgba(255,255,255,0.12)';">
@@ -1226,15 +1572,65 @@ function setupSettingsFooterObserver() {
</a>
</div>
`;
container.appendChild(footer);
return footer;
}
function updateFooter() {
const dialog = document.querySelector('.p-dialog[role="dialog"]') ||
document.querySelector('.comfy-settings-dialog');
const existingFooter = document.querySelector(".saturnnodes-settings-footer");
if (!dialog) {
if (existingFooter) existingFooter.remove();
return;
}
const searchTerm = getSearchTerm();
// Hide footer immediately when searching so it doesn't pollute search results
if (searchTerm.length > 0) {
if (existingFooter) existingFooter.remove();
return;
}
// Find all setting groups in the dialog
const allGroups = Array.from(dialog.querySelectorAll('.setting-group'));
const saturnGroups = allGroups.filter(g => g.querySelector('[data-setting-id^="SaturnNodes."]'));
// If no SaturnNodes groups are rendered in the DOM, remove footer
if (saturnGroups.length === 0) {
if (existingFooter) existingFooter.remove();
return;
}
// Check if other category groups exist (e.g. Comfy core settings)
const otherGroups = allGroups.filter(g => g.querySelector('[data-setting-id]:not([data-setting-id^="SaturnNodes."])'));
// If other non-Saturn groups outnumber Saturn groups, user navigated to another category
if (otherGroups.length > 0 && otherGroups.length > saturnGroups.length) {
if (existingFooter) existingFooter.remove();
return;
}
// We are on SaturnNodes page! Locate the last SaturnNodes setting group
const lastGroup = saturnGroups[saturnGroups.length - 1];
if (!lastGroup) return;
let footer = existingFooter;
if (!footer) {
footer = createSettingsFooter();
}
// Append footer directly after the last SaturnNodes group
if (lastGroup.parentElement && lastGroup.nextElementSibling !== footer) {
lastGroup.after(footer);
}
}
try {
const observer = new MutationObserver(() => {
injectFooterIfMissing();
updateFooter();
});
observer.observe(document.body, { childList: true, subtree: true });
} catch (_) {}
setInterval(injectFooterIfMissing, 1000);
setInterval(updateFooter, 500);
}