Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
005efdd7c5 | ||
|
|
78682dd0cb | ||
|
|
29198a5fd7 | ||
|
|
5c09936c3a | ||
|
|
ba35d58e81 | ||
|
|
ef95266cf4 | ||
|
|
b4228dc340 | ||
|
|
88f1485cc9 | ||
|
|
16ef5bf5db | ||
|
|
983862eec3 | ||
|
|
acc1570629 | ||
|
|
30c0547009 | ||
|
|
13e24c3a57 | ||
|
|
cd00cce1f8 | ||
|
|
a26d060d5f | ||
|
|
e9594ec428 | ||
|
|
b6b2356389 | ||
|
|
c3375c61af | ||
|
|
d39c0c89fe | ||
|
|
e1a0187f8f | ||
|
|
ac181210d1 | ||
|
|
e47ce1dd43 |
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -1 +1 @@
|
||||
# Package marker for LeafFlow nodes
|
||||
# Package marker for SaturnNodes nodes
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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,4 +1,4 @@
|
||||
PREVIEW_CATEGORY = "🍃 LeafFlow/Previews"
|
||||
PREVIEW_CATEGORY = "🪐 SaturnNodes/Previews"
|
||||
|
||||
class PreviewLatentLiveNode:
|
||||
@classmethod
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -1,4 +1,4 @@
|
||||
Pillow
|
||||
piexif
|
||||
numpy
|
||||
pystray
|
||||
# pystray # Optional: install manually if desktop system tray icon integration is enabled
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user