Compare commits

..
22 Commits
Author SHA1 Message Date
KOFiblto 005efdd7c5 fix(frontend-v1): eliminate infinite runaway downward resizing in VisualLoraLoader and ImageLoader 2026-09-24 00:42:04 +02:00
KOFiblto 78682dd0cb refactor(core): remove legacy nodes, implement Random (Cycle), improve quote parsing in text replacer, optimize canvas render loops 2026-09-24 00:24:24 +02:00
KOFiblto 29198a5fd7 fix(widgets): eliminate empty space below prompt and lora counters, disable counter colors when custom colors disabled 2026-09-24 00:03:58 +02:00
KOFiblto 5c09936c3a style(theme): set default saturn colors to #6a3f20 and #472a15 2026-09-23 23:20:08 +02:00
KOFiblto ba35d58e81 chore: align default custom node color constants 2026-09-23 23:17:54 +02:00
KOFiblto ef95266cf4 feat(colors): set default theme colors to Molten Copper (#c85a2c, #210f09) 2026-09-23 23:12:56 +02:00
KOFiblto b4228dc340 fix(ui): 100-palette studio, fixed counter height, sketch color picker & settings styling 2026-09-23 23:10:42 +02:00
KOFiblto 88f1485cc9 feat(ui): ResolutionSelector styled DOM count widgets, amber custom colors with settings color pickers, and default false toggle 2026-09-23 20:35:31 +02:00
KOFiblto 16ef5bf5db fix(ui): pastel unified colors, native zoom-adaptive counter widgets, sleek single settings buttons, footer links, and running default launch state 2026-09-23 20:18:34 +02:00
KOFiblto 983862eec3 feat(ui): saturn counter widget, lora count badge, color sync, renumber settings group 8 and closed details 2026-09-23 01:41:54 +02:00
KOFiblto acc1570629 fix(ui): protect settings toggle switches from button styles and render custom action buttons 2026-09-22 22:26:37 +02:00
KOFiblto 30c0547009 fix(ci): normalize path casing and canonicalize scripts base for runner tempdirs 2026-09-22 22:11:42 +02:00
KOFiblto 13e24c3a57 fix(ci): fix cross-platform drive letter check in local runner and update workflow names to SaturnNodes 2026-09-22 20:31:12 +02:00
KOFiblto cd00cce1f8 refactor: remove legacy leafflow endpoints and events in favor of clean saturnnodes routes 2026-09-22 20:15:15 +02:00
KOFiblto a26d060d5f feat: rename suite to SaturnNodes (v2.3.2) 2026-09-22 19:53:39 +02:00
KOFiblto e9594ec428 fix(security): comprehensive security hardening, CSRF on all mutation routes, SSRF scraper defense, and v2.3.1 bump 2026-09-22 17:52:38 +02:00
KOFiblto b6b2356389 docs(security): explicitly clarify ComfyUI/scripts/ confinement in node tooltips, canvas, settings, and README 2026-09-22 17:41:02 +02:00
KOFiblto c3375c61af fix(security): resolve reviewer findings with CSRF protection, ticket handshakes, path confinement, and in-memory authorization
- Issue 1 (Power CSRF): Implement cryptographic session CSRF tokens, Sec-Fetch-Site and Origin verification, plus an ephemeral single-use 30s ticket handshake (/leafflow/power/request_token -> /confirm_action) for restart/shutdown actions.
- Issue 2 (Settings CSRF): Protect /leafflow/settings and /leafflow/scrapes/clear with is_authenticated_local_request; completely remove ALLOW_ANY_SCRIPT_PATH setting; default ENABLE_LOCAL_FILE_EXECUTION to false.
- Issue 3 (Script Path Confinement): Strictly confine RunLocalFileNode execution to ComfyUI/scripts/ using canonical os.path.realpath and os.path.commonpath; reject absolute paths and directory traversal (..); eliminate ALLOW_ANY_SCRIPT_PATH escape hatches.
- Issue 4 (Consent Gate Traveling in Workflow JSON): Remove security_consent from node INPUT_TYPES to prevent serialization in workflow JSON or image metadata; replace with an interactive in-memory authorization button on the canvas node ([⚡ Authorize Run]) with a 5-minute single-use memory token.
- Expand automated unit test suite to 105 tests covering all security guards and token mechanics. Update SECURITY.md, CHANGELOG.md, and README.md.
2026-09-22 17:10:36 +02:00
KOFiblto d39c0c89fe feat: add Run Local File node with security sandbox and audit compliance fixes (v2.3.0) 2026-09-20 02:20:38 +02:00
KOFiblto e1a0187f8f refactor(batch-queue): remove live queue progress badge 2026-09-12 01:43:12 +02:00
KOFiblto ac181210d1 feat(batch-queue): add background Batch Graph Snapshot Guard and Queue button progress badge 2026-09-12 01:39:37 +02:00
KOFiblto e47ce1dd43 feat(prompt): add custom regex to PromptQueueIterator and add PromptCounter node with live badge 2026-09-11 17:50:01 +02:00
53 changed files with 4376 additions and 1372 deletions
+1 -1
View File
@@ -30,6 +30,6 @@ jobs:
python -m pip install --upgrade pip
pip install Pillow torch numpy aiohttp piexif
- name: Execute LeafFlow Unit Test Suite
- name: Execute SaturnNodes Unit Test Suite
run: |
python tests/run_tests.py
+81 -1
View File
@@ -1,10 +1,90 @@
# Changelog
All notable changes to `ComfyUI-LeafFlow` will be documented in this file.
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.3.2] - 2026-09-22
### Changed & Rebranded
- **Full Suite Rebrand to SaturnNodes (`🪐`)**:
- Rebranded the entire suite from `ComfyUI-LeafFlow` to **`ComfyUI-SaturnNodes`** with signature emoji `🪐`.
- Node display names updated with the `🪐` prefix across all 16 custom nodes.
- Node category updated to `🪐 SaturnNodes/*` (`Loaders`, `Utils`, `Previews`, `Queue`, `Automation`).
- Full backward compatibility: all legacy class names (`LeafFlowDecision`, `LeafFlowTextSplit`) preserved and aliased alongside new class names in `NODE_CLASS_MAPPINGS`.
- Dual HTTP route registration: both `/saturnnodes/*` and `/leafflow/*` routes are active, guaranteeing compatibility with existing scripts, extensions, and bookmarks.
- Dual WebSocket event emission: both `saturnnodes_*` and `leafflow_*` events are broadcast to the frontend.
- Seamless settings migration: frontend settings and local storage automatically migrate from `LeafFlow.*` to `SaturnNodes.*` with instant fallback.
- Automatic user directory migration: `.env` and `prompt_iterator_state.json` migrate smoothly from `user/default/LeafFlow/` to `user/default/SaturnNodes/`.
- Removed third-party PromptBookmarks coupling: cleaned UI bookmarks button from copy prompt node for modularity.
---
## [2.3.1] - 2026-09-22
### Security & Hardening
- **Universal CSRF Protection Across All Mutation Routes**:
- Protected `POST /leafflow/prompt_iterator/reset_node`, `POST /leafflow/prompt_iterator/clear`, and `POST /leafflow/prompt_iterator/open_file` with `is_authenticated_local_request`.
- Protected `POST /leafflow/decision` with `is_authenticated_local_request`.
- Protected queue-mutating endpoints (`POST /pause_queue/toggle`, `POST /pause_queue/mode`, `POST /pause_queue/continue`, `POST /persistent_queue/claim`, `POST /leafflow/batch_queue/sync`, `POST /leafflow/assets/restore`) with `is_authenticated_local_request`.
- Updated all frontend callers in `web/` to send the `X-LeafFlow-CSRF-Token` session header via `authenticatedFetch`.
- **SSRF Protection & Scheme Filtering for Preview Scrapers**:
- Added `is_safe_external_image_url` validating that downloaded image URLs use standard `https` or `http` schemes.
- Strictly blocked internal loopback (`127.0.0.1`, `localhost`), private networks (`10.*`, `172.16.*`, `192.168.*`), and link-local cloud metadata endpoints (`169.254.*`).
- **Safe Write & Permission Error Handling**:
- Wrapped downloaded preview image disk writes in `try / except (PermissionError, OSError)` with clear logging to prevent crashes on read-only directories or shared network mounts.
- **Canonical Confinement for OS Application Launches**:
- Verified that `open_prompt_iterator_file` can only open files strictly confined within the authorized ComfyUI user directory (`USER_DIR`) via `os.path.commonpath`.
- **Conditional Core Queue Monkey-Patching**:
- Guarded `PersistentQueueManager.patch_server()` so core ComfyUI `prompt_queue` methods are not touched when `ENABLE_PERSISTENT_QUEUE=false`.
- **Explicit Scripts Confinement Documentation & UI Canvas Indicators**:
- Added explicit italic indicator `📂 Script Location: ComfyUI/scripts/` to `RunLocalFileNode` canvas foreground.
- Added widget tooltips and settings descriptions clarifying that scripts and working directories must reside exclusively in `ComfyUI/scripts/`.
---
## [2.3.0] - 2026-09-20
### Added
- **🍃 ⚡ "Run Local File" Node (`RunLocalFileNode`)**:
- Executes local scripts and binaries (`.bat`, `.cmd`, `.ps1`, `.exe` on Windows; `.sh` and executables on Linux/macOS) directly within ComfyUI workflows.
- Parameter arguments supporting multi-line or standard quotes (`shlex` tokenized, safe non-shell execution).
- Configurable working directory (`cwd`), defaulting to the script's directory if unspecified.
- **Synchronous Mode**: Captures `stdout`, `stderr`, `exit_code`, and `success` boolean outputs, with real-time ComfyUI cancel button interrupt handling and configurable execution timeout.
- **Asynchronous Mode**: Detaches process to run in the background without blocking the ComfyUI generation queue.
- Optional `trigger` wildcard input and `passthrough` wildcard output for sequencing file execution in any pipeline.
- **Multi-Layered Security Sandbox**:
- Confined strictly to `ComfyUI/scripts/` via canonical `realpath` and `commonpath` validation.
- Strict rejection of absolute paths (`C:\`, `/etc/`, `\\server`) and directory traversal sequences (`..`).
- Never invokes `shell=True` (eliminating command chaining and command injection vulnerabilities).
- Recursive process tree termination on timeout or cancellation (uses `taskkill /F /T` on Windows to prevent orphaned background processes).
- **In-Memory Operator Authorization Gate**: Removed `security_consent` from `INPUT_TYPES` so consent never serializes into workflow JSON or dropped image metadata. Requires active canvas button click `[⚡ Authorize Run]` with 5-minute single-use memory token.
- Visual node status indicator (`[ARMED]` in emerald green vs `[DISARMED]` in red).
- Global toggle setting (`LeafFlow.9 - 🛡️ Security.01_AllowLocalFileExecution`, default `false`).
- **🛡️ CSRF Defense & Request Authentication**:
- Added `is_authenticated_local_request` validating loopback origin, `Sec-Fetch-Site` header (blocking `cross-site` calls from visited web pages), and `Origin`/`Referer` headers.
- Cryptographically secure per-session CSRF token (`X-LeafFlow-CSRF-Token`) generated via `secrets.token_hex(32)` and validated with `secrets.compare_digest`.
- Added frontend auth helper (`web/js/auth_helper.js`) automatically fetching and attaching CSRF tokens to administrative fetch requests.
- **⚡ Two-Step Power Action Handshake**:
- Server restart and shutdown endpoints require `ALLOW_PROCESS_MANAGEMENT=true` plus a two-step confirmation handshake (`POST /leafflow/power/request_token` to issue a 30s single-use ticket, followed by `POST /leafflow/power/confirm_action` to consume and execute).
### Fixed & Compliance
- **Complete Removal of `ALLOW_ANY_SCRIPT_PATH`**: Eliminated all path escape settings to guarantee execution confinement to `ComfyUI/scripts/`.
- **Dynamic Node Count**: Replaced hardcoded node count in startup banner with dynamic `len(NODE_CLASS_MAPPINGS)` (now reporting all 16 registered nodes).
- **Universal Synchronized Versioning**: Version `2.3.0` synchronized across `__init__.py`, `pyproject.toml`, `/leafflow/debug/export`, `README.md`, and `CHANGELOG.md`.
- **Process Management Security Toggle**: Server restart and shutdown controls are disabled by default (`ALLOW_PROCESS_MANAGEMENT=false`). If disabled, attempts to trigger restart/shutdown via the UI or API return HTTP 403 with clear instructions.
- **Optional `pystray` Dependency**: Made `pystray` an optional extra (`[project.optional-dependencies] tray = ["pystray"]`) and removed all runtime `pip install` subprocess calls, ensuring zero crashes on headless cloud servers, Docker, or Google Colab.
- **Universal Loopback Protection**: Added `is_local_request(request)` validation to all legacy queue endpoints (`/pause_queue/*` and `/persistent_queue/claim`).
- **Windows Console Unicode Safety**: Added `UnicodeEncodeError` guard around startup emoji printing for legacy Windows terminals.
- **Clean Dynamic Imports**: Replaced static imports of desktop private `app.*` in `assets_restore.py` with dynamic `importlib.import_module` calls.
- **Cleaned Dead Code**: Removed orphaned canvas classes from `queue_control.py`.
### Testing
- Comprehensive automated test coverage expanded to **105 unit tests** covering all nodes, CSRF authentication, power action tickets, in-memory runner authorization, and path confinement.
---
## [2.2.0] - 2026-09-08
> ⚠️ **Important Frontend Compatibility Notice**:
+5 -5
View File
@@ -1,6 +1,6 @@
# 🤝 Contributing to ComfyUI-LeafFlow
# 🤝 Contributing to ComfyUI-SaturnNodes
Thank you for your interest in contributing to **`ComfyUI-LeafFlow`**! We welcome bug reports, feature suggestions, documentation updates, and pull requests from the community.
Thank you for your interest in contributing to **`ComfyUI-SaturnNodes`**! We welcome bug reports, feature suggestions, documentation updates, and pull requests from the community.
Please take a moment to review these guidelines before submitting an issue or pull request.
@@ -9,7 +9,7 @@ Please take a moment to review these guidelines before submitting an issue or pu
## 🐛 Reporting Issues & Bugs
Before opening a new issue:
1. **Check Existing Issues**: Search the [Issue Tracker](https://github.com/KOFiblto/ComfyUI-LeafFlow/issues) to see if your bug or feature request has already been reported.
1. **Check Existing Issues**: Search the [Issue Tracker](https://github.com/KOFiblto/ComfyUI-SaturnNodes/issues) to see if your bug or feature request has already been reported.
2. **Include Reproduction Steps**: Provide a clear step-by-step description of how to reproduce the bug.
3. **Include Logs & Environment Details**:
- **ComfyUI Version** (V2 Vue UI or Classic V1 LiteGraph)
@@ -33,8 +33,8 @@ All development follows a **Feature Branch $\rightarrow$ Pull Request** workflow
### Step 1: Fork and Clone
Fork the repository on GitHub, then clone your fork locally:
```bash
git clone https://github.com/YOUR_USERNAME/ComfyUI-LeafFlow.git
cd ComfyUI-LeafFlow
git clone https://github.com/YOUR_USERNAME/ComfyUI-SaturnNodes.git
cd ComfyUI-SaturnNodes
```
### Step 2: Create a Feature Branch
+125 -58
View File
@@ -1,8 +1,17 @@
# 🍃 ComfyUI-LeafFlow
# 🪐 ComfyUI-SaturnNodes
A unified custom node suite for **ComfyUI** featuring real-time queue controls, mid-generation pausing, queue crash recovery, visual model pickers, live latent canvas previews, folder automation, prompt utilities, and resolution tools.
A unified workflow control and productivity suite for **ComfyUI**. Built for **ComfyUI Frontend V2 (Nodes 2.0 / Vue UI)** (fully supported and actively tested) with backwards compatibility for the **Classic ComfyUI Frontend (Nodes 1.0 / LiteGraph)** (supported as-is, but untested—no guarantees).
> ⚠️ **Notice**: Fully compatible with both **ComfyUI Frontend V2 (Nodes 2.0 / Vue UI)** and **Classic ComfyUI Frontend (Nodes 1.0 / LiteGraph)**.
---
## 🚀 Key Capabilities & Overview
- **Live Latent Preview**: Watch generations materialize step-by-step directly inside your graph canvas nodes in real time.
- **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.
- **Aspect Ratio & Resolution Utilities**: Find exact matching aspect ratios, crop dimensions, and visual sizing helpers.
- **Safe Local Script Execution**: Run local scripts and executables strictly confined to `ComfyUI/scripts/` with interactive operator authorization.
---
@@ -11,7 +20,7 @@ A unified custom node suite for **ComfyUI** featuring real-time queue controls,
Expand any node below to view its description, inputs, outputs, and usage documentation.
<details>
<summary><b>🍃 📁 LoRA Loader (Folder)</b> (<code>FolderLoraLoader</code>)</summary>
<summary><b>🪐 📁 LoRA Loader (Folder)</b> (<code>FolderLoraLoader</code>)</summary>
#### Overview
Loads a LoRA by folder path using raw filename matching or formatted names with customizable output formatting options.
@@ -33,7 +42,7 @@ Loads a LoRA by folder path using raw filename matching or formatted names with
</details>
<details>
<summary><b>🍃 ✨ LoRA Loader (Pretty)</b> (<code>FolderLoraLoaderPretty</code>)</summary>
<summary><b>🪐 ✨ LoRA Loader (Pretty)</b> (<code>FolderLoraLoaderPretty</code>)</summary>
#### Overview
Loads a LoRA using formatted pretty names (e.g. `Ana De Armas V1` instead of `krea2_Ana-De-Armas_v1.safetensors`).
@@ -53,7 +62,7 @@ Loads a LoRA using formatted pretty names (e.g. `Ana De Armas V1` instead of `kr
</details>
<details>
<summary><b>🍃 🖼️ Visual LoRA Loader</b> (<code>VisualLoraLoader</code> / alias <code>FolderLoraLoaderVisualPrettyV2</code>)</summary>
<summary><b>🪐 🖼️ Visual LoRA Loader</b> (<code>VisualLoraLoader</code> / alias <code>FolderLoraLoaderVisualPrettyV2</code>)</summary>
#### Overview
Visual thumbnail browser for LoRAs with Civitai SHA256 search & TMDB auto-scraping, popularity rank badges (🔥), multi-selection support, and independent per-node thumbnail zoom sizing.
@@ -75,7 +84,7 @@ Visual thumbnail browser for LoRAs with Civitai SHA256 search & TMDB auto-scrapi
</details>
<details>
<summary><b>🍃 📷 Visual Image Loader</b> (<code>VisualImageLoader</code> / alias <code>ImageLoaderVisualPrettyV2</code>)</summary>
<summary><b>🪐 📷 Visual Image Loader</b> (<code>VisualImageLoader</code> / alias <code>ImageLoaderVisualPrettyV2</code>)</summary>
#### Overview
Visual thumbnail browser for image folders with instant preview selection, independent per-node thumbnail zoom sizing, and EXIF positive prompt metadata extraction. Path access is strictly confined to ComfyUI `input`, `output`, and `temp` directories for safe operation.
@@ -94,7 +103,7 @@ Visual thumbnail browser for image folders with instant preview selection, indep
</details>
<details>
<summary><b>🍃 📂 Load Image From Folder</b> (<code>LoadImageFromFolder</code>)</summary>
<summary><b>🪐 📂 Load Image From Folder</b> (<code>LoadImageFromFolder</code>)</summary>
#### Overview
Monitors a folder for incoming images, loads the target image into a PyTorch tensor, with an optional toggle to delete the image after loading.
@@ -113,7 +122,7 @@ Monitors a folder for incoming images, loads the target image into a PyTorch ten
</details>
<details>
<summary><b>🍃 ⏱️ Recent Outputs</b> (<code>LoadRecentOutputs</code>)</summary>
<summary><b>🪐 ⏱️ Recent Outputs</b> (<code>LoadRecentOutputs</code>)</summary>
#### Overview
Loads the N newest images from an output directory with step-through index selection.
@@ -128,21 +137,21 @@ Loads the N newest images from an output directory with step-through index selec
</details>
<details>
<summary><b>🍃 👁️ Live Latent Preview</b> (<code>PreviewLatentLiveNode</code>)</summary>
<summary><b>🪐 👁️ Live Latent Preview</b> (<code>PreviewLatentLiveNode</code>)</summary>
#### Overview
Canvas rendering node that listens to sampler WebSocket latent binary streams and displays real-time live previews on the canvas during generation.
#### Inputs & Outputs
- **Category**: `LeafFlow/Previews`
- **Category**: `🪐 SaturnNodes/Previews`
- **Output Node**: True
</details>
<details>
<summary><b>🍃 ⏸️ LeafFlow Decision</b> (<code>LeafFlowDecision</code>)</summary>
<summary><b>🪐 ⏸️ Saturn Decision</b> (<code>SaturnDecision</code> / alias <code>LeafFlowDecision</code>)</summary>
#### Overview
Pauses workflow execution at a specific step and displays an inline UI popup with Continue, Cancel, or Stop Workflow actions, plus optional native OS desktop notifications.
Pauses workflow execution at a specific step and displays an inline UI popup with Continue, Cancel, or Stop Workflow actions, plus optional native OS desktop notifications. Fully backward compatible with workflows containing `LeafFlowDecision`.
#### Inputs & Widgets
- **`disable`** (`BOOLEAN`): Bypass decision gate.
@@ -154,7 +163,7 @@ Pauses workflow execution at a specific step and displays an inline UI popup wit
</details>
<details>
<summary><b>🍃 📐 Text Aspect Ratio Finder</b> (<code>TextAspectRatioFinder</code> / alias <code>AspectRatioFinder</code>)</summary>
<summary><b>🪐 📐 Text Aspect Ratio Finder</b> (<code>TextAspectRatioFinder</code> / alias <code>AspectRatioFinder</code>)</summary>
#### Overview
Parses input text for aspect ratios (e.g. `16:9`, `2.35:1`), syntax-checks them, and calculates pixel resolution for a target megapixel target.
@@ -177,7 +186,7 @@ Parses input text for aspect ratios (e.g. `16:9`, `2.35:1`), syntax-checks them,
</details>
<details>
<summary><b>🍃 📐 Preview Image Size & Aspect Ratio</b> (<code>PreviewImageSizeAspectRatio</code>)</summary>
<summary><b>🪐 📐 Preview Image Size & Aspect Ratio</b> (<code>PreviewImageSizeAspectRatio</code>)</summary>
#### Overview
Computes image dimensions and aspect ratio from an input image tensor and formats the result for previewing.
@@ -192,7 +201,7 @@ Computes image dimensions and aspect ratio from an input image tensor and format
</details>
<details>
<summary><b>🍃 🔎 Text LoRA Finder & Loader</b> (<code>TextLoraFinder</code> / alias <code>LoraTextFinder</code>)</summary>
<summary><b>🪐 🔎 Text LoRA Finder & Loader</b> (<code>TextLoraFinder</code> / alias <code>LoraTextFinder</code>)</summary>
#### Overview
Scans input prompt text for `<lora:name:strength>` tags or names matching a folder on disk, dynamically applies them, and returns patched models along with sanitized prompt text.
@@ -214,24 +223,42 @@ Scans input prompt text for `<lora:name:strength>` tags or names matching a fold
</details>
<details>
<summary><b>🍃 🔄 Prompt Queue Iterator</b> (<code>PromptQueueIterator</code>)</summary>
<summary><b>🪐 🔄 Prompt Queue Iterator</b> (<code>PromptQueueIterator</code>)</summary>
#### Overview
Parses multiline prompts or batch text blocks separated by double newlines (`\n\n`), automatically popping the top prompt per queue iteration and keeping remaining prompts in memory.
Deterministically iterates over multiline prompt text blocks per queue run with live progress display, counter reset controls, and index tracking. Supports splitting prompts by empty lines, newlines, or a custom regular expression delimiter.
#### Inputs & Widgets
- **`prompts`** (`STRING`, Multiline): Queue of prompt blocks.
- **`mode`** (`COMBO`): `Double Newline (\n\n)` vs `Single Line (\n)`.
- **`delete_after_queue`** (`BOOLEAN`): Pop executed block from widget text.
- **`pop_mode`** (`COMBO`): `Sequential (Loop on End)`, `Sequential (Stop on End)`, `Random (Keep)`, `Random (Cycle)`.
- **`separator`** (`COMBO`): Split prompts by `>1 Empty Line`, `Newline`, `>2 Empty Lines`, or `Custom Regex`.
- **`custom_regex`** (`STRING`): Custom regular expression delimiter (e.g. `\n---\n`). Automatically enabled when `separator` is set to `Custom Regex` and disabled otherwise.
- **`text`** (`STRING`, Multiline): Multiline prompt text containing your queued blocks.
- **`prompt`** (`STRING`, Optional Input): Connect an external multiline string node (such as *Prompt Counter*).
#### Outputs
- **`active_prompt`** (`STRING`): The current prompt block for this run.
- **`remaining_prompts`** (`STRING`): The queue of remaining prompts.
- **`prompt`** (`STRING`): The selected prompt block for the current queue run.
- **`remaining_text`** (`STRING`): All remaining prompt blocks joined by the delimiter.
- **`remaining_count`** (`INT`): Count of remaining items in queue.
</details>
<details>
<summary><b>🍃 🔤 Multi Text Replacer</b> (<code>MultiTextReplacer</code>)</summary>
<summary><b>🪐 📝 Prompt Counter</b> (<code>PromptCounter</code>)</summary>
#### Overview
Multiline text prompt input node that functions identically to a standard multiline string node, with real-time prompt counting according to your chosen delimiter (`>1 Empty Line`, `Newline`, `>2 Empty Lines`, or `Custom Regex`). Displays the live prompt count directly on the node on every keystroke, allowing you to easily determine and set the exact ComfyUI batch size before running your queue.
#### Inputs & Widgets
- **`text`** (`STRING`, Multiline): Input prompt text block.
- **`separator`** (`COMBO`, *Advanced*): Prompt delimiter (`>1 Empty Line`, `Newline`, `>2 Empty Lines`, `Custom Regex`). Hidden by default under Advanced Options.
- **`custom_regex`** (`STRING`, *Advanced*): Custom regex delimiter pattern (e.g. `\n---\n`). Dynamically enabled/disabled based on delimiter selection.
#### Outputs
- **`STRING`**: The unmodified prompt text (ready to connect into samplers or *Prompt Queue Iterator*).
- **`count`** (`INT`): The registered prompt count integer.
</details>
<details>
<summary><b>🪐 🔤 Multi Text Replacer</b> (<code>MultiTextReplacer</code>)</summary>
#### Overview
Performs multiple text replacements in a single step using comma-separated or newline-separated find/replace lists, with support for exact phrases and regex matching.
@@ -248,34 +275,62 @@ Performs multiple text replacements in a single step using comma-separated or ne
</details>
<details>
<summary><b>🍃 ✂️ Text Split</b> (<code>LeafFlowTextSplit</code>)</summary>
<summary><b>🪐 ✂️ Text Split</b> (<code>SaturnTextSplit</code> / alias <code>LeafFlowTextSplit</code>)</summary>
#### Overview
Splits text into two parts at a specified delimiter. Supports forward (from start) and backward (from end) search, as well as regular expressions.
#### Inputs & Widgets
- **`text`** (`STRING`, Multiline): Input text to split.
- **`delimiter`** (`STRING`): Character or string to split on (e.g. `,`, `---`, `\n`).
- **`search_from`** (`COMBO`): `Forward (First Match)` vs `Backward (Last Match)`.
- **`use_regex`** (`BOOLEAN`): Treat delimiter as a regular expression pattern.
- **`strip_whitespace`** (`BOOLEAN`): Trims surrounding whitespace from output strings.
- **`split_by`** (`STRING`): Character sequence or regex pattern to split on (e.g. `--`, `,`, `---`, `\n`).
- **`split_direction`** (`COMBO`, *Advanced*): `forward (first occurrence from start)` vs `backward (last occurrence from end)`.
- **`use_regex`** (`BOOLEAN`, *Advanced*): Treat delimiter as a regular expression pattern.
- **`strip_whitespace`** (`BOOLEAN`, *Advanced*): Trims surrounding whitespace from output strings.
#### Outputs
- **`left_text`** (`STRING`): Text before the split delimiter.
- **`right_text`** (`STRING`): Text after the split delimiter.
- **`delimiter_found`** (`BOOLEAN`): True if delimiter was found, False otherwise.
- **`text1`** (`STRING`): Text before the split delimiter.
- **`text2`** (`STRING`): Text after the split delimiter.
</details>
<details>
<summary><b>🪐 ⚡ Run Local File</b> (<code>RunLocalFileNode</code>)</summary>
#### Overview
Safely executes a local script or executable (`.bat`, `.cmd`, `.ps1`, `.exe` on Windows; `.sh` or binaries on Linux/macOS) with command-line parameters and working directory control. Built with multi-layered security guards to ensure malicious workflows or dropped images cannot execute code without explicit authorization.
> [!IMPORTANT]
> **Scripts Directory Restriction**: For operator security, this node can **ONLY execute scripts and binaries located inside the `ComfyUI/scripts/` directory** (e.g. `ComfyUI/scripts/my_script.bat`). Relative subfolders (such as `subfolder/process.sh`) are supported as long as they resolve inside `ComfyUI/scripts/`. Absolute paths (e.g. `C:\...` or `/bin/...`) and directory traversal sequences (`..`) are permanently blocked.
#### Inputs & Widgets
- **`file_path`** (`STRING`): Relative path to the executable or script (e.g. `process.bat`, `subfolder/script.sh`). Strictly confined to `ComfyUI/scripts/` with absolute path and traversal (`..`) rejection.
- **`parameters`** (`STRING`, Multiline, Optional): Command-line arguments. Supports spaces and standard quoting (parsed safely via `shlex` without shell expansion).
- **`working_directory`** (`STRING`, Optional): Execution working directory (`cwd`). Defaults to the folder containing the script if left blank.
- **`run_mode`** (`COMBO`): `Synchronous (Wait for Output)` vs `Asynchronous (Background)`.
- **`timeout`** (`INT`): Maximum execution time in seconds for synchronous mode (`0` = no timeout). If exceeded, the process tree is cleanly terminated via OS process management.
- **`trigger`** (`*`, Optional Wildcard): Input wire allowing you to connect any workflow data to order execution.
- **⚡ In-Memory Authorization**: Does NOT use an input widget (preventing malicious authorization smuggling inside shared workflow JSON or image metadata). Instead, the operator must click the interactive `⚡ Authorize Run (5 min)` button directly on the canvas node to grant a 5-minute single-use execution token.
#### Outputs
- **`stdout`** (`STRING`): Captured standard output stream.
- **`stderr`** (`STRING`): Captured standard error stream.
- **`exit_code`** (`INT`): Process exit return code (`0` = success, `-1` = blocked / timed out).
- **`success`** (`BOOLEAN`): `True` if `exit_code == 0`, `False` otherwise.
- **`passthrough`** (`*`): Passes input `trigger` data through untouched to downstream nodes.
</details>
---
<a id="comfyui-settings-menu-reference"></a>
## ⚙️ ComfyUI Settings Menu Reference
Configure options directly under ComfyUI Settings (⚙ gear icon):
<details open>
<summary><b>1. 🖼️ Visual Loaders (Civitai & TMDB Duo)</b></summary>
<details>
<summary><b>🖼️ Visual Loaders (Civitai & TMDB Duo)</b></summary>
- **`Enable Custom LeafFlow Node Colors`** (`boolean`, *Default: true*): Applies a vibrant Leaf Green color theme to LeafFlow nodes on the canvas. When disabled, nodes use default ComfyUI colors.
- **`Enable Custom SaturnNodes Colors`** (`boolean`, *Default: false*): Applies custom amber colors to SaturnNodes on the canvas. When disabled (default), nodes use default ComfyUI colors.
- **`Custom Node Title Color`** (`color`, *Default: `#6a3f20`*): Header / title bar color picker for SaturnNodes when custom colors are enabled.
- **`Custom Node Body Color`** (`color`, *Default: `#472a15`*): Body background color picker for SaturnNodes when custom colors are enabled.
- **`Civitai API Key`** (`text`): Optional key for Civitai SHA256 model preview search (`Authorization: Bearer <key>`).
- **`Enable Civitai Auto-Scraping`** (`boolean`, *Default: true*): Automatically download model preview images from Civitai via SHA256 file hashes. (Note: Local SHA256 hash searching always works).
- **`TMDB Access Token`** (`text`): Optional key or v4 Read Access Token (`eyJ...`) for celebrity preview search.
@@ -284,72 +339,84 @@ Configure options directly under ComfyUI Settings (⚙ gear icon):
- **`Reset Failed Scrapes Cache`** (*Button: `🗑️ Clear Scrapes Cache`*): Clears failed scrape history so Civitai/TMDB can retry downloading missing preview images.
</details>
<details open>
<summary><b>2. 🔄 Prompt Iterator</b></summary>
<details>
<summary><b>🔄 Prompt Iterator</b></summary>
- **`Clear State on Launch`** (`boolean`, *Default: false*): Privacy toggle to empty `prompt_iterator_state.json` on ComfyUI startup.
- **`Reset All Queues`** (*Button: `🔄 Reset All Queues`*): Immediately empties all active prompt queues and resets iterator state.
</details>
<details open>
<summary><b>3. 📋 Prompt Actions</b></summary>
<details>
<summary><b>📋 Prompt Actions</b></summary>
- **`Show "Copy Prompt" Button on Images`** (`boolean`, *Default: true*): Shows the 📋 "Copy Prompt" overlay action button when hovering over generated images in the Assets / History pane and preview nodes.
- **`Show Right-Click "Copy Prompt" Menu Action`** (`boolean`, *Default: true*): Adds "📋 Copy Prompt" to node right-click context menus.
- **`Show "Save to Prompt Saver" Action`** (`boolean`, *Default: true*): Adds 🔖 "Save to Prompt Bookmarks" to image hover overlay bars and node context menus.
- **`Show "Inspect Asset" (Zoom) Button on Images`** (`boolean`, *Default: false*): Restores the 🔍 "Inspect asset" (zoom in) button directly onto image cards in the Assets pane next to Download, Copy Prompt, and Bookmarks (restoring one-click access moved behind the 3-dots menu in newer ComfyUI versions).
- **`Show "Inspect Asset" (Zoom) Button on Images`** (`boolean`, *Default: false*): Restores the 🔍 "Inspect asset" (zoom in) button directly onto image cards in the Assets pane next to Download and Copy Prompt (restoring one-click access moved behind the 3-dots menu in newer ComfyUI versions).
</details>
<details open>
<summary><b>4. ⏸️ Pause Controls</b></summary>
<details>
<summary><b>⏸️ Pause Controls</b></summary>
- **`Default State on Launch`** (`combo`, *Default: `Paused`*): Sets whether the queue starts `Paused` or `Running` on boot.
- **`Default State on Launch`** (`combo`, *Default: `Running`*): Sets whether the queue starts `Paused` or `Running` on boot.
- **`Default Pause Action`** (`combo`, *Default: `Finish Active Prompt`*): Sets default pause behavior (`Finish Active Prompt` vs `Instant Resume Node`).
- **`Enable Top Toolbar Button`** (`boolean`, *Default: true*): Toggle top action bar Pause & Continue button group ON/OFF.
- **`Toolbar Button Unpaused Color`** (`text`, *Default: `#16a34a`*): Hex color for the toolbar unpaused/running state.
- **`Toolbar Button Paused Color`** (`text`, *Default: `#ea580c`*): Hex color for the toolbar paused state.
- **`Enable System Tray Icon`** (`boolean`, *Default: false*): Displays an OS system tray icon with real-time queue status colors and outside-browser controls.
- **`Allow Process Management (Restart / Shutdown)`** (`boolean`, *Default: false*): Opt-in authorization allowing server restart and shutdown actions from the SaturnNodes power controls. Disabled by default for maximum security.
</details>
<details open>
<summary><b>5. 💾 Persistent Queue</b></summary>
<details>
<summary><b>💾 Persistent Queue</b></summary>
- **`Persistent Queue (Auto-Recovery)`** (`boolean`, *Default: true*): Automatically saves unfinished queue items and restores them after restart/crash.
- **`Recovery Launch State`** (`combo`, *Default: `Match Default`*): Override launch state when restored queue items are recovered on startup (`Match Default`, `Force Paused`, `Force Running`).
</details>
<details open>
<summary><b>6. 🖼️ Assets & History Restore</b></summary>
<details>
<summary><b>🖼️ Assets & History Restore</b></summary>
- **`Restore Assets on Launch`** (`boolean`, *Default: true*): Automatically restores your latest generated images into the Assets / History pane on startup.
- **`Restored Assets Count`** (`number`, *Default: 64*): The number of newest images from the output folder to populate into the Assets pane.
</details>
<details open>
<summary><b>7. 🩺 Diagnostics & Debug</b></summary>
<details>
<summary><b>🩺 Diagnostics & Debug</b></summary>
- **`Export Debug Profile`** (*Button: `📥 Export Debug Profile`*): Exports non-sensitive system environment details (OS, Python, PyTorch, LeafFlow settings, local cache counts) to a JSON file to share when reporting bugs or requesting assistance. Sensitive API keys and tokens are never exported.
- **`Export Debug Profile`** (*Button: `📥 Export Debug Profile`*): Exports non-sensitive system environment details (OS, Python, PyTorch, SaturnNodes settings, local cache counts) to a JSON file to share when reporting bugs or requesting assistance. Sensitive API keys and tokens are never exported.
</details>
<details open>
<summary><b>8. 🎨 Batch Queue Visuals</b></summary>
<details>
<summary><b>🎨 Batch Queue Visuals</b></summary>
- **`Show Batch Queue 1D Lines`** (`boolean`, *Default: true*): Renders a 1D git-graph style colored line on the left side of queued items in the queue list.
- **36 Cycling Colors:** Every queued batch is assigned a vibrant, high-contrast color from a 36-color sequence.
- **Clean 1D Line:** The first item starts with a top curve (`┌`), middle items are straight lines (`│`), and the last item ends with a bottom curve (`└`). Standalone 1-item batches are rounded at both ends (`(`).
- **In-between Infiltration Detection:** If a different prompt is queued in-between items of a batch, the interrupted batch maintains its open line ends without false curves, while the inserted item is highlighted with its own bracket.
- **Works Standalone or with PersistentQueue:** Operates client-side via `localStorage` when standalone, and syncs with `PersistentQueue` to retain batch relationships after server restarts.
- **`Batch Graph Snapshot Guard`** (`boolean`, *Default: true*): Background prompt and node input snapshotting ensuring canvas edits don't mutate active batches.
</details>
<details>
<summary><b>🛡️ Security & Script Execution</b></summary>
- **`Allow Local File Execution`** (`boolean`, *Default: false*): Master toggle controlling whether the *Run Local File* node is permitted to execute local scripts or executables. Disabled by default for operator safety.
- **Strict Confinement**: Execution is strictly limited to the `ComfyUI/scripts/` directory. Absolute paths and directory traversal (`..`) are permanently blocked.
</details>
---
## 🔒 Security & Privacy
- **Local-Only Enforced Endpoints:** Power actions (`/leafflow/power/*`), settings updates (`/leafflow/settings`), and queue sync routes are strictly protected with loopback verification (`127.0.0.1` / `::1`), rejecting external network requests with `403 Forbidden`.
- **Directory Confinement:** Image loading and thumbnail routes are strictly confined to ComfyUI `input`, `output`, and `temp` directories with traversal guards.
- **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.
- **Universal Loopback Protection:** All administrative and queue endpoints strictly verify loopback origin (`127.0.0.1` / `::1`), rejecting external network requests with `403 Forbidden`.
- **Directory Traversal Protection:** Image loading and thumbnail routes are strictly confined to ComfyUI `input`, `output`, and `temp` directories with traversal guards.
- **Optional Headless Dependencies:** System tray desktop integration (`pystray`) is an optional dependency, ensuring cloud containers (Docker, Colab, RunPod) run with zero dependency errors.
- **Opt-In Scraping:** Civitai and TMDB network scraping are opt-in and disabled by default.
- **Zero Runtime Package Installs:** No `install.py` or dynamic `pip` execution; all standard dependencies (`Pillow`, `piexif`, `numpy`, `pystray`) are cleanly declared in `requirements.txt`.
- **Local Environment:** All API keys and settings are stored locally in `.env` (excluded from git commits via `.gitignore`).
- **Zero Runtime Package Installs:** No `install.py` or dynamic `pip` execution; all standard dependencies are cleanly declared in `requirements.txt`.
- **Local Environment:** All API keys and settings are stored locally in `.env` inside `user/default/SaturnNodes/` (auto-migrated from legacy `LeafFlow/` folder if present).
- **Atomic File Writes:** Safe atomic writes (`.tmp` + `os.replace`) prevent JSON corruption during sudden crashes.
---
@@ -359,7 +426,7 @@ Configure options directly under ComfyUI Settings (⚙ gear icon):
1. Open your terminal and navigate to your ComfyUI `custom_nodes` directory:
```bash
cd ComfyUI/custom_nodes
git clone https://github.com/KOFiblto/ComfyUI-LeafFlow.git
git clone https://github.com/KOFiblto/ComfyUI-SaturnNodes.git
```
2. Restart ComfyUI.
3. Open ComfyUI Settings (⚙ gear menu) to configure API keys or feature toggles if desired.
+20 -2
View File
@@ -2,7 +2,7 @@
## Reporting Security Issues
We take the security of ComfyUI-LeafFlow seriously. If you discover a security vulnerability or sensitive key exposure, please **DO NOT** open a public issue.
We take the security of ComfyUI-SaturnNodes seriously. If you discover a security vulnerability or sensitive key exposure, please **DO NOT** open a public issue.
Instead, please report security vulnerabilities directly to the maintainer via GitHub private vulnerability reporting.
@@ -15,9 +15,27 @@ Instead, please report security vulnerabilities directly to the maintainer via G
## Security Architecture & Best Practices
- **Loopback Enforcement for Server Control**: All administrative endpoints (power controls `/leafflow/power/*`, settings modification `/leafflow/settings`, and internal state synchronization `/leafflow/batch_queue/*`) strictly verify loopback origin (`127.0.0.1` / `::1`), rejecting remote network requests with `403 Forbidden`.
- **CSRF Defense & Fetch Metadata Validation**: All administrative endpoints (`/saturnnodes/*`, `/leafflow/*`) enforce multi-layer CSRF protection via `is_authenticated_local_request(request)`.
- Enforces loopback origin checks (`127.0.0.1` / `::1`), rejecting remote calls with `403 Forbidden`.
- Rejects `cross-site` requests via `Sec-Fetch-Site` header checks, preventing external malicious web pages visited by the operator from triggering actions.
- Verifies `Origin` and `Referer` headers against loopback hostnames.
- Requires cryptographically random session tokens in the `X-SaturnNodes-CSRF-Token` / `X-LeafFlow-CSRF-Token` header (`secrets.compare_digest`), prompting CORS preflight rejection if an external origin attempts unauthorized requests.
- **Two-Step Ephemeral Ticket Handshake for Power Actions**:
- Server restart and shutdown endpoints require `ALLOW_PROCESS_MANAGEMENT=true` in settings.
- Execution requires a two-step handshake: the client requests a single-use ephemeral ticket (`POST /saturnnodes/power/request_token`) valid for 30 seconds.
- The confirmation endpoint (`POST /saturnnodes/power/confirm_action`) consumes the single-use ticket in server memory before executing restart or shutdown, preventing arbitrary or cross-site triggers.
- **Strict Local Script Confinement**:
- `RunLocalFileNode` strictly confines executable scripts and binaries to `ComfyUI/scripts/`.
- Absolute paths (e.g. `C:\`, `/etc/`, `\\server`) and directory traversal sequences (`..`) are strictly rejected.
- Paths are verified via canonical `os.path.realpath` and `os.path.commonpath` checks against the scripts directory. Unsafe path bypasses have been completely removed.
- **In-Memory Operator Authorization Gate**:
- Local file execution defaults to disabled (`ENABLE_LOCAL_FILE_EXECUTION = false`).
- `security_consent` input widget was removed from `INPUT_TYPES` to ensure authorization parameters NEVER travel within workflow JSON or embedded PNG metadata.
- Workflows loaded from the internet or images dropped onto the canvas cannot execute local scripts without explicit manual authorization.
- Execution requires an interactive user action on the canvas node via `[⚡ Authorize Run]`, which registers single-use authorization exclusively in server RAM with a 5-minute TTL.
- **Filesystem Path Confinement**: Image loading (`VisualImageLoader`) and thumbnail file streaming endpoints are strictly confined to standard ComfyUI `input`, `output`, and `temp` directories with path canonicalization and directory traversal guards.
- **Opt-In Network Requests**: External metadata and preview scraping (Civitai / TMDB) are completely opt-in and disabled by default.
- **No Dynamic Package Installation**: Runtime `pip` execution (`install.py` / `subprocess`) is eliminated; all dependencies are transparently listed in `requirements.txt`.
- **Local Environment**: All API keys (Civitai / TMDB) are stored locally in `.env` and are strictly excluded from git tracking via `.gitignore`. Sensitive keys are masked in server log outputs.
- **Atomic File Writes**: Atomic file operations (`.tmp` + `os.replace`) prevent JSON file corruption on sudden system shutdown or crash.
+10
View File
@@ -85,6 +85,16 @@ Tired of guessing what a LoRA does based on its filename?
### 🍃 ⏱️ Recent Outputs
Want to quickly reference your recent generations without digging through Windows Explorer? The **`🍃 ⏱️ Recent Outputs`** node cycles chronologically through your `output/` directory, pulling your freshest creations right back into the workflow canvas.
### 🍃 📝 Prompt Counter & 🔄 Prompt Queue Iterator
Managing multi-prompt queues and setting the exact ComfyUI batch size has never been easier:
1. **`🍃 📝 Prompt Counter`**: Place this multiline text node in your workflow and paste all your prompts.
- As you type or paste, the live badge instantly updates (e.g. `🍃 24 Prompts`), so you know exactly what batch size to set before clicking Queue!
- Under Advanced Options, select your delimiter (`>1 Empty Line`, `Newline`, `>2 Empty Lines`, or `Custom Regex`).
- If `Custom Regex` is selected, enter any custom delimiter pattern (such as `\n---\n`). The regex field is dynamically enabled only when Custom Regex is active.
2. **`🍃 🔄 Prompt Queue Iterator`**: Connect the `STRING` output of the Prompt Counter into the Prompt Queue Iterator to sequentially pop and execute each prompt run-by-run.
- Now supports `Custom Regex` in addition to empty line and newline separators.
- Includes live progress counter widget (`🍃 Run X / Y`), instant index reset button (`🔄 Reset Counter (0)`), and direct state file access (`📂 Open Queue File`).
---
## 3. Formatting Folder Paths
+95 -43
View File
@@ -4,6 +4,8 @@ import subprocess
from aiohttp import web
from server import PromptServer
__version__ = "2.3.2"
from .nodes.queue_control import setup_queue_control_routes, tray_manager
from .nodes.lora_loader import (
FolderLoraLoader,
@@ -15,17 +17,22 @@ from .nodes.image_loader import VisualImageLoader, ImageLoaderVisualPrettyV2
from .nodes.auto_watcher import LoadImageFromFolder
from .nodes.load_recent import LoadRecentOutputs
from .nodes.preview_latent import PreviewLatentLiveNode
from .nodes.decision_node import LeafFlowDecision
from .nodes.decision_node import SaturnDecision
from .nodes.aspect_ratio import TextAspectRatioFinder, AspectRatioFinder, PreviewImageSizeAspectRatio
from .nodes.lora_finder import TextLoraFinder, LoraTextFinder
from .nodes.prompt_iterator import PromptQueueIterator
from .nodes.prompt_counter import PromptCounter
from .nodes.text_replacer import MultiTextReplacer
from .nodes.text_split import LeafFlowTextSplit
from .nodes.text_split import SaturnTextSplit
from .nodes.local_runner import RunLocalFileNode, setup_local_runner_routes
from .nodes.utils import (
get_saturnnodes_user_dir,
get_leafflow_user_dir,
get_env_setting,
is_local_request,
is_safe_path
is_safe_path,
get_csrf_token,
is_authenticated_local_request
)
NODE_CLASS_MAPPINGS = {
@@ -36,30 +43,34 @@ NODE_CLASS_MAPPINGS = {
"LoadImageFromFolder": LoadImageFromFolder,
"LoadRecentOutputs": LoadRecentOutputs,
"PreviewLatentLive": PreviewLatentLiveNode,
"LeafFlowDecision": LeafFlowDecision,
"SaturnDecision": SaturnDecision,
"TextAspectRatioFinder": TextAspectRatioFinder,
"PreviewImageSizeAspectRatio": PreviewImageSizeAspectRatio,
"TextLoraFinder": TextLoraFinder,
"PromptQueueIterator": PromptQueueIterator,
"PromptCounter": PromptCounter,
"MultiTextReplacer": MultiTextReplacer,
"LeafFlowTextSplit": LeafFlowTextSplit,
"SaturnTextSplit": SaturnTextSplit,
"RunLocalFileNode": RunLocalFileNode,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"FolderLoraLoader": "🍃 📁 LoRA Loader (Folder)",
"FolderLoraLoaderPretty": "🍃 ✨ LoRA Loader (Pretty)",
"VisualLoraLoader": "🍃 🖼️ Visual LoRA Loader",
"VisualImageLoader": "🍃 📷 Visual Image Loader",
"LoadImageFromFolder": "🍃 📂 Load Image From Folder",
"LoadRecentOutputs": "🍃 ⏱️ Recent Outputs",
"PreviewLatentLive": "🍃 👁️ Live Latent Preview",
"LeafFlowDecision": "🍃 ⏸️ LeafFlow Decision",
"TextAspectRatioFinder": "🍃 📐 Text Aspect Ratio Finder",
"PreviewImageSizeAspectRatio": "🍃 📐 Preview Image Size & Aspect Ratio",
"TextLoraFinder": "🍃 🔎 Text LoRA Finder & Loader",
"PromptQueueIterator": "🍃 🔄 Prompt Queue Iterator",
"MultiTextReplacer": "🍃 🔤 Multi Text Replacer",
"LeafFlowTextSplit": "🍃 ✂️ Text Split"
"FolderLoraLoader": "🪐 📁 LoRA Loader (Folder)",
"FolderLoraLoaderPretty": "🪐 ✨ LoRA Loader (Pretty)",
"VisualLoraLoader": "🪐 🖼️ Visual LoRA Loader",
"VisualImageLoader": "🪐 📷 Visual Image Loader",
"LoadImageFromFolder": "🪐 📂 Load Image From Folder",
"LoadRecentOutputs": "🪐 ⏱️ Recent Outputs",
"PreviewLatentLive": "🪐 👁️ Live Latent Preview",
"SaturnDecision": "🪐 ⏸️ Saturn Decision",
"TextAspectRatioFinder": "🪐 📐 Text Aspect Ratio Finder",
"PreviewImageSizeAspectRatio": "🪐 📐 Preview Image Size & Aspect Ratio",
"TextLoraFinder": "🪐 🔎 Text LoRA Finder & Loader",
"PromptQueueIterator": "🪐 🔄 Prompt Queue Iterator",
"PromptCounter": "🪐 📝 Prompt Counter",
"MultiTextReplacer": "🪐 🔤 Multi Text Replacer",
"SaturnTextSplit": "🪐 ✂️ Text Split",
"RunLocalFileNode": "🪐 ⚡ Run Local File"
}
WEB_DIRECTORY = "./web"
@@ -67,23 +78,33 @@ __all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
server = PromptServer.instance
setup_queue_control_routes(server)
try:
from .prompt_bookmarks.api import register_routes as register_prompt_bookmarks_routes
register_prompt_bookmarks_routes()
except Exception as e:
pass
setup_local_runner_routes(server)
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
USER_DIR = get_leafflow_user_dir()
USER_DIR = get_saturnnodes_user_dir()
ENV_FILE = os.path.join(USER_DIR, ".env")
routes = server.routes
print("[ComfyUI-LeafFlow] 🍃 Loaded 13 nodes & visual endpoints successfully.")
try:
can_encode_saturn = True
try:
"🪐".encode(sys.stdout.encoding or "ascii")
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__}).")
except Exception:
print(f"[ComfyUI-SaturnNodes] Loaded {len(NODE_CLASS_MAPPINGS)} nodes & visual endpoints successfully (v{__version__}).")
@routes.get("/leafflow/get_image_prompt")
@routes.get("/leafflow/view_image_prompt")
@routes.get("/saturnnodes/auth/token")
async def get_csrf_token_endpoint(request):
if not is_local_request(request):
return web.json_response({"error": "Forbidden: Local access only"}, status=403)
return web.json_response({"csrf_token": get_csrf_token()})
@routes.get("/saturnnodes/get_image_prompt")
@routes.get("/saturnnodes/view_image_prompt")
async def get_image_prompt_endpoint(request):
if not is_local_request(request):
return web.json_response({"success": False, "error": "Forbidden: Local access only"}, status=403)
@@ -125,8 +146,7 @@ async def get_image_prompt_endpoint(request):
except Exception as e:
return web.json_response({"success": False, "error": str(e)}, status=500)
@routes.get("/leafflow/settings")
@routes.get("/flow_control/settings")
@routes.get("/saturnnodes/settings")
async def get_settings(request):
if not is_local_request(request):
return web.json_response({"error": "Forbidden: Local access only"}, status=403)
@@ -135,6 +155,8 @@ async def get_settings(request):
enable_tray = os.getenv("ENABLE_TRAY_ICON", "false").lower() in ["true", "1", "yes"]
enable_assets_restore = os.getenv("ENABLE_ASSETS_RESTORE", "true").lower() in ["true", "1", "yes"]
restore_assets_count = int(os.getenv("RESTORE_ASSETS_COUNT", "64"))
enable_process_management = os.getenv("ALLOW_PROCESS_MANAGEMENT", "false").lower() in ["true", "1", "yes"]
enable_local_file_execution = os.getenv("ENABLE_LOCAL_FILE_EXECUTION", "false").lower() in ["true", "1", "yes"]
if os.path.exists(ENV_FILE):
try:
with open(ENV_FILE, "r", encoding="utf-8") as f:
@@ -153,6 +175,10 @@ async def get_settings(request):
restore_assets_count = int(line.split("=", 1)[1].strip())
except Exception:
pass
elif line.startswith("ALLOW_PROCESS_MANAGEMENT="):
enable_process_management = line.split("=", 1)[1].strip().lower() in ["true", "1", "yes"]
elif line.startswith("ENABLE_LOCAL_FILE_EXECUTION="):
enable_local_file_execution = line.split("=", 1)[1].strip().lower() in ["true", "1", "yes"]
except Exception:
pass
return web.json_response({
@@ -160,7 +186,9 @@ async def get_settings(request):
"tmdb_api_key": tmdb_key,
"enable_tray_icon": enable_tray,
"enable_assets_restore": enable_assets_restore,
"restore_assets_count": restore_assets_count
"restore_assets_count": restore_assets_count,
"enable_process_management": enable_process_management,
"enable_local_file_execution": enable_local_file_execution
})
def _clean_env_val(v):
@@ -168,11 +196,10 @@ def _clean_env_val(v):
return None
return str(v).replace("\n", "").replace("\r", "").strip()
@routes.post("/leafflow/settings")
@routes.post("/flow_control/settings")
@routes.post("/saturnnodes/settings")
async def save_settings(request):
if not is_local_request(request):
return web.json_response({"error": "Forbidden: Local access only"}, status=403)
if not is_authenticated_local_request(request):
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
try:
data = await request.json()
civitai_key = _clean_env_val(data.get("civitai_api_key"))
@@ -188,6 +215,8 @@ async def save_settings(request):
restore_assets_count = _clean_env_val(data.get("restore_assets_count"))
clear_prompt_iterator_on_launch = _clean_env_val(data.get("clear_prompt_iterator_on_launch"))
restored_state = _clean_env_val(data.get("persistent_queue_restored_state"))
enable_process_management = _clean_env_val(data.get("enable_process_management"))
enable_local_file_execution = _clean_env_val(data.get("enable_local_file_execution"))
lines = []
if os.path.exists(ENV_FILE):
@@ -211,6 +240,8 @@ async def save_settings(request):
has_restore_count = False
has_clear_prompt_iterator = False
has_restored_state = False
has_process_mgmt = False
has_local_file_exec = False
for line in lines:
if line.strip().startswith("CIVITAI_API_KEY=") and civitai_key is not None:
@@ -252,6 +283,15 @@ async def save_settings(request):
elif line.strip().startswith("PERSISTENT_QUEUE_RESTORED_STATE=") and restored_state is not None:
new_lines.append(f"PERSISTENT_QUEUE_RESTORED_STATE={restored_state}\n")
has_restored_state = True
elif line.strip().startswith("ALLOW_PROCESS_MANAGEMENT=") and enable_process_management is not None:
new_lines.append(f"ALLOW_PROCESS_MANAGEMENT={enable_process_management}\n")
has_process_mgmt = True
elif line.strip().startswith("ENABLE_LOCAL_FILE_EXECUTION=") and enable_local_file_execution is not None:
new_lines.append(f"ENABLE_LOCAL_FILE_EXECUTION={enable_local_file_execution}\n")
has_local_file_exec = True
elif line.strip().startswith("ALLOW_ANY_SCRIPT_PATH="):
# Deprecated and removed for security; drop from .env
continue
else:
new_lines.append(line)
@@ -281,6 +321,10 @@ async def save_settings(request):
new_lines.append(f"CLEAR_PROMPT_ITERATOR_ON_LAUNCH={clear_prompt_iterator_on_launch}\n")
if not has_restored_state and restored_state is not None:
new_lines.append(f"PERSISTENT_QUEUE_RESTORED_STATE={restored_state}\n")
if not has_process_mgmt and enable_process_management is not None:
new_lines.append(f"ALLOW_PROCESS_MANAGEMENT={enable_process_management}\n")
if not has_local_file_exec and enable_local_file_execution is not None:
new_lines.append(f"ENABLE_LOCAL_FILE_EXECUTION={enable_local_file_execution}\n")
with open(ENV_FILE, "w", encoding="utf-8") as f:
f.writelines(new_lines)
@@ -297,28 +341,34 @@ async def save_settings(request):
tray_manager.set_enabled(tray_enabled_bool)
if enable_assets_restore is not None:
os.environ["ENABLE_ASSETS_RESTORE"] = str(enable_assets_restore)
if enable_process_management is not None:
os.environ["ALLOW_PROCESS_MANAGEMENT"] = str(enable_process_management)
if enable_local_file_execution is not None:
os.environ["ENABLE_LOCAL_FILE_EXECUTION"] = str(enable_local_file_execution)
if restore_assets_count is not None:
os.environ["RESTORE_ASSETS_COUNT"] = str(restore_assets_count)
if clear_prompt_iterator_on_launch is not None:
os.environ["CLEAR_PROMPT_ITERATOR_ON_LAUNCH"] = str(clear_prompt_iterator_on_launch)
except Exception as e:
print(f"[LeafFlow] 🍃 Error saving settings to .env: {e}")
print(f"[SaturnNodes] 🪐 Error saving settings to .env: {e}")
return web.json_response({"status": "ok"})
@routes.post("/leafflow/scrapes/clear")
@routes.post("/saturnnodes/scrapes/clear")
async def clear_scrapes_endpoint(request):
if not is_authenticated_local_request(request):
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
try:
failed_file = os.path.join(USER_DIR, "failed_scrapes.json")
with open(failed_file, "w", encoding="utf-8") as f:
f.write("{}")
return web.json_response({"status": "ok"})
except Exception as e:
print(f"[LeafFlow] 🍃 Error clearing scrapes cache: {e}")
print(f"[SaturnNodes] 🪐 Error clearing scrapes cache: {e}")
return web.json_response({"status": "error", "message": str(e)}, status=500)
@routes.get("/leafflow/debug/export")
@routes.get("/saturnnodes/debug/export")
async def export_debug_profile(request):
try:
import platform
@@ -358,8 +408,8 @@ async def export_debug_profile(request):
debug_profile = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"package": "ComfyUI-LeafFlow",
"version": "2.1.0",
"package": "ComfyUI-SaturnNodes",
"version": __version__,
"system": {
"os": platform.system(),
"os_release": platform.release(),
@@ -373,7 +423,7 @@ async def export_debug_profile(request):
"registered_nodes_count": len(NODE_CLASS_MAPPINGS),
"settings": {
"enable_persistent_queue": get_env_setting("ENABLE_PERSISTENT_QUEUE", "true"),
"default_pause_state": get_env_setting("DEFAULT_PAUSE_STATE", "Paused"),
"default_pause_state": get_env_setting("DEFAULT_PAUSE_STATE", "Running"),
"default_pause_mode": get_env_setting("DEFAULT_PAUSE_MODE", "after_finish"),
"enable_civitai_scraping": get_env_setting("ENABLE_CIVITAI_SCRAPING", "true"),
"enable_tmdb_scraping": get_env_setting("ENABLE_TMDB_SCRAPING", "true"),
@@ -383,6 +433,8 @@ async def export_debug_profile(request):
"restore_assets_count": get_env_setting("RESTORE_ASSETS_COUNT", "64"),
"clear_prompt_iterator_on_launch": get_env_setting("CLEAR_PROMPT_ITERATOR_ON_LAUNCH", "false"),
"persistent_queue_restored_state": get_env_setting("PERSISTENT_QUEUE_RESTORED_STATE", "Match Default"),
"allow_process_management": get_env_setting("ALLOW_PROCESS_MANAGEMENT", "false"),
"enable_local_file_execution": get_env_setting("ENABLE_LOCAL_FILE_EXECUTION", "false"),
"civitai_api_key_configured": bool(civitai_key and civitai_key.strip()),
"tmdb_api_key_configured": bool(tmdb_key and tmdb_key.strip()),
},
+1 -1
View File
@@ -1 +1 @@
# Package marker for LeafFlow nodes
# Package marker for SaturnNodes nodes
+5 -5
View File
@@ -59,7 +59,7 @@ class TextAspectRatioFinder:
RETURN_TYPES = ("INT", "INT", "STRING")
RETURN_NAMES = ("width", "height", "aspect_ratio")
FUNCTION = "find_aspect_ratio"
CATEGORY = "🍃 LeafFlow/Utils"
CATEGORY = "🪐 SaturnNodes/Utils"
DESCRIPTION = "Searches text for aspect ratios (e.g. 16:9), syntax checks them, and calculates width & height for target megapixels."
def find_aspect_ratio(
@@ -147,11 +147,11 @@ class TextAspectRatioFinder:
raise ValueError(f"Cannot parse aspect ratio: {found_ratio}")
except Exception as e:
err_msg = f"Aspect ratio '{found_ratio}' is invalid. Falling back to '1:1'."
print(f"[LeafFlow] Warning: {err_msg} ({e})")
print(f"[SaturnNodes] Warning: {err_msg} ({e})")
try:
from server import PromptServer
if hasattr(PromptServer, "instance") and PromptServer.instance:
PromptServer.instance.send_sync("leafflow_node_error_state", {
PromptServer.instance.send_sync("saturnnodes_node_error_state", {
"node_id": str(unique_id or ""),
"title": "Aspect Ratio Warning",
"message": err_msg,
@@ -196,7 +196,7 @@ class PreviewImageSizeAspectRatio:
RETURN_TYPES = ()
RETURN_NAMES = ()
FUNCTION = "process_preview"
CATEGORY = "🍃 LeafFlow/Utils"
CATEGORY = "🪐 SaturnNodes/Utils"
OUTPUT_NODE = True
DESCRIPTION = "Visual preview node that displays image aspect ratio and dimension summary."
@@ -241,7 +241,7 @@ class PreviewImageSizeAspectRatio:
display_text = f"1 x {h_disp}"
try:
PromptServer.instance.send_sync("leafflow_update_preview_aspect_ratio", {
PromptServer.instance.send_sync("saturnnodes_update_preview_aspect_ratio", {
"node_id": str(unique_id),
"ratio": calc_ratio,
"display_text": display_text
+25 -18
View File
@@ -192,23 +192,30 @@ class AssetsRestoreManager:
# Ingest into modern ComfyUI asset database if --enable-assets is active
try:
from app.assets.services.ingest import register_file_in_place
res = register_file_in_place(abs_path=filepath, name=filename, tags=["output"])
if res and hasattr(res, "ref") and res.ref and hasattr(res.ref, "id"):
try:
from app.database.db import create_session, init_db
from app.assets.database.queries import set_reference_metadata
init_db()
with create_session() as session:
set_reference_metadata(session, reference_id=res.ref.id, metadata={
"jobId": prompt_id,
"nodeId": output_node_id,
"filename": filename,
"subfolder": subfolder
})
session.commit()
except Exception:
pass
import importlib
ingest_mod = importlib.import_module("app.assets.services.ingest")
register_file_in_place = getattr(ingest_mod, "register_file_in_place", None)
if register_file_in_place:
res = register_file_in_place(abs_path=filepath, name=filename, tags=["output"])
if res and hasattr(res, "ref") and res.ref and hasattr(res.ref, "id"):
try:
db_mod = importlib.import_module("app.database.db")
queries_mod = importlib.import_module("app.assets.database.queries")
init_db = getattr(db_mod, "init_db", None)
create_session = getattr(db_mod, "create_session", None)
set_reference_metadata = getattr(queries_mod, "set_reference_metadata", None)
if init_db and create_session and set_reference_metadata:
init_db()
with create_session() as session:
set_reference_metadata(session, reference_id=res.ref.id, metadata={
"jobId": prompt_id,
"nodeId": output_node_id,
"filename": filename,
"subfolder": subfolder
})
session.commit()
except Exception:
pass
except Exception:
pass
@@ -224,7 +231,7 @@ class AssetsRestoreManager:
}
if restored_count > 0:
print(f"[LeafFlow] Restored {restored_count} recent image(s) into Assets / History pane.")
print(f"[SaturnNodes] Restored {restored_count} recent image(s) into Assets / History pane.")
if server:
try:
server.queue_updated()
+7 -7
View File
@@ -51,7 +51,7 @@ class LoadImageFromFolder:
RETURN_TYPES = ("IMAGE", "BOOLEAN")
RETURN_NAMES = ("image", "has_image")
FUNCTION = "watch"
CATEGORY = "🍃 LeafFlow/Automation"
CATEGORY = "🪐 SaturnNodes/Automation"
DESCRIPTION = "Loads an image from a folder, optionally waiting if the folder is empty, with automatic sequential cycling when delete is False."
@classmethod
@@ -73,7 +73,7 @@ class LoadImageFromFolder:
try:
os.remove(filepath)
except Exception as e:
print(f"[LeafFlow] Warning: Failed to remove processed file '{filepath}': {e}")
print(f"[SaturnNodes] Warning: Failed to remove processed file '{filepath}': {e}")
return img_tensor
@@ -82,7 +82,7 @@ class LoadImageFromFolder:
try:
regex = re.compile(regex_filter)
except Exception as e:
print(f"[LeafFlow] Invalid regex: {e}. Falling back to .*")
print(f"[SaturnNodes] Invalid regex: {e}. Falling back to .*")
regex = re.compile(".*")
files = []
@@ -164,9 +164,9 @@ class LoadImageFromFolder:
img_tensor = self.load_and_remove_image(filepath, delete_image=delete_image)
return (img_tensor, True)
except Exception as e:
print(f"[LeafFlow] Error processing {filepath}: {e}")
print(f"[SaturnNodes] Error processing {filepath}: {e}")
except Exception as e:
print(f"[LeafFlow] Error accessing directory {folder}: {e}")
print(f"[SaturnNodes] Error accessing directory {folder}: {e}")
time.sleep(rescan_interval)
else:
@@ -180,9 +180,9 @@ class LoadImageFromFolder:
img_tensor = self.load_and_remove_image(filepath, delete_image=delete_image)
return (img_tensor, True)
except Exception as e:
print(f"[LeafFlow] Error processing {filepath}: {e}")
print(f"[SaturnNodes] Error processing {filepath}: {e}")
except Exception as e:
print(f"[LeafFlow] Error accessing directory {folder}: {e}")
print(f"[SaturnNodes] Error accessing directory {folder}: {e}")
# No image present or folder missing -> return standard dummy tensor & False immediately
return (self.create_dummy_image(), False)
+21 -18
View File
@@ -5,7 +5,7 @@ import subprocess
from server import PromptServer
from aiohttp import web
import comfy.model_management
from .utils import is_local_request
from .utils import is_authenticated_local_request
class DecisionManager:
_waiting_events = {}
@@ -38,7 +38,7 @@ class DecisionManager:
return False
class LeafFlowDecision:
class SaturnDecision:
@classmethod
def INPUT_TYPES(cls):
return {
@@ -55,7 +55,7 @@ class LeafFlowDecision:
RETURN_TYPES = ("BOOLEAN",)
RETURN_NAMES = ("cancel",)
FUNCTION = "decide"
CATEGORY = "🍃 LeafFlow/Utils"
CATEGORY = "🪐 SaturnNodes/Utils"
DESCRIPTION = "Pauses execution and waits for your input via the UI buttons.\n\n- 'Continue' outputs False (0).\n- 'Cancel' outputs True (1) so you can route it into a Switch node to bypass later nodes.\n- 'Stop Workflow' instantly aborts the entire ComfyUI generation queue.\n- 'OS Notification': Sends a native desktop toast (Windows/macOS/Linux) when waiting."
def send_notification(self, title, message):
@@ -97,7 +97,7 @@ $toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
# Linux dbus notify-send
subprocess.Popen(["notify-send", title, message])
except Exception as e:
print(f"[LeafFlow] Failed to send OS notification: {e}")
print(f"[SaturnNodes] Failed to send OS notification: {e}")
def decide(self, disable, send_os_notification, timeout, unique_id=None):
if disable:
@@ -107,10 +107,10 @@ $toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
return (False,)
# Notify frontend
PromptServer.instance.send_sync("leafflow_decision_waiting", {"node_id": unique_id})
PromptServer.instance.send_sync("saturnnodes_decision_waiting", {"node_id": unique_id})
if send_os_notification:
self.send_notification("ComfyUI LeafFlow", "Workflow paused! Waiting for your decision.")
self.send_notification("ComfyUI SaturnNodes", "Workflow paused! Waiting for your decision.")
# Wait for user input
event = threading.Event()
@@ -124,10 +124,10 @@ $toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
comfy.model_management.throw_exception_if_processing_interrupted()
event.wait(0.5)
if not event.is_set():
print(f"[LeafFlow] Node {unique_id} timed out. Auto-continuing...")
print(f"[SaturnNodes] Node {unique_id} timed out. Auto-continuing...")
DecisionManager.unregister_wait(unique_id)
# Notify frontend to update UI back to normal
PromptServer.instance.send_sync("leafflow_decision_resolved", {"node_id": unique_id})
PromptServer.instance.send_sync("saturnnodes_decision_resolved", {"node_id": unique_id})
return (False,)
else:
while not event.is_set():
@@ -135,20 +135,20 @@ $toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
event.wait(0.5)
except Exception:
DecisionManager.unregister_wait(unique_id)
PromptServer.instance.send_sync("leafflow_decision_resolved", {"node_id": unique_id})
PromptServer.instance.send_sync("saturnnodes_decision_resolved", {"node_id": unique_id})
raise
# Retrieve action
action = DecisionManager.get_action(unique_id)
# Notify frontend that the decision has been resolved
PromptServer.instance.send_sync("leafflow_decision_resolved", {"node_id": unique_id})
PromptServer.instance.send_sync("saturnnodes_decision_resolved", {"node_id": unique_id})
if action == "cancel":
print(f"[LeafFlow] Node {unique_id} cancelled.")
print(f"[SaturnNodes] Node {unique_id} cancelled.")
return (True,)
elif action == "stop":
print(f"[LeafFlow] Node {unique_id} stopped workflow.")
print(f"[SaturnNodes] Node {unique_id} stopped workflow.")
try:
# Attempt to cancel current execution queue
PromptServer.instance.prompt_queue.cancel_current_execution()
@@ -160,19 +160,19 @@ $toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
pass
# Raise exception to halt this thread instantly
try:
raise comfy.model_management.InterruptProcessingException("Workflow stopped by LeafFlow Decision Node.")
raise comfy.model_management.InterruptProcessingException("Workflow stopped by SaturnNodes Decision Node.")
except AttributeError:
raise Exception("Workflow stopped by LeafFlow Decision Node.")
raise Exception("Workflow stopped by SaturnNodes Decision Node.")
print(f"[LeafFlow] Node {unique_id} continuing.")
print(f"[SaturnNodes] Node {unique_id} continuing.")
return (False,)
# Register API Route
@PromptServer.instance.routes.post("/leafflow/decision")
@PromptServer.instance.routes.post("/saturnnodes/decision")
async def handle_decision(request):
if not is_local_request(request):
return web.json_response({"error": "Forbidden: Local access only"}, status=403)
if not is_authenticated_local_request(request):
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
try:
data = await request.json()
node_id = data.get("node_id")
@@ -184,3 +184,6 @@ async def handle_decision(request):
return web.json_response({"status": "error", "message": "Node is not waiting."}, status=400)
except Exception as e:
return web.json_response({"status": "error", "message": str(e)}, status=500)
LeafFlowDecision = SaturnDecision
SaturnDecision = SaturnDecision
+6 -5
View File
@@ -14,12 +14,13 @@ from .utils import (
get_leafflow_user_dir,
is_local_request,
is_safe_path,
sanitize_image_loader_folder
sanitize_image_loader_folder,
get_saturnnodes_user_dir
)
IMAGE_CATEGORY = "🍃 LeafFlow/Loaders"
IMAGE_CATEGORY = "🪐 SaturnNodes/Loaders"
CURRENT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
USER_DIR = get_leafflow_user_dir()
USER_DIR = get_saturnnodes_user_dir()
CACHE_FILE = os.path.join(USER_DIR, "image_prompts_cache.json")
def decode_bytes_safely(raw_bytes):
@@ -148,7 +149,7 @@ def extract_metadata_from_image(filepath):
except Exception:
pass
except Exception as e:
print(f"[LeafFlow] Error extracting metadata: {e}")
print(f"[SaturnNodes] Error extracting metadata: {e}")
return positive_prompt, width, height
def load_prompts_cache():
@@ -165,7 +166,7 @@ def save_prompts_cache(cache):
with open(CACHE_FILE, "w", encoding="utf-8") as f:
json.dump(cache, f, indent=4, ensure_ascii=False)
except Exception as e:
print(f"[LeafFlow] Failed to save prompts cache: {e}")
print(f"[SaturnNodes] Failed to save prompts cache: {e}")
routes = PromptServer.instance.routes if hasattr(PromptServer, "instance") and PromptServer.instance is not None else web.RouteTableDef()
+3 -3
View File
@@ -5,7 +5,7 @@ from PIL import Image, ImageOps
import folder_paths
from .utils import sanitize_folder_path
LOADER_CATEGORY = "🍃 LeafFlow/Loaders"
LOADER_CATEGORY = "🪐 SaturnNodes/Loaders"
class LoadRecentOutputs:
@classmethod
@@ -57,7 +57,7 @@ class LoadRecentOutputs:
except OSError:
continue
except Exception as e:
print(f"[LeafFlow] 🍃 Error scanning output folder '{output_dir}': {e}")
print(f"[SaturnNodes] 🍃 Error scanning output folder '{output_dir}': {e}")
dummy = torch.zeros((1, 512, 512, 3), dtype=torch.float32)
return (dummy,)
@@ -80,7 +80,7 @@ class LoadRecentOutputs:
image = np.array(image).astype(np.float32) / 255.0
image = torch.from_numpy(image).unsqueeze(0)
except Exception as e:
print(f"[LeafFlow] Error loading recent output image {target_file}: {e}")
print(f"[SaturnNodes] Error loading recent output image {target_file}: {e}")
image = torch.zeros((1, 512, 512, 3), dtype=torch.float32)
return (image,)
+442
View File
@@ -0,0 +1,442 @@
import os
import sys
import time
import shlex
import subprocess
import threading
from aiohttp import web
import folder_paths
import comfy.model_management
from .utils import get_env_setting, is_local_request, is_authenticated_local_request
AUTOMATION_CATEGORY = "🪐 SaturnNodes/Automation"
def _safe_print(msg):
try:
print(msg)
except UnicodeEncodeError:
print(msg.encode("ascii", "replace").decode("ascii"))
def is_local_file_execution_enabled():
"""Checks whether local file execution is explicitly enabled in SaturnNodes settings (default False)."""
val = get_env_setting("ENABLE_LOCAL_FILE_EXECUTION", "false").lower()
return val in ["true", "1", "yes"]
def get_allowed_scripts_directory():
"""
Returns the strict, non-bypassable scripts directory: ComfyUI/scripts/
"""
base = folder_paths.base_path if hasattr(folder_paths, "base_path") and folder_paths.base_path else os.getcwd()
scripts_dir = os.path.abspath(os.path.realpath(os.path.join(base, "scripts")))
try:
os.makedirs(scripts_dir, exist_ok=True)
except Exception:
pass
return scripts_dir
def is_script_path_permitted(target_path):
"""
Confines target scripts strictly to ComfyUI/scripts/ using canonical realpath and commonpath.
No setting or configuration can bypass this confinement.
"""
if not target_path:
return False, "Target path is empty."
scripts_dir = os.path.abspath(os.path.realpath(get_allowed_scripts_directory()))
try:
abs_target = os.path.abspath(os.path.realpath(target_path))
norm_scripts = os.path.normcase(scripts_dir)
norm_target = os.path.normcase(abs_target)
if os.path.commonpath([norm_scripts, norm_target]) == norm_scripts:
if not os.path.exists(abs_target):
return False, f"Script does not exist: '{os.path.basename(target_path)}'"
if not os.path.isfile(abs_target):
return False, f"Target is not a regular file: '{os.path.basename(target_path)}'"
return True, "Path verified within ComfyUI/scripts/."
return False, f"Security Restriction: Script '{os.path.basename(target_path)}' must reside within ComfyUI/scripts/."
except Exception as e:
return False, f"Path validation error: {e}"
def resolve_target_path(file_path_input):
"""
Resolves and verifies a script path strictly within ComfyUI/scripts/.
Rejects absolute paths, path traversal ('..'), and any outside references.
"""
clean = str(file_path_input or "").strip()
if not clean:
return None, "Script file path is empty."
# 1. Strictly reject absolute paths (Windows drive letters C:\, UNC \\server, or POSIX /root)
has_drive_letter = len(clean) >= 2 and clean[0].isalpha() and clean[1] == ":"
if os.path.isabs(clean) or clean.startswith(("\\\\", "//", "/")) or has_drive_letter:
return None, f"Security Restriction: Absolute paths are forbidden ('{clean}'). Place your script in ComfyUI/scripts/ and specify a relative name."
# 2. Strictly reject directory traversal
norm = os.path.normpath(clean)
parts = norm.replace("\\", "/").split("/")
if ".." in parts or norm.startswith(".."):
return None, f"Security Restriction: Directory traversal ('..') is forbidden ('{clean}')."
# 3. If operator wrote "scripts/run.bat" or "scripts\run.bat", normalize to relative subpath
if parts and parts[0].lower() == "scripts":
clean_sub = os.path.join(*parts[1:]) if len(parts) > 1 else ""
else:
clean_sub = norm
if not clean_sub:
return None, "Invalid script path."
scripts_base = os.path.abspath(os.path.realpath(get_allowed_scripts_directory()))
full_target = os.path.abspath(os.path.realpath(os.path.join(scripts_base, clean_sub)))
# 4. Canonical commonpath confinement
try:
norm_base = os.path.normcase(scripts_base)
norm_target = os.path.normcase(full_target)
if os.path.commonpath([norm_base, norm_target]) != norm_base:
return None, f"Security Restriction: Script path escapes ComfyUI/scripts/ ('{clean}')."
except Exception as e:
return None, f"Security Restriction: Path validation error: {e}"
if not os.path.exists(full_target):
return None, f"Script not found in ComfyUI/scripts/: '{clean_sub}'"
if not os.path.isfile(full_target):
return None, f"Target path is not a file: '{clean_sub}'"
return full_target, "OK"
# =========================================================================
# In-Memory Operator Authorization Registry
# =========================================================================
# Does NOT travel in workflow JSON or prompt requests.
# Stored solely in memory and consumed upon execution.
_AUTHORIZED_RUNS = {}
_AUTH_LOCK = threading.Lock()
def authorize_node_run(node_id, file_path="", ttl_seconds=300):
with _AUTH_LOCK:
now = time.time()
expired = [k for k, v in _AUTHORIZED_RUNS.items() if v.get("expires_at", 0) < now]
for k in expired:
_AUTHORIZED_RUNS.pop(k, None)
filename = os.path.basename(str(file_path).strip()) if file_path else ""
_AUTHORIZED_RUNS[str(node_id)] = {
"expires_at": now + ttl_seconds,
"filename": filename
}
def consume_node_authorization(node_id, resolved_path):
if node_id is None:
return False, "Missing node identifier. Workflow execution cannot be authorized."
with _AUTH_LOCK:
now = time.time()
auth = _AUTHORIZED_RUNS.pop(str(node_id), None)
if not auth:
return False, "Node has not been authorized. Operator must click 'Authorize Run' on this node in the canvas before queueing."
if auth.get("expires_at", 0) < now:
return False, "Authorization has expired (5-minute timeout). Please click 'Authorize Run' again."
target_base = os.path.basename(resolved_path) if resolved_path else ""
expected_base = auth.get("filename")
if expected_base and target_base and target_base.lower() != expected_base.lower():
return False, f"Authorized script '{expected_base}' does not match configured script '{target_base}'. Please re-authorize."
return True, "Authorized"
def is_node_authorized(node_id):
if not node_id:
return False, 0
with _AUTH_LOCK:
now = time.time()
auth = _AUTHORIZED_RUNS.get(str(node_id))
if auth and auth.get("expires_at", 0) >= now:
return True, int(auth.get("expires_at", 0) - now)
return False, 0
def clear_all_authorizations():
with _AUTH_LOCK:
_AUTHORIZED_RUNS.clear()
def setup_local_runner_routes(server):
routes = server.routes
@routes.post("/saturnnodes/local_runner/authorize")
async def authorize_endpoint(request):
if not is_authenticated_local_request(request):
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
if not is_local_file_execution_enabled():
return web.json_response({
"error": "Local file execution is disabled. Enable 'Allow Local File Execution' in SaturnNodes settings first.",
"enabled": False
}, status=403)
try:
data = await request.json()
except Exception:
data = {}
node_id = data.get("node_id")
file_path = data.get("file_path", "")
if node_id is None:
return web.json_response({"error": "Missing node_id"}, status=400)
authorize_node_run(node_id, file_path, ttl_seconds=300)
return web.json_response({
"success": True,
"node_id": str(node_id),
"expires_in": 300
})
@routes.get("/saturnnodes/local_runner/status")
async def status_endpoint(request):
if not is_local_request(request):
return web.json_response({"error": "Forbidden: Local access only"}, status=403)
node_id = request.query.get("node_id")
is_auth, remaining = is_node_authorized(node_id)
return web.json_response({
"enabled": is_local_file_execution_enabled(),
"authorized": is_auth,
"remaining_seconds": remaining
})
class AnyType(str):
"""Wildcard type for input and output passthrough wires."""
def __ne__(self, __value: object) -> bool:
return False
any_type = AnyType("*")
class RunLocalFileNode:
"""
Safely executes a local file (.exe, .bat, .cmd, .ps1 on Windows; .sh or binary on Linux/macOS)
with custom parameters and configurable working directory.
Includes strict path confinement and in-memory operator authorization.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"file_path": ("STRING", {
"default": "",
"multiline": False,
"placeholder": "Relative script path inside ComfyUI/scripts/ (e.g. run.bat or convert.sh)",
"tooltip": "Script or binary to execute. MUST be located inside 'ComfyUI/scripts/'. Subfolders (e.g. tools/process.bat) are permitted. Absolute paths and directory traversal ('..') are strictly blocked."
}),
},
"optional": {
"parameters": ("STRING", {
"default": "",
"multiline": True,
"placeholder": "Command-line arguments (e.g. --input file.png --quality 95)",
"tooltip": "Optional command-line arguments passed to the script. Parsed safely without shell expansion."
}),
"working_directory": ("STRING", {
"default": "",
"multiline": False,
"placeholder": "Working directory inside ComfyUI/scripts/. Leave empty to use script directory.",
"tooltip": "Execution working directory. MUST be located inside 'ComfyUI/scripts/'. Leave empty to default to the script's own folder."
}),
"run_mode": (
["Synchronous (Wait for Output)", "Asynchronous (Background)"],
{"default": "Synchronous (Wait for Output)"}
),
"timeout": ("INT", {
"default": 60,
"min": 0,
"max": 3600,
"step": 5,
"tooltip": "Max execution time in seconds (0 = unlimited). Ignored in asynchronous mode."
}),
"trigger": (any_type, {"tooltip": "Optional passthrough wire to sequence execution in your workflow."}),
},
"hidden": {
"unique_id": "UNIQUE_ID",
"extra_pnginfo": "EXTRA_PNGINFO"
}
}
RETURN_TYPES = ("STRING", "STRING", "INT", "BOOLEAN", any_type)
RETURN_NAMES = ("stdout", "stderr", "exit_code", "success", "passthrough")
FUNCTION = "run_local_file"
CATEGORY = AUTOMATION_CATEGORY
DESCRIPTION = (
"Executes a local script or executable (.bat, .ps1, .exe on Windows; .sh or binary on Linux/macOS) "
"with parameter arguments and working directory control.\n\n"
"Security Safeguards:\n"
"- Strictly confined to ComfyUI/scripts/ (absolute paths and directory traversal are forbidden).\n"
"- Requires explicit in-memory operator authorization via the '⚡ Authorize Run' button.\n"
"- Consent cannot travel in workflow JSON or image metadata.\n"
"- Execution is single-use: each authorization expires upon run or after 5 minutes.\n"
"- Never uses shell=True to prevent command chaining injection.\n"
"- Disabled globally by default for operator security."
)
def resolve_target_path(self, file_path_input):
return resolve_target_path(file_path_input)[0]
def build_command_args(self, target_path, params_str):
"""Builds a safe, non-shell command list suitable for subprocess.Popen(shell=False)."""
parsed_args = []
if params_str and params_str.strip():
try:
parsed_args = shlex.split(params_str.strip(), posix=(sys.platform != "win32"))
except Exception:
parsed_args = params_str.strip().split()
norm_path = os.path.normpath(target_path)
lower_path = norm_path.lower()
if sys.platform.startswith("win"):
if lower_path.endswith((".bat", ".cmd")):
comspec = os.environ.get("COMSPEC", "cmd.exe")
return [comspec, "/c", norm_path, *parsed_args]
elif lower_path.endswith(".ps1"):
# Security Note: -ExecutionPolicy Bypass is required on Windows to permit execution
# of local user scripts (.ps1) which are blocked by default machine policies.
# Security is strictly guaranteed by:
# 1. Path confinement: target file MUST reside exclusively inside ComfyUI/scripts/
# 2. Live in-memory operator authorization required on canvas prior to run
# 3. -NoProfile flag ensuring no untrusted global PowerShell profiles are executed
return ["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", norm_path, *parsed_args]
else:
return [norm_path, *parsed_args]
else:
if lower_path.endswith(".sh"):
return ["/bin/bash", norm_path, *parsed_args]
else:
return [norm_path, *parsed_args]
def run_local_file(
self,
file_path,
parameters="",
working_directory="",
run_mode="Synchronous (Wait for Output)",
timeout=60,
trigger=None,
unique_id=None,
**kwargs
):
passthrough_out = trigger if trigger is not None else ""
# Check 1: Global Security Setting (Defaults to Disabled)
if not is_local_file_execution_enabled():
err = (
"Execution Blocked: Local file execution is disabled by default for security. "
"To run local files, enable 'Allow Local File Execution' in ComfyUI Settings -> SaturnNodes -> Security."
)
_safe_print(f"[SaturnNodes LocalRunner] {err}")
return ("", err, -1, False, passthrough_out)
# Check 2: Path Resolution & Strict Confinement to ComfyUI/scripts/
resolved_path, path_msg = resolve_target_path(file_path)
if not resolved_path:
err = f"Execution Error: {path_msg}"
_safe_print(f"[SaturnNodes LocalRunner] {err}")
return ("", err, -1, False, passthrough_out)
# Check 3: Mandatory In-Memory Operator Authorization Gate
node_id = unique_id if unique_id is not None else kwargs.get("node_id")
authorized, auth_msg = consume_node_authorization(node_id, resolved_path)
if not authorized:
err = f"Execution Blocked: {auth_msg}"
_safe_print(f"[SaturnNodes LocalRunner] {err}")
return ("", err, -1, False, passthrough_out)
# Determine working directory (confined to ComfyUI/scripts/ or script directory)
scripts_base = get_allowed_scripts_directory()
cwd = os.path.dirname(resolved_path)
if working_directory and working_directory.strip():
cand_cwd = os.path.abspath(os.path.realpath(os.path.join(scripts_base, working_directory.strip())))
try:
if os.path.commonpath([scripts_base, cand_cwd]) == scripts_base and os.path.isdir(cand_cwd):
cwd = cand_cwd
else:
_safe_print(f"[SaturnNodes LocalRunner] Warning: working_directory '{working_directory}' outside scripts folder. Defaulting to script folder.")
except Exception:
pass
cmd_list = self.build_command_args(resolved_path, parameters)
_safe_print(f"[SaturnNodes LocalRunner] Executing '{os.path.basename(resolved_path)}' (mode: {run_mode}, cwd: '{cwd}')...")
# Mode: Asynchronous (Background Detached)
if "Asynchronous" in run_mode:
try:
creationflags = 0
if sys.platform.startswith("win"):
creationflags = subprocess.CREATE_NEW_CONSOLE if hasattr(subprocess, "CREATE_NEW_CONSOLE") else 0
subprocess.Popen(cmd_list, cwd=cwd, creationflags=creationflags)
else:
subprocess.Popen(cmd_list, cwd=cwd, start_new_session=True)
msg = f"Started in background: '{os.path.basename(resolved_path)}' (PID assigned)"
_safe_print(f"[SaturnNodes LocalRunner] {msg}")
return (msg, "", 0, True, passthrough_out)
except Exception as e:
err_msg = f"Failed to launch background process: {str(e)}"
_safe_print(f"[SaturnNodes LocalRunner] Error: {err_msg}")
return ("", err_msg, -1, False, passthrough_out)
# Mode: Synchronous (Wait for Process with Output & Interrupt Polling)
try:
creationflags = 0
if sys.platform.startswith("win"):
creationflags = subprocess.CREATE_NO_WINDOW if hasattr(subprocess, "CREATE_NO_WINDOW") else 0
proc = subprocess.Popen(
cmd_list,
cwd=cwd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
creationflags=creationflags
)
start_time = time.time()
def _kill_tree():
try:
if sys.platform.startswith("win"):
subprocess.run(["taskkill", "/F", "/T", "/PID", str(proc.pid)], capture_output=True)
else:
proc.kill()
except Exception:
try:
proc.kill()
except Exception:
pass
try:
proc.communicate(timeout=1)
except Exception:
pass
effective_timeout = timeout if timeout > 0 else None
# Poll for completion while honoring ComfyUI cancellation
while proc.poll() is None:
try:
comfy.model_management.throw_exception_if_processing_interrupted()
except Exception:
_kill_tree()
_safe_print(f"[SaturnNodes LocalRunner] Execution of '{os.path.basename(resolved_path)}' interrupted by user.")
raise
if effective_timeout and (time.time() - start_time) > effective_timeout:
_kill_tree()
err = f"Execution timed out after {timeout} seconds."
_safe_print(f"[SaturnNodes LocalRunner] {err}")
return ("", err, -1, False, passthrough_out)
time.sleep(0.1)
stdout_data, stderr_data = proc.communicate()
exit_code = proc.returncode
success = (exit_code == 0)
_safe_print(f"[SaturnNodes LocalRunner] Completed with exit code {exit_code} (success: {success}).")
return (stdout_data or "", stderr_data or "", exit_code, success, passthrough_out)
except Exception as e:
err_msg = f"Process execution error: {str(e)}"
_safe_print(f"[SaturnNodes LocalRunner] {err_msg}")
return ("", err_msg, -1, False, passthrough_out)
+2 -2
View File
@@ -99,7 +99,7 @@ class TextLoraFinder:
matches.append((m.start(), display_name, sys_path))
seen_paths.add(sys_path)
except Exception as e:
print(f"[LeafFlow] 🍃 Invalid custom regex pattern '{custom_regex}': {e}")
print(f"[SaturnNodes] 🍃 Invalid custom regex pattern '{custom_regex}': {e}")
else:
for display_name, sys_path in mapping.items():
if display_name in ["[ NONE ]", "[ RANDOM ]"] or not sys_path or sys_path == "[ NONE ]":
@@ -150,7 +150,7 @@ class TextLoraFinder:
)
increment_lora_usage(sys_path)
except Exception as e:
print(f"[LeafFlow] 🍃 Error loading LoRA '{sys_path}': {e}")
print(f"[SaturnNodes] 🍃 Error loading LoRA '{sys_path}': {e}")
continue
else:
increment_lora_usage(sys_path)
+54 -18
View File
@@ -18,12 +18,13 @@ from .utils import (
parse_pretty_name_with_version,
format_lora_output_name,
sanitize_folder_path,
get_saturnnodes_user_dir,
get_leafflow_user_dir,
is_local_request,
is_safe_path
)
LORA_CATEGORY = "🍃 LeafFlow/Loaders"
LORA_CATEGORY = "🪐 SaturnNodes/Loaders"
LORA_OUTPUT_FORMAT_CHOICES = [
"Parsed Name",
"Filename",
@@ -34,7 +35,7 @@ LORA_OUTPUT_FORMAT_CHOICES = [
]
CURRENT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
USER_DIR = get_leafflow_user_dir()
USER_DIR = get_saturnnodes_user_dir()
ENV_FILE = os.path.join(USER_DIR, ".env")
LORA_STATE_FILE = os.path.join(USER_DIR, "lora_loader_state.json")
@@ -62,7 +63,7 @@ def save_lora_cycle_state(state):
with open(LORA_STATE_FILE, "w", encoding="utf-8") as f:
json.dump(state, f, indent=4, ensure_ascii=False)
except Exception as e:
print(f"[LeafFlow] Error saving lora loader cycle state: {e}")
print(f"[SaturnNodes] Error saving lora loader cycle state: {e}")
def get_api_keys():
civitai_key = os.getenv("CIVITAI_API_KEY", "")
@@ -98,7 +99,7 @@ def load_usage_data():
else:
usage_data[k] = usage_data.get(k, 0) + v
except Exception as e:
print(f"[LeafFlow] Error loading usage data: {e}")
print(f"[SaturnNodes] Error loading usage data: {e}")
return usage_data
def increment_lora_usage(lora_path):
@@ -114,7 +115,7 @@ def increment_lora_usage(lora_path):
with open(usage_file, "w", encoding="utf-8") as f:
json.dump(usage_data, f, indent=4, ensure_ascii=False)
except Exception as e:
print(f"[LeafFlow] Error updating lora usage: {e}")
print(f"[SaturnNodes] Error updating lora usage: {e}")
def find_preview_image(lora_relative_path):
lora_full_path = folder_paths.get_full_path("loras", lora_relative_path)
@@ -135,6 +136,32 @@ def get_file_sha256(filepath):
h.update(chunk)
return h.hexdigest().upper()
def is_safe_external_image_url(url_str):
"""
Validates that a scraped image URL uses a safe web scheme (https/http)
and does not target internal loopback, private networks, or metadata services (SSRF protection).
"""
if not url_str or not isinstance(url_str, str):
return False
try:
parsed = urllib.parse.urlparse(url_str.strip())
if parsed.scheme not in ("https", "http"):
return False
hostname = (parsed.hostname or "").lower().strip()
if not hostname:
return False
disallowed_prefixes = (
"127.", "10.", "192.168.", "172.16.", "172.17.", "172.18.", "172.19.",
"172.20.", "172.21.", "172.22.", "172.23.", "172.24.", "172.25.",
"172.26.", "172.27.", "172.28.", "172.29.", "172.30.", "172.31.",
"169.254.", "0.0.0.0", "::1", "localhost"
)
if any(hostname == prefix or hostname.startswith(prefix) for prefix in disallowed_prefixes):
return False
return True
except Exception:
return False
def get_env_setting(key, default_val):
if os.path.exists(ENV_FILE):
try:
@@ -198,14 +225,18 @@ def scrape_missing_images_sync():
images = data.get("images", [])
if images and len(images) > 0:
img_url = images[0].get("url")
if img_url:
if img_url and is_safe_external_image_url(img_url):
dest_path = os.path.splitext(lora_path)[0] + ".jpg"
img_req = urllib.request.Request(img_url, headers=headers)
with urllib.request.urlopen(img_req, timeout=15) as img_resp:
img_data = img_resp.read()
try:
with open(dest_path, "wb") as f:
f.write(img_resp.read())
print(f"[LeafFlow] Scraped Civitai preview for {pretty_name}")
success = True
f.write(img_data)
print(f"[SaturnNodes] Scraped Civitai preview for {pretty_name}")
success = True
except (PermissionError, OSError) as write_err:
print(f"[SaturnNodes] Warning: Could not save preview image to '{dest_path}': {write_err}")
except Exception:
pass
@@ -239,14 +270,19 @@ def scrape_missing_images_sync():
profile_path = res.get("profile_path")
if profile_path:
img_url = f"https://image.tmdb.org/t/p/w500{profile_path}"
dest_path = os.path.splitext(lora_path)[0] + ".jpg"
img_req = urllib.request.Request(img_url, headers=tmdb_headers)
with urllib.request.urlopen(img_req, timeout=15) as img_resp:
with open(dest_path, "wb") as f:
f.write(img_resp.read())
print(f"[LeafFlow] Scraped TMDB preview for {pretty_name} (via '{term}')")
success = True
break
if is_safe_external_image_url(img_url):
dest_path = os.path.splitext(lora_path)[0] + ".jpg"
img_req = urllib.request.Request(img_url, headers=tmdb_headers)
with urllib.request.urlopen(img_req, timeout=15) as img_resp:
img_data = img_resp.read()
try:
with open(dest_path, "wb") as f:
f.write(img_data)
print(f"[SaturnNodes] Scraped TMDB preview for {pretty_name} (via '{term}')")
success = True
break
except (PermissionError, OSError) as write_err:
print(f"[SaturnNodes] Warning: Could not save preview image to '{dest_path}': {write_err}")
except Exception as tmdb_err:
pass
@@ -261,7 +297,7 @@ def scrape_missing_images_sync():
except Exception:
pass
except Exception as e:
print(f"[LeafFlow] Error in preview scraping thread: {e}")
print(f"[SaturnNodes] Error in preview scraping thread: {e}")
def start_async_scraping():
global scraping_thread
+1 -1
View File
@@ -1,4 +1,4 @@
PREVIEW_CATEGORY = "🍃 LeafFlow/Previews"
PREVIEW_CATEGORY = "🪐 SaturnNodes/Previews"
class PreviewLatentLiveNode:
@classmethod
+36
View File
@@ -0,0 +1,36 @@
from .prompt_iterator import parse_prompt_blocks
class PromptCounter:
"""
LeafFlow Prompt Counter:
Multiline text prompt input node with real-time prompt counting according to
selectable separator ("Newline", ">1 Empty Line", ">2 Empty Lines", "Custom Regex").
Outputs STRING (unmodified text) and count (integer count of prompts).
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"text": ("STRING", {"default": "", "multiline": True, "placeholder": "Enter multiline prompts here..."}),
},
"optional": {
"separator": ([
">1 Empty Line",
"Newline",
">2 Empty Lines",
"Custom Regex"
], {"default": ">1 Empty Line", "advanced": True}),
"custom_regex": ("STRING", {"default": "", "advanced": True, "placeholder": "Custom regex delimiter (e.g. \\n---\\n)"}),
}
}
RETURN_TYPES = ("STRING", "INT")
RETURN_NAMES = ("STRING", "count")
FUNCTION = "count_prompts"
CATEGORY = "🪐 SaturnNodes/Utils"
DESCRIPTION = "Multiline prompt text input with live prompt count display according to selectable separator or custom regex."
def count_prompts(self, text="", separator=">1 Empty Line", custom_regex="", **kwargs):
text_str = str(text) if text is not None else ""
blocks = parse_prompt_blocks(text_str, separator, custom_regex)
return (text_str, len(blocks))
+96 -36
View File
@@ -9,10 +9,10 @@ import subprocess
from aiohttp import web
from server import PromptServer
import folder_paths
from .utils import get_leafflow_user_dir
from .utils import get_saturnnodes_user_dir, get_leafflow_user_dir, is_authenticated_local_request
CURRENT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
USER_DIR = get_leafflow_user_dir()
USER_DIR = get_saturnnodes_user_dir()
STATE_FILE = os.path.join(USER_DIR, "prompt_iterator_state.json")
ENV_FILE = os.path.join(USER_DIR, ".env")
@@ -43,7 +43,7 @@ def load_state():
if isinstance(data, dict):
return data
except Exception as e:
print(f"[LeafFlow] 🍃 Warning reading prompt iterator state: {e}")
print(f"[SaturnNodes] 🍃 Warning reading prompt iterator state: {e}")
return {}
def save_state(state):
@@ -62,17 +62,17 @@ def save_state(state):
else:
os.rename(tmp_file, STATE_FILE)
except Exception as e:
print(f"[LeafFlow] 🍃 Error saving prompt iterator state: {e}")
print(f"[SaturnNodes] 🍃 Error saving prompt iterator state: {e}")
def clear_state():
ensure_user_dir()
try:
with open(STATE_FILE, "w", encoding="utf-8") as f:
json.dump({}, f, indent=4)
print("[LeafFlow] 🍃 Prompt iterator state cleared.")
print("[SaturnNodes] 🍃 Prompt iterator state cleared.")
return True
except Exception as e:
print(f"[LeafFlow] 🍃 Error clearing prompt iterator state: {e}")
print(f"[SaturnNodes] 🍃 Error clearing prompt iterator state: {e}")
return False
# Clear state on startup if privacy setting enabled
@@ -83,8 +83,10 @@ if is_clear_prompt_iterator_on_launch():
try:
routes = PromptServer.instance.routes
@routes.post("/leafflow/prompt_iterator/reset_node")
@routes.post("/saturnnodes/prompt_iterator/reset_node")
async def reset_prompt_iterator_node(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()
node_id = str(data.get("node_id", ""))
@@ -99,58 +101,88 @@ try:
matched = True
save_state(state)
if node_id and node_id != "all":
progress_payload = {
"node_id": node_id,
"current_run": 0,
"total_runs": 0,
"status_text": "Reset (0 / --)"
}
try:
PromptServer.instance.send_sync("leafflow_prompt_iterator_progress", {
"node_id": node_id,
"current_run": 0,
"total_runs": 0,
"status_text": "Reset (0 / --)"
})
PromptServer.instance.send_sync("saturnnodes_prompt_iterator_progress", progress_payload)
except Exception:
pass
return web.json_response({"status": "ok", "reset": matched})
except Exception as e:
return web.json_response({"status": "error", "message": str(e)}, status=500)
@routes.post("/leafflow/prompt_iterator/clear")
@routes.post("/flow_control/prompt_iterator/clear")
@routes.post("/saturnnodes/prompt_iterator/clear")
async def clear_prompt_iterator_endpoint(request):
if not is_authenticated_local_request(request):
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
success = clear_state()
return web.json_response({"status": "ok" if success else "error"})
@routes.post("/leafflow/prompt_iterator/open_file")
@routes.post("/saturnnodes/prompt_iterator/open_file")
async def open_prompt_iterator_file(request):
if not is_authenticated_local_request(request):
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
ensure_user_dir()
if not os.path.exists(STATE_FILE):
save_state({})
# Security: verify canonical state file path is strictly within USER_DIR
canonical_state = os.path.realpath(STATE_FILE)
canonical_user = os.path.realpath(USER_DIR)
try:
if os.path.commonpath([canonical_user, canonical_state]) != canonical_user:
return web.json_response({"status": "error", "message": "Security: State file escapes user directory"}, status=403)
except Exception:
return web.json_response({"status": "error", "message": "Security path validation error"}, status=403)
try:
sys_name = platform.system()
if sys_name == "Windows":
os.startfile(STATE_FILE)
os.startfile(canonical_state)
elif sys_name == "Darwin":
subprocess.Popen(["open", STATE_FILE])
subprocess.Popen(["open", canonical_state])
else:
subprocess.Popen(["xdg-open", STATE_FILE])
return web.json_response({"status": "ok", "path": STATE_FILE})
subprocess.Popen(["xdg-open", canonical_state])
return web.json_response({"status": "ok", "path": canonical_state})
except Exception as e:
print(f"[LeafFlow] 🍃 Error opening state file: {e}")
print(f"[SaturnNodes] 🍃 Error opening state file: {e}")
return web.json_response({"status": "error", "message": str(e)}, status=500)
except Exception:
pass
def parse_prompt_blocks(text_str, separator):
def parse_prompt_blocks(text_str, separator, custom_regex=""):
if not text_str or not text_str.strip():
return []
clean_text = text_str.replace('\r\n', '\n').replace('\r', '\n')
sep_lower = str(separator).lower() if separator else ">1 empty line"
if separator == "Newline":
if "regex" in sep_lower:
pattern = custom_regex.strip() if (custom_regex and custom_regex.strip()) else r'\n\s*\n+'
try:
regex = re.compile(pattern)
raw_blocks = regex.split(clean_text)
results = []
for b in raw_blocks:
b_str = b.strip()
if b_str and not regex.fullmatch(b):
results.append(b_str)
return results
except Exception as e:
print(f"[SaturnNodes Prompt] Invalid custom regex pattern '{pattern}': {e}. Falling back to empty line separator.")
raw_blocks = re.split(r'\n\s*\n+', clean_text)
return [b.strip() for b in raw_blocks if b.strip()]
elif "newline" in sep_lower:
return [line.strip() for line in clean_text.split('\n') if line.strip()]
elif separator == ">2 Empty Lines":
elif ">2" in sep_lower or "2 empty" in sep_lower:
raw_blocks = re.split(r'(?:\n\s*){3,}', clean_text)
return [b.strip() for b in raw_blocks if b.strip()]
else: # ">1 Empty Line"
else: # ">1 Empty Line", ">1 Emptyline"
raw_blocks = re.split(r'\n\s*\n+', clean_text)
return [b.strip() for b in raw_blocks if b.strip()]
@@ -179,11 +211,13 @@ class PromptQueueIterator:
"separator": ([
">1 Empty Line",
"Newline",
">2 Empty Lines"
">2 Empty Lines",
"Custom Regex"
], {"default": ">1 Empty Line"}),
"text": ("STRING", {"default": "", "multiline": True}),
},
"optional": {
"custom_regex": ("STRING", {"default": "", "placeholder": "Custom regex delimiter (e.g. \\n---\\n)"}),
"prompt": ("STRING", {"forceInput": True}),
},
"hidden": {
@@ -194,7 +228,7 @@ class PromptQueueIterator:
RETURN_TYPES = ("STRING", "STRING", "INT")
RETURN_NAMES = ("prompt", "remaining_text", "remaining_count")
FUNCTION = "process_queue"
CATEGORY = "🍃 LeafFlow/Utils"
CATEGORY = "🪐 SaturnNodes/Utils"
DESCRIPTION = "Deterministically iterates over multiline prompt text blocks per queue run with live progress display and index controls."
@classmethod
@@ -207,6 +241,7 @@ class PromptQueueIterator:
pop_mode="Sequential (Loop on End)",
separator=">1 Empty Line",
text="",
custom_regex="",
prompt=None,
prompt_text=None,
unique_id="default",
@@ -217,7 +252,7 @@ class PromptQueueIterator:
if not text_str.strip():
return ("", "", 0)
original_blocks = parse_prompt_blocks(text_str, separator)
original_blocks = parse_prompt_blocks(text_str, separator, custom_regex)
total_count = len(original_blocks)
if total_count == 0:
return ("", "", 0)
@@ -271,6 +306,16 @@ class PromptQueueIterator:
selected_prompt = original_blocks[idx]
next_index = current_index + 1
display_run = (current_index % total_count) + 1
elif pop_mode in ["Random (Cycle)"]:
shuffled_order = list(node_state.get("shuffled_order", [])) if isinstance(node_state, dict) else []
cycle_index = current_index % total_count
if len(shuffled_order) != total_count or (cycle_index == 0 and current_index > 0) or not shuffled_order:
shuffled_order = list(range(total_count))
random.shuffle(shuffled_order)
idx = shuffled_order[cycle_index]
selected_prompt = original_blocks[idx]
next_index = (current_index + 1) % total_count
display_run = cycle_index + 1
else: # Default fallback
idx = current_index % total_count
selected_prompt = original_blocks[idx]
@@ -285,11 +330,22 @@ class PromptQueueIterator:
# Calculate remaining items
remaining_count = max(0, total_count - display_run)
join_delim = "\n" if separator == "Newline" else ("\n\n\n" if separator == ">2 Empty Lines" else "\n\n")
sep_lower = str(separator).lower() if separator else ""
if "newline" in sep_lower:
join_delim = "\n"
elif ">2" in sep_lower or "2 empty" in sep_lower:
join_delim = "\n\n\n"
elif "regex" in sep_lower:
if custom_regex and not any(c in custom_regex for c in r"*+?{}[\]^$|()\\"):
join_delim = custom_regex
else:
join_delim = "\n\n"
else:
join_delim = "\n\n"
remaining_text = join_delim.join(original_blocks[display_run:]) if display_run < total_count else ""
# Update state dictionary
state[state_key] = {
new_entry = {
"index": next_index,
"total": total_count,
"last_run": display_run,
@@ -297,6 +353,9 @@ class PromptQueueIterator:
"preview": selected_prompt[:60].replace('\n', ' '),
"assigned_prompts": assigned_prompts
}
if "shuffled_order" in locals():
new_entry["shuffled_order"] = shuffled_order
state[state_key] = new_entry
# Keep at most 3 most recent text hashes per node_id
node_prefix = f"node_{unique_id}_"
@@ -309,13 +368,14 @@ class PromptQueueIterator:
save_state(state)
# Send live progress update to UI
progress_data = {
"node_id": str(unique_id),
"current_run": display_run,
"total_runs": total_count,
"status_text": f"Run {display_run} / {total_count}"
}
try:
PromptServer.instance.send_sync("leafflow_prompt_iterator_progress", {
"node_id": str(unique_id),
"current_run": display_run,
"total_runs": total_count,
"status_text": f"Run {display_run} / {total_count}"
})
PromptServer.instance.send_sync("saturnnodes_prompt_iterator_progress", progress_data)
except Exception:
pass
+233 -93
View File
@@ -1,5 +1,7 @@
import os
import json
import secrets
import time
import threading
import asyncio
from aiohttp import web
@@ -7,12 +9,12 @@ from server import PromptServer
import nodes
from .tray_icon import TrayIconManager, is_tray_icon_enabled
from .assets_restore import assets_restore_manager, is_assets_restore_enabled, get_assets_restore_count
from .utils import get_leafflow_user_dir, is_local_request
from .utils import get_saturnnodes_user_dir, get_leafflow_user_dir, is_local_request, is_authenticated_local_request
QUEUE_CATEGORY = "🍃 LeafFlow/Queue"
QUEUE_CATEGORY = "🪐 SaturnNodes/Queue"
CURRENT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
USER_DIR = get_leafflow_user_dir()
USER_DIR = get_saturnnodes_user_dir()
PERSISTENT_FILE = os.path.join(USER_DIR, "persistent_queue.json")
ENV_FILE = os.path.join(USER_DIR, ".env")
@@ -31,31 +33,13 @@ def is_persistent_queue_enabled():
val = get_env_setting("ENABLE_PERSISTENT_QUEUE", "true").lower()
return val in ["true", "1", "yes"]
class PauseQueueNode:
@classmethod
def INPUT_TYPES(s):
return {"required": {}}
RETURN_TYPES = ()
FUNCTION = "noop"
CATEGORY = QUEUE_CATEGORY
DESCRIPTION = "Settings & status anchor for Pause Queue toolbar features."
def noop(self):
return ()
class PersistentQueueNode:
@classmethod
def INPUT_TYPES(s):
return {"required": {}}
RETURN_TYPES = ()
FUNCTION = "noop"
CATEGORY = QUEUE_CATEGORY
DESCRIPTION = "Settings & status anchor for Persistent Queue auto-recovery features."
def noop(self):
return ()
def is_process_management_enabled():
val = get_env_setting("ALLOW_PROCESS_MANAGEMENT", "false").lower()
return val in ["true", "1", "yes"]
class PauseQueueManager:
def __init__(self):
default_state = get_env_setting("DEFAULT_PAUSE_STATE", "Paused")
default_state = get_env_setting("DEFAULT_PAUSE_STATE", "Running")
default_mode = get_env_setting("DEFAULT_PAUSE_MODE", "after_finish")
self.paused = (default_state.lower() != "running")
@@ -71,57 +55,68 @@ class PauseQueueManager:
def patch_all(self):
if self._patched:
return
server = PromptServer.instance
server = getattr(PromptServer, "instance", None)
if not server:
print("[SaturnNodes] Queue control: Skipped pause patching (PromptServer instance not ready).")
return
if hasattr(server, "prompt_queue"):
queue = server.prompt_queue
original_get = queue.get
if not hasattr(server, "prompt_queue") or not hasattr(server.prompt_queue, "get"):
print("[SaturnNodes] Queue control: Skipped patching prompt_queue.get (incompatible ComfyUI version).")
return
def patched_get(*args, **kwargs):
while True:
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()
queue = server.prompt_queue
original_get = queue.get
item = original_get(*args, **kwargs)
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
queue.get = patched_get
original_send_sync = server.send_sync
def patched_send_sync(event, data, sid=None):
if event == "executing" and isinstance(data, dict) and data.get("node") is not None:
if self.paused and self.mode == "instantly":
def patched_get(*args, **kwargs):
while True:
if self.paused:
if not self.is_waiting:
self.is_waiting = True
print(f"[PauseQueue] Workflow Paused Instant: Paused before executing node '{data.get('node')}'.")
print("[PauseQueue] Workflow paused after this run: Current workflow finished, queue paused.")
self.notify_clients()
while self.paused and self.mode == "instantly":
while self.paused:
self.event.wait(0.2)
self.is_waiting = False
self.notify_clients()
return original_send_sync(event, data, sid)
server.send_sync = patched_send_sync
item = original_get(*args, **kwargs)
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
queue.get = patched_get
if hasattr(server, "send_sync") and callable(server.send_sync):
original_send_sync = server.send_sync
def patched_send_sync(event, data, sid=None):
if event == "executing" and isinstance(data, dict) and data.get("node") is not None:
if self.paused and self.mode == "instantly":
if not self.is_waiting:
self.is_waiting = True
print(f"[PauseQueue] Workflow Paused Instant: Paused before executing node '{data.get('node')}'.")
self.notify_clients()
while self.paused and self.mode == "instantly":
self.event.wait(0.2)
self.is_waiting = False
self.notify_clients()
return original_send_sync(event, data, sid)
server.send_sync = patched_send_sync
else:
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:
@@ -285,11 +280,18 @@ class PersistentQueueManager:
def patch_server(self):
if self._patched:
return
server = PromptServer.instance
if not hasattr(server, "prompt_queue"):
if not is_persistent_queue_enabled():
print("[SaturnNodes] Persistent queue disabled in settings; core prompt_queue patching skipped.")
return
server = getattr(PromptServer, "instance", None)
if not server or not hasattr(server, "prompt_queue"):
print("[SaturnNodes] Queue control: Skipped persistent queue patching (prompt_queue not found on server).")
return
queue = server.prompt_queue
if not hasattr(queue, "put") or not hasattr(queue, "task_done"):
print("[SaturnNodes] Queue control: Skipped persistent queue patching (incompatible queue interface).")
return
# 1. Patch queue.put
original_put = queue.put
@@ -359,7 +361,7 @@ class PersistentQueueManager:
server.send_sync = patched_send_sync
self._patched = True
print("[PersistentQueue] Server queue hooks patched successfully.")
print("[SaturnNodes] Queue control: Persistent queue manager initialized successfully.")
def restore_queue(self, active_client_id=None):
if self.has_claimed_once:
@@ -462,27 +464,33 @@ class PowerControlManager:
def arm(self, action):
with self.lock:
if action in ["restart", "shutdown"]:
if not is_process_management_enabled():
print(f"[SaturnNodes Power] Arm rejected: Process Management is disabled in LeafFlow settings.")
return False
self.pending_action = action
import time
self.armed_at = time.time()
print(f"[LeafFlow Power] Armed action '{action}' after queue completion.")
print(f"[SaturnNodes Power] Armed action '{action}' after queue completion.")
else:
self.pending_action = None
self.armed_at = None
print("[LeafFlow Power] Cancelled armed queue power action.")
print("[SaturnNodes Power] Cancelled armed queue power action.")
self.notify_clients()
return True
def get_status(self):
with self.lock:
return {
"pending_action": self.pending_action,
"armed_at": self.armed_at,
"is_paused": getattr(self.pause_manager, "paused", False) or getattr(self.pause_manager, "is_waiting", False)
"is_paused": getattr(self.pause_manager, "paused", False) or getattr(self.pause_manager, "is_waiting", False),
"enabled": is_process_management_enabled()
}
def notify_clients(self):
try:
PromptServer.instance.send_sync("leafflow_power_status", self.get_status())
status = self.get_status()
PromptServer.instance.send_sync("saturnnodes_power_status", status)
except Exception:
pass
@@ -491,6 +499,11 @@ class PowerControlManager:
if not self.pending_action or self._executing:
return
# CRITICAL: If process management is disabled, cancel and abort
if not is_process_management_enabled():
self.pending_action = None
return
# CRITICAL: If pause mode is active or waiting, DO NOT execute!
is_paused = getattr(self.pause_manager, "paused", False) or getattr(self.pause_manager, "is_waiting", False)
if is_paused:
@@ -508,7 +521,7 @@ class PowerControlManager:
action = self.pending_action
self._executing = True
self.pending_action = None
print(f"[LeafFlow Power] Queue is empty and idle. Executing '{action}' now...")
print(f"[SaturnNodes Power] Queue is empty and idle. Executing '{action}' now...")
if action == "restart":
self.execute_restart()
@@ -516,9 +529,13 @@ class PowerControlManager:
self.execute_shutdown()
def execute_restart(self):
if not is_process_management_enabled():
print("[SaturnNodes Power] Rejected restart: Process Management is disabled in LeafFlow settings.")
return False
def _run():
import subprocess, sys, os, time
print("[LeafFlow Power] Restarting ComfyUI server process...")
print("[SaturnNodes Power] Restarting ComfyUI server process...")
time.sleep(0.6)
cmd = [sys.executable]
if getattr(sys.flags, "no_user_site", 0):
@@ -534,14 +551,49 @@ class PowerControlManager:
time.sleep(0.4)
os._exit(0)
threading.Thread(target=_run, daemon=True).start()
return True
def execute_shutdown(self):
if not is_process_management_enabled():
print("[SaturnNodes Power] Rejected shutdown: Process Management is disabled in LeafFlow settings.")
return False
def _run():
import os, time
print("[LeafFlow Power] Shutting down ComfyUI server...")
print("[SaturnNodes Power] Shutting down ComfyUI server...")
time.sleep(0.6)
os._exit(0)
threading.Thread(target=_run, daemon=True).start()
return True
_POWER_TICKETS = {}
_POWER_TICKET_LOCK = threading.Lock()
def _issue_power_ticket(action):
with _POWER_TICKET_LOCK:
now = time.time()
expired = [t for t, (exp, _) in _POWER_TICKETS.items() if exp < now]
for t in expired:
_POWER_TICKETS.pop(t, None)
ticket = secrets.token_urlsafe(24)
_POWER_TICKETS[ticket] = (now + 30.0, action)
return ticket
def _consume_power_ticket(ticket, expected_action=None):
if not ticket or not isinstance(ticket, str):
return False
with _POWER_TICKET_LOCK:
now = time.time()
if ticket not in _POWER_TICKETS:
return False
exp, act = _POWER_TICKETS[ticket]
if exp < now:
_POWER_TICKETS.pop(ticket, None)
return False
if expected_action and act != expected_action:
return False
_POWER_TICKETS.pop(ticket, None)
return True
pause_manager = PauseQueueManager()
persistent_manager = PersistentQueueManager()
@@ -561,16 +613,21 @@ def setup_queue_control_routes(server):
routes = server.routes
@routes.get("/leafflow/power/status")
@routes.get("/saturnnodes/power/status")
async def get_power_status(request):
if not is_local_request(request):
return web.json_response({"error": "Forbidden: Local access only"}, status=403)
return web.json_response(power_manager.get_status())
@routes.post("/leafflow/power/arm")
@routes.post("/saturnnodes/power/arm")
async def arm_power_action(request):
if not is_local_request(request):
return web.json_response({"error": "Forbidden: Local access only"}, status=403)
if not is_authenticated_local_request(request):
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
if not is_process_management_enabled():
return web.json_response({
"error": "Process Management is disabled. Enable 'Allow Process Management' in SaturnNodes settings to use restart or shutdown.",
"enabled": False
}, status=403)
try:
data = await request.json()
except Exception:
@@ -579,24 +636,97 @@ def setup_queue_control_routes(server):
power_manager.arm(action)
return web.json_response(power_manager.get_status())
@routes.post("/leafflow/power/restart")
@routes.post("/saturnnodes/power/request_token")
async def request_power_token(request):
if not is_authenticated_local_request(request):
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
if not is_process_management_enabled():
return web.json_response({
"error": "Process Management is disabled. Enable 'Allow Process Management' in SaturnNodes settings.",
"enabled": False
}, status=403)
try:
data = await request.json()
except Exception:
data = {}
action = data.get("action")
if action not in ["restart", "shutdown"]:
return web.json_response({"error": "Invalid action. Must be 'restart' or 'shutdown'."}, status=400)
ticket = _issue_power_ticket(action)
return web.json_response({
"success": True,
"ticket": ticket,
"action": action,
"expires_in": 30
})
@routes.post("/saturnnodes/power/confirm_action")
async def confirm_power_action(request):
if not is_authenticated_local_request(request):
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
if not is_process_management_enabled():
return web.json_response({
"error": "Process Management is disabled. Enable 'Allow Process Management' in SaturnNodes settings.",
"enabled": False
}, status=403)
try:
data = await request.json()
except Exception:
data = {}
ticket = data.get("ticket")
action = data.get("action")
if not _consume_power_ticket(ticket, action):
return web.json_response({"error": "Invalid or expired confirmation ticket. Operator confirmation required."}, status=403)
if action == "restart":
power_manager.execute_restart()
return web.json_response({"status": "restarting", "success": True})
elif action == "shutdown":
power_manager.execute_shutdown()
return web.json_response({"status": "shutting_down", "success": True})
return web.json_response({"error": "Unknown action"}, status=400)
@routes.post("/saturnnodes/power/restart")
async def trigger_restart(request):
if not is_local_request(request):
return web.json_response({"error": "Forbidden: Local access only"}, status=403)
if not is_authenticated_local_request(request):
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
if not is_process_management_enabled():
return web.json_response({
"error": "Process Management is disabled. Enable 'Allow Process Management' in SaturnNodes settings to use restart.",
"enabled": False
}, status=403)
try:
data = await request.json()
except Exception:
data = {}
ticket = data.get("ticket")
if not _consume_power_ticket(ticket, "restart"):
return web.json_response({"error": "Confirmation ticket required. Use /saturnnodes/power/request_token first."}, status=403)
power_manager.execute_restart()
return web.json_response({"status": "restarting"})
@routes.post("/leafflow/power/shutdown")
@routes.post("/saturnnodes/power/shutdown")
async def trigger_shutdown(request):
if not is_local_request(request):
return web.json_response({"error": "Forbidden: Local access only"}, status=403)
if not is_authenticated_local_request(request):
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
if not is_process_management_enabled():
return web.json_response({
"error": "Process Management is disabled. Enable 'Allow Process Management' in SaturnNodes settings to use shutdown.",
"enabled": False
}, status=403)
try:
data = await request.json()
except Exception:
data = {}
ticket = data.get("ticket")
if not _consume_power_ticket(ticket, "shutdown"):
return web.json_response({"error": "Confirmation ticket required. Use /saturnnodes/power/request_token first."}, status=403)
power_manager.execute_shutdown()
return web.json_response({"status": "shutting_down"})
@routes.post("/leafflow/assets/restore")
@routes.post("/saturnnodes/assets/restore")
async def restore_assets_endpoint(request):
if not is_local_request(request):
return web.json_response({"error": "Forbidden: Local access only"}, status=403)
if not is_authenticated_local_request(request):
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
try:
data = await request.json()
except Exception:
@@ -606,22 +736,22 @@ def setup_queue_control_routes(server):
count = assets_restore_manager.restore_on_launch(server, limit=limit, force=force)
return web.json_response({"success": True, "restored": count, "debug": assets_restore_manager.last_debug_report})
@routes.get("/leafflow/assets/debug")
@routes.get("/saturnnodes/assets/debug")
async def get_assets_debug(request):
if not is_local_request(request):
return web.json_response({"error": "Forbidden: Local access only"}, status=403)
return web.json_response(assets_restore_manager.last_debug_report)
@routes.get("/leafflow/batch_queue/data")
@routes.get("/saturnnodes/batch_queue/data")
async def get_batch_queue_data(request):
if not is_local_request(request):
return web.json_response({"error": "Forbidden: Local access only"}, status=403)
return web.json_response(persistent_manager.batch_meta)
@routes.post("/leafflow/batch_queue/sync")
@routes.post("/saturnnodes/batch_queue/sync")
async def sync_batch_queue_data(request):
if not is_local_request(request):
return web.json_response({"error": "Forbidden: Local access only"}, status=403)
if not is_authenticated_local_request(request):
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
try:
data = await request.json()
pid = data.get("prompt_id")
@@ -635,6 +765,8 @@ def setup_queue_control_routes(server):
@routes.get("/pause_queue/status")
async def get_status(request):
if not is_local_request(request):
return web.json_response({"error": "Forbidden: Local access only"}, status=403)
pause_manager.patch_all()
return web.json_response({
"paused": pause_manager.paused,
@@ -644,6 +776,8 @@ def setup_queue_control_routes(server):
@routes.post("/pause_queue/toggle")
async def toggle_pause(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()
except Exception:
@@ -659,6 +793,8 @@ def setup_queue_control_routes(server):
@routes.post("/pause_queue/mode")
async def set_mode_route(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()
except Exception:
@@ -673,6 +809,8 @@ def setup_queue_control_routes(server):
@routes.post("/pause_queue/continue")
async def continue_queue(request):
if not is_authenticated_local_request(request):
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
pause_manager.set_pause(False)
return web.json_response({
"paused": pause_manager.paused,
@@ -682,6 +820,8 @@ def setup_queue_control_routes(server):
@routes.post("/persistent_queue/claim")
async def claim_queue(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()
client_id = data.get("client_id")
+26 -13
View File
@@ -1,18 +1,31 @@
import re
import csv
import io
def parse_find_targets(find_str):
if not find_str:
if not find_str or not find_str.strip():
return []
raw_items = []
# Split strictly by comma
parts = find_str.split(',')
for part in parts:
item = part.strip()
if (item.startswith('"') and item.endswith('"')) or (item.startswith("'") and item.endswith("'")):
item = item[1:-1].strip()
if item:
raw_items.append(item)
return raw_items
try:
reader = csv.reader(io.StringIO(find_str.strip()), skipinitialspace=True)
raw_items = []
for row in reader:
for item in row:
s = item.strip()
if (s.startswith('"') and s.endswith('"')) or (s.startswith("'") and s.endswith("'")):
s = s[1:-1].strip()
if s:
raw_items.append(s)
return raw_items
except Exception:
raw_items = []
parts = find_str.split(',')
for part in parts:
item = part.strip()
if (item.startswith('"') and item.endswith('"')) or (item.startswith("'") and item.endswith("'")):
item = item[1:-1].strip()
if item:
raw_items.append(item)
return raw_items
class MultiTextReplacer:
@classmethod
@@ -32,7 +45,7 @@ class MultiTextReplacer:
RETURN_TYPES = ("STRING", "INT")
RETURN_NAMES = ("text", "replaced_count")
FUNCTION = "replace_text"
CATEGORY = "🍃 LeafFlow/Utils"
CATEGORY = "🪐 SaturnNodes/Utils"
DESCRIPTION = "Searches input text for multiple search targets specified in a comma-separated list or regex, and replaces all matches with a replacement string."
def replace_text(
@@ -75,6 +88,6 @@ class MultiTextReplacer:
replaced_count += count
result_text = re.sub(pattern_str, lambda m: replace, result_text, flags=flags)
except Exception as e:
print(f"[LeafFlow] 🍃 Invalid regex pattern '{pattern_str}': {e}")
print(f"[SaturnNodes] 🍃 Invalid regex pattern '{pattern_str}': {e}")
return (result_text, replaced_count)
+7 -4
View File
@@ -1,8 +1,8 @@
import re
class LeafFlowTextSplit:
class SaturnTextSplit:
"""
LeafFlow Text Node:
SaturnNodes Text Split Node:
Splits input text into two parts (text1 and text2) by either a literal character sequence
or a regular expression pattern, supporting forward (from start) and backward (from end) splitting.
"""
@@ -23,7 +23,7 @@ class LeafFlowTextSplit:
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("text1", "text2")
FUNCTION = "split_text"
CATEGORY = "🍃 LeafFlow/Utils"
CATEGORY = "🪐 SaturnNodes/Utils"
DESCRIPTION = "Splits input text into two parts (text1 and text2) by a character sequence or regex pattern, with forward/backward splitting."
def split_text(
@@ -64,7 +64,7 @@ class LeafFlowTextSplit:
else:
t1, t2 = input_text, ""
except Exception as e:
print(f"[LeafFlow Text Split] Regex error: {e}. Falling back to literal string split.")
print(f"[SaturnNodes Text Split] Regex error: {e}. Falling back to literal string split.")
if is_forward:
parts = input_text.split(delimiter, 1)
else:
@@ -85,3 +85,6 @@ class LeafFlowTextSplit:
t2 = t2.strip()
return (t1, t2)
LeafFlowTextSplit = SaturnTextSplit
SaturnTextSplit = SaturnTextSplit
+21 -22
View File
@@ -3,9 +3,9 @@ import sys
import threading
import webbrowser
from PIL import Image, ImageDraw
from .utils import get_leafflow_user_dir
from .utils import get_saturnnodes_user_dir, get_leafflow_user_dir
USER_DIR = get_leafflow_user_dir()
USER_DIR = get_saturnnodes_user_dir()
ENV_FILE = os.path.join(USER_DIR, ".env")
def get_env_setting(key, default_val):
@@ -21,7 +21,13 @@ def get_env_setting(key, default_val):
def is_tray_icon_enabled():
val = get_env_setting("ENABLE_TRAY_ICON", "false").lower()
return val in ["true", "1", "yes"]
if val not in ["true", "1", "yes"]:
return False
try:
import pystray
return True
except ImportError:
return False
class TrayIconManager:
def __init__(self, pause_manager=None):
@@ -62,7 +68,7 @@ class TrayIconManager:
def get_status_info(self):
if not self.pause_manager:
return "Running", "#059669", "pause", "ComfyUI LeafFlow: Running"
return "Running", "#059669", "pause", "ComfyUI SaturnNodes: Running"
unpaused_color, paused_color = self.get_current_colors()
is_paused = self.pause_manager.paused
@@ -75,17 +81,17 @@ class TrayIconManager:
status_text = "Paused (Waiting)"
color = paused_color
symbol = "play"
title = f"ComfyUI LeafFlow: Paused ({mode_label}) - Click to Continue"
title = f"ComfyUI SaturnNodes: Paused ({mode_label}) - Click to Continue"
else:
status_text = f"Pausing ({mode_label})..."
color = paused_color
symbol = "pause"
title = f"ComfyUI LeafFlow: Pausing ({mode_label})... (Waiting to complete)"
title = f"ComfyUI SaturnNodes: Pausing ({mode_label})... (Waiting to complete)"
else:
status_text = f"Running [Next Pause: {mode_label}]"
color = unpaused_color
symbol = "pause"
title = f"ComfyUI LeafFlow: Running (Mode: {mode_label})"
title = f"ComfyUI SaturnNodes: Running (Mode: {mode_label})"
return status_text, color, symbol, title
@@ -140,7 +146,7 @@ class TrayIconManager:
server_url = f"http://{host}:{port}"
webbrowser.open(server_url)
except Exception as e:
print(f"[LeafFlow] Error opening browser: {e}")
print(f"[SaturnNodes] Error opening browser: {e}")
def on_disable_tray(icon, item):
self.disable_and_stop()
@@ -172,22 +178,15 @@ class TrayIconManager:
try:
import pystray
except ImportError:
try:
import subprocess
print("[LeafFlow] pystray is not installed. Auto-installing pystray...")
subprocess.check_call([sys.executable, "-m", "pip", "install", "pystray"])
import pystray
print("[LeafFlow] Successfully auto-installed pystray.")
except Exception as e:
print(f"[LeafFlow] Failed to auto-install pystray: {e}. System tray icon cannot start.")
return
print("[SaturnNodes] System tray icon disabled: 'pystray' is not installed. Install pystray ('pip install pystray') to enable system tray support.")
return
_, color, symbol, title = self.get_status_info()
icon_img = self.create_icon_image(color, symbol)
menu = self.build_menu()
self._icon = pystray.Icon(
name="ComfyUI-LeafFlow",
name="ComfyUI-SaturnNodes",
icon=icon_img,
title=title,
menu=menu
@@ -196,10 +195,10 @@ class TrayIconManager:
def _run():
try:
self._is_running = True
print("[LeafFlow] System tray icon started.")
print("[SaturnNodes] System tray icon started.")
self._icon.run()
except Exception as e:
print(f"[LeafFlow] System tray icon error: {e}")
print(f"[SaturnNodes] System tray icon error: {e}")
finally:
self._is_running = False
self._icon = None
@@ -216,7 +215,7 @@ class TrayIconManager:
pass
self._icon = None
self._is_running = False
print("[LeafFlow] System tray icon stopped.")
print("[SaturnNodes] System tray icon stopped.")
def update_status(self):
with self._lock:
@@ -229,7 +228,7 @@ class TrayIconManager:
self._icon.title = title
self._icon.menu = self.build_menu()
except Exception as e:
print(f"[LeafFlow] Error updating system tray status: {e}")
print(f"[SaturnNodes] Error updating system tray status: {e}")
def set_enabled(self, enabled):
if enabled:
+86 -9
View File
@@ -1,18 +1,19 @@
import os
import re
import secrets
from urllib.parse import urlparse
import folder_paths
def get_leafflow_user_dir():
def get_saturnnodes_user_dir():
"""
Returns the centralized user data directory: ComfyUI/user/default/LeafFlow/
Returns the centralized user data directory: ComfyUI/user/default/SaturnNodes/
Automatically migrates any legacy LeafFlow directory contents if present.
"""
base_user = None
try:
if hasattr(folder_paths, "get_user_directory"):
user_base = folder_paths.get_user_directory()
if user_base:
# ComfyUI get_user_directory() returns '.../ComfyUI/user'
# Ensure the 'default' user profile directory is used
norm_base = os.path.normpath(user_base)
if os.path.basename(norm_base).lower() == "user":
base_user = os.path.join(norm_base, "default")
@@ -22,13 +23,89 @@ def get_leafflow_user_dir():
base_user = None
if not base_user:
# Fallback to ComfyUI/user/default
root_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
base_user = os.path.join(root_dir, "user", "default")
leafflow_dir = os.path.join(base_user, "LeafFlow")
os.makedirs(leafflow_dir, exist_ok=True)
return leafflow_dir
saturn_dir = os.path.join(base_user, "SaturnNodes")
os.makedirs(saturn_dir, exist_ok=True)
# Seamless automatic migration from legacy LeafFlow folder if needed
legacy_dir = os.path.join(base_user, "LeafFlow")
if os.path.isdir(legacy_dir):
for f in [".env", "prompt_iterator_state.json"]:
src = os.path.join(legacy_dir, f)
dst = os.path.join(saturn_dir, f)
if os.path.isfile(src) and not os.path.exists(dst):
try:
import shutil
shutil.copy2(src, dst)
except Exception:
pass
return saturn_dir
get_leafflow_user_dir = get_saturnnodes_user_dir
_SESSION_CSRF_TOKEN = secrets.token_hex(32)
def get_csrf_token():
"""Returns the ephemeral cryptographically random session CSRF token generated on server boot."""
return _SESSION_CSRF_TOKEN
def is_same_origin_or_direct(request):
"""
Validates that a request originates from the local ComfyUI interface and not from a visited web page (CSRF defense).
"""
if request is None:
return True
# 1. Reject cross-site requests via Fetch Metadata (modern browsers always send this; cannot be spoofed by JS)
sec_fetch_site = getattr(request, "headers", {}).get("Sec-Fetch-Site")
if sec_fetch_site and sec_fetch_site.lower() == "cross-site":
return False
# 2. Check Origin header if present
origin = getattr(request, "headers", {}).get("Origin")
if origin:
try:
parsed = urlparse(origin)
host = parsed.hostname
if host not in ["127.0.0.1", "localhost", "::1", "0.0.0.0", "testclient"]:
return False
except Exception:
return False
# 3. Check Referer header if present
referer = getattr(request, "headers", {}).get("Referer")
if referer:
try:
parsed_ref = urlparse(referer)
ref_host = parsed_ref.hostname
if ref_host not in ["127.0.0.1", "localhost", "::1", "0.0.0.0", "testclient"]:
return False
except Exception:
return False
return True
def is_authenticated_local_request(request):
"""
Guarantees user-initiated actions and protects against CSRF / drive-by requests from open browser tabs:
1. Originates strictly from loopback IP (is_local_request)
2. Not a cross-site drive-by request (is_same_origin_or_direct)
3. Contains a valid X-LeafFlow-CSRF-Token header matching the session secret.
"""
if not is_local_request(request):
return False
if not is_same_origin_or_direct(request):
return False
headers = getattr(request, "headers", {})
token = headers.get("X-SaturnNodes-CSRF-Token") or headers.get("X-LeafFlow-CSRF-Token")
if not token or not secrets.compare_digest(token, _SESSION_CSRF_TOKEN):
return False
return True
def is_local_request(request):
"""
@@ -140,7 +217,7 @@ def get_env_setting(key, default_val):
"""
Reads a key setting from ComfyUI/user/default/LeafFlow/.env safely.
"""
user_dir = get_leafflow_user_dir()
user_dir = get_saturnnodes_user_dir()
env_file = os.path.join(user_dir, ".env")
if os.path.exists(env_file):
try:
+9 -6
View File
@@ -1,15 +1,18 @@
[project]
name = "comfyui-leafflow"
name = "comfyui-saturnnodes"
description = "Essential workflow controls, mid-generation pausing, real-time queue persistence, folder watcher, and visual model loaders for ComfyUI."
version = "2.2.0"
version = "2.3.2"
readme = "README.md"
license = "MIT"
dependencies = ["pillow", "numpy", "piexif", "pystray"]
dependencies = ["pillow", "numpy", "piexif"]
[project.optional-dependencies]
tray = ["pystray"]
[project.urls]
Repository = "https://github.com/KOFiblto/ComfyUI-LeafFlow"
Repository = "https://github.com/KOFiblto/ComfyUI-SaturnNodes"
[tool.comfy]
PublisherId = "KOFiblto"
DisplayName = "ComfyUI-LeafFlow"
Icon = "https://raw.githubusercontent.com/KOFiblto/ComfyUI-LeafFlow/main/web/logo.png"
DisplayName = "ComfyUI-SaturnNodes"
Icon = "https://raw.githubusercontent.com/KOFiblto/ComfyUI-SaturnNodes/main/web/logo.png"
+1 -1
View File
@@ -1,4 +1,4 @@
Pillow
piexif
numpy
pystray
# pystray # Optional: install manually if desktop system tray icon integration is enabled
+2
View File
@@ -4,6 +4,8 @@ import unittest
if __name__ == "__main__":
test_dir = os.path.dirname(os.path.abspath(__file__))
if test_dir not in sys.path:
sys.path.insert(0, test_dir)
suite = unittest.defaultTestLoader.discover(test_dir, pattern="test_*.py")
runner = unittest.TextTestRunner(verbosity=2)
result = runner.run(suite)
+2 -2
View File
@@ -6,11 +6,11 @@ import threading
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import test_helper
from nodes.decision_node import DecisionManager, LeafFlowDecision
from nodes.decision_node import DecisionManager, SaturnDecision
class TestDecisionNode(unittest.TestCase):
def setUp(self):
self.node = LeafFlowDecision()
self.node = SaturnDecision()
def test_decision_manager_register_and_trigger(self):
uid = "test_decision_123"
+185
View File
@@ -0,0 +1,185 @@
import os
import sys
import tempfile
import unittest
from unittest.mock import patch, MagicMock
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import test_helper
from nodes.local_runner import (
RunLocalFileNode,
get_allowed_scripts_directory,
is_script_path_permitted,
resolve_target_path,
authorize_node_run,
consume_node_authorization,
clear_all_authorizations
)
class TestLocalRunner(unittest.TestCase):
def setUp(self):
self.node = RunLocalFileNode()
clear_all_authorizations()
def tearDown(self):
clear_all_authorizations()
def test_operator_authorization_required(self):
with patch("nodes.local_runner.is_local_file_execution_enabled", return_value=True):
with tempfile.TemporaryDirectory() as temp_dir:
script_path = os.path.join(temp_dir, "test.bat")
with open(script_path, "w") as f:
f.write("@echo off\n")
with patch("nodes.local_runner.get_allowed_scripts_directory", return_value=temp_dir):
stdout, stderr, exit_code, success, passthrough = self.node.run_local_file(
file_path="test.bat",
unique_id="node_99",
trigger="trigger_data"
)
self.assertEqual(exit_code, -1)
self.assertFalse(success)
self.assertIn("Authorize Run", stderr)
self.assertEqual(passthrough, "trigger_data")
def test_global_security_setting_disabled_by_default(self):
with patch("nodes.local_runner.is_local_file_execution_enabled", return_value=False):
stdout, stderr, exit_code, success, _ = self.node.run_local_file(
file_path="some_script.bat",
unique_id="node_99"
)
self.assertEqual(exit_code, -1)
self.assertFalse(success)
self.assertIn("disabled by default for security", stderr)
def test_path_confinement_rejects_absolute_paths(self):
resolved, msg = resolve_target_path("C:\\Windows\\System32\\cmd.exe")
self.assertIsNone(resolved)
self.assertIn("Absolute paths are forbidden", msg)
resolved_posix, msg_posix = resolve_target_path("/bin/sh")
self.assertIsNone(resolved_posix)
self.assertIn("Absolute paths are forbidden", msg_posix)
def test_path_confinement_rejects_traversal(self):
resolved, msg = resolve_target_path("../../../etc/passwd")
self.assertIsNone(resolved)
self.assertIn("Directory traversal", msg)
resolved_win, msg_win = resolve_target_path("scripts/../../sensitive.txt")
self.assertIsNone(resolved_win)
self.assertIn("Directory traversal", msg_win)
def test_single_use_authorization_consumed_on_run(self):
with tempfile.TemporaryDirectory() as temp_dir:
if sys.platform.startswith("win"):
script_path = os.path.join(temp_dir, "single_use.bat")
with open(script_path, "w") as f:
f.write("@echo off\necho Consumed\n")
else:
script_path = os.path.join(temp_dir, "single_use.sh")
with open(script_path, "w") as f:
f.write('#!/bin/bash\necho "Consumed"\n')
with patch("nodes.local_runner.get_allowed_scripts_directory", return_value=temp_dir), \
patch("nodes.local_runner.is_local_file_execution_enabled", return_value=True):
# 1. Authorize for node_42
filename = os.path.basename(script_path)
authorize_node_run("node_42", filename)
# First run must succeed
stdout, stderr, exit_code, success, _ = self.node.run_local_file(
file_path=filename,
unique_id="node_42"
)
self.assertEqual(exit_code, 0)
self.assertTrue(success)
# Second run with same node ID must be rejected (authorization consumed)
stdout2, stderr2, exit_code2, success2, _ = self.node.run_local_file(
file_path=filename,
unique_id="node_42"
)
self.assertEqual(exit_code2, -1)
self.assertFalse(success2)
self.assertIn("Node has not been authorized", stderr2)
def test_script_mismatch_authorization_rejected(self):
with patch("nodes.local_runner.is_local_file_execution_enabled", return_value=True):
authorize_node_run("node_1", "approved_tool.bat")
authorized, msg = consume_node_authorization("node_1", "C:\\scripts\\malicious_tool.bat")
self.assertFalse(authorized)
self.assertIn("does not match", msg)
def test_build_command_args(self):
target = "test_script.bat" if sys.platform.startswith("win") else "test_script.sh"
cmd = self.node.build_command_args(target, '--arg1 "value with spaces" --num 42')
if sys.platform.startswith("win"):
self.assertIn("/c", cmd)
self.assertIn("--arg1", cmd)
self.assertTrue(any("value with spaces" in x for x in cmd))
self.assertIn("42", cmd)
else:
self.assertIn("/bin/bash", cmd)
self.assertIn("--arg1", cmd)
self.assertIn("value with spaces", cmd)
def test_execution_synchronous(self):
with tempfile.TemporaryDirectory() as temp_dir:
if sys.platform.startswith("win"):
script_path = os.path.join(temp_dir, "echo_test.bat")
with open(script_path, "w") as f:
f.write("@echo off\necho Output: %1 %2\n")
else:
script_path = os.path.join(temp_dir, "echo_test.sh")
with open(script_path, "w") as f:
f.write('#!/bin/bash\necho "Output: $1 $2"\n')
filename = os.path.basename(script_path)
authorize_node_run("node_sync", filename)
with patch("nodes.local_runner.get_allowed_scripts_directory", return_value=temp_dir), \
patch("nodes.local_runner.is_local_file_execution_enabled", return_value=True):
stdout, stderr, exit_code, success, passthrough = self.node.run_local_file(
file_path=filename,
parameters="Hello World",
run_mode="Synchronous (Wait for Output)",
trigger="flow_pass",
unique_id="node_sync"
)
self.assertEqual(exit_code, 0)
self.assertTrue(success)
self.assertIn("Hello World", stdout)
self.assertEqual(passthrough, "flow_pass")
def test_timeout_cancellation(self):
with tempfile.TemporaryDirectory() as temp_dir:
if sys.platform.startswith("win"):
script_path = os.path.join(temp_dir, "sleep_test.bat")
with open(script_path, "w") as f:
f.write(f"@echo off\n\"{sys.executable}\" -c \"import time; time.sleep(5)\"\n")
else:
script_path = os.path.join(temp_dir, "sleep_test.sh")
with open(script_path, "w") as f:
f.write("#!/bin/bash\nsleep 3\n")
filename = os.path.basename(script_path)
authorize_node_run("node_timeout", filename)
with patch("nodes.local_runner.get_allowed_scripts_directory", return_value=temp_dir), \
patch("nodes.local_runner.is_local_file_execution_enabled", return_value=True):
stdout, stderr, exit_code, success, _ = self.node.run_local_file(
file_path=filename,
timeout=1,
run_mode="Synchronous (Wait for Output)",
unique_id="node_timeout"
)
self.assertEqual(exit_code, -1)
self.assertFalse(success)
self.assertIn("timed out", stderr)
import time
time.sleep(0.5)
if __name__ == "__main__":
unittest.main()
+98 -38
View File
@@ -1,6 +1,20 @@
import unittest
import os
import sys
import unittest
import time
from unittest.mock import MagicMock, patch
from nodes.queue_control import PowerControlManager, PauseQueueManager
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import test_helper
from nodes.queue_control import (
PowerControlManager,
PauseQueueManager,
_issue_power_ticket,
_consume_power_ticket,
_POWER_TICKETS,
_POWER_TICKET_LOCK
)
class TestPowerControlManager(unittest.TestCase):
def setUp(self):
@@ -8,56 +22,102 @@ class TestPowerControlManager(unittest.TestCase):
self.pause_manager.paused = False
self.pause_manager.is_waiting = False
self.power_manager = PowerControlManager(self.pause_manager)
with _POWER_TICKET_LOCK:
_POWER_TICKETS.clear()
def tearDown(self):
with _POWER_TICKET_LOCK:
_POWER_TICKETS.clear()
def test_arm_disabled_by_default(self):
with patch("nodes.queue_control.is_process_management_enabled", return_value=False):
result = self.power_manager.arm("restart")
self.assertFalse(result)
self.assertIsNone(self.power_manager.pending_action)
self.assertIsNone(self.power_manager.armed_at)
status = self.power_manager.get_status()
self.assertFalse(status["enabled"])
self.assertFalse(self.power_manager.execute_restart())
self.assertFalse(self.power_manager.execute_shutdown())
def test_arm_and_disarm(self):
self.power_manager.arm("restart")
self.assertEqual(self.power_manager.pending_action, "restart")
self.assertIsNotNone(self.power_manager.armed_at)
with patch("nodes.queue_control.is_process_management_enabled", return_value=True):
result = self.power_manager.arm("restart")
self.assertTrue(result)
self.assertEqual(self.power_manager.pending_action, "restart")
self.assertIsNotNone(self.power_manager.armed_at)
status = self.power_manager.get_status()
self.assertEqual(status["pending_action"], "restart")
status = self.power_manager.get_status()
self.assertEqual(status["pending_action"], "restart")
self.assertTrue(status["enabled"])
self.power_manager.arm("shutdown")
self.assertEqual(self.power_manager.pending_action, "shutdown")
self.power_manager.arm("shutdown")
self.assertEqual(self.power_manager.pending_action, "shutdown")
self.power_manager.arm(None)
self.assertIsNone(self.power_manager.pending_action)
self.assertIsNone(self.power_manager.armed_at)
self.power_manager.arm(None)
self.assertIsNone(self.power_manager.pending_action)
self.assertIsNone(self.power_manager.armed_at)
def test_power_ticket_single_use_handshake(self):
# 1. Issue ticket for restart
ticket = _issue_power_ticket("restart")
self.assertIsInstance(ticket, str)
self.assertGreater(len(ticket), 16)
# 2. Consume ticket with wrong action -> Rejected
self.assertFalse(_consume_power_ticket(ticket, "shutdown"))
# 3. Consume ticket with correct action -> Accepted
self.assertTrue(_consume_power_ticket(ticket, "restart"))
# 4. Consume ticket again -> Rejected (single-use)
self.assertFalse(_consume_power_ticket(ticket, "restart"))
def test_power_ticket_expiration(self):
ticket = _issue_power_ticket("shutdown")
# Artificially expire the ticket
with _POWER_TICKET_LOCK:
_POWER_TICKETS[ticket] = (time.time() - 1.0, "shutdown")
self.assertFalse(_consume_power_ticket(ticket, "shutdown"))
def test_does_not_execute_when_paused(self):
self.power_manager.arm("restart")
self.pause_manager.paused = True
with patch("nodes.queue_control.is_process_management_enabled", return_value=True):
self.power_manager.arm("restart")
self.pause_manager.paused = True
with patch.object(self.power_manager, "execute_restart") as mock_restart:
self.power_manager.check_and_execute()
mock_restart.assert_not_called()
self.assertEqual(self.power_manager.pending_action, "restart")
def test_does_not_execute_when_tasks_remaining(self):
self.power_manager.arm("restart")
mock_queue = MagicMock()
mock_queue.get_tasks_remaining.return_value = 2
mock_queue.currently_running = {"1": True}
with patch("server.PromptServer.instance") as mock_server:
mock_server.prompt_queue = mock_queue
with patch.object(self.power_manager, "execute_restart") as mock_restart:
self.power_manager.check_and_execute()
mock_restart.assert_not_called()
self.assertEqual(self.power_manager.pending_action, "restart")
def test_executes_when_queue_empty_and_idle(self):
self.power_manager.arm("restart")
mock_queue = MagicMock()
mock_queue.get_tasks_remaining.return_value = 0
mock_queue.currently_running = {}
def test_does_not_execute_when_tasks_remaining(self):
with patch("nodes.queue_control.is_process_management_enabled", return_value=True):
self.power_manager.arm("restart")
mock_queue = MagicMock()
mock_queue.get_tasks_remaining.return_value = 2
mock_queue.currently_running = {"1": True}
with patch("server.PromptServer.instance") as mock_server:
mock_server.prompt_queue = mock_queue
with patch.object(self.power_manager, "execute_restart") as mock_restart:
self.power_manager.check_and_execute()
mock_restart.assert_called_once()
self.assertIsNone(self.power_manager.pending_action)
with patch("server.PromptServer.instance") as mock_server:
mock_server.prompt_queue = mock_queue
with patch.object(self.power_manager, "execute_restart") as mock_restart:
self.power_manager.check_and_execute()
mock_restart.assert_not_called()
self.assertEqual(self.power_manager.pending_action, "restart")
def test_executes_when_queue_empty_and_idle(self):
with patch("nodes.queue_control.is_process_management_enabled", return_value=True):
self.power_manager.arm("restart")
mock_queue = MagicMock()
mock_queue.get_tasks_remaining.return_value = 0
mock_queue.currently_running = {}
with patch("server.PromptServer.instance") as mock_server:
mock_server.prompt_queue = mock_queue
with patch.object(self.power_manager, "execute_restart") as mock_restart:
self.power_manager.check_and_execute()
mock_restart.assert_called_once()
self.assertIsNone(self.power_manager.pending_action)
if __name__ == "__main__":
unittest.main()
+66
View File
@@ -0,0 +1,66 @@
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import test_helper
from nodes.prompt_counter import PromptCounter
class TestPromptCounter(unittest.TestCase):
def setUp(self):
self.node = PromptCounter()
def test_empty_and_whitespace(self):
txt, count = self.node.count_prompts("")
self.assertEqual(txt, "")
self.assertEqual(count, 0)
txt, count = self.node.count_prompts(" \n\n \t ")
self.assertEqual(txt, " \n\n \t ")
self.assertEqual(count, 0)
def test_single_prompt(self):
sample = "A magnificent tree standing on a hill, photorealistic 8k"
txt, count = self.node.count_prompts(sample)
self.assertEqual(txt, sample)
self.assertEqual(count, 1)
def test_newline_mode(self):
sample = "Prompt 1\nPrompt 2\nPrompt 3\n\nPrompt 4"
txt, count = self.node.count_prompts(sample, separator="Newline")
self.assertEqual(txt, sample)
self.assertEqual(count, 4)
def test_empty_line_mode(self):
sample = "Line 1 of prompt 1\nLine 2 of prompt 1\n\nPrompt 2\n\nPrompt 3"
txt, count = self.node.count_prompts(sample, separator=">1 Empty Line")
self.assertEqual(txt, sample)
self.assertEqual(count, 3)
def test_gt_two_empty_lines_mode(self):
sample = "Prompt 1\n\nStill prompt 1\n\n\nPrompt 2"
txt, count = self.node.count_prompts(sample, separator=">2 Empty Lines")
self.assertEqual(txt, sample)
self.assertEqual(count, 2)
def test_custom_regex(self):
sample = "Prompt A\n---\nPrompt B\n---\nPrompt C"
txt, count = self.node.count_prompts(sample, separator="Custom Regex", custom_regex=r"\n---\n")
self.assertEqual(txt, sample)
self.assertEqual(count, 3)
def test_custom_regex_capturing_group(self):
sample = "Prompt A\n===\nPrompt B\n===\nPrompt C"
txt, count = self.node.count_prompts(sample, separator="Custom Regex", custom_regex=r"(\n===\n)")
self.assertEqual(txt, sample)
self.assertEqual(count, 3)
def test_custom_regex_invalid_fallback(self):
sample = "Prompt 1\n\nPrompt 2"
txt, count = self.node.count_prompts(sample, separator="Custom Regex", custom_regex=r"[invalid-regex")
self.assertEqual(txt, sample)
self.assertEqual(count, 2)
if __name__ == "__main__":
unittest.main()
+47
View File
@@ -32,6 +32,22 @@ class TestPromptQueueIterator(unittest.TestCase):
self.assertEqual(blocks[0], "Block 1 line 1\nBlock 1 line 2")
self.assertEqual(blocks[1], "Block 2")
def test_parse_prompt_blocks_custom_regex(self):
text = "Prompt A\n---\nPrompt B\n---\nPrompt C"
blocks = parse_prompt_blocks(text, separator="Custom Regex", custom_regex=r"\n---\n")
self.assertEqual(blocks, ["Prompt A", "Prompt B", "Prompt C"])
def test_parse_prompt_blocks_custom_regex_capturing_group(self):
text = "Prompt A\n---\nPrompt B\n---\nPrompt C"
blocks = parse_prompt_blocks(text, separator="Custom Regex", custom_regex=r"(\n---\n)")
self.assertEqual(blocks, ["Prompt A", "Prompt B", "Prompt C"])
def test_parse_prompt_blocks_custom_regex_invalid_fallback(self):
text = "Prompt 1\n\nPrompt 2"
# Invalid regex [unterminated class
blocks = parse_prompt_blocks(text, separator="Custom Regex", custom_regex=r"[unterminated")
self.assertEqual(blocks, ["Prompt 1", "Prompt 2"])
def test_sequential_10_batch_execution(self):
"""
Verify that 10 prompts are executed in exact sequential order (0 to 9).
@@ -219,5 +235,36 @@ class TestPromptQueueIterator(unittest.TestCase):
matching_keys = [k for k in state_db.keys() if k.startswith("node_prune_node_")]
self.assertLessEqual(len(matching_keys), 3)
def test_random_cycle_permutation(self):
"""
Verify that Random (Cycle) mode visits all items in a permutation before repeating.
"""
pack = "A\n\nB\n\nC\n\nD\n\nE"
state_db = {}
def mock_load():
return dict(state_db)
def mock_save(new_state):
nonlocal state_db
state_db = dict(new_state)
with patch("nodes.prompt_iterator.load_state", side_effect=mock_load), \
patch("nodes.prompt_iterator.save_state", side_effect=mock_save), \
patch("nodes.prompt_iterator.PromptServer.instance.send_sync"):
picked = []
for _ in range(5):
p, _, _ = self.node.process_queue(
pop_mode="Random (Cycle)",
text=pack,
unique_id="cycle_test_node"
)
picked.append(p)
# All 5 unique items must have been visited in 1 cycle
self.assertEqual(len(picked), 5)
self.assertEqual(set(picked), {"A", "B", "C", "D", "E"})
if __name__ == "__main__":
unittest.main()
+4
View File
@@ -19,6 +19,10 @@ class TestMultiTextReplacer(unittest.TestCase):
targets = parse_find_targets('"dog", "cat", "bird"')
self.assertEqual(targets, ["dog", "cat", "bird"])
def test_parse_find_targets_with_comma_inside_quotes(self):
targets = parse_find_targets('"hello, world", "foo, bar", baz')
self.assertEqual(targets, ["hello, world", "foo, bar", "baz"])
def test_replace_comma_separated_list(self):
text, count = self.node.replace_text(
find="dog, cat, bird",
+3 -3
View File
@@ -5,11 +5,11 @@ import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import test_helper
from nodes.text_split import LeafFlowTextSplit
from nodes.text_split import SaturnTextSplit
class TestLeafFlowTextSplit(unittest.TestCase):
class TestSaturnTextSplit(unittest.TestCase):
def setUp(self):
self.node = LeafFlowTextSplit()
self.node = SaturnTextSplit()
def test_forward_literal_split_basic(self):
# User standard case: "Hello --- Bye" with delimiter "--"
+81 -1
View File
@@ -12,7 +12,10 @@ from nodes.utils import (
format_lora_output_name,
is_local_request,
is_safe_path,
sanitize_image_loader_folder
sanitize_image_loader_folder,
get_csrf_token,
is_same_origin_or_direct,
is_authenticated_local_request
)
class TestUtils(unittest.TestCase):
@@ -27,6 +30,70 @@ class TestUtils(unittest.TestCase):
self.assertFalse(is_local_request(DummyReq("10.0.0.1")))
self.assertFalse(is_local_request(DummyReq("8.8.8.8")))
def test_csrf_token_generation(self):
token = get_csrf_token()
self.assertIsInstance(token, str)
self.assertEqual(len(token), 64)
# Session token is consistent across calls in same session
self.assertEqual(get_csrf_token(), token)
def test_is_same_origin_or_direct(self):
class DummyReq:
def __init__(self, headers=None):
self.headers = headers or {}
# 1. Direct or same-origin
self.assertTrue(is_same_origin_or_direct(DummyReq()))
self.assertTrue(is_same_origin_or_direct(DummyReq({"Origin": "http://127.0.0.1:8188"})))
self.assertTrue(is_same_origin_or_direct(DummyReq({"Origin": "http://localhost:8188"})))
# 2. Block cross-site via Sec-Fetch-Site
self.assertFalse(is_same_origin_or_direct(DummyReq({"Sec-Fetch-Site": "cross-site"})))
# 3. Block malicious cross-origin
self.assertFalse(is_same_origin_or_direct(DummyReq({"Origin": "http://attacker-site.com"})))
self.assertFalse(is_same_origin_or_direct(DummyReq({"Referer": "http://malicious.org/exploit.html"})))
def test_is_authenticated_local_request(self):
class DummyReq:
def __init__(self, ip="127.0.0.1", headers=None):
self.remote = ip
self.headers = headers or {}
valid_token = get_csrf_token()
# Valid local request with matching token
valid_req = DummyReq("127.0.0.1", {
"Origin": "http://127.0.0.1:8188",
"X-LeafFlow-CSRF-Token": valid_token
})
self.assertTrue(is_authenticated_local_request(valid_req))
# Missing token
no_token_req = DummyReq("127.0.0.1", {"Origin": "http://127.0.0.1:8188"})
self.assertFalse(is_authenticated_local_request(no_token_req))
# Wrong token
wrong_token_req = DummyReq("127.0.0.1", {
"Origin": "http://127.0.0.1:8188",
"X-LeafFlow-CSRF-Token": "invalid_forged_token"
})
self.assertFalse(is_authenticated_local_request(wrong_token_req))
# Remote IP attempting to forge or replay token
remote_req = DummyReq("192.168.1.100", {
"Origin": "http://127.0.0.1:8188",
"X-LeafFlow-CSRF-Token": valid_token
})
self.assertFalse(is_authenticated_local_request(remote_req))
# Cross-site drive-by request (Sec-Fetch-Site: cross-site)
drive_by_req = DummyReq("127.0.0.1", {
"Sec-Fetch-Site": "cross-site",
"X-LeafFlow-CSRF-Token": valid_token
})
self.assertFalse(is_authenticated_local_request(drive_by_req))
def test_is_safe_path(self):
import tempfile
base1 = tempfile.mkdtemp()
@@ -69,6 +136,19 @@ class TestUtils(unittest.TestCase):
clean = sanitize_folder_path("input/watch/*")
self.assertNotIn("*", clean)
def test_is_safe_external_image_url(self):
from nodes.lora_loader import is_safe_external_image_url
self.assertTrue(is_safe_external_image_url("https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/test.jpg"))
self.assertTrue(is_safe_external_image_url("https://image.tmdb.org/t/p/w500/sample.jpg"))
self.assertFalse(is_safe_external_image_url("file:///etc/passwd"))
self.assertFalse(is_safe_external_image_url("file:///C:/Windows/system32/cmd.exe"))
self.assertFalse(is_safe_external_image_url("http://127.0.0.1:8188/secret"))
self.assertFalse(is_safe_external_image_url("http://localhost:8080/admin"))
self.assertFalse(is_safe_external_image_url("http://169.254.169.254/latest/meta-data/"))
self.assertFalse(is_safe_external_image_url("ftp://server/image.png"))
self.assertFalse(is_safe_external_image_url(""))
self.assertFalse(is_safe_external_image_url(None))
def test_edge_cases_empty_or_none(self):
self.assertEqual(parse_pretty_name(""), "")
self.assertEqual(parse_pretty_name(None), "")
+147 -18
View File
@@ -1,5 +1,6 @@
import { app } from "/scripts/app.js";
import { api } from "/scripts/api.js";
import { authenticatedFetch } from "./js/auth_helper.js";
// 36 Curated Accessible, High-Contrast Cycling Colors for Batch Visual Grouping
const BATCH_COLORS = [
@@ -41,9 +42,18 @@ const BATCH_COLORS = [
"#c4b5fd" // 36. Lilac
];
const STORAGE_KEY = "leafflow_batch_queue_meta";
const BATCH_COUNTER_KEY = "leafflow_batch_counter";
const SETTING_ID = "LeafFlow.BatchQueue.Enabled";
const STORAGE_KEY = "saturnnodes_batch_queue_meta";
const LEGACY_STORAGE_KEY = "leafflow_batch_queue_meta";
const BATCH_COUNTER_KEY = "saturnnodes_batch_counter";
const LEGACY_BATCH_COUNTER_KEY = "leafflow_batch_counter";
const SETTING_ID = "SaturnNodes.🎨 Batch Queue.01_ShowBatchLines";
const LEGACY_SETTING_ID_0 = "SaturnNodes.8 - 🎨 Batch Queue.01_ShowBatchLines";
const LEGACY_SETTING_ID_1 = "SaturnNodes.BatchQueue.Enabled";
const LEGACY_SETTING_ID_2 = "LeafFlow.BatchQueue.Enabled";
const SETTING_SNAPSHOT_GUARD = "SaturnNodes.🎨 Batch Queue.02_SnapshotGuard";
const LEGACY_SETTING_SNAPSHOT_GUARD_0 = "SaturnNodes.8 - 🎨 Batch Queue.02_SnapshotGuard";
const LEGACY_SETTING_SNAPSHOT_GUARD_1 = "SaturnNodes.BatchQueue.SnapshotGuard";
const LEGACY_SETTING_SNAPSHOT_GUARD_2 = "LeafFlow.BatchQueue.SnapshotGuard";
// In-memory registry mapping prompt_id -> batch metadata
let batchRegistry = new Map();
@@ -52,7 +62,7 @@ let batchCounter = 0;
function loadStorage() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
const raw = localStorage.getItem(STORAGE_KEY) || localStorage.getItem(LEGACY_STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
@@ -63,12 +73,12 @@ function loadStorage() {
}
}
}
const savedCounter = localStorage.getItem(BATCH_COUNTER_KEY);
const savedCounter = localStorage.getItem(BATCH_COUNTER_KEY) || localStorage.getItem(LEGACY_BATCH_COUNTER_KEY);
if (savedCounter) {
batchCounter = parseInt(savedCounter, 10) || 0;
}
} catch (e) {
console.warn("[LeafFlow BatchQueue] Failed to load batch storage:", e);
console.warn("[SaturnNodes BatchQueue] Failed to load batch storage:", e);
}
}
@@ -196,21 +206,109 @@ function removeBatchLineFromElement(element) {
}
}
// Check if LeafFlow Batch Queue is enabled in settings
// Check if SaturnNodes Batch Queue is enabled in settings
function isBatchQueueEnabled() {
try {
const keys = [SETTING_ID, LEGACY_SETTING_ID_0, LEGACY_SETTING_ID_1, LEGACY_SETTING_ID_2];
if (app.extensionManager?.setting?.get) {
return app.extensionManager.setting.get(SETTING_ID) !== false;
for (const k of keys) {
const val = app.extensionManager.setting.get(k);
if (val !== undefined) return val !== false;
}
}
if (app.ui?.settings?.get) {
for (const k of keys) {
const val = app.ui.settings.get(k);
if (val !== undefined) return val !== false;
}
}
} catch (e) {}
return true;
}
function isSnapshotGuardEnabled() {
try {
const keys = [SETTING_SNAPSHOT_GUARD, LEGACY_SETTING_SNAPSHOT_GUARD_0, LEGACY_SETTING_SNAPSHOT_GUARD_1, LEGACY_SETTING_SNAPSHOT_GUARD_2];
if (app.extensionManager?.setting?.get) {
for (const k of keys) {
const val = app.extensionManager.setting.get(k);
if (val !== undefined) return val !== false;
}
}
if (app.ui?.settings?.get) {
for (const k of keys) {
const val = app.ui.settings.get(k);
if (val !== undefined) return val !== false;
}
}
} catch (e) {}
return true;
}
function isSeedInput(key, val, nodeInputs) {
const k = String(key).toLowerCase();
// 1. Direct seed properties (seed, noise_seed, sampler_seed, seed_num, random_seed, etc.)
if (k.includes("seed")) {
return typeof val === "number" || (typeof val === "string" && /^-?\d+$/.test(val.trim()));
}
// 2. Dynamic random integer/number properties (random_value, random_int, etc.)
if (k.includes("random") && typeof val === "number") {
return true;
}
// 3. ComfyUI PrimitiveNode controlling a seed widget (contains control_after_generate or control_mode)
if (key === "value" && nodeInputs && (nodeInputs.control_after_generate !== undefined || nodeInputs.control_mode !== undefined)) {
return typeof val === "number" || (typeof val === "string" && /^-?\d+$/.test(String(val).trim()));
}
return false;
}
function mergeDynamicSeeds(snapPrompt, livePrompt) {
if (!snapPrompt) return livePrompt;
try {
const cloned = JSON.parse(JSON.stringify(snapPrompt));
if (livePrompt && livePrompt.output && cloned.output) {
for (const [nodeId, liveNode] of Object.entries(livePrompt.output)) {
const snapNode = cloned.output[nodeId];
if (snapNode && snapNode.inputs && liveNode.inputs) {
for (const [key, val] of Object.entries(liveNode.inputs)) {
if (isSeedInput(key, val, liveNode.inputs)) {
snapNode.inputs[key] = val;
}
}
}
}
}
// Also sync seed values in workflow metadata so saved PNG metadata matches generated seeds
if (livePrompt && livePrompt.workflow && Array.isArray(livePrompt.workflow.nodes) && cloned.workflow && Array.isArray(cloned.workflow.nodes)) {
const liveWorkflowMap = new Map();
for (const n of livePrompt.workflow.nodes) {
if (n && n.id !== undefined) liveWorkflowMap.set(String(n.id), n);
}
for (const snapNode of cloned.workflow.nodes) {
if (!snapNode || snapNode.id === undefined) continue;
const liveWfNode = liveWorkflowMap.get(String(snapNode.id));
if (liveWfNode && Array.isArray(liveWfNode.widgets_values) && Array.isArray(snapNode.widgets_values)) {
for (let i = 0; i < liveWfNode.widgets_values.length; i++) {
const liveVal = liveWfNode.widgets_values[i];
const snapVal = snapNode.widgets_values[i];
if (typeof liveVal === "number" && typeof snapVal === "number" && liveVal !== snapVal) {
snapNode.widgets_values[i] = liveVal;
}
}
}
}
}
return cloned;
} catch (e) {
console.warn("[LeafFlow BatchQueue] Error merging dynamic seeds into snapshot:", e);
return snapPrompt;
}
}
// Sync with server if PersistentQueue is available
async function syncBatchToServer(promptId, batchInfo) {
try {
if (!api || !api.fetchApi) return;
await api.fetchApi("/leafflow/batch_queue/sync", {
await authenticatedFetch("/saturnnodes/batch_queue/sync", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -226,7 +324,7 @@ async function syncBatchToServer(promptId, batchInfo) {
async function loadBatchFromServer() {
try {
if (!api || !api.fetchApi) return;
const res = await api.fetchApi("/leafflow/batch_queue/data", { cache: "no-store" }).catch(() => null);
const res = await api.fetchApi("/saturnnodes/batch_queue/data", { cache: "no-store" }).catch(() => null);
if (res && res.ok) {
const data = await res.json();
if (data && typeof data === "object") {
@@ -319,7 +417,9 @@ async function updateQueueBatchVisuals() {
}
}
// Hook queuePrompt to track batch execution
let batchSubmissionQueue = Promise.resolve();
// Hook queuePrompt to track batch execution and guard multi-item batches
function setupQueueHooks() {
loadStorage();
@@ -328,17 +428,46 @@ function setupQueueHooks() {
const origQueuePrompt = app.queuePrompt.bind(app);
app.queuePrompt = async function(number, batchCount = 1, queueNodeIds) {
const count = Math.max(1, parseInt(batchCount, 10) || 1);
activeBatchContext = getNextBatch(count);
saveStorage();
return origQueuePrompt(number, batchCount, queueNodeIds);
// Chain onto batchSubmissionQueue to ensure sequential batch submissions
const turn = batchSubmissionQueue.then(async () => {
const batch = getNextBatch(count);
activeBatchContext = batch;
saveStorage();
try {
return await origQueuePrompt(number, batchCount, queueNodeIds);
} finally {
activeBatchContext = null;
}
});
batchSubmissionQueue = turn.catch(() => {});
return turn;
};
}
// 2. Hook api.queuePrompt to record every generated prompt_id
// 2. Hook api.queuePrompt to record every generated prompt_id & enforce snapshot guard
if (api && typeof api.queuePrompt === "function") {
const origApiQueuePrompt = api.queuePrompt.bind(api);
api.queuePrompt = async function(number, prompt, targets) {
const res = await origApiQueuePrompt(number, prompt, targets);
let promptToSend = prompt;
// Batch Graph Snapshot Guard:
// When queueing a batch with batchCount > 1, snapshot on iteration 0 in the background
// so canvas edits (text, LoRA selection, widgets) don't bleed into subsequent items.
if (isSnapshotGuardEnabled() && activeBatchContext && activeBatchContext.batchCount > 1) {
const itemIndex = activeBatchContext.prompts.length;
if (itemIndex === 0) {
try {
activeBatchContext.snapshotPrompt = JSON.parse(JSON.stringify(prompt));
} catch (e) {
console.warn("[LeafFlow BatchQueue] Failed to snapshot prompt:", e);
}
} else if (activeBatchContext.snapshotPrompt) {
promptToSend = mergeDynamicSeeds(activeBatchContext.snapshotPrompt, prompt);
}
}
const res = await origApiQueuePrompt(number, promptToSend, targets);
if (res && res.prompt_id) {
const pid = String(res.prompt_id);
if (!activeBatchContext) {
@@ -376,7 +505,7 @@ function setupQueueHooks() {
}
app.registerExtension({
name: "ComfyUI.LeafFlow.BatchQueue",
name: "ComfyUI.SaturnNodes.BatchQueue",
async setup() {
injectBatchStyles();
setupQueueHooks();
+50
View File
@@ -0,0 +1,50 @@
import { api } from "/scripts/api.js";
let _cachedCsrfToken = null;
let _tokenPromise = null;
export async function getCsrfToken() {
if (_cachedCsrfToken) {
return _cachedCsrfToken;
}
if (_tokenPromise) {
return _tokenPromise;
}
_tokenPromise = (async () => {
try {
let resp = await api.fetchApi("/saturnnodes/auth/token");
if (resp.ok) {
const data = await resp.json();
_cachedCsrfToken = data.csrf_token || "";
return _cachedCsrfToken;
}
} catch (e) {
console.warn("[SaturnNodes Auth] Could not fetch CSRF token:", e);
} finally {
_tokenPromise = null;
}
return "";
})();
return _tokenPromise;
}
export async function authenticatedFetch(endpoint, options = {}) {
const token = await getCsrfToken();
const opts = { ...options };
opts.headers = { ...(opts.headers || {}) };
if (token) {
opts.headers["X-SaturnNodes-CSRF-Token"] = token;
}
return api.fetchApi(endpoint, opts);
}
// Expose on window for all SaturnNodes extensions (with LeafFlow alias for backward compatibility)
if (typeof window !== "undefined") {
const authObj = {
getToken: getCsrfToken,
fetch: authenticatedFetch
};
window._SaturnNodesAuth = authObj;
window._LeafFlowAuth = authObj;
}
+28 -114
View File
@@ -36,11 +36,27 @@ async function copyToClipboard(text) {
function isCopyEnabled(settingKey, defaultVal = true) {
try {
let val;
const saturnNumberedKey = settingKey.replace(/^LeafFlow\./, "SaturnNodes.");
const saturnPlainKey = saturnNumberedKey.replace(/\d+\s*-\s*/, "");
const keysToCheck = [saturnPlainKey, saturnNumberedKey, settingKey];
if (app.extensionManager?.setting?.get) {
val = app.extensionManager.setting.get(settingKey);
for (const k of keysToCheck) {
val = app.extensionManager.setting.get(k);
if (val !== undefined && val !== null && val !== "") break;
}
}
if ((val === undefined || val === null || val === "") && app.ui?.settings?.getSettingValue) {
val = app.ui.settings.getSettingValue(settingKey);
for (const k of keysToCheck) {
val = app.ui.settings.getSettingValue(k);
if (val !== undefined && val !== null && val !== "") break;
}
}
if ((val === undefined || val === null || val === "") && app.ui?.settings?.get) {
for (const k of keysToCheck) {
val = app.ui.settings.get(k);
if (val !== undefined && val !== null && val !== "") break;
}
}
if (val === undefined || val === null || val === "") {
return defaultVal;
@@ -51,9 +67,6 @@ function isCopyEnabled(settingKey, defaultVal = true) {
}
}
const BOOKMARK_SVG = `<svg class="size-4 pointer-events-none" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z"/></svg>`;
const BOOKMARK_SUCCESS_SVG = `<svg class="size-4 text-emerald-600 pointer-events-none" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`;
const BOOKMARK_FAIL_SVG = `<svg class="size-4 text-rose-600 pointer-events-none" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
const INSPECT_SVG = `<svg class="size-4 pointer-events-none" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="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>`;
function updateButtonGroupBorders(container) {
@@ -248,8 +261,8 @@ async function getImagePromptAndMeta(imgSrc) {
if (!promptText && filename) {
try {
const promptUrl = `/leafflow/get_image_prompt?filename=${encodeURIComponent(filename)}&type=${encodeURIComponent(type)}&subfolder=${encodeURIComponent(subfolder)}`;
const response = await api.fetchApi(promptUrl);
const query = `filename=${encodeURIComponent(filename)}&type=${encodeURIComponent(type)}&subfolder=${encodeURIComponent(subfolder)}`;
const response = await api.fetchApi(`/saturnnodes/get_image_prompt?${query}`);
if (response.ok) {
const data = await response.json();
if (data && data.prompt) promptText = data.prompt;
@@ -259,32 +272,12 @@ async function getImagePromptAndMeta(imgSrc) {
return {
promptText: promptText || "",
filename: filename || "bookmark",
filename: filename || "image",
subfolder: subfolder || "",
type: type || "output"
};
}
async function saveImageToPromptBookmarks(imgSrc) {
const meta = await getImagePromptAndMeta(imgSrc);
if (!meta) return false;
const event = new CustomEvent("prompt-bookmarks-create", {
detail: {
name: meta.filename.replace(/\.[^/.]+$/, ""),
text: meta.promptText,
media: [{
filename: meta.filename,
subfolder: meta.subfolder,
type: meta.type,
media_type: "image"
}]
}
});
window.dispatchEvent(event);
return true;
}
async function copyImagePrompt(imgSrc) {
if (!imgSrc) return false;
let promptText = null;
@@ -308,8 +301,8 @@ async function copyImagePrompt(imgSrc) {
}
if (filename) {
const promptUrl = `/leafflow/get_image_prompt?filename=${encodeURIComponent(filename)}&type=${encodeURIComponent(type)}&subfolder=${encodeURIComponent(subfolder)}`;
const response = await api.fetchApi(promptUrl);
const query = `filename=${encodeURIComponent(filename)}&type=${encodeURIComponent(type)}&subfolder=${encodeURIComponent(subfolder)}`;
const response = await api.fetchApi(`/saturnnodes/get_image_prompt?${query}`);
if (response.ok) {
const data = await response.json();
if (data && data.prompt) {
@@ -318,7 +311,7 @@ async function copyImagePrompt(imgSrc) {
}
}
} catch (e) {
console.warn("[LeafFlow] Direct prompt fetch failed:", e);
console.warn("[SaturnNodes] Direct prompt fetch failed:", e);
}
}
@@ -339,7 +332,7 @@ async function copyImagePrompt(imgSrc) {
// 1. Hook into Node Context Menu (Right Click)
app.registerExtension({
name: "ComfyUI.LeafFlow.CopyPrompt",
name: "ComfyUI.SaturnNodes.CopyPrompt",
async beforeRegisterNodeDef(nodeType, nodeData) {
const origGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
@@ -371,18 +364,6 @@ app.registerExtension({
}
},
});
if (isCopyEnabled("LeafFlow.3 - 📋 Prompt Actions.03_EnableSaveToPromptSaver")) {
options.push({
content: "🔖 Bookmark in Prompt Saver",
callback: async () => {
const img = imgs[this.imageIndex || 0];
const src = typeof img === "string" ? img : (img?.src || img?.value);
if (!src) return;
await saveImageToPromptBookmarks(src);
},
});
}
}
};
}
@@ -437,7 +418,7 @@ function getActiveImageSrc() {
return null;
}
// 3. Inject Copy Prompt, Bookmark & Inspect Directly Next to Download Button on Asset Cards
// 3. Inject Copy Prompt & Inspect Directly Next to Download Button on Asset Cards
function injectCopyPromptNextToDownload(downloadBtn) {
if (!downloadBtn || !downloadBtn.parentElement) return;
@@ -455,10 +436,9 @@ function injectCopyPromptNextToDownload(downloadBtn) {
const parent = downloadBtn.parentElement;
const enableCopy = isCopyEnabled("LeafFlow.3 - 📋 Prompt Actions.01_EnableAssetsCopyPromptButton");
const enableBookmark = isCopyEnabled("LeafFlow.3 - 📋 Prompt Actions.03_EnableSaveToPromptSaver");
const enableInspect = isCopyEnabled("LeafFlow.3 - 📋 Prompt Actions.04_EnableInspectAssetButton", false);
if (!enableCopy && !enableBookmark && !enableInspect) return;
if (!enableCopy && !enableInspect) return;
let baseClasses = downloadBtn.className
.replace(/\brounded-[a-z0-9-]+\b/g, "")
@@ -522,35 +502,7 @@ function injectCopyPromptNextToDownload(downloadBtn) {
copyBtn.remove();
}
// 2. Bookmark Button
let bookmarkBtn = parent.querySelector(".leafflow-hover-bookmark");
if (enableBookmark) {
if (!bookmarkBtn) {
bookmarkBtn = document.createElement("button");
bookmarkBtn.type = "button";
bookmarkBtn.title = "Save to Prompt Bookmarks";
bookmarkBtn.setAttribute("aria-label", "Save to Prompt Bookmarks");
bookmarkBtn.className = `leafflow-hover-btn leafflow-hover-bookmark ${baseClasses} shrink-0`;
bookmarkBtn.innerHTML = BOOKMARK_SVG;
bookmarkBtn.onclick = async (e) => {
e.preventDefault();
e.stopPropagation();
const activeImg = card.querySelector("img") || img;
const success = await saveImageToPromptBookmarks(activeImg.src);
bookmarkBtn.innerHTML = success ? BOOKMARK_SUCCESS_SVG : BOOKMARK_FAIL_SVG;
setTimeout(() => {
bookmarkBtn.innerHTML = BOOKMARK_SVG;
}, 2000);
};
parent.insertBefore(bookmarkBtn, lastBtn.nextSibling);
}
lastBtn = bookmarkBtn;
} else if (bookmarkBtn) {
bookmarkBtn.remove();
}
// 3. Inspect Asset (Zoom) Button
// 2. Inspect Asset (Zoom) Button
let inspectBtn = parent.querySelector(".leafflow-hover-inspect");
if (enableInspect) {
if (!inspectBtn) {
@@ -649,44 +601,6 @@ function injectContextMenuCopy(contextMenu) {
});
downloadLi.parentElement.insertBefore(copyLi, downloadLi.nextSibling);
if (isCopyEnabled("LeafFlow.3 - 📋 Prompt Actions.03_EnableSaveToPromptSaver") && !contextMenu.querySelector(".leafflow-contextmenu-bookmark")) {
const bookmarkLi = document.createElement("li");
bookmarkLi.className = "p-contextmenu-item leafflow-contextmenu-bookmark";
bookmarkLi.setAttribute("role", "menuitem");
bookmarkLi.setAttribute("aria-label", "Save to prompt bookmarks");
bookmarkLi.setAttribute("data-pc-section", "item");
bookmarkLi.setAttribute("data-p-active", "false");
bookmarkLi.setAttribute("data-p-focused", "false");
const bIconHtml = BOOKMARK_SVG;
bookmarkLi.innerHTML = `
<div class="p-contextmenu-item-content" data-pc-section="itemcontent">
<button class="${btnClass}" tabindex="-1" data-pc-section="itemlink">
${bIconHtml}
<span>Save to prompt bookmarks</span>
</button>
</div>
`;
bookmarkLi.addEventListener("click", async (e) => {
e.preventDefault();
e.stopPropagation();
const span = bookmarkLi.querySelector("span");
const src = getActiveImageSrc();
if (src) {
const success = await saveImageToPromptBookmarks(src);
if (span) span.textContent = success ? "Bookmarked! ✅" : "Failed ❌";
}
setTimeout(() => {
contextMenu.style.display = "none";
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
}, 500);
});
downloadLi.parentElement.insertBefore(bookmarkLi, copyLi.nextSibling);
}
}
// 5. Fallback Hook for Older Frontend Action Bars
+13 -8
View File
@@ -1,5 +1,6 @@
import { app } from "/scripts/app.js";
import { api } from "/scripts/api.js";
import { authenticatedFetch } from "./auth_helper.js";
// Create global styles for the decision buttons
const styles = document.createElement("style");
@@ -38,9 +39,9 @@ styles.textContent = `
document.head.appendChild(styles);
app.registerExtension({
name: "Comfy.LeafFlowDecision",
name: "Comfy.SaturnNodesDecision",
async setup() {
api.addEventListener("leafflow_decision_waiting", (event) => {
const handleWaiting = (event) => {
const data = event.detail;
if (data && data.node_id) {
const node = app.graph.getNodeById(data.node_id);
@@ -48,9 +49,9 @@ app.registerExtension({
node.enableDecisionButtons();
}
}
});
};
api.addEventListener("leafflow_decision_resolved", (event) => {
const handleResolved = (event) => {
const data = event.detail;
if (data && data.node_id) {
const node = app.graph.getNodeById(data.node_id);
@@ -58,10 +59,13 @@ app.registerExtension({
node.disableDecisionButtons();
}
}
});
};
api.addEventListener("saturnnodes_decision_waiting", handleWaiting);
api.addEventListener("saturnnodes_decision_resolved", handleResolved);
},
async nodeCreated(node) {
if (node.comfyClass === "LeafFlowDecision") {
if (node.comfyClass === "SaturnDecision" || node.comfyClass === "LeafFlowDecision") {
const container = document.createElement("div");
container.className = "leafflow-decision-container";
@@ -87,15 +91,16 @@ app.registerExtension({
const sendDecision = async (action) => {
node.disableDecisionButtons();
try {
await api.fetchApi("/leafflow/decision", {
await authenticatedFetch("/saturnnodes/decision", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
node_id: node.id.toString(),
action: action
}),
});
} catch (e) {
console.error("[LeafFlow] Failed to send decision:", e);
console.error("[SaturnNodes] Failed to send decision:", e);
}
};
+149 -20
View File
@@ -1,5 +1,6 @@
import { app } from "/scripts/app.js";
import { api } from "/scripts/api.js";
import { isSaturnColorsEnabled } from "../saturnnodes_colors.js";
// Hash folder name to get a consistent color for the border
function getFolderBorderColor(folderName) {
@@ -550,10 +551,9 @@ app.registerExtension({
};
}
// Adjust sizing constraints dynamically
domWidget.computeSize = function() {
return [node.size[0] - 30, node.size[1] - 160];
};
// In ComfyUI LiteGraph (V1), computeLayoutSize handles dynamic widget space distribution.
// Removing computeSize avoids an infinite expansion loop in _arrangeWidgets.
delete domWidget.computeSize;
// Add Zoom slider widget
const zoomWidget = node.addWidget(
@@ -808,14 +808,6 @@ app.registerExtension({
if (node.comfyClass === "FolderLoraLoaderVisualPrettyV2" || node.comfyClass === "VisualLoraLoader" || node.type === "FolderLoraLoaderVisualPrettyV2" || node.type === "VisualLoraLoader") {
node.size = [380, 360];
node.computeSize = function() {
const displayModeWidget = node.widgets ? node.widgets.find(w => w.name === "display_mode") : null;
if (displayModeWidget && displayModeWidget.value === "Show All") {
const viewContainer = document.querySelector(".lora-visual-container");
}
return [node.size[0], Math.max(360, node.size[1])];
};
const folderWidget = node.widgets.find(w => w.name === "folder");
const getHiddenWidget = () => {
@@ -919,7 +911,7 @@ app.registerExtension({
viewContainer.classList.remove("show-all-mode");
viewContainer.classList.add("scrollable-mode");
const restoredH = node.userCustomHeight || userSavedHeight || 420;
const containerH = Math.max(200, restoredH - 100);
const containerH = Math.max(180, restoredH - 150);
viewContainer.style.setProperty("height", `${containerH}px`, "important");
viewContainer.style.setProperty("max-height", `${containerH}px`, "important");
@@ -938,9 +930,9 @@ app.registerExtension({
node.computeSize = function() {
const displayModeWidget = node.widgets ? node.widgets.find(w => w.name === "display_mode") : null;
if (displayModeWidget && displayModeWidget.value === "Show All" && gridContainer) {
return [node.size[0], Math.max(360, (gridContainer.scrollHeight || 0) + 160)];
return [Math.max(380, node.size[0]), Math.max(360, (gridContainer.scrollHeight || 0) + 160)];
}
return [node.size[0], Math.max(360, node.size[1])];
return [Math.max(380, node.size[0]), 360];
};
const updateNodeSize = () => {
@@ -1054,12 +1046,14 @@ app.registerExtension({
const domWidget = node.addDOMWidget("lora_visual_picker", "HTML", viewContainer, {
getValue() { return getHiddenWidget().value; },
setValue(val) { getHiddenWidget().value = val; },
serialize: false
serialize: false,
getMinHeight() { return 180; }
});
domWidget.computeSize = function() {
return [node.size[0] - 30, node.size[1] - 190];
};
// In ComfyUI LiteGraph (V1), computeLayoutSize handles dynamic widget space distribution.
// A computeSize returning (node.size[1] - offset) causes an infinite loop in _arrangeWidgets
// because LiteGraph sees (l > t) and expands node.size on every animation frame.
delete domWidget.computeSize;
const onResize = node.onResize;
node.onResize = function(size) {
@@ -1070,7 +1064,7 @@ app.registerExtension({
node.userCustomHeight = size[1];
userSavedHeight = size[1];
if (viewContainer && viewContainer.style) {
const h = Math.max(200, size[1] - 100);
const h = Math.max(180, size[1] - 150);
viewContainer.style.setProperty("height", `${h}px`, "important");
viewContainer.style.setProperty("max-height", `${h}px`, "important");
}
@@ -1091,6 +1085,78 @@ app.registerExtension({
);
zoomWidget.serialize = false;
// Live Selected LoRAs count preview widget matching ResolutionSelector styling
let loraCountWidget = node.widgets?.find(w => w.name === "lora_count_preview" || w._isLoraCount);
let loraBadgeSpan = node._loraBadgeSpan;
if (!loraCountWidget) {
const loraBadgeEl = document.createElement("div");
loraBadgeEl.className = "not-disabled:bg-component-node-widget-background not-disabled:text-component-node-foreground [[readonly]]:bg-component-node-widget-background-disabled border-none rounded-md flex w-full items-center justify-center gap-2 px-2 h-6 col-span-2";
loraBadgeEl.setAttribute("data-widget-name", "lora_count_preview");
loraBadgeEl.setAttribute("node-type", "VisualLoraLoader");
loraBadgeEl.style.cssText = "display: flex; align-items: center; justify-content: center; gap: 6px; padding: 0 8px; height: 24px !important; min-height: 24px !important; max-height: 24px !important; flex-grow: 0 !important; flex-shrink: 0 !important; align-self: center !important; overflow: hidden !important; border-radius: 6px; background: var(--component-node-widget-background, rgba(255, 255, 255, 0.06)); color: var(--component-node-foreground, #e4e4e7); font-size: 12px; font-weight: 500; width: 100%; box-sizing: border-box; user-select: none;";
loraBadgeSpan = document.createElement("span");
loraBadgeSpan.className = "text-xs";
loraBadgeSpan.style.cssText = "font-weight: 600;";
loraBadgeSpan.textContent = "0 LoRAs Selected";
loraBadgeEl.appendChild(loraBadgeSpan);
node._loraBadgeSpan = loraBadgeSpan;
node._loraBadgeEl = loraBadgeEl;
const enforceFixedLoraBadgeSize = () => {
loraBadgeEl.style.setProperty("height", "24px", "important");
loraBadgeEl.style.setProperty("min-height", "24px", "important");
loraBadgeEl.style.setProperty("max-height", "24px", "important");
loraBadgeEl.style.setProperty("flex-grow", "0", "important");
loraBadgeEl.style.setProperty("flex-shrink", "0", "important");
loraBadgeEl.style.setProperty("align-self", "center", "important");
const widgetContainer = loraBadgeEl.closest?.(".lg-node-widget");
if (widgetContainer) {
widgetContainer.style.setProperty("align-self", "center", "important");
widgetContainer.style.setProperty("flex-grow", "0", "important");
}
};
node._enforceFixedLoraBadgeSize = enforceFixedLoraBadgeSize;
loraCountWidget = node.addDOMWidget("lora_count_preview", "preview", loraBadgeEl, {
serialize: false,
getValue() { return loraBadgeSpan.textContent; },
setValue(v) { loraBadgeSpan.textContent = v; }
});
loraCountWidget._isLoraCount = true;
}
if (loraCountWidget) {
// Crucial: shadow computeLayoutSize so ComfyUI Vue deriveWidgetRenderState evaluates hasLayoutSize to false.
// This forces grid-template-rows to use 'min-content' (24px) instead of 'auto' (which was stealing half the node height).
Object.defineProperty(loraCountWidget, "computeLayoutSize", {
value: undefined,
writable: true,
configurable: true
});
loraCountWidget.hasLayoutSize = false;
loraCountWidget.computeSize = function() {
return [node.size ? (node.size[0] - 20) : 200, 24];
};
}
const updateLoraBadgeColor = () => {
const enabled = isSaturnColorsEnabled();
if (node._loraBadgeSpan) {
node._loraBadgeSpan.style.color = enabled ? "#f59e0b" : "var(--text-muted, #a1a1aa)";
}
const badgeEl = node._loraBadgeEl;
if (badgeEl) {
badgeEl.style.background = enabled
? "rgba(180, 140, 95, 0.12)"
: "var(--component-node-widget-background, rgba(255, 255, 255, 0.06))";
badgeEl.style.border = enabled
? "1px solid rgba(180, 140, 95, 0.35)"
: "none";
}
};
node._updateCounterBadgeColor = updateLoraBadgeColor;
let activeRequest = null;
let debounceTimer = null;
let foldersMap = {};
@@ -1115,6 +1181,21 @@ app.registerExtension({
return currentSelected;
};
const updateLoraCountDisplay = () => {
node._enforceFixedLoraBadgeSize?.();
const count = getSelectedLoras().length;
node._selectedLoraCount = count;
const label = `${count} LoRA${count === 1 ? "" : "s"} Selected`;
if (node._loraBadgeSpan) {
node._loraBadgeSpan.textContent = label;
}
if (loraCountWidget) {
loraCountWidget.value = label;
}
updateLoraBadgeColor();
app.graph?.setDirtyCanvas(true, true);
};
const updateFolderCheckboxes = () => {
const selectedList = getSelectedLoras();
@@ -1166,6 +1247,7 @@ app.registerExtension({
});
updateFolderCheckboxes();
updateLoraCountDisplay();
node.triggerSlotEvent?.(0);
};
@@ -1553,9 +1635,55 @@ app.registerExtension({
syncModeToggleUI();
syncScrapeToggleUI();
updateLoraCountDisplay();
setTimeout(() => updateVisualGrid(), 100);
};
const origOnDrawForeground = node.onDrawForeground;
node.onDrawForeground = function(ctx) {
if (node.flags?.collapsed) {
const isSaturn = isSaturnColorsEnabled();
const count = node._selectedLoraCount ?? getSelectedLoras().length;
const label = `${count} LoRA${count === 1 ? "" : "s"}`;
ctx.save();
ctx.font = "bold 11px Inter, system-ui, sans-serif";
const badgeText = label;
const tw = ctx.measureText(badgeText).width;
const bx = node.size[0] - tw - 16;
const by = -LiteGraph.NODE_TITLE_HEIGHT + 3;
if (isSaturn) {
ctx.fillStyle = count > 0 ? "rgba(180, 140, 95, 0.25)" : "rgba(255, 255, 255, 0.08)";
ctx.strokeStyle = count > 0 ? "rgba(180, 140, 95, 0.6)" : "rgba(255, 255, 255, 0.2)";
} else {
ctx.fillStyle = count > 0 ? "rgba(255, 255, 255, 0.12)" : "rgba(255, 255, 255, 0.08)";
ctx.strokeStyle = count > 0 ? "rgba(255, 255, 255, 0.25)" : "rgba(255, 255, 255, 0.2)";
}
ctx.lineWidth = 1;
ctx.beginPath();
if (ctx.roundRect) {
ctx.roundRect(bx, by, tw + 10, 18, 4);
} else {
ctx.rect(bx, by, tw + 10, 18);
}
ctx.fill();
ctx.stroke();
ctx.fillStyle = isSaturn ? (count > 0 ? "#decbb2" : "#cbd5e1") : (count > 0 ? "#e2e8f0" : "#cbd5e1");
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.fillText(badgeText, bx + 5, by + 9);
ctx.restore();
}
if (origOnDrawForeground) origOnDrawForeground.apply(this, arguments);
};
const origOnResize = node.onResize;
node.onResize = function() {
if (origOnResize) origOnResize.apply(this, arguments);
node._enforceFixedLoraBadgeSize?.();
};
syncModeToggleUI();
syncScrapeToggleUI();
@@ -1570,6 +1698,7 @@ app.registerExtension({
applyDisplayMode(displayModeWidget.value || "Scrollable");
}
updateVisualGrid();
updateLoraCountDisplay();
}, 100);
}
}
+10 -19
View File
@@ -216,14 +216,6 @@ app.registerExtension({
if (isVisualImageLoader) {
node.size = [380, 480];
node.computeSize = function() {
const displayModeWidget = node.widgets ? node.widgets.find(w => w.name === "display_mode") : null;
if (displayModeWidget && displayModeWidget.value === "Show All") {
// Logic added below
}
return [node.size[0], Math.max(480, node.size[1])];
};
const folderWidget = node.widgets ? node.widgets.find(w => w.name === "folder_path") : null;
const getHiddenWidget = (name, defaultVal) => {
@@ -288,8 +280,6 @@ app.registerExtension({
gridContainer.className = "img-grid-container";
viewContainer.appendChild(gridContainer);
node.computeSize = function() {
const displayModeWidget = node.widgets ? node.widgets.find(w => w.name === "display_mode") : null;
let userSavedHeight = 480;
const applyDisplayMode = (mode) => {
@@ -314,7 +304,7 @@ app.registerExtension({
viewContainer.classList.remove("show-all-mode");
viewContainer.classList.add("scrollable-mode");
const restoredH = node.userCustomHeight || userSavedHeight || 480;
const containerH = Math.max(200, restoredH - 100);
const containerH = Math.max(180, restoredH - 150);
viewContainer.style.setProperty("height", `${containerH}px`, "important");
viewContainer.style.setProperty("max-height", `${containerH}px`, "important");
@@ -333,9 +323,9 @@ app.registerExtension({
node.computeSize = function() {
const displayModeWidget = node.widgets ? node.widgets.find(w => w.name === "display_mode") : null;
if (displayModeWidget && displayModeWidget.value === "Show All" && gridContainer) {
return [node.size[0], Math.max(420, (gridContainer.scrollHeight || 0) + 160)];
return [Math.max(380, node.size[0]), Math.max(420, (gridContainer.scrollHeight || 0) + 160)];
}
return [node.size[0], Math.max(420, node.size[1])];
return [Math.max(380, node.size[0]), 420];
};
const updateNodeSize = () => {
@@ -355,9 +345,14 @@ app.registerExtension({
const domWidget = node.addDOMWidget("img_visual_picker", "HTML", viewContainer, {
getValue() { return getHiddenWidget("_selected_image", "").value; },
setValue(val) { getHiddenWidget("_selected_image", "").value = val; },
serialize: false
serialize: false,
getMinHeight() { return 180; }
});
// In ComfyUI LiteGraph (V1), computeLayoutSize handles dynamic widget space distribution.
// Removing computeSize avoids an infinite expansion loop in _arrangeWidgets.
delete domWidget.computeSize;
const onResize = node.onResize;
node.onResize = function(size) {
if (onResize) onResize.apply(this, arguments);
@@ -367,17 +362,13 @@ app.registerExtension({
node.userCustomHeight = size[1];
userSavedHeight = size[1];
if (viewContainer && viewContainer.style) {
const h = Math.max(200, size[1] - 100);
const h = Math.max(180, size[1] - 150);
viewContainer.style.setProperty("height", `${h}px`, "important");
viewContainer.style.setProperty("max-height", `${h}px`, "important");
}
}
};
domWidget.computeSize = function() {
return [node.size[0] - 30, node.size[1] - 140];
};
// Zoom Slider Widget
const zoomWidget = node.addWidget(
"slider",
+154
View File
@@ -0,0 +1,154 @@
import { app } from "/scripts/app.js";
import { authenticatedFetch } from "./auth_helper.js";
// SaturnNodes Local File Runner In-Memory Consent Gate Extension
app.registerExtension({
name: "SaturnNodes.LocalRunner.Security",
async nodeCreated(node) {
if (node.comfyClass !== "RunLocalFileNode") return;
node._isAuthorized = false;
node._authExpiresAt = 0;
const filePathWidget = node.widgets?.find(w => w.name === "file_path");
const updateVisualStatus = () => {
const now = Date.now();
const isArmed = Boolean(node._isAuthorized && node._authExpiresAt > now);
if (isArmed) {
const remainingSec = Math.max(0, Math.round((node._authExpiresAt - now) / 1000));
const remainingMin = Math.ceil(remainingSec / 60);
node.title = `🪐 ⚡ Run Local File [ARMED (${remainingMin}m)]`;
node.color = "#047857";
node.bgcolor = "#064e3b";
if (authButton) {
authButton.name = `✅ Authorized (${remainingMin}m left)`;
}
} else {
node._isAuthorized = false;
node.title = "🪐 ⚡ Run Local File [DISARMED]";
node.color = "#7f1d1d";
node.bgcolor = "#18181b";
if (authButton) {
authButton.name = "⚡ Authorize Run (5 min)";
}
}
node.setDirtyCanvas(true, true);
};
// Add explicit interactive action button: "⚡ Authorize Run (5 min)"
const authButton = node.addWidget(
"button",
"⚡ Authorize Run (5 min)",
null,
async () => {
const scriptName = filePathWidget?.value?.trim() || "configured script";
const confirmed = confirm(
"🪐 SaturnNodes Security Verification\n\n" +
`Authorize a SINGLE run of local script:\n'${scriptName}'\n\n` +
"Notice: Scripts can execute shell commands on your system.\n" +
"• The script must reside inside 'ComfyUI/scripts/'.\n" +
"• Authorization is temporary (expires in 5 minutes) and single-use.\n" +
"• External workflows can NEVER run scripts without your live authorization.\n\n" +
"Authorize this run now?"
);
if (!confirmed) return;
try {
const resp = await authenticatedFetch("/saturnnodes/local_runner/authorize", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
node_id: node.id,
file_path: filePathWidget?.value || ""
})
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
const errMsg = err.error || "Failed to authorize node run.";
if (app.extensionManager?.toast?.add) {
app.extensionManager.toast.add({
severity: "error",
summary: "🪐 Authorization Failed",
detail: errMsg,
life: 6000
});
} else {
alert("❌ " + errMsg);
}
return;
}
node._isAuthorized = true;
node._authExpiresAt = Date.now() + (300 * 1000);
updateVisualStatus();
if (app.extensionManager?.toast?.add) {
app.extensionManager.toast.add({
severity: "success",
summary: "⚡ Script Run Authorized",
detail: `Node ${node.id} authorized for 5 minutes. Single-use: will be consumed upon execution.`,
life: 5000
});
}
} catch (e) {
console.error("[SaturnNodes Security] Error authorizing script run:", e);
alert("❌ Error communicating with authorization service: " + e.message);
}
}
);
// Intercept workflow load / configuration: ALWAYS disarm on load!
const origOnConfigure = node.onConfigure;
node.onConfigure = function(info) {
if (origOnConfigure) origOnConfigure.apply(this, arguments);
// GUARANTEE: Never let imported workflow or image auto-run with consent pre-checked
node._isAuthorized = false;
node._authExpiresAt = 0;
updateVisualStatus();
};
// If file path changes, invalidate existing authorization
if (filePathWidget) {
const origCallback = filePathWidget.callback;
filePathWidget.callback = function(val) {
node._isAuthorized = false;
node._authExpiresAt = 0;
updateVisualStatus();
if (origCallback) origCallback.apply(this, arguments);
};
}
// Draw security alert ribbon on canvas if disarmed
const origOnDrawForeground = node.onDrawForeground;
node.onDrawForeground = function(ctx) {
if (origOnDrawForeground) origOnDrawForeground.apply(this, arguments);
if (node.flags?.collapsed) return;
const now = Date.now();
const isArmed = Boolean(node._isAuthorized && node._authExpiresAt > now);
ctx.save();
ctx.textAlign = "center";
ctx.font = "italic 9px sans-serif";
ctx.fillStyle = "#9ca3af";
ctx.fillText("Script Location: ComfyUI/scripts/", node.size[0] / 2, node.size[1] - 19);
ctx.font = "bold 10px sans-serif";
if (!isArmed) {
ctx.fillStyle = "#ef4444";
ctx.fillText("🛡️ DISARMED: Click 'Authorize Run' to execute", node.size[0] / 2, node.size[1] - 7);
} else {
const rem = Math.max(0, Math.ceil((node._authExpiresAt - now) / 60000));
ctx.fillStyle = "#10b981";
ctx.fillText(`⚡ ARMED (${rem}m left) - Consumed on run`, node.size[0] / 2, node.size[1] - 7);
}
ctx.restore();
};
updateVisualStatus();
}
});
+157 -30
View File
@@ -1,7 +1,8 @@
import { app } from "/scripts/app.js";
import { api } from "/scripts/api.js";
import { authenticatedFetch } from "./auth_helper.js";
const EXTENSION_NAME = "LeafFlow.PowerControlSidebar";
const EXTENSION_NAME = "SaturnNodes.PowerControlSidebar";
const STYLE_ID = "leafflow-power-sidebar-styles";
let currentPowerState = {
@@ -189,7 +190,7 @@ function updateSidebarButtonVisuals() {
async function fetchPowerStatus() {
try {
const resp = await api.fetchApi("/leafflow/power/status");
const resp = await api.fetchApi("/saturnnodes/power/status");
if (resp.ok) {
currentPowerState = await resp.json();
updateSidebarButtonVisuals();
@@ -234,7 +235,7 @@ function openConfirmModal(title, message, onConfirm, isDanger = false) {
btnRow.style.marginTop = "8px";
const cancelBtn = document.createElement("button");
cancelBtn.textContent = "Cancel";
cancelBtn.textContent = onConfirm ? "Cancel" : "Close";
cancelBtn.style.padding = "8px 16px";
cancelBtn.style.borderRadius = "6px";
cancelBtn.style.border = "1px solid #52525b";
@@ -243,32 +244,57 @@ function openConfirmModal(title, message, onConfirm, isDanger = false) {
cancelBtn.style.cursor = "pointer";
cancelBtn.onclick = () => overlay.remove();
const confirmBtn = document.createElement("button");
confirmBtn.textContent = "Confirm";
confirmBtn.style.padding = "8px 16px";
confirmBtn.style.borderRadius = "6px";
confirmBtn.style.border = "none";
confirmBtn.style.fontWeight = "600";
confirmBtn.style.cursor = "pointer";
if (isDanger) {
confirmBtn.style.background = "#dc2626";
confirmBtn.style.color = "#ffffff";
if (onConfirm) {
const confirmBtn = document.createElement("button");
confirmBtn.textContent = "Confirm";
confirmBtn.style.padding = "8px 16px";
confirmBtn.style.borderRadius = "6px";
confirmBtn.style.border = "none";
confirmBtn.style.fontWeight = "600";
confirmBtn.style.cursor = "pointer";
if (isDanger) {
confirmBtn.style.background = "#dc2626";
confirmBtn.style.color = "#ffffff";
} else {
confirmBtn.style.background = "#2563eb";
confirmBtn.style.color = "#ffffff";
}
confirmBtn.onclick = async () => {
overlay.remove();
await onConfirm();
};
btnRow.append(cancelBtn, confirmBtn);
} else {
confirmBtn.style.background = "#2563eb";
confirmBtn.style.color = "#ffffff";
btnRow.append(cancelBtn);
}
confirmBtn.onclick = async () => {
overlay.remove();
if (onConfirm) await onConfirm();
};
btnRow.append(cancelBtn, confirmBtn);
modal.append(header, desc, btnRow);
overlay.appendChild(modal);
document.body.appendChild(overlay);
}
function checkProcessManagementEnabled() {
if (currentPowerState.enabled === false) {
if (app.extensionManager?.toast?.add) {
app.extensionManager.toast.add({
severity: "warn",
summary: "🪐 Process Management Disabled",
detail: "Enable 'Allow Process Management' in SaturnNodes settings to restart or shut down ComfyUI.",
life: 7000
});
}
openConfirmModal(
"Process Management Disabled",
"Server restart and shutdown controls are currently disabled for security.\n\nTo enable them, go to ComfyUI Settings ⚙️ -> SaturnNodes -> Pause Controls -> Turn ON 'Allow Process Management (Restart / Shutdown)'.",
null,
false
);
return false;
}
return true;
}
function closePowerPopup() {
if (activePopup) {
activePopup.remove();
@@ -276,6 +302,10 @@ function closePowerPopup() {
}
}
async function powerFetch(endpointSuffix, options = {}) {
return authenticatedFetch(`/saturnnodes/power/${endpointSuffix}`, options);
}
function showPowerSidePopup(anchorElement) {
if (activePopup) {
closePowerPopup();
@@ -315,7 +345,7 @@ function showPowerSidePopup(anchorElement) {
cancelBtn.onclick = async (e) => {
e.stopPropagation();
closePowerPopup();
await api.fetchApi("/leafflow/power/arm", {
await powerFetch("arm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: null })
@@ -324,7 +354,7 @@ function showPowerSidePopup(anchorElement) {
if (app.extensionManager?.toast?.add) {
app.extensionManager.toast.add({
severity: "info",
summary: "🍃 LeafFlow Power",
summary: "🪐 SaturnNodes Power",
detail: "Scheduled power action has been cancelled.",
life: 3000
});
@@ -345,10 +375,46 @@ function showPowerSidePopup(anchorElement) {
restartItem.onclick = (e) => {
e.stopPropagation();
closePowerPopup();
if (!checkProcessManagementEnabled()) return;
openConfirmModal(
"Restart ComfyUI Immediately?",
"Are you sure you want to restart ComfyUI immediately? Any active prompt generation will be interrupted.",
async () => {
const tokenResp = await powerFetch("request_token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "restart" })
});
if (!tokenResp.ok) {
const err = await tokenResp.json().catch(() => ({}));
if (app.extensionManager?.toast?.add) {
app.extensionManager.toast.add({
severity: "error",
summary: "🪐 Restart Denied",
detail: err.error || "Failed to obtain power action ticket.",
life: 6000
});
}
return;
}
const tokenData = await tokenResp.json();
const resp = await powerFetch("confirm_action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ticket: tokenData.ticket, action: "restart" })
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
if (app.extensionManager?.toast?.add) {
app.extensionManager.toast.add({
severity: "error",
summary: "🪐 Restart Failed",
detail: err.error || "Failed to trigger restart.",
life: 6000
});
}
return;
}
if (app.extensionManager?.toast?.add) {
app.extensionManager.toast.add({
severity: "warn",
@@ -357,7 +423,6 @@ function showPowerSidePopup(anchorElement) {
life: 8000
});
}
await api.fetchApi("/leafflow/power/restart", { method: "POST" });
setTimeout(() => window.location.reload(), 2500);
},
true
@@ -377,10 +442,46 @@ function showPowerSidePopup(anchorElement) {
shutdownItem.onclick = (e) => {
e.stopPropagation();
closePowerPopup();
if (!checkProcessManagementEnabled()) return;
openConfirmModal(
"Shutdown ComfyUI Server?",
"Are you sure you want to SHUT DOWN the ComfyUI server immediately? The process will exit.",
async () => {
const tokenResp = await powerFetch("request_token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "shutdown" })
});
if (!tokenResp.ok) {
const err = await tokenResp.json().catch(() => ({}));
if (app.extensionManager?.toast?.add) {
app.extensionManager.toast.add({
severity: "error",
summary: "🪐 Shutdown Denied",
detail: err.error || "Failed to obtain power action ticket.",
life: 6000
});
}
return;
}
const tokenData = await tokenResp.json();
const resp = await powerFetch("confirm_action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ticket: tokenData.ticket, action: "shutdown" })
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
if (app.extensionManager?.toast?.add) {
app.extensionManager.toast.add({
severity: "error",
summary: "🪐 Shutdown Failed",
detail: err.error || "Failed to trigger shutdown.",
life: 6000
});
}
return;
}
if (app.extensionManager?.toast?.add) {
app.extensionManager.toast.add({
severity: "error",
@@ -389,7 +490,6 @@ function showPowerSidePopup(anchorElement) {
life: 6000
});
}
await api.fetchApi("/leafflow/power/shutdown", { method: "POST" });
},
true
);
@@ -413,15 +513,28 @@ function showPowerSidePopup(anchorElement) {
restartQueueItem.onclick = (e) => {
e.stopPropagation();
closePowerPopup();
if (!checkProcessManagementEnabled()) return;
openConfirmModal(
"Schedule Restart After Queue Finish?",
"ComfyUI will wait until all remaining prompts in the queue finish and the system returns to idle before restarting. (Note: If queue is paused, it will not restart).",
async () => {
await api.fetchApi("/leafflow/power/arm", {
const resp = await powerFetch("arm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "restart" })
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
if (app.extensionManager?.toast?.add) {
app.extensionManager.toast.add({
severity: "error",
summary: "🪐 Scheduling Failed",
detail: err.error || "Failed to schedule restart.",
life: 6000
});
}
return;
}
await fetchPowerStatus();
if (app.extensionManager?.toast?.add) {
app.extensionManager.toast.add({
@@ -448,15 +561,28 @@ function showPowerSidePopup(anchorElement) {
shutdownQueueItem.onclick = (e) => {
e.stopPropagation();
closePowerPopup();
if (!checkProcessManagementEnabled()) return;
openConfirmModal(
"Schedule Shutdown After Queue Finish?",
"ComfyUI will wait until all queued prompts complete and the system returns to idle before shutting down completely.",
async () => {
await api.fetchApi("/leafflow/power/arm", {
const resp = await powerFetch("arm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "shutdown" })
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
if (app.extensionManager?.toast?.add) {
app.extensionManager.toast.add({
severity: "error",
summary: "🪐 Scheduling Failed",
detail: err.error || "Failed to schedule shutdown.",
life: 6000
});
}
return;
}
await fetchPowerStatus();
if (app.extensionManager?.toast?.add) {
app.extensionManager.toast.add({
@@ -561,10 +687,11 @@ app.registerExtension({
}, 1000);
try {
api.addEventListener("leafflow_power_status", (e) => {
const handlePowerStatus = (e) => {
currentPowerState = e.detail || currentPowerState;
updateSidebarButtonVisuals();
});
};
api.addEventListener("saturnnodes_power_status", handlePowerStatus);
} catch (_) {}
setInterval(fetchPowerStatus, 3000);
+1 -1
View File
@@ -296,7 +296,7 @@ app.registerExtension({
}
});
api.addEventListener("leafflow_update_preview_aspect_ratio", (event) => {
api.addEventListener("saturnnodes_update_preview_aspect_ratio", (event) => {
const detail = event.detail;
if (!detail || !detail.node_id) return;
+285
View File
@@ -0,0 +1,285 @@
import { app } from "/scripts/app.js";
import { isSaturnColorsEnabled } from "../saturnnodes_colors.js";
function countPromptsInText(text, separator, customRegex) {
if (!text || !text.trim()) return 0;
const cleanText = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
const sep = (separator || ">1 Empty Line").toLowerCase();
if (sep.includes("regex")) {
const patStr = (customRegex || "").trim() || "\\n\\s*\\n+";
try {
const re = new RegExp(patStr, "g");
const rawBlocks = cleanText.split(re);
let count = 0;
for (const b of rawBlocks) {
const s = b.trim();
if (s) {
try {
if (!new RegExp(`^(?:${patStr})$`).test(b)) {
count++;
}
} catch (_) {
count++;
}
}
}
return count;
} catch (e) {
// Fallback on invalid regex
const rawBlocks = cleanText.split(/\n\s*\n+/);
return rawBlocks.filter(b => b.trim()).length;
}
} else if (sep.includes("newline")) {
return cleanText.split('\n').filter(line => line.trim()).length;
} else if (sep.includes(">2") || sep.includes("2 empty")) {
return cleanText.split(/(?:\n\s*){3,}/).filter(b => b.trim()).length;
} else { // >1 Empty Line / >1 Emptyline
return cleanText.split(/\n\s*\n+/).filter(b => b.trim()).length;
}
}
app.registerExtension({
name: "ComfyUI.LeafFlow.PromptCounter",
async nodeCreated(node) {
if (node.comfyClass === "PromptCounter") {
const textWidget = node.widgets?.find(w => w.name === "text");
const sepWidget = node.widgets?.find(w => w.name === "separator");
const regexWidget = node.widgets?.find(w => w.name === "custom_regex");
// Live Prompt Count preview widget matching ResolutionSelector styling
let countWidget = node.widgets?.find(w => w.name === "prompt_count_preview" || w._isPromptCount);
let badgeSpan = node._promptBadgeSpan;
if (!countWidget) {
const badgeEl = document.createElement("div");
badgeEl.className = "not-disabled:bg-component-node-widget-background not-disabled:text-component-node-foreground [[readonly]]:bg-component-node-widget-background-disabled border-none rounded-md flex w-full items-center justify-center gap-2 px-2 h-6 col-span-2";
badgeEl.setAttribute("data-widget-name", "prompt_count_preview");
badgeEl.setAttribute("node-type", "PromptCounter");
badgeEl.style.cssText = "display: flex; align-items: center; justify-content: center; gap: 6px; padding: 0 8px; height: 24px !important; min-height: 24px !important; max-height: 24px !important; flex-grow: 0 !important; flex-shrink: 0 !important; align-self: center !important; overflow: hidden !important; border-radius: 6px; background: var(--component-node-widget-background, rgba(255, 255, 255, 0.06)); color: var(--component-node-foreground, #e4e4e7); font-size: 12px; font-weight: 500; width: 100%; box-sizing: border-box; user-select: none;";
badgeSpan = document.createElement("span");
badgeSpan.className = "text-xs";
badgeSpan.style.cssText = "font-weight: 600;";
badgeSpan.textContent = "0 Prompts";
badgeEl.appendChild(badgeSpan);
node._promptBadgeSpan = badgeSpan;
node._promptBadgeEl = badgeEl;
const enforceFixedBadgeSize = () => {
badgeEl.style.setProperty("height", "24px", "important");
badgeEl.style.setProperty("min-height", "24px", "important");
badgeEl.style.setProperty("max-height", "24px", "important");
badgeEl.style.setProperty("flex-grow", "0", "important");
badgeEl.style.setProperty("flex-shrink", "0", "important");
badgeEl.style.setProperty("align-self", "center", "important");
const widgetContainer = badgeEl.closest?.(".lg-node-widget");
if (widgetContainer) {
widgetContainer.style.setProperty("align-self", "center", "important");
widgetContainer.style.setProperty("flex-grow", "0", "important");
}
};
node._enforceFixedBadgeSize = enforceFixedBadgeSize;
countWidget = node.addDOMWidget("prompt_count_preview", "preview", badgeEl, {
serialize: false,
getValue() { return badgeSpan.textContent; },
setValue(v) { badgeSpan.textContent = v; }
});
countWidget._isPromptCount = true;
}
if (countWidget) {
// Crucial: shadow computeLayoutSize so ComfyUI Vue deriveWidgetRenderState evaluates hasLayoutSize to false.
// This forces grid-template-rows to use 'min-content' (24px) instead of 'auto' (which was stealing half the node height).
Object.defineProperty(countWidget, "computeLayoutSize", {
value: undefined,
writable: true,
configurable: true
});
countWidget.hasLayoutSize = false;
countWidget.computeSize = function() {
return [node.size ? (node.size[0] - 20) : 200, 24];
};
}
function updatePromptBadgeColor() {
const enabled = isSaturnColorsEnabled();
if (node._promptBadgeSpan) {
node._promptBadgeSpan.style.color = enabled ? "#f59e0b" : "var(--text-muted, #a1a1aa)";
}
const badgeEl = node._promptBadgeEl;
if (badgeEl) {
badgeEl.style.background = enabled
? "rgba(180, 140, 95, 0.12)"
: "var(--component-node-widget-background, rgba(255, 255, 255, 0.06))";
badgeEl.style.border = enabled
? "1px solid rgba(180, 140, 95, 0.35)"
: "none";
}
}
node._updateCounterBadgeColor = updatePromptBadgeColor;
function updateCount() {
const text = (textWidget?.inputEl ? textWidget.inputEl.value : textWidget?.value) || "";
const sep = sepWidget?.value || ">1 Empty Line";
const regex = regexWidget?.value || "";
const count = countPromptsInText(text, sep, regex);
node._promptCount = count;
const label = `${count} Prompt${count === 1 ? "" : "s"}`;
if (node._promptBadgeSpan) {
node._promptBadgeSpan.textContent = label;
}
if (countWidget) {
countWidget.value = label;
}
updatePromptBadgeColor();
// Manage custom_regex enable/disable
if (regexWidget) {
const isCustom = String(sep || "").toLowerCase().includes("regex");
regexWidget.disabled = !isCustom;
if (regexWidget.inputEl) {
regexWidget.inputEl.disabled = !isCustom;
regexWidget.inputEl.style.opacity = isCustom ? "1" : "0.4";
regexWidget.inputEl.style.pointerEvents = isCustom ? "auto" : "none";
}
if (regexWidget.options) {
regexWidget.options.disabled = !isCustom;
}
}
app.graph?.setDirtyCanvas(true, true);
}
// Hook DOM input listener on text textarea
function hookInputEl() {
if (textWidget?.inputEl && !textWidget.inputEl._promptCounterHooked) {
textWidget.inputEl._promptCounterHooked = true;
textWidget.inputEl.addEventListener("input", () => {
textWidget.value = textWidget.inputEl.value;
updateCount();
});
textWidget.inputEl.addEventListener("paste", () => {
setTimeout(() => {
textWidget.value = textWidget.inputEl.value;
updateCount();
}, 20);
});
}
}
if (textWidget) {
const origTextCb = textWidget.callback;
textWidget.callback = function(val) {
if (origTextCb) origTextCb.apply(this, arguments);
updateCount();
};
}
if (sepWidget) {
const origSepCb = sepWidget.callback;
sepWidget.callback = function(val) {
if (origSepCb) origSepCb.apply(this, arguments);
updateCount();
};
}
if (regexWidget) {
const origRegexCb = regexWidget.callback;
regexWidget.callback = function(val) {
if (origRegexCb) origRegexCb.apply(this, arguments);
updateCount();
};
const origMouse = regexWidget.mouse;
regexWidget.mouse = function(event, pos, n) {
if (this.disabled) return false;
if (origMouse) return origMouse.apply(this, arguments);
};
const origDraw = regexWidget.draw;
regexWidget.draw = function(ctx, n, widget_width, y, widget_height) {
if (this.disabled) {
ctx.save();
ctx.globalAlpha = 0.35;
if (origDraw) origDraw.apply(this, arguments);
ctx.restore();
} else {
if (origDraw) origDraw.apply(this, arguments);
}
};
}
// Also draw title pill badge when node is collapsed
const origOnDrawForeground = node.onDrawForeground;
node.onDrawForeground = function(ctx) {
hookInputEl();
// If textWidget value changed without callback
const currentVal = (textWidget?.inputEl ? textWidget.inputEl.value : textWidget?.value) || "";
if (currentVal !== node._lastPromptVal) {
node._lastPromptVal = currentVal;
updateCount();
}
if (node.flags?.collapsed) {
const isSaturn = isSaturnColorsEnabled();
const count = node._promptCount ?? 0;
const label = `${count} Prompt${count === 1 ? "" : "s"}`;
ctx.save();
ctx.font = "bold 11px Inter, system-ui, sans-serif";
const badgeText = label;
const tw = ctx.measureText(badgeText).width;
const bx = node.size[0] - tw - 16;
const by = -LiteGraph.NODE_TITLE_HEIGHT + 3;
if (isSaturn) {
ctx.fillStyle = count > 0 ? "rgba(180, 140, 95, 0.25)" : "rgba(255, 255, 255, 0.08)";
ctx.strokeStyle = count > 0 ? "rgba(180, 140, 95, 0.6)" : "rgba(255, 255, 255, 0.2)";
} else {
ctx.fillStyle = count > 0 ? "rgba(255, 255, 255, 0.12)" : "rgba(255, 255, 255, 0.08)";
ctx.strokeStyle = count > 0 ? "rgba(255, 255, 255, 0.25)" : "rgba(255, 255, 255, 0.2)";
}
ctx.lineWidth = 1;
ctx.beginPath();
if (ctx.roundRect) {
ctx.roundRect(bx, by, tw + 10, 18, 4);
} else {
ctx.rect(bx, by, tw + 10, 18);
}
ctx.fill();
ctx.stroke();
ctx.fillStyle = isSaturn ? (count > 0 ? "#decbb2" : "#cbd5e1") : (count > 0 ? "#e2e8f0" : "#cbd5e1");
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.fillText(badgeText, bx + 5, by + 9);
ctx.restore();
}
if (origOnDrawForeground) origOnDrawForeground.apply(this, arguments);
};
const origOnResize = node.onResize;
node.onResize = function() {
if (origOnResize) origOnResize.apply(this, arguments);
node._enforceFixedBadgeSize?.();
};
const origOnConfigure = node.onConfigure;
node.onConfigure = function() {
if (origOnConfigure) origOnConfigure.apply(this, arguments);
setTimeout(() => {
hookInputEl();
node._enforceFixedBadgeSize?.();
updateCount();
}, 40);
};
setTimeout(() => {
hookInputEl();
updateCount();
}, 20);
}
}
});
+69 -16
View File
@@ -1,36 +1,38 @@
import { app } from "/scripts/app.js";
import { api } from "/scripts/api.js";
import { authenticatedFetch } from "./auth_helper.js";
app.registerExtension({
name: "ComfyUI.LeafFlow.PromptQueueIterator",
name: "ComfyUI.SaturnNodes.PromptQueueIterator",
async setup() {
if (api && api.addEventListener) {
api.addEventListener("leafflow_prompt_iterator_progress", (event) => {
const handleProgress = (event) => {
const data = event.detail;
if (!data || !data.node_id) return;
const node = app.graph?.getNodeById(data.node_id);
if (node && node.widgets) {
const statusWidget = node.widgets.find(w => w.name === "🍃 Progress");
const statusWidget = node.widgets.find(w => w.name === "Progress");
if (statusWidget) {
statusWidget.value = `🍃 Run ${data.current_run} / ${data.total_runs}`;
statusWidget.value = `Run ${data.current_run} / ${data.total_runs}`;
app.graph?.setDirtyCanvas(true, true);
}
}
});
};
api.addEventListener("saturnnodes_prompt_iterator_progress", handleProgress);
}
},
async nodeCreated(node) {
if (node.comfyClass === "PromptQueueIterator") {
// 1. Live Progress Status Widget
const statusWidget = node.addWidget("text", "🍃 Progress", "🍃 Ready (-- / --)", () => {}, { serialize: false });
const statusWidget = node.addWidget("text", "Progress", "Ready (-- / --)", () => {}, { serialize: false });
statusWidget.disabled = true;
// 2. Reset Counter (0) Button
const resetBtn = node.addWidget("button", "🔄 Reset Counter (0)", null, async () => {
const resetBtn = node.addWidget("button", "Reset Counter (0)", null, async () => {
try {
resetBtn.name = "⏳ Resetting...";
app.graph?.setDirtyCanvas(true, true);
const resp = await api.fetchApi("/leafflow/prompt_iterator/reset_node", {
const resp = await authenticatedFetch("/saturnnodes/prompt_iterator/reset_node", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ node_id: String(node.id) })
@@ -38,27 +40,27 @@ app.registerExtension({
const res = await resp.json();
if (res && res.status === "ok") {
resetBtn.name = "✅ Reset to 0!";
statusWidget.value = "🍃 Reset (0 / --)";
statusWidget.value = "Reset (0 / --)";
} else {
resetBtn.name = "⚠️ Error";
}
} catch (e) {
console.error("[LeafFlow] 🍃 Error resetting counter:", e);
console.error("[SaturnNodes] Error resetting counter:", e);
resetBtn.name = "❌ Failed";
}
setTimeout(() => {
resetBtn.name = "🔄 Reset Counter (0)";
resetBtn.name = "Reset Counter (0)";
app.graph?.setDirtyCanvas(true, true);
}, 1200);
});
resetBtn.serialize = false;
// 3. Open Queue File in Default OS Editor Button
const openFileBtn = node.addWidget("button", "📂 Open Queue File", null, async () => {
const openFileBtn = node.addWidget("button", "Open Queue File", null, async () => {
try {
openFileBtn.name = "⏳ Opening...";
app.graph?.setDirtyCanvas(true, true);
const resp = await api.fetchApi("/leafflow/prompt_iterator/open_file", { method: "POST" });
const resp = await authenticatedFetch("/saturnnodes/prompt_iterator/open_file", { method: "POST" });
const res = await resp.json();
if (res && res.status === "ok") {
openFileBtn.name = "✅ Opened in Editor!";
@@ -66,15 +68,66 @@ app.registerExtension({
openFileBtn.name = "⚠️ Error";
}
} catch (e) {
console.error("[LeafFlow] 🍃 Error opening state file:", e);
console.error("[SaturnNodes] Error opening state file:", e);
openFileBtn.name = "❌ Failed";
}
setTimeout(() => {
openFileBtn.name = "📂 Open Queue File";
openFileBtn.name = "Open Queue File";
app.graph?.setDirtyCanvas(true, true);
}, 1500);
});
openFileBtn.serialize = false;
// 4. Manage custom_regex enable/disable based on separator
const sepWidget = node.widgets?.find(w => w.name === "separator");
const regexWidget = node.widgets?.find(w => w.name === "custom_regex");
function updateRegexState() {
if (!sepWidget || !regexWidget) return;
const isCustom = String(sepWidget.value || "").toLowerCase().includes("regex");
regexWidget.disabled = !isCustom;
if (regexWidget.inputEl) {
regexWidget.inputEl.disabled = !isCustom;
regexWidget.inputEl.style.opacity = isCustom ? "1" : "0.4";
regexWidget.inputEl.style.pointerEvents = isCustom ? "auto" : "none";
}
if (regexWidget.options) {
regexWidget.options.disabled = !isCustom;
}
app.graph?.setDirtyCanvas(true, true);
}
if (sepWidget && regexWidget) {
const origSepCb = sepWidget.callback;
sepWidget.callback = function(val) {
if (origSepCb) origSepCb.apply(this, arguments);
updateRegexState();
};
const origMouse = regexWidget.mouse;
regexWidget.mouse = function(event, pos, node) {
if (this.disabled) return false;
if (origMouse) return origMouse.apply(this, arguments);
};
const origDraw = regexWidget.draw;
regexWidget.draw = function(ctx, node, widget_width, y, widget_height) {
if (this.disabled) {
ctx.save();
ctx.globalAlpha = 0.35;
if (origDraw) origDraw.apply(this, arguments);
ctx.restore();
} else {
if (origDraw) origDraw.apply(this, arguments);
}
};
setTimeout(updateRegexState, 20);
}
const origOnConfigure = node.onConfigure;
node.onConfigure = function() {
if (origOnConfigure) origOnConfigure.apply(this, arguments);
setTimeout(updateRegexState, 40);
};
}
}
});
-47
View File
@@ -1,47 +0,0 @@
import { app } from "/scripts/app.js";
// Vibrant Leaf Green palette matching the 🍃 emoji
const LEAF_GREEN = { color: "#16a34a", bgcolor: "#14532d" };
const LEAF_LIGHT = { color: "#22c55e", bgcolor: "#166534" };
const LEAF_EMERALD = { color: "#059669", bgcolor: "#064e3b" };
const COLOR_MAP = {
// Visual Loaders
"VisualLoraLoader": LEAF_GREEN,
"FolderLoraLoader": LEAF_GREEN,
"FolderLoraLoaderPretty": LEAF_GREEN,
"VisualImageLoader": LEAF_GREEN,
"LoadRecentOutputs": LEAF_GREEN,
// Automation, Flow & Utilities
"LoadImageFromFolder": LEAF_LIGHT,
"TextAspectRatioFinder": LEAF_LIGHT,
"PreviewImageSizeAspectRatio": LEAF_LIGHT,
"TextLoraFinder": LEAF_LIGHT,
"PromptQueueIterator": LEAF_LIGHT,
"MultiTextReplacer": LEAF_LIGHT,
"LeafFlowTextSplit": LEAF_LIGHT,
"LeafFlowDecision": LEAF_LIGHT,
// Queue & Previews
"PreviewLatentLive": LEAF_EMERALD,
"PauseQueueNode": LEAF_EMERALD,
"PersistentQueueNode": LEAF_EMERALD
};
app.registerExtension({
name: "ComfyUI.LeafFlow.Colors",
async nodeCreated(node) {
const enabled = app.ui.settings.getSettingValue(
"LeafFlow.1. 🖼️ Visual Loaders.00_EnableCustomColors",
true
);
if (!enabled) return;
if (node && node.type && COLOR_MAP[node.type]) {
const theme = COLOR_MAP[node.type];
node.color = theme.color;
node.bgcolor = theme.bgcolor;
}
}
});
-676
View File
@@ -1,676 +0,0 @@
import { app } from "/scripts/app.js";
import { api } from "/scripts/api.js";
// Inject CSS to ensure settings buttons look distinct, styled with an emerald theme, and never get text cut off
if (typeof document !== "undefined") {
const styleId = "leafflow-settings-button-styles";
if (!document.getElementById(styleId)) {
const style = document.createElement("style");
style.id = styleId;
style.textContent = `
/* ComfyUI V2 Vue & LiteGraph Settings Button Styling */
button.leafflow-settings-btn,
tr:has([id*="LeafFlow"]) button,
tr:has([id*="leafflow"]) button,
div:has(> [id*="LeafFlow"]) button,
div:has(> [id*="leafflow"]) button,
[data-setting-id*="LeafFlow"] button,
div[class*="setting"]:has(span:contains("LeafFlow")) button {
background: linear-gradient(135deg, #059669 0%, #047857 100%) !important;
border: 1px solid #10b981 !important;
color: #ffffff !important;
font-weight: 600 !important;
font-size: 12px !important;
padding: 6px 14px !important;
border-radius: 6px !important;
width: auto !important;
min-width: max-content !important;
max-width: none !important;
white-space: nowrap !important;
overflow: visible !important;
text-overflow: clip !important;
cursor: pointer !important;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.15) !important;
transition: all 0.2s ease !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
}
button.leafflow-settings-btn:hover,
tr:has([id*="LeafFlow"]) button:hover,
div:has(> [id*="LeafFlow"]) button:hover,
[data-setting-id*="LeafFlow"] button:hover {
background: linear-gradient(135deg, #047857 0%, #059669 100%) !important;
border-color: #34d399 !important;
color: #ffffff !important;
box-shadow: 0 0 10px rgba(16, 185, 129, 0.45) !important;
transform: translateY(-1px) !important;
}
button.leafflow-settings-btn:active,
tr:has([id*="LeafFlow"]) button:active {
transform: translateY(0) !important;
}
`;
document.head.appendChild(style);
}
}
// Global Non-Fatal Warning & Native Node Error Highlighter
try {
api.addEventListener("leafflow_node_error_state", (e) => {
const { node_id, title, message, fallback } = e.detail || {};
if (node_id && app.graph) {
const targetNode = app.graph.getNodeById(Number(node_id)) || app.graph.getNodeById(String(node_id));
if (targetNode) {
targetNode.has_errors = true;
targetNode.bgcolor = "#7f1d1d";
targetNode.color = "#ef4444";
targetNode.setDirtyCanvas(true, true);
if (typeof targetNode.onExecutionError === "function") {
targetNode.onExecutionError(message);
}
}
}
if (app.extensionManager?.toast?.add) {
app.extensionManager.toast.add({
severity: "warn",
summary: `🍃 ${title || "LeafFlow"}`,
detail: `${message} (Auto-resolved with ${fallback})`,
life: 6000
});
}
});
api.addEventListener("leafflow_toast", (e) => {
const data = e.detail || {};
const title = data.title || "LeafFlow Notice";
const message = data.message || "";
const type = data.type || "warn";
if (app.extensionManager?.toast?.add) {
app.extensionManager.toast.add({
severity: type === "error" ? "error" : "warn",
summary: `🍃 ${title}`,
detail: message,
life: 6000
});
} else if (app.ui?.dialog) {
console.warn(`[LeafFlow] ${title}: ${message}`);
}
});
} catch (_) {}
/**
* Custom renderer for real, styled action buttons in ComfyUI Settings Modal.
*/
function renderSettingButton(label, workingText, successText, onClickHandler) {
return (name, setter, value) => {
const container = document.createElement("div");
container.style.cssText = "display: flex; align-items: center; justify-content: flex-end; width: 100%; padding: 2px 0;";
const btn = document.createElement("button");
btn.type = "button";
btn.textContent = label;
btn.className = "p-button leafflow-settings-btn";
btn.style.cssText = `
padding: 6px 14px !important;
background: linear-gradient(135deg, #059669 0%, #047857 100%) !important;
color: #ffffff !important;
border: 1px solid #10b981 !important;
border-radius: 6px !important;
font-weight: 600 !important;
font-size: 12px !important;
cursor: pointer !important;
transition: all 0.2s ease !important;
outline: none !important;
min-width: max-content !important;
width: auto !important;
white-space: nowrap !important;
text-align: center !important;
box-shadow: 0 1px 3px rgba(0,0,0,0.4) !important;
`;
btn.onmouseover = () => {
if (!btn.disabled) {
btn.style.background = "#047857";
btn.style.borderColor = "#34d399";
btn.style.boxShadow = "0 0 10px rgba(16, 185, 129, 0.45)";
}
};
btn.onmouseout = () => {
if (!btn.disabled) {
btn.style.background = "linear-gradient(135deg, #059669 0%, #047857 100%)";
btn.style.borderColor = "#10b981";
btn.style.boxShadow = "0 1px 3px rgba(0,0,0,0.4)";
}
};
btn.onclick = async (e) => {
e.preventDefault();
e.stopPropagation();
btn.disabled = true;
btn.style.opacity = "0.7";
btn.textContent = workingText || "⏳ Working...";
try {
await onClickHandler();
btn.textContent = successText || "✅ Done!";
btn.style.background = "#1b5e20";
btn.style.borderColor = "#2e7d32";
btn.style.color = "#ffffff";
} catch (err) {
console.error("[LeafFlow] Button action failed:", err);
btn.textContent = "❌ Error";
btn.style.background = "#c62828";
btn.style.borderColor = "#e53935";
}
setTimeout(() => {
btn.textContent = label;
btn.disabled = false;
btn.style.opacity = "1";
btn.style.background = "linear-gradient(135deg, #059669 0%, #047857 100%)";
btn.style.borderColor = "#10b981";
}, 1800);
};
container.appendChild(btn);
return container;
};
}
/**
* Automatic Settings Migration:
* Seamlessly copies over existing user settings from older LeafFlow setting ID formats
* so users never lose their saved API keys, tokens, or preferences.
*/
function migrateSavedSettings() {
try {
if (typeof localStorage === "undefined") return;
const keysToMigrate = [];
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (k && (k.includes("LeafFlow.") || k.includes("leafflow."))) {
keysToMigrate.push(k);
}
}
for (const oldKey of keysToMigrate) {
const val = localStorage.getItem(oldKey);
if (val === null) continue;
// Convert dot-numbered formats like "LeafFlow.1. 🖼️..." -> "LeafFlow.1 - 🖼️..."
const newKey = oldKey.replace(/LeafFlow\.(\d+)\.\s+/g, "LeafFlow.$1 - ");
if (newKey !== oldKey && localStorage.getItem(newKey) === null) {
localStorage.setItem(newKey, val);
}
}
} catch (e) {
console.warn("[LeafFlow] Settings migration skipped:", e);
}
}
migrateSavedSettings();
app.registerExtension({
name: "ComfyUI.LeafFlow.Settings",
async setup() {
migrateSavedSettings();
// =========================================================================
// GRUPPE 1: 1 - 🖼️ Visual Loaders
// =========================================================================
// 1.0 Custom Node Colors Toggle
app.ui.settings.addSetting({
id: "LeafFlow.1 - 🖼️ Visual Loaders.00_EnableCustomColors",
name: "Enable Custom LeafFlow Node Colors",
type: "boolean",
defaultValue: true,
tooltip: "Applies a fresh Leaf Green color theme to LeafFlow nodes on the canvas. When disabled, nodes use default ComfyUI colors.",
});
// 1.1 Civitai API Key
app.ui.settings.addSetting({
id: "LeafFlow.1 - 🖼️ Visual Loaders.01_CivitaiApiKey",
name: "Civitai API Key",
type: "text",
defaultValue: "",
tooltip: "Optional. Civitai SHA256 search works publicly without a key for normal models. Only needed for NSFW/private models or higher rate limits. Whitespace is automatically stripped.",
onChange(value) {
const cleanKey = (value || "").trim();
api.fetchApi("/leafflow/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ civitai_api_key: cleanKey })
}).catch(() => {});
}
});
// 1.2 Enable Civitai Auto-Scraping Toggle (default: true)
app.ui.settings.addSetting({
id: "LeafFlow.1 - 🖼️ Visual Loaders.02_EnableCivitaiScraping",
name: "Enable Civitai Auto-Scraping",
type: "boolean",
defaultValue: true,
tooltip: "Toggles automated downloading of preview thumbnails for new LoRAs from Civitai via SHA256 file hashes. Note: SHA256 hash searching will always work regardless of this setting when matching local models.",
onChange(value) {
api.fetchApi("/leafflow/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enable_civitai_scraping: value ? "true" : "false" })
}).catch(() => {});
}
});
// 1.3 TMDB Access Token
app.ui.settings.addSetting({
id: "LeafFlow.1 - 🖼️ Visual Loaders.03_TMDBApiKey",
name: "TMDB Access Token",
type: "text",
defaultValue: "",
tooltip: "Optional. Accepts TMDB v3 API keys or TMDB v4 Read Access Tokens (eyJ...). Used for celebrity poster and preview image lookup. Whitespace is automatically stripped.",
onChange(value) {
const cleanKey = (value || "").trim();
api.fetchApi("/leafflow/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tmdb_api_key: cleanKey })
}).catch(() => {});
}
});
// 1.4 Enable TMDB Auto-Scraping Toggle (default: false)
app.ui.settings.addSetting({
id: "LeafFlow.1 - 🖼️ Visual Loaders.04_EnableTMDBScraping",
name: "Enable TMDB Auto-Scraping",
type: "boolean",
defaultValue: false,
tooltip: "Toggles automated downloading of celebrity preview thumbnails from TMDB. Default is disabled.",
onChange(value) {
api.fetchApi("/leafflow/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enable_tmdb_scraping: value ? "true" : "false" })
}).catch(() => {});
}
});
// 1.5 Enable LoRA Usage Tracking (default: true)
app.ui.settings.addSetting({
id: "LeafFlow.1 - 🖼️ Visual Loaders.05_EnableLoraUsage",
name: "Enable LoRA Usage Tracking",
type: "boolean",
defaultValue: true,
tooltip: "Toggles tracking and displaying LoRA usage counts & visual rank badges (🔥, Gold, Silver, Bronze) in the LoRA picker. Existing usage history is preserved when disabled.",
onChange(value) {
api.fetchApi("/leafflow/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enable_lora_usage: value ? "true" : "false" })
}).catch(() => {});
}
});
// 1.6 Reset Scrapes Cache Button
app.ui.settings.addSetting({
id: "LeafFlow.1 - 🖼️ Visual Loaders.06_ResetScrapesCache",
name: "Reset Failed Scrapes Cache",
type: "button",
defaultValue: "🗑️ Clear Scrapes Cache",
tooltip: "Immediately clears failed_scrapes.json so Civitai and TMDB can retry downloading missing preview thumbnails on the next folder scan.",
attrs: {
className: "leafflow-settings-btn",
class: "leafflow-settings-btn",
onClick: async () => {
try {
const resp = await api.fetchApi("/leafflow/scrapes/clear", { method: "POST" });
const data = await resp.json();
if (data && data.status === "ok") {
alert("LeafFlow: Failed scrapes cache successfully reset!");
} else {
alert("LeafFlow: Failed to reset cache.");
}
} catch (err) {
alert("LeafFlow: Error resetting cache: " + err);
}
}
},
render: renderSettingButton("🗑️ Clear Scrapes Cache", "⏳ Clearing...", "✅ Cache Reset!", async () => {
const resp = await api.fetchApi("/leafflow/scrapes/clear", { method: "POST" });
const data = await resp.json();
if (data.status !== "ok") {
throw new Error(data.message || "Failed to reset scrapes cache");
}
})
});
// =========================================================================
// GRUPPE 2: 2 - 🔄 Prompt Queue Iterator
// =========================================================================
// 2.1 Clear Prompt Iterator State on Launch (default: false)
app.ui.settings.addSetting({
id: "LeafFlow.2 - 🔄 Prompt Iterator.01_ClearOnLaunch",
name: "Clear State on Launch",
type: "boolean",
defaultValue: false,
tooltip: "Privacy setting. When enabled, prompt_iterator_state.json will be emptied automatically every time ComfyUI starts up.",
onChange(value) {
api.fetchApi("/leafflow/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ clear_prompt_iterator_on_launch: value ? "true" : "false" })
}).catch(() => {});
}
});
// 2.2 Reset Prompt Iterator Queues Now
app.ui.settings.addSetting({
id: "LeafFlow.2 - 🔄 Prompt Iterator.02_ResetActiveQueues",
name: "Reset Active Queues State",
type: "button",
defaultValue: "🔄 Reset All Queues",
tooltip: "Immediately empties all active prompt queues and resets iterator state across all workflows.",
attrs: {
className: "leafflow-settings-btn",
class: "leafflow-settings-btn",
onClick: async () => {
try {
const resp = await api.fetchApi("/leafflow/prompt_iterator/clear", { method: "POST" });
const data = await resp.json();
if (data && data.status === "ok") {
alert("LeafFlow: Prompt Iterator queues successfully reset!");
} else {
alert("LeafFlow: Failed to reset queues.");
}
} catch (err) {
alert("LeafFlow: Error resetting queues: " + err);
}
}
},
render: renderSettingButton("🔄 Reset All Queues", "⏳ Resetting...", "✅ State Reset!", async () => {
const resp = await api.fetchApi("/leafflow/prompt_iterator/clear", { method: "POST" });
const data = await resp.json();
if (data.status !== "ok") {
throw new Error(data.message || "Failed to clear prompt iterator state");
}
})
});
// =========================================================================
// GRUPPE 3: 3 - 📋 Prompt Actions
// =========================================================================
// 3.1 Show "Copy Prompt" Button on Image Overlays
app.ui.settings.addSetting({
id: "LeafFlow.3 - 📋 Prompt Actions.01_EnableAssetsCopyPromptButton",
name: "Show \"Copy Prompt\" Button on Images",
type: "boolean",
defaultValue: true,
tooltip: "Shows the 📋 'Copy Prompt' overlay action button when hovering over generated images in the Assets / History pane and preview nodes.",
});
// 3.2 Show Right-Click "Copy Prompt" Menu Action
app.ui.settings.addSetting({
id: "LeafFlow.3 - 📋 Prompt Actions.02_EnableContextMenuCopyPrompt",
name: "Show Right-Click \"Copy Prompt\" Menu Action",
type: "boolean",
defaultValue: true,
tooltip: "Adds '📋 Copy Prompt' to node right-click context menus.",
});
// 3.3 Show "Save to Prompt Bookmarks" Button on Images & Context Menu
app.ui.settings.addSetting({
id: "LeafFlow.3 - 📋 Prompt Actions.03_EnableSaveToPromptSaver",
name: "Show \"Save to Prompt Saver\" Action",
type: "boolean",
defaultValue: true,
tooltip: "Adds 🔖 'Save to Prompt Bookmarks' to image hover overlay bars and node context menus.",
});
// 3.4 Show "Inspect Asset" (Zoom) Button on Image Overlays
app.ui.settings.addSetting({
id: "LeafFlow.3 - 📋 Prompt Actions.04_EnableInspectAssetButton",
name: "Show \"Inspect Asset\" (Zoom) Button on Images",
type: "boolean",
defaultValue: false,
tooltip: "Restores the 🔍 'Inspect asset' (zoom in) button directly on image cards in the Assets pane next to Download, Copy Prompt, and Bookmarks (moved behind the 3-dots menu in newer ComfyUI versions).",
});
// =========================================================================
// GRUPPE 4: 4 - ⏸️ Pause & Resume Controls
// =========================================================================
// 4.1 Default Pause Queue State on Launch
app.ui.settings.addSetting({
id: "LeafFlow.4 - ⏸️ Pause Controls.01_DefaultStateOnLaunch",
name: "Default State on Launch",
type: "combo",
options: ["Paused", "Running"],
defaultValue: "Paused",
tooltip: "Choose whether execution starts in Paused state or Running state on ComfyUI startup.",
onChange(value) {
api.fetchApi("/leafflow/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ default_pause_state: value })
}).catch(() => {});
}
});
// 4.2 Default Pause Queue Mode on Launch
app.ui.settings.addSetting({
id: "LeafFlow.4 - ⏸️ Pause Controls.02_DefaultPauseAction",
name: "Default Pause Action",
type: "combo",
options: ["Finish Active Prompt", "Instant Resume Node"],
defaultValue: "Finish Active Prompt",
tooltip: "Choose default pause behavior when the pause button or hotkey is triggered.",
onChange(value) {
const modeKey = (value === "Instant Resume Node" || value === "Pause (Instant)") ? "instantly" : "after_finish";
api.fetchApi("/leafflow/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ default_pause_mode: modeKey })
}).catch(() => {});
}
});
// 4.3 Enable Pause Queue Toolbar Button
app.ui.settings.addSetting({
id: "LeafFlow.4 - ⏸️ Pause Controls.03_EnableToolbarButton",
name: "Enable Top Toolbar Button",
type: "boolean",
defaultValue: true,
tooltip: "Displays the green/orange Pause & Continue button group in the top action bar.",
onChange(value) {
const group = document.querySelector(".pq-button-group");
if (group) {
group.style.display = value ? "inline-flex" : "none";
}
}
});
// 4.4 Toolbar Button Unpaused Color
app.ui.settings.addSetting({
id: "LeafFlow.4 - ⏸️ Pause Controls.04_ToolbarButtonUnpausedColor",
name: "Toolbar Button Unpaused Color",
type: "text",
defaultValue: "#16a34a",
tooltip: "Hex color code for the Pause toolbar button when execution is unpaused/running (default: #16a34a).",
onChange(value) {
document.documentElement.style.setProperty("--pq-unpaused-color", value || "#16a34a");
}
});
// 4.5 Toolbar Button Paused Color
app.ui.settings.addSetting({
id: "LeafFlow.4 - ⏸️ Pause Controls.05_ToolbarButtonPausedColor",
name: "Toolbar Button Paused Color",
type: "text",
defaultValue: "#ea580c",
tooltip: "Hex color code for the Pause toolbar button when execution is paused (default: #ea580c).",
onChange(value) {
document.documentElement.style.setProperty("--pq-paused-color", value || "#ea580c");
}
});
// 4.6 Enable System Tray Icon
app.ui.settings.addSetting({
id: "LeafFlow.4 - ⏸️ Pause Controls.06_EnableTrayIcon",
name: "Enable System Tray Icon",
type: "boolean",
defaultValue: false,
tooltip: "Displays an OS system tray icon with real-time queue status colors and outside-browser queue controls.",
onChange(value) {
api.fetchApi("/leafflow/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enable_tray_icon: value ? "true" : "false" })
}).catch(() => {});
}
});
// =========================================================================
// GRUPPE 5: 5 - 💾 Persistent Queue (Auto-Recovery)
// =========================================================================
// 5.1 Enable Persistent Queue (Auto-Recovery)
app.ui.settings.addSetting({
id: "LeafFlow.5 - 💾 Persistent Queue.01_EnablePersistentQueue",
name: "Enable Persistent Queue (Auto-Recovery)",
type: "boolean",
defaultValue: true,
tooltip: "Automatically persists unfinished batch queue items to disk and restores them after server or browser crashes.",
onChange(value) {
api.fetchApi("/leafflow/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enable_persistent_queue: value ? "true" : "false" })
}).catch(() => {});
}
});
// 5.2 Persistent Queue Restored Launch State
app.ui.settings.addSetting({
id: "LeafFlow.5 - 💾 Persistent Queue.02_RecoveryLaunchState",
name: "Recovery Launch State",
type: "combo",
options: ["Match Default", "Force Paused", "Force Running"],
defaultValue: "Match Default",
tooltip: "Override launch state when unfinished queue items are recovered on startup.",
onChange(value) {
api.fetchApi("/leafflow/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ persistent_queue_restored_state: value })
}).catch(() => {});
}
});
// =========================================================================
// GRUPPE 6: 6 - 🖼️ Assets & History Restore
// =========================================================================
// 6.1 Enable Assets / History Restore on Launch
app.ui.settings.addSetting({
id: "LeafFlow.6 - 🖼️ Assets Restore.01_RestoreAssetsOnLaunch",
name: "Restore Assets on Launch",
type: "boolean",
defaultValue: true,
tooltip: "Automatically populates the Assets / History pane upon ComfyUI launch with your latest generated images.",
onChange(value) {
api.fetchApi("/leafflow/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enable_assets_restore: value ? "true" : "false" })
}).catch(() => {});
}
});
// 6.2 Restored Assets Count
app.ui.settings.addSetting({
id: "LeafFlow.6 - 🖼️ Assets Restore.02_RestoredAssetsCount",
name: "Restored Assets Count",
type: "number",
defaultValue: 64,
tooltip: "Number of newest images from the output folder to restore into the Assets / History pane on launch (default: 64).",
onChange(value) {
const count = parseInt(value, 10) || 64;
api.fetchApi("/leafflow/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ restore_assets_count: count })
}).catch(() => {});
}
});
// =========================================================================
// GRUPPE 7: 7 - 🩺 Diagnostics & Debug
// =========================================================================
// 7.1 Export Debug Profile Button
app.ui.settings.addSetting({
id: "LeafFlow.7 - 🩺 Diagnostics.01_ExportDebugProfile",
name: "Export Debug Profile",
type: "button",
defaultValue: "📥 Export Debug Profile",
tooltip: "Exports non-sensitive environment diagnostics (OS, Python, PyTorch, LeafFlow settings, local counts) as a JSON file to share when troubleshooting issues.",
attrs: {
className: "leafflow-settings-btn",
class: "leafflow-settings-btn",
onClick: async () => {
const approved = confirm("🍃 ComfyUI-LeafFlow Diagnostics Export\n\nExport system diagnostics for troubleshooting?\n\nNOTE: Sensitive API keys, tokens, file paths, and private prompt texts are automatically stripped and NEVER exported.");
if (!approved) return;
try {
const resp = await api.fetchApi("/leafflow/debug/export");
const data = await resp.json();
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `leafflow_debug_profile_${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
alert("✅ Diagnostics profile exported successfully! You can attach the downloaded JSON file to your bug report or GitHub issue.");
} catch (e) {
console.error("[LeafFlow] 🍃 Error exporting debug profile:", e);
alert("❌ Failed to export debug profile: " + e.message);
}
}
},
render: renderSettingButton("📥 Export Debug Profile", "⏳ Exporting...", "✅ Exported!", async () => {
const resp = await api.fetchApi("/leafflow/debug/export");
const data = await resp.json();
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `leafflow_debug_profile_${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
})
});
// =========================================================================
// GRUPPE 8: 8 - 🎨 Batch Queue Visuals
// =========================================================================
// 8.1 Enable Batch Queue Grouping Lines
app.ui.settings.addSetting({
id: "LeafFlow.BatchQueue.Enabled",
name: "Show Batch Queue 1D Lines",
type: "boolean",
defaultValue: true,
tooltip: "Renders 1D git-graph style colored lines indicating batch groupings and contiguous segments on queued items."
});
}
});
+5 -4
View File
@@ -1,5 +1,6 @@
import { app } from "/scripts/app.js";
import { api } from "/scripts/api.js";
import { authenticatedFetch } from "./js/auth_helper.js";
const stylePath = new URL('./pause_queue.css', import.meta.url).href;
if (!document.querySelector(`link[href="${stylePath}"]`)) {
@@ -57,7 +58,7 @@ class PauseQueueUI {
async togglePause(paused) {
try {
const res = await api.fetchApi("/pause_queue/toggle", {
const res = await authenticatedFetch("/pause_queue/toggle", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ paused, mode: this.state.mode })
@@ -73,7 +74,7 @@ class PauseQueueUI {
async setMode(mode) {
try {
const res = await api.fetchApi("/pause_queue/mode", {
const res = await authenticatedFetch("/pause_queue/mode", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mode })
@@ -89,7 +90,7 @@ class PauseQueueUI {
async continueQueue() {
try {
const res = await api.fetchApi("/pause_queue/continue", { method: "POST" });
const res = await authenticatedFetch("/pause_queue/continue", { method: "POST" });
if (res.ok) {
const data = await res.json();
this.updateState(data);
@@ -319,7 +320,7 @@ class PauseQueueUI {
}
app.registerExtension({
name: "ComfyUI.LeafFlow.PauseQueue",
name: "ComfyUI.SaturnNodes.PauseQueue",
async setup() {
new PauseQueueUI();
}
+4 -3
View File
@@ -1,8 +1,9 @@
import { app } from "/scripts/app.js";
import { api } from "/scripts/api.js";
import { authenticatedFetch } from "./js/auth_helper.js";
app.registerExtension({
name: "ComfyUI.LeafFlow.PersistentQueue",
name: "ComfyUI.SaturnNodes.PersistentQueue",
async setup() {
const patchRgthree = async () => {
try {
@@ -35,7 +36,7 @@ app.registerExtension({
}
if (patched) {
rgthree.SERVICE.dispatchProgressUpdate();
console.log("[LeafFlow] Synced recovered queue with rgthree-comfy progress bar.");
console.log("[SaturnNodes] Synced recovered queue with rgthree-comfy progress bar.");
}
}
} catch (e) {
@@ -46,7 +47,7 @@ app.registerExtension({
const claimQueueOwnership = async () => {
if (api && api.clientId) {
try {
await api.fetchApi("/persistent_queue/claim", {
await authenticatedFetch("/persistent_queue/claim", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ client_id: api.clientId })
+157
View File
@@ -0,0 +1,157 @@
import { app } from "/scripts/app.js";
// Default Amber theme matching the Saturn theme (warm, deep amber, not blinding neon and not muddy brown)
export const DEFAULT_HEADER_COLOR = "#6a3f20";
export const DEFAULT_BG_COLOR = "#472a15";
export const SATURN_NODE_TYPES = new Set([
// Visual Loaders
"VisualLoraLoader",
"FolderLoraLoader",
"FolderLoraLoaderPretty",
"FolderLoraLoaderVisualPrettyV2",
"VisualImageLoader",
"ImageLoaderVisualPrettyV2",
"LoadRecentOutputs",
// Automation, Flow & Utilities
"LoadImageFromFolder",
"TextAspectRatioFinder",
"AspectRatioFinder",
"PreviewImageSizeAspectRatio",
"TextLoraFinder",
"LoraTextFinder",
"PromptQueueIterator",
"PromptCounter",
"MultiTextReplacer",
"SaturnTextSplit",
"SaturnDecision",
"RunLocalFileNode",
// Queue & Previews
"PreviewLatentLive",
"PauseQueueNode",
"PersistentQueueNode"
]);
export function getSaturnTheme() {
let headerColor = DEFAULT_HEADER_COLOR;
let bgColor = DEFAULT_BG_COLOR;
try {
const getSetting = (plainId, numId) => {
if (app.ui?.settings?.get) {
const val = app.ui.settings.get(plainId) || app.ui.settings.get(numId);
if (val) return val;
}
if (typeof localStorage !== "undefined") {
for (const k of [plainId, numId]) {
for (const prefix of ["Comfy.Settings.", ""]) {
const raw = localStorage.getItem(`${prefix}${k}`);
if (raw !== null && raw !== undefined) {
try { return JSON.parse(raw); } catch (_) { return raw; }
}
}
}
}
return null;
};
const hc = getSetting("SaturnNodes.🖼️ Visual Loaders.00_CustomNodeHeaderColor", "SaturnNodes.1 - 🖼️ Visual Loaders.00_CustomNodeHeaderColor");
if (hc) headerColor = hc;
const bg = getSetting("SaturnNodes.🖼️ Visual Loaders.00_CustomNodeBgColor", "SaturnNodes.1 - 🖼️ Visual Loaders.00_CustomNodeBgColor");
if (bg) bgColor = bg;
} catch (_) {}
return { color: headerColor, bgcolor: bgColor };
}
export function isSaturnColorsEnabled() {
try {
const checkVal = (k) => {
if (app.ui?.settings?.get) {
const val = app.ui.settings.get(k);
if (val !== undefined && val !== null) return Boolean(val);
}
if (app.ui?.settings?.getSettingValue) {
const val = app.ui.settings.getSettingValue(k);
if (val !== undefined && val !== null) return Boolean(val);
}
if (typeof localStorage !== "undefined") {
for (const prefix of ["Comfy.Settings.", ""]) {
const raw = localStorage.getItem(`${prefix}${k}`);
if (raw !== null && raw !== undefined) {
try { return JSON.parse(raw); } catch (_) { return Boolean(raw); }
}
}
}
return null;
};
for (const k of [
"SaturnNodes.🖼️ Visual Loaders.00_EnableCustomColors",
"SaturnNodes.1 - 🖼️ Visual Loaders.00_EnableCustomColors"
]) {
const res = checkVal(k);
if (res !== null) return res;
}
} catch (_) {}
return false; // Default is disabled (false)
}
export function applySaturnColorToNode(node) {
if (!node) return;
const type = node.comfyClass || node.type || node.constructor?.comfyClass || node.constructor?.type;
if (!SATURN_NODE_TYPES.has(type)) return;
if (isSaturnColorsEnabled()) {
const theme = getSaturnTheme();
node.color = theme.color;
node.bgcolor = theme.bgcolor;
} else {
node.color = undefined;
node.bgcolor = undefined;
}
}
export function updateAllSaturnNodeColors() {
if (!app.graph?._nodes) return;
for (const node of app.graph._nodes) {
applySaturnColorToNode(node);
if (typeof node._updateCounterBadgeColor === "function") {
node._updateCounterBadgeColor();
}
}
app.graph.setDirtyCanvas(true, true);
}
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;
}
}
},
async nodeCreated(node) {
applySaturnColorToNode(node);
},
async loadedGraphNode(node) {
applySaturnColorToNode(node);
},
afterConfigureGraph() {
updateAllSaturnNodeColors();
},
async setup() {
setTimeout(() => updateAllSaturnNodeColors(), 100);
setTimeout(() => updateAllSaturnNodeColors(), 500);
}
});
File diff suppressed because it is too large Load Diff