Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e596c6de5a | ||
|
|
387b5ceb0b | ||
|
|
5369980d57 | ||
|
|
61011eea96 | ||
|
|
d706f0eb00 | ||
|
|
77637e0626 | ||
|
|
9c9917961c | ||
|
|
a32bb7f913 | ||
|
|
4c6493fe63 | ||
|
|
c12beff2d2 | ||
|
|
5e2872d5f8 | ||
|
|
5a8f23ea2b | ||
|
|
12492185d2 | ||
|
|
c99af98e09 | ||
|
|
a8a8946edc | ||
|
|
16d2ed916d | ||
|
|
31047dc9da | ||
|
|
e15f716563 | ||
|
|
a6fbfe06e0 |
@@ -23,3 +23,8 @@ user/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Downloaded models and checkpoints
|
||||
models/
|
||||
*.onnx
|
||||
*.onnx.tmp
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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)
|
||||
|
||||
+42
-3
@@ -5,10 +5,49 @@ 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.4.1] - Latest (Development)
|
||||
## [2.5.0] - 2026-10-10 [Stable]
|
||||
|
||||
### Changed
|
||||
- Active development track for subsequent patches and visual LoRA loader V1 enhancements.
|
||||
### 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+9
-7
@@ -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!
|
||||
|
||||
@@ -7,6 +7,7 @@ A unified workflow control and productivity suite for **ComfyUI**. Both **Fronte
|
||||
## 🚀 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.
|
||||
@@ -321,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>
|
||||
@@ -411,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.
|
||||
|
||||
+394
-128
@@ -1,169 +1,435 @@
|
||||
# 🛡️ ComfyUI Node Security Audit Playbook
|
||||
*A Repeatable, Step-by-Step Security Review & Conformance Framework for ComfyUI Custom Node Developers and Auditors.*
|
||||
# 🛡️ ComfyUI Custom Node Security, Compliance & Architecture Rulebook
|
||||
*The Authoritative Engineering Standard & Audit Playbook Derived from ComfyUI-Manager PR #3200 Review Precedents*
|
||||
|
||||
---
|
||||
|
||||
## 🎯 The Core Philosophy & Threat Model
|
||||
|
||||
> **The Golden Rule (by @ltdrdata, ComfyUI-Manager maintainer):**
|
||||
> *"Treat every input, widget values included, as attacker-controlled, because it can come from a shared workflow or a direct `/prompt` call."*
|
||||
|
||||
### Why ComfyUI Nodes Require a Hostile Threat Model
|
||||
1. **Shared Workflow Vectors:** ComfyUI workflows are routinely shared across the internet as `.json` files or embedded inside `.png` image metadata (on Civitai, Reddit, Discord, GitHub).
|
||||
2. **Zero-Interaction Poisoning:** When a user drags and drops a contaminated image or loads a workflow JSON, widget values are immediately populated with whatever data the author specified.
|
||||
3. **Headless & API Execution:** Malicious payloads can be sent directly to the local server via `POST /prompt` without any UI interaction.
|
||||
4. **Local Host Privileges:** ComfyUI runs with the full local privileges of the host user account. Any vulnerability can lead to Remote Code Execution (RCE), arbitrary file deletion, or sensitive data theft.
|
||||
## 📑 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)
|
||||
|
||||
---
|
||||
|
||||
## 📋 The 4-Phase Security Audit Checklist
|
||||
## 1. Core Threat Model & The Golden Rule
|
||||
|
||||
When auditing a new or existing custom node pack, execute the following four phases step by step.
|
||||
### 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.
|
||||
|
||||
---
|
||||
|
||||
### Phase 1: Node-by-Node Audit Matrix (The 7 Vector Checklist)
|
||||
## 2. Case Study: ComfyUI-Manager PR #3200 Review Precedents
|
||||
|
||||
Repeat this checklist for **every single custom node** in `NODE_CLASS_MAPPINGS`.
|
||||
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:
|
||||
|
||||
#### 1.1 Filesystem Paths & Directory Traversal
|
||||
* [ ] **No Unchecked Absolute Paths:** Does the node accept file or folder paths as input strings?
|
||||
- Absolute paths (e.g. `C:\`, `/etc/`, `\\server\share`) must be strictly rejected unless explicitly intended and approved.
|
||||
- Windows drive letter patterns (`^[A-Za-z]:`) and UNC paths (`^//`, `^\\\\`) must be blocked.
|
||||
* [ ] **No Directory Traversal (`..`):** Does the node reject any path segment containing `..`?
|
||||
* [ ] **Canonical Path Confinement via `commonpath`:**
|
||||
- Are paths resolved to canonical real paths using `os.path.realpath`?
|
||||
- Are resolved paths checked against an authorized base directory using `os.path.commonpath`?
|
||||
```python
|
||||
real_base = os.path.realpath(allowed_base)
|
||||
real_target = os.path.realpath(target_path)
|
||||
if os.path.commonpath([real_base, real_target]) != real_base:
|
||||
raise ValueError("Directory traversal attempt detected.")
|
||||
```
|
||||
* [ ] **Confined Base Directories:** Are file reads and writes confined strictly to approved ComfyUI folders (`input/`, `output/`, `temp/`, or `models/`)?
|
||||
### 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.
|
||||
|
||||
#### 1.2 Destructive Actions (Deletions, Writes, Overwrites)
|
||||
* [ ] **Safe Deletion Guards:** If the node contains `os.remove`, `os.unlink`, or `shutil.rmtree`:
|
||||
- Is file deletion strictly confined to `input/` or `output/`?
|
||||
- Does the node refuse to delete files outside the confined sandbox, even if a manipulated path is provided?
|
||||
- Does the deletion toggle default to `False`?
|
||||
* [ ] **Atomic File Writes:** When writing JSON, cache, or state files, are writes executed atomically via temporary files (`.tmp` + `os.replace`) to prevent corruption during unexpected shutdowns?
|
||||
* [ ] **Read-Only Filesystem Defense:** Are disk writes wrapped in `try / except (PermissionError, OSError)` with graceful logging to prevent server crashes on read-only Docker/network mounts?
|
||||
### 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.
|
||||
|
||||
#### 1.3 Command & Process Execution
|
||||
* [ ] **Never Use `shell=True`:** Are all subprocess calls executed with `shell=False` and tokenized argument lists (`shlex.split` or list of strings)?
|
||||
* [ ] **Strict Executable Confinement:** Are scripts or binaries restricted strictly to a dedicated directory (e.g., `ComfyUI/scripts/`)?
|
||||
* [ ] **In-Memory Operator Authorization Gate:**
|
||||
- Can script execution consent travel inside workflow JSON or image metadata? (Answer must be **NO**).
|
||||
- Does execution require an interactive, in-memory user action on the canvas node (e.g. `[⚡ Authorize Run]` with an ephemeral RAM token) before queueing?
|
||||
* [ ] **Master Safety Switch:** Is local script/process execution disabled by default in settings (`ENABLE_LOCAL_FILE_EXECUTION = False`)?
|
||||
* [ ] **Process Tree Termination & Timeouts:** Do subprocesses enforce strict execution timeouts and clean process-tree termination on user cancellation?
|
||||
|
||||
#### 1.4 Dynamic Code Evaluation
|
||||
* [ ] **Zero `eval()` or `exec()` on Inputs:** Does the node evaluate mathematical expressions, conditions, or strings using Python's `eval()` or `exec()`?
|
||||
- `eval()` and `exec()` on widget inputs represent instantaneous Remote Code Execution (RCE).
|
||||
- Use safe AST parsers (such as `ast.literal_eval` or custom math parsers) instead.
|
||||
* [ ] **No Dynamic Imports from Input Strings:** Does the node invoke `__import__` or `importlib.import_module` using user-supplied module names?
|
||||
|
||||
#### 1.5 Outbound Network Requests & SSRF
|
||||
* [ ] **Strict Protocol Validation:** Do web requests (e.g. preview scrapers, API fetchers) strictly require `https://` or `http://`? (`file://`, `ftp://`, `gopher://` must be rejected).
|
||||
* [ ] **Internal Network & Loopback Blocking:**
|
||||
- Are requests to `127.0.0.1`, `localhost`, `::1`, and `0.0.0.0` blocked?
|
||||
- Are private IPv4/IPv6 ranges blocked (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`)?
|
||||
- Are cloud metadata services blocked (`169.254.169.254` on AWS/GCP/Azure)?
|
||||
* [ ] **Opt-In Network Activity:** Are external web scraping or API features disabled by default, requiring explicit user activation?
|
||||
|
||||
#### 1.6 Regular Expression DoS (ReDoS) & Input Validation
|
||||
* [ ] **Regex Error Handling:** Are user-provided regular expressions wrapped in `try / except re.error` with a safe fallback to prevent unhandled crashes?
|
||||
* [ ] **Catastrophic Backtracking Defense:** Are regex patterns inspected for exponential backtracking vulnerability (e.g. `(a+)+$`)?
|
||||
* [ ] **String Splitting Delimiters:** When parsing delimited inputs (e.g. CSV find/replace targets), does the parser handle quoted strings containing delimiters without fragmentation?
|
||||
|
||||
#### 1.7 Resource Exhaustion & Memory Allocation
|
||||
* [ ] **Bounded Iteration:** Do loop nodes (e.g. batch iterators, queue steppers) enforce sane upper bounds to prevent infinite loops?
|
||||
* [ ] **Image Dimension Clamping:** Are image dimensions, aspect ratio scalers, and tensor buffers validated to prevent out-of-memory (OOM) allocation bombs?
|
||||
### 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`.
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Administrative Endpoints & Web Routes Review
|
||||
## 3. The 8 Core Security Rules
|
||||
|
||||
Examine all routes registered via `PromptServer.instance.app.routes` in `__init__.py` or route handlers.
|
||||
### Rule 1: Filesystem Confinement & Traversal Prevention
|
||||
|
||||
#### 2.1 Loopback Origin Enforcement (`is_local_request`)
|
||||
* [ ] **Local-Only Verification:** Do sensitive endpoints check the client's peer IP address and reject non-loopback connections (`127.0.0.1` / `::1`) with `403 Forbidden`?
|
||||
Nodes must never read, write, or list files outside designated ComfyUI directories (`input/`, `output/`, `temp/`, and `models/`).
|
||||
|
||||
#### 2.2 Cross-Site Request Forgery (CSRF) & Fetch Metadata
|
||||
* [ ] **Same-Machine Browser Exploit Defense:** A local-only IP check is insufficient on its own because any malicious website visited by the user in another browser tab makes requests from `localhost`!
|
||||
* [ ] **`Sec-Fetch-Site` Validation:** Does the route inspect the browser-enforced `Sec-Fetch-Site` header and reject `cross-site` calls?
|
||||
* [ ] **Origin / Referer Validation:** Does the route verify that `Origin` or `Referer` matches the ComfyUI host?
|
||||
* [ ] **Cryptographic Session CSRF Token:** Do state-mutating `POST` endpoints require a session token header (e.g. `X-CSRF-Token`) generated on server startup?
|
||||
#### ❌ 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()
|
||||
```
|
||||
|
||||
#### 2.3 Two-Step Ephemeral Ticket Handshakes for High-Impact Actions
|
||||
* [ ] **Destructive Action Isolation:** Are high-impact administrative actions (server restart, shutdown, mass cache wipe) separated into a two-step handshake?
|
||||
1. `POST /request_token`: Client requests a single-use token stored in server RAM with a short TTL (30 seconds).
|
||||
2. `POST /confirm_action`: Client presents the single-use token to execute the action.
|
||||
* [ ] **Default Disabled:** Are server management features disabled by default in settings (`ALLOW_PROCESS_MANAGEMENT = False`)?
|
||||
#### ✅ 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Settings & Persistent Configuration Review
|
||||
### Rule 2: Destructive File Actions & Deletion Guards
|
||||
|
||||
Inspect how preferences, credentials, and states are stored.
|
||||
Any node that deletes or overwrites files (`os.remove`, `os.unlink`, `shutil.rmtree`) must verify containment immediately prior to invocation.
|
||||
|
||||
#### 3.1 Safe Directory Isolation
|
||||
* [ ] **Centralized Data Directory:** Are user data and settings stored in `ComfyUI/user/default/<PackName>/` rather than cluttering root or writing to random system paths?
|
||||
* [ ] **No Secret Tracking in Git:** Are `.env` files, API keys, and local state files strictly added to `.gitignore`?
|
||||
* [ ] **Log Masking:** Are secret keys (e.g. Civitai API keys, TMDB tokens) masked (`***` or truncated) when output to server logs?
|
||||
#### ❌ 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
|
||||
```
|
||||
|
||||
#### 3.2 Configuration File Injection Defense
|
||||
* [ ] **Newline Injection Defense:** When writing `.env` or configuration key-value pairs, are keys and values sanitized against `\r` and `\n` to prevent config poisoning?
|
||||
#### ✅ 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: ComfyUI Ecosystem & Registry Compliance
|
||||
### Rule 3: Process Execution & The Workflow Serialization Trap
|
||||
|
||||
Review package structure against the official Comfy-Org registry and ComfyUI-Manager requirements.
|
||||
Executing external binaries or scripts is high risk. You must adhere to the following principles:
|
||||
|
||||
#### 4.1 Packaging & Dependency Standards
|
||||
* [ ] **No `install.py`:** Is `install.py` completely eliminated? (Comfy-Org prohibits `install.py` executing `pip install` subprocesses on startup).
|
||||
* [ ] **Transparent `requirements.txt` & `pyproject.toml`:** Are dependencies clearly declared with standard version constraints?
|
||||
* [ ] **No PyTorch in Dependencies:** Is `"torch"` omitted from `requirements.txt` / `pyproject.toml` dependencies? (ComfyUI manages its own PyTorch/CUDA environment).
|
||||
* [ ] **Optional Headless Dependencies:** Are GUI-dependent packages (like system tray `pystray`) made optional or headless-tolerant so cloud containers (Docker, RunPod, Colab) boot cleanly?
|
||||
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`.
|
||||
|
||||
#### 4.2 Frontend Compatibility & Widget Serialization
|
||||
* [ ] **Dual Frontend Support:** Does the node suite function cleanly on both **Frontend V1 (LiteGraph)** and **Frontend V2 (Vue UI)**?
|
||||
* [ ] **No Canvas Runaway Loops:** Do custom DOM widgets avoid overriding `computeSize()` with formulas tied to `node.size[1]`, which triggers infinite downward expansion in LiteGraph?
|
||||
* [ ] **Serialization Hygiene:** Are UI-only preview badges and widgets explicitly marked `{ serialize: false }` to avoid polluting saved workflow JSON?
|
||||
#### ❌ 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
|
||||
```
|
||||
|
||||
#### 4.3 Core Monkey-Patching Isolation
|
||||
* [ ] **Conditional Patching:** If the node pack overrides any core ComfyUI server or queue functions (e.g. `PromptQueue`), is the patch completely bypassed when the feature toggle is disabled?
|
||||
#### ✅ 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)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Rapid Audit Checklist Matrix
|
||||
### Rule 4: Dynamic Code Evaluation & Deserialization
|
||||
|
||||
Use this quick scorecard when reviewing a custom node pack:
|
||||
Never pass widget string inputs into `eval()`, `exec()`, or dynamic `importlib` calls.
|
||||
|
||||
| Category | Check | Status |
|
||||
| :--- | :--- | :---: |
|
||||
| **Inputs** | Absolute paths rejected | [ ] |
|
||||
| **Inputs** | Directory traversal (`..`) rejected | [ ] |
|
||||
| **Inputs** | Path confinement verified with `commonpath` | [ ] |
|
||||
| **Inputs** | Regex errors caught and handled | [ ] |
|
||||
| **Execution** | `shell=False` enforced on all subprocesses | [ ] |
|
||||
| **Execution** | Scripts confined to dedicated directory | [ ] |
|
||||
| **Execution** | Consent cannot travel in workflow JSON | [ ] |
|
||||
| **Execution** | In-memory interactive operator authorization | [ ] |
|
||||
| **Network** | SSRF protection: loopback & private LAN blocked | [ ] |
|
||||
| **Network** | Protocol restricted to HTTP/HTTPS | [ ] |
|
||||
| **Network** | Scrapers & network features opt-in | [ ] |
|
||||
| **Endpoints** | Loopback origin checks on admin routes | [ ] |
|
||||
| **Endpoints** | `Sec-Fetch-Site` CSRF defense | [ ] |
|
||||
| **Endpoints** | Ephemeral ticket handshake for power actions | [ ] |
|
||||
| **Settings** | Dangerous features default to `False` | [ ] |
|
||||
| **Settings** | Data stored in `user/default/<pack>/` | [ ] |
|
||||
| **Packaging** | No `install.py` / import-time `pip` | [ ] |
|
||||
| **Packaging** | Dependencies in `requirements.txt` | [ ] |
|
||||
| **Frontend** | Clean V1 and V2 compatibility without resize loops | [ ] |
|
||||
#### ❌ 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))
|
||||
```
|
||||
|
||||
---
|
||||
*Created for ComfyUI-SaturnNodes to maintain institutional security compliance.*
|
||||
|
||||
### 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
@@ -4,9 +4,19 @@ import subprocess
|
||||
from aiohttp import web
|
||||
from server import PromptServer
|
||||
|
||||
__version__ = "2.4.1"
|
||||
__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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
+53
-4
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
+1
-1
@@ -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.4.1"
|
||||
version = "2.5.0"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
dependencies = ["pillow", "numpy", "piexif"]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
+413
-17
@@ -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)) {
|
||||
@@ -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.4.1 (latest)</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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user