Compare commits

..
15 Commits
Author SHA1 Message Date
KOFiblto 005efdd7c5 fix(frontend-v1): eliminate infinite runaway downward resizing in VisualLoraLoader and ImageLoader 2026-09-24 00:42:04 +02:00
KOFiblto 78682dd0cb refactor(core): remove legacy nodes, implement Random (Cycle), improve quote parsing in text replacer, optimize canvas render loops 2026-09-24 00:24:24 +02:00
KOFiblto 29198a5fd7 fix(widgets): eliminate empty space below prompt and lora counters, disable counter colors when custom colors disabled 2026-09-24 00:03:58 +02:00
KOFiblto 5c09936c3a style(theme): set default saturn colors to #6a3f20 and #472a15 2026-09-23 23:20:08 +02:00
KOFiblto ba35d58e81 chore: align default custom node color constants 2026-09-23 23:17:54 +02:00
KOFiblto ef95266cf4 feat(colors): set default theme colors to Molten Copper (#c85a2c, #210f09) 2026-09-23 23:12:56 +02:00
KOFiblto b4228dc340 fix(ui): 100-palette studio, fixed counter height, sketch color picker & settings styling 2026-09-23 23:10:42 +02:00
KOFiblto 88f1485cc9 feat(ui): ResolutionSelector styled DOM count widgets, amber custom colors with settings color pickers, and default false toggle 2026-09-23 20:35:31 +02:00
KOFiblto 16ef5bf5db fix(ui): pastel unified colors, native zoom-adaptive counter widgets, sleek single settings buttons, footer links, and running default launch state 2026-09-23 20:18:34 +02:00
KOFiblto 983862eec3 feat(ui): saturn counter widget, lora count badge, color sync, renumber settings group 8 and closed details 2026-09-23 01:41:54 +02:00
KOFiblto acc1570629 fix(ui): protect settings toggle switches from button styles and render custom action buttons 2026-09-22 22:26:37 +02:00
KOFiblto 30c0547009 fix(ci): normalize path casing and canonicalize scripts base for runner tempdirs 2026-09-22 22:11:42 +02:00
KOFiblto 13e24c3a57 fix(ci): fix cross-platform drive letter check in local runner and update workflow names to SaturnNodes 2026-09-22 20:31:12 +02:00
KOFiblto cd00cce1f8 refactor: remove legacy leafflow endpoints and events in favor of clean saturnnodes routes 2026-09-22 20:15:15 +02:00
KOFiblto a26d060d5f feat: rename suite to SaturnNodes (v2.3.2) 2026-09-22 19:53:39 +02:00
46 changed files with 2209 additions and 1297 deletions
+1 -1
View File
@@ -30,6 +30,6 @@ jobs:
python -m pip install --upgrade pip
pip install Pillow torch numpy aiohttp piexif
- name: Execute LeafFlow Unit Test Suite
- name: Execute SaturnNodes Unit Test Suite
run: |
python tests/run_tests.py
+17 -1
View File
@@ -1,10 +1,26 @@
# 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
+5 -5
View File
@@ -1,6 +1,6 @@
# 🤝 Contributing to ComfyUI-LeafFlow
# 🤝 Contributing to ComfyUI-SaturnNodes
Thank you for your interest in contributing to **`ComfyUI-LeafFlow`**! We welcome bug reports, feature suggestions, documentation updates, and pull requests from the community.
Thank you for your interest in contributing to **`ComfyUI-SaturnNodes`**! We welcome bug reports, feature suggestions, documentation updates, and pull requests from the community.
Please take a moment to review these guidelines before submitting an issue or pull request.
@@ -9,7 +9,7 @@ Please take a moment to review these guidelines before submitting an issue or pu
## 🐛 Reporting Issues & Bugs
Before opening a new issue:
1. **Check Existing Issues**: Search the [Issue Tracker](https://github.com/KOFiblto/ComfyUI-LeafFlow/issues) to see if your bug or feature request has already been reported.
1. **Check Existing Issues**: Search the [Issue Tracker](https://github.com/KOFiblto/ComfyUI-SaturnNodes/issues) to see if your bug or feature request has already been reported.
2. **Include Reproduction Steps**: Provide a clear step-by-step description of how to reproduce the bug.
3. **Include Logs & Environment Details**:
- **ComfyUI Version** (V2 Vue UI or Classic V1 LiteGraph)
@@ -33,8 +33,8 @@ All development follows a **Feature Branch $\rightarrow$ Pull Request** workflow
### Step 1: Fork and Clone
Fork the repository on GitHub, then clone your fork locally:
```bash
git clone https://github.com/YOUR_USERNAME/ComfyUI-LeafFlow.git
cd ComfyUI-LeafFlow
git clone https://github.com/YOUR_USERNAME/ComfyUI-SaturnNodes.git
cd ComfyUI-SaturnNodes
```
### Step 2: Create a Feature Branch
+69 -58
View File
@@ -1,8 +1,17 @@
# 🍃 ComfyUI-LeafFlow
# 🪐 ComfyUI-SaturnNodes
A unified custom node suite for **ComfyUI** featuring real-time queue controls, mid-generation pausing, queue crash recovery, visual model pickers, live latent canvas previews, folder automation, prompt utilities, and resolution tools.
A unified workflow control and productivity suite for **ComfyUI**. Built for **ComfyUI Frontend V2 (Nodes 2.0 / Vue UI)** (fully supported and actively tested) with backwards compatibility for the **Classic ComfyUI Frontend (Nodes 1.0 / LiteGraph)** (supported as-is, but untested—no guarantees).
> ⚠️ **Notice**: Fully compatible with both **ComfyUI Frontend V2 (Nodes 2.0 / Vue UI)** and **Classic ComfyUI Frontend (Nodes 1.0 / LiteGraph)**.
---
## 🚀 Key Capabilities & Overview
- **Live Latent Preview**: Watch generations materialize step-by-step directly inside your graph canvas nodes in real time.
- **Batch Prompting & Iteration**: Queue multiple prompts automatically with delimiter splits, regex blocks, and persistent queue state tracking.
- **Visual Thumbnail Browsing**: Browse LoRAs and image folders with auto-scraped Civitai & TMDB previews, ranking badges, and multi-selection.
- **Real-Time Queue & Pause Controls**: Pause, resume, or cleanly cancel execution mid-generation, with automatic crash recovery for interrupted queues.
- **Aspect Ratio & Resolution Utilities**: Find exact matching aspect ratios, crop dimensions, and visual sizing helpers.
- **Safe Local Script Execution**: Run local scripts and executables strictly confined to `ComfyUI/scripts/` with interactive operator authorization.
---
@@ -11,7 +20,7 @@ A unified custom node suite for **ComfyUI** featuring real-time queue controls,
Expand any node below to view its description, inputs, outputs, and usage documentation.
<details>
<summary><b>🍃 📁 LoRA Loader (Folder)</b> (<code>FolderLoraLoader</code>)</summary>
<summary><b>🪐 📁 LoRA Loader (Folder)</b> (<code>FolderLoraLoader</code>)</summary>
#### Overview
Loads a LoRA by folder path using raw filename matching or formatted names with customizable output formatting options.
@@ -33,7 +42,7 @@ Loads a LoRA by folder path using raw filename matching or formatted names with
</details>
<details>
<summary><b>🍃 ✨ LoRA Loader (Pretty)</b> (<code>FolderLoraLoaderPretty</code>)</summary>
<summary><b>🪐 ✨ LoRA Loader (Pretty)</b> (<code>FolderLoraLoaderPretty</code>)</summary>
#### Overview
Loads a LoRA using formatted pretty names (e.g. `Ana De Armas V1` instead of `krea2_Ana-De-Armas_v1.safetensors`).
@@ -53,7 +62,7 @@ Loads a LoRA using formatted pretty names (e.g. `Ana De Armas V1` instead of `kr
</details>
<details>
<summary><b>🍃 🖼️ Visual LoRA Loader</b> (<code>VisualLoraLoader</code> / alias <code>FolderLoraLoaderVisualPrettyV2</code>)</summary>
<summary><b>🪐 🖼️ Visual LoRA Loader</b> (<code>VisualLoraLoader</code> / alias <code>FolderLoraLoaderVisualPrettyV2</code>)</summary>
#### Overview
Visual thumbnail browser for LoRAs with Civitai SHA256 search & TMDB auto-scraping, popularity rank badges (🔥), multi-selection support, and independent per-node thumbnail zoom sizing.
@@ -75,7 +84,7 @@ Visual thumbnail browser for LoRAs with Civitai SHA256 search & TMDB auto-scrapi
</details>
<details>
<summary><b>🍃 📷 Visual Image Loader</b> (<code>VisualImageLoader</code> / alias <code>ImageLoaderVisualPrettyV2</code>)</summary>
<summary><b>🪐 📷 Visual Image Loader</b> (<code>VisualImageLoader</code> / alias <code>ImageLoaderVisualPrettyV2</code>)</summary>
#### Overview
Visual thumbnail browser for image folders with instant preview selection, independent per-node thumbnail zoom sizing, and EXIF positive prompt metadata extraction. Path access is strictly confined to ComfyUI `input`, `output`, and `temp` directories for safe operation.
@@ -94,7 +103,7 @@ Visual thumbnail browser for image folders with instant preview selection, indep
</details>
<details>
<summary><b>🍃 📂 Load Image From Folder</b> (<code>LoadImageFromFolder</code>)</summary>
<summary><b>🪐 📂 Load Image From Folder</b> (<code>LoadImageFromFolder</code>)</summary>
#### Overview
Monitors a folder for incoming images, loads the target image into a PyTorch tensor, with an optional toggle to delete the image after loading.
@@ -113,7 +122,7 @@ Monitors a folder for incoming images, loads the target image into a PyTorch ten
</details>
<details>
<summary><b>🍃 ⏱️ Recent Outputs</b> (<code>LoadRecentOutputs</code>)</summary>
<summary><b>🪐 ⏱️ Recent Outputs</b> (<code>LoadRecentOutputs</code>)</summary>
#### Overview
Loads the N newest images from an output directory with step-through index selection.
@@ -128,21 +137,21 @@ Loads the N newest images from an output directory with step-through index selec
</details>
<details>
<summary><b>🍃 👁️ Live Latent Preview</b> (<code>PreviewLatentLiveNode</code>)</summary>
<summary><b>🪐 👁️ Live Latent Preview</b> (<code>PreviewLatentLiveNode</code>)</summary>
#### Overview
Canvas rendering node that listens to sampler WebSocket latent binary streams and displays real-time live previews on the canvas during generation.
#### Inputs & Outputs
- **Category**: `LeafFlow/Previews`
- **Category**: `🪐 SaturnNodes/Previews`
- **Output Node**: True
</details>
<details>
<summary><b>🍃 ⏸️ LeafFlow Decision</b> (<code>LeafFlowDecision</code>)</summary>
<summary><b>🪐 ⏸️ Saturn Decision</b> (<code>SaturnDecision</code> / alias <code>LeafFlowDecision</code>)</summary>
#### Overview
Pauses workflow execution at a specific step and displays an inline UI popup with Continue, Cancel, or Stop Workflow actions, plus optional native OS desktop notifications.
Pauses workflow execution at a specific step and displays an inline UI popup with Continue, Cancel, or Stop Workflow actions, plus optional native OS desktop notifications. Fully backward compatible with workflows containing `LeafFlowDecision`.
#### Inputs & Widgets
- **`disable`** (`BOOLEAN`): Bypass decision gate.
@@ -154,7 +163,7 @@ Pauses workflow execution at a specific step and displays an inline UI popup wit
</details>
<details>
<summary><b>🍃 📐 Text Aspect Ratio Finder</b> (<code>TextAspectRatioFinder</code> / alias <code>AspectRatioFinder</code>)</summary>
<summary><b>🪐 📐 Text Aspect Ratio Finder</b> (<code>TextAspectRatioFinder</code> / alias <code>AspectRatioFinder</code>)</summary>
#### Overview
Parses input text for aspect ratios (e.g. `16:9`, `2.35:1`), syntax-checks them, and calculates pixel resolution for a target megapixel target.
@@ -177,7 +186,7 @@ Parses input text for aspect ratios (e.g. `16:9`, `2.35:1`), syntax-checks them,
</details>
<details>
<summary><b>🍃 📐 Preview Image Size & Aspect Ratio</b> (<code>PreviewImageSizeAspectRatio</code>)</summary>
<summary><b>🪐 📐 Preview Image Size & Aspect Ratio</b> (<code>PreviewImageSizeAspectRatio</code>)</summary>
#### Overview
Computes image dimensions and aspect ratio from an input image tensor and formats the result for previewing.
@@ -192,7 +201,7 @@ Computes image dimensions and aspect ratio from an input image tensor and format
</details>
<details>
<summary><b>🍃 🔎 Text LoRA Finder & Loader</b> (<code>TextLoraFinder</code> / alias <code>LoraTextFinder</code>)</summary>
<summary><b>🪐 🔎 Text LoRA Finder & Loader</b> (<code>TextLoraFinder</code> / alias <code>LoraTextFinder</code>)</summary>
#### Overview
Scans input prompt text for `<lora:name:strength>` tags or names matching a folder on disk, dynamically applies them, and returns patched models along with sanitized prompt text.
@@ -214,7 +223,7 @@ 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
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.
@@ -233,10 +242,10 @@ Deterministically iterates over multiline prompt text blocks per queue run with
</details>
<details>
<summary><b>🍃 📝 Prompt Counter</b> (<code>PromptCounter</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 (e.g. `🍃 24 Prompts`) on every keystroke, allowing you to easily determine and set the exact ComfyUI batch size before running your queue.
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.
@@ -249,7 +258,7 @@ Multiline text prompt input node that functions identically to a standard multil
</details>
<details>
<summary><b>🍃 🔤 Multi Text Replacer</b> (<code>MultiTextReplacer</code>)</summary>
<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.
@@ -266,26 +275,25 @@ 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>
<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.
@@ -300,7 +308,7 @@ Safely executes a local script or executable (`.bat`, `.cmd`, `.ps1`, `.exe` on
- **`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]` button directly on the canvas node to grant a 5-minute single-use execution token.
- **⚡ 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.
@@ -312,14 +320,17 @@ Safely executes a local script or executable (`.bat`, `.cmd`, `.ps1`, `.exe` on
---
<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.
@@ -328,66 +339,66 @@ 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 LeafFlow sidebar and API. Disabled by default for maximum security.
- **`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 open>
<summary><b>9. 🛡️ Security & Script Execution</b></summary>
<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.
@@ -397,15 +408,15 @@ Configure options directly under ComfyUI Settings (⚙ gear icon):
## 🔒 Security & Privacy
- **Local Runner Sandbox & Operator Authorization Gate:** The `Run Local File` node enforces single-use interactive operator authorization (`[⚡ Authorize Run]`), 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 (`/leafflow/settings`, `/leafflow/power/*`, `/leafflow/local_runner/*`, `/leafflow/scrapes/clear`) enforce loopback checks, `Sec-Fetch-Site` header checks (blocking cross-site requests from visited web pages), and cryptographic per-session `X-LeafFlow-CSRF-Token` headers.
- **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 are cleanly declared in `requirements.txt`.
- **Local Environment:** All API keys and settings are stored locally in `.env` (excluded from git commits via `.gitignore`).
- **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.
---
@@ -415,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.
+5 -5
View File
@@ -2,7 +2,7 @@
## Reporting Security Issues
We take the security of ComfyUI-LeafFlow seriously. If you discover a security vulnerability or sensitive key exposure, please **DO NOT** open a public issue.
We take the security of ComfyUI-SaturnNodes seriously. If you discover a security vulnerability or sensitive key exposure, please **DO NOT** open a public issue.
Instead, please report security vulnerabilities directly to the maintainer via GitHub private vulnerability reporting.
@@ -15,15 +15,15 @@ Instead, please report security vulnerabilities directly to the maintainer via G
## Security Architecture & Best Practices
- **CSRF Defense & Fetch Metadata Validation**: All administrative endpoints (`/leafflow/settings`, `/leafflow/power/*`, `/leafflow/local_runner/*`, `/leafflow/scrapes/clear`) enforce multi-layer CSRF protection via `is_authenticated_local_request(request)`.
- **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-LeafFlow-CSRF-Token` header (`secrets.compare_digest`), prompting CORS preflight rejection if an external origin attempts unauthorized requests.
- 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 /leafflow/power/request_token`) valid for 30 seconds.
- The confirmation endpoint (`POST /leafflow/power/confirm_action`) consumes the single-use ticket in server memory before executing restart or shutdown, preventing arbitrary or cross-site triggers.
- 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.
+43 -44
View File
@@ -4,7 +4,7 @@ import subprocess
from aiohttp import web
from server import PromptServer
__version__ = "2.3.1"
__version__ = "2.3.2"
from .nodes.queue_control import setup_queue_control_routes, tray_manager
from .nodes.lora_loader import (
@@ -17,15 +17,16 @@ 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,
@@ -42,34 +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",
"PromptCounter": "🍃 📝 Prompt Counter",
"MultiTextReplacer": "🍃 🔤 Multi Text Replacer",
"LeafFlowTextSplit": "🍃 ✂️ Text Split",
"RunLocalFileNode": "🍃 ⚡ Run Local File"
"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"
@@ -79,31 +80,31 @@ server = PromptServer.instance
setup_queue_control_routes(server)
setup_local_runner_routes(server)
try:
from .prompt_bookmarks.api import register_routes as register_prompt_bookmarks_routes
register_prompt_bookmarks_routes()
except Exception as e:
pass
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
try:
print(f"[ComfyUI-LeafFlow] 🍃 Loaded {len(NODE_CLASS_MAPPINGS)} nodes & visual endpoints successfully (v{__version__}).")
except UnicodeEncodeError:
print(f"[ComfyUI-LeafFlow] Loaded {len(NODE_CLASS_MAPPINGS)} nodes & visual endpoints successfully (v{__version__}).")
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/auth/token")
@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("/leafflow/get_image_prompt")
@routes.get("/leafflow/view_image_prompt")
@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)
@@ -145,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)
@@ -196,8 +196,7 @@ 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_authenticated_local_request(request):
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
@@ -352,11 +351,11 @@ async def save_settings(request):
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)
@@ -366,10 +365,10 @@ async def clear_scrapes_endpoint(request):
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
@@ -409,7 +408,7 @@ async def export_debug_profile(request):
debug_profile = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"package": "ComfyUI-LeafFlow",
"package": "ComfyUI-SaturnNodes",
"version": __version__,
"system": {
"os": platform.system(),
@@ -424,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"),
+1 -1
View File
@@ -1 +1 @@
# Package marker for LeafFlow nodes
# Package marker for SaturnNodes nodes
+5 -5
View File
@@ -59,7 +59,7 @@ class TextAspectRatioFinder:
RETURN_TYPES = ("INT", "INT", "STRING")
RETURN_NAMES = ("width", "height", "aspect_ratio")
FUNCTION = "find_aspect_ratio"
CATEGORY = "🍃 LeafFlow/Utils"
CATEGORY = "🪐 SaturnNodes/Utils"
DESCRIPTION = "Searches text for aspect ratios (e.g. 16:9), syntax checks them, and calculates width & height for target megapixels."
def find_aspect_ratio(
@@ -147,11 +147,11 @@ class TextAspectRatioFinder:
raise ValueError(f"Cannot parse aspect ratio: {found_ratio}")
except Exception as e:
err_msg = f"Aspect ratio '{found_ratio}' is invalid. Falling back to '1:1'."
print(f"[LeafFlow] Warning: {err_msg} ({e})")
print(f"[SaturnNodes] Warning: {err_msg} ({e})")
try:
from server import PromptServer
if hasattr(PromptServer, "instance") and PromptServer.instance:
PromptServer.instance.send_sync("leafflow_node_error_state", {
PromptServer.instance.send_sync("saturnnodes_node_error_state", {
"node_id": str(unique_id or ""),
"title": "Aspect Ratio Warning",
"message": err_msg,
@@ -196,7 +196,7 @@ class PreviewImageSizeAspectRatio:
RETURN_TYPES = ()
RETURN_NAMES = ()
FUNCTION = "process_preview"
CATEGORY = "🍃 LeafFlow/Utils"
CATEGORY = "🪐 SaturnNodes/Utils"
OUTPUT_NODE = True
DESCRIPTION = "Visual preview node that displays image aspect ratio and dimension summary."
@@ -241,7 +241,7 @@ class PreviewImageSizeAspectRatio:
display_text = f"1 x {h_disp}"
try:
PromptServer.instance.send_sync("leafflow_update_preview_aspect_ratio", {
PromptServer.instance.send_sync("saturnnodes_update_preview_aspect_ratio", {
"node_id": str(unique_id),
"ratio": calc_ratio,
"display_text": display_text
+1 -1
View File
@@ -231,7 +231,7 @@ class AssetsRestoreManager:
}
if restored_count > 0:
print(f"[LeafFlow] Restored {restored_count} recent image(s) into Assets / History pane.")
print(f"[SaturnNodes] Restored {restored_count} recent image(s) into Assets / History pane.")
if server:
try:
server.queue_updated()
+7 -7
View File
@@ -51,7 +51,7 @@ class LoadImageFromFolder:
RETURN_TYPES = ("IMAGE", "BOOLEAN")
RETURN_NAMES = ("image", "has_image")
FUNCTION = "watch"
CATEGORY = "🍃 LeafFlow/Automation"
CATEGORY = "🪐 SaturnNodes/Automation"
DESCRIPTION = "Loads an image from a folder, optionally waiting if the folder is empty, with automatic sequential cycling when delete is False."
@classmethod
@@ -73,7 +73,7 @@ class LoadImageFromFolder:
try:
os.remove(filepath)
except Exception as e:
print(f"[LeafFlow] Warning: Failed to remove processed file '{filepath}': {e}")
print(f"[SaturnNodes] Warning: Failed to remove processed file '{filepath}': {e}")
return img_tensor
@@ -82,7 +82,7 @@ class LoadImageFromFolder:
try:
regex = re.compile(regex_filter)
except Exception as e:
print(f"[LeafFlow] Invalid regex: {e}. Falling back to .*")
print(f"[SaturnNodes] Invalid regex: {e}. Falling back to .*")
regex = re.compile(".*")
files = []
@@ -164,9 +164,9 @@ class LoadImageFromFolder:
img_tensor = self.load_and_remove_image(filepath, delete_image=delete_image)
return (img_tensor, True)
except Exception as e:
print(f"[LeafFlow] Error processing {filepath}: {e}")
print(f"[SaturnNodes] Error processing {filepath}: {e}")
except Exception as e:
print(f"[LeafFlow] Error accessing directory {folder}: {e}")
print(f"[SaturnNodes] Error accessing directory {folder}: {e}")
time.sleep(rescan_interval)
else:
@@ -180,9 +180,9 @@ class LoadImageFromFolder:
img_tensor = self.load_and_remove_image(filepath, delete_image=delete_image)
return (img_tensor, True)
except Exception as e:
print(f"[LeafFlow] Error processing {filepath}: {e}")
print(f"[SaturnNodes] Error processing {filepath}: {e}")
except Exception as e:
print(f"[LeafFlow] Error accessing directory {folder}: {e}")
print(f"[SaturnNodes] Error accessing directory {folder}: {e}")
# No image present or folder missing -> return standard dummy tensor & False immediately
return (self.create_dummy_image(), False)
+18 -15
View File
@@ -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,16 +160,16 @@ $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_authenticated_local_request(request):
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
@@ -184,3 +184,6 @@ async def handle_decision(request):
return web.json_response({"status": "error", "message": "Node is not waiting."}, status=400)
except Exception as e:
return web.json_response({"status": "error", "message": str(e)}, status=500)
LeafFlowDecision = SaturnDecision
SaturnDecision = SaturnDecision
+6 -5
View File
@@ -14,12 +14,13 @@ from .utils import (
get_leafflow_user_dir,
is_local_request,
is_safe_path,
sanitize_image_loader_folder
sanitize_image_loader_folder,
get_saturnnodes_user_dir
)
IMAGE_CATEGORY = "🍃 LeafFlow/Loaders"
IMAGE_CATEGORY = "🪐 SaturnNodes/Loaders"
CURRENT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
USER_DIR = get_leafflow_user_dir()
USER_DIR = get_saturnnodes_user_dir()
CACHE_FILE = os.path.join(USER_DIR, "image_prompts_cache.json")
def decode_bytes_safely(raw_bytes):
@@ -148,7 +149,7 @@ def extract_metadata_from_image(filepath):
except Exception:
pass
except Exception as e:
print(f"[LeafFlow] Error extracting metadata: {e}")
print(f"[SaturnNodes] Error extracting metadata: {e}")
return positive_prompt, width, height
def load_prompts_cache():
@@ -165,7 +166,7 @@ def save_prompts_cache(cache):
with open(CACHE_FILE, "w", encoding="utf-8") as f:
json.dump(cache, f, indent=4, ensure_ascii=False)
except Exception as e:
print(f"[LeafFlow] Failed to save prompts cache: {e}")
print(f"[SaturnNodes] Failed to save prompts cache: {e}")
routes = PromptServer.instance.routes if hasattr(PromptServer, "instance") and PromptServer.instance is not None else web.RouteTableDef()
+3 -3
View File
@@ -5,7 +5,7 @@ from PIL import Image, ImageOps
import folder_paths
from .utils import sanitize_folder_path
LOADER_CATEGORY = "🍃 LeafFlow/Loaders"
LOADER_CATEGORY = "🪐 SaturnNodes/Loaders"
class LoadRecentOutputs:
@classmethod
@@ -57,7 +57,7 @@ class LoadRecentOutputs:
except OSError:
continue
except Exception as e:
print(f"[LeafFlow] 🍃 Error scanning output folder '{output_dir}': {e}")
print(f"[SaturnNodes] 🍃 Error scanning output folder '{output_dir}': {e}")
dummy = torch.zeros((1, 512, 512, 3), dtype=torch.float32)
return (dummy,)
@@ -80,7 +80,7 @@ class LoadRecentOutputs:
image = np.array(image).astype(np.float32) / 255.0
image = torch.from_numpy(image).unsqueeze(0)
except Exception as e:
print(f"[LeafFlow] Error loading recent output image {target_file}: {e}")
print(f"[SaturnNodes] Error loading recent output image {target_file}: {e}")
image = torch.zeros((1, 512, 512, 3), dtype=torch.float32)
return (image,)
+27 -22
View File
@@ -9,7 +9,7 @@ import folder_paths
import comfy.model_management
from .utils import get_env_setting, is_local_request, is_authenticated_local_request
AUTOMATION_CATEGORY = "🍃 LeafFlow/Automation"
AUTOMATION_CATEGORY = "🪐 SaturnNodes/Automation"
def _safe_print(msg):
try:
@@ -18,7 +18,7 @@ def _safe_print(msg):
print(msg.encode("ascii", "replace").decode("ascii"))
def is_local_file_execution_enabled():
"""Checks whether local file execution is explicitly enabled in LeafFlow settings (default False)."""
"""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"]
@@ -42,10 +42,12 @@ def is_script_path_permitted(target_path):
if not target_path:
return False, "Target path is empty."
scripts_dir = get_allowed_scripts_directory()
scripts_dir = os.path.abspath(os.path.realpath(get_allowed_scripts_directory()))
try:
abs_target = os.path.abspath(os.path.realpath(target_path))
if os.path.commonpath([scripts_dir, abs_target]) == scripts_dir:
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):
@@ -65,7 +67,8 @@ def resolve_target_path(file_path_input):
return None, "Script file path is empty."
# 1. Strictly reject absolute paths (Windows drive letters C:\, UNC \\server, or POSIX /root)
if os.path.isabs(clean) or clean.startswith(("\\\\", "//")):
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
@@ -83,12 +86,14 @@ def resolve_target_path(file_path_input):
if not clean_sub:
return None, "Invalid script path."
scripts_base = get_allowed_scripts_directory()
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:
if os.path.commonpath([scripts_base, full_target]) != scripts_base:
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}"
@@ -156,13 +161,13 @@ def clear_all_authorizations():
def setup_local_runner_routes(server):
routes = server.routes
@routes.post("/leafflow/local_runner/authorize")
@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 LeafFlow settings first.",
"error": "Local file execution is disabled. Enable 'Allow Local File Execution' in SaturnNodes settings first.",
"enabled": False
}, status=403)
try:
@@ -180,7 +185,7 @@ def setup_local_runner_routes(server):
"expires_in": 300
})
@routes.get("/leafflow/local_runner/status")
@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)
@@ -319,16 +324,16 @@ class RunLocalFileNode:
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 -> LeafFlow -> Security."
"To run local files, enable 'Allow Local File Execution' in ComfyUI Settings -> SaturnNodes -> Security."
)
_safe_print(f"[LeafFlow LocalRunner] {err}")
_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"[LeafFlow LocalRunner] {err}")
_safe_print(f"[SaturnNodes LocalRunner] {err}")
return ("", err, -1, False, passthrough_out)
# Check 3: Mandatory In-Memory Operator Authorization Gate
@@ -336,7 +341,7 @@ class RunLocalFileNode:
authorized, auth_msg = consume_node_authorization(node_id, resolved_path)
if not authorized:
err = f"Execution Blocked: {auth_msg}"
_safe_print(f"[LeafFlow LocalRunner] {err}")
_safe_print(f"[SaturnNodes LocalRunner] {err}")
return ("", err, -1, False, passthrough_out)
# Determine working directory (confined to ComfyUI/scripts/ or script directory)
@@ -348,12 +353,12 @@ class RunLocalFileNode:
if os.path.commonpath([scripts_base, cand_cwd]) == scripts_base and os.path.isdir(cand_cwd):
cwd = cand_cwd
else:
_safe_print(f"[LeafFlow LocalRunner] Warning: working_directory '{working_directory}' outside scripts folder. Defaulting to script folder.")
_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"[LeafFlow LocalRunner] Executing '{os.path.basename(resolved_path)}' (mode: {run_mode}, cwd: '{cwd}')...")
_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:
@@ -366,11 +371,11 @@ class RunLocalFileNode:
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"[LeafFlow LocalRunner] {msg}")
_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"[LeafFlow LocalRunner] Error: {err_msg}")
_safe_print(f"[SaturnNodes LocalRunner] Error: {err_msg}")
return ("", err_msg, -1, False, passthrough_out)
# Mode: Synchronous (Wait for Process with Output & Interrupt Polling)
@@ -413,13 +418,13 @@ class RunLocalFileNode:
comfy.model_management.throw_exception_if_processing_interrupted()
except Exception:
_kill_tree()
_safe_print(f"[LeafFlow LocalRunner] Execution of '{os.path.basename(resolved_path)}' interrupted by user.")
_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"[LeafFlow LocalRunner] {err}")
_safe_print(f"[SaturnNodes LocalRunner] {err}")
return ("", err, -1, False, passthrough_out)
time.sleep(0.1)
@@ -428,10 +433,10 @@ class RunLocalFileNode:
exit_code = proc.returncode
success = (exit_code == 0)
_safe_print(f"[LeafFlow LocalRunner] Completed with exit code {exit_code} (success: {success}).")
_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"[LeafFlow LocalRunner] {err_msg}")
_safe_print(f"[SaturnNodes LocalRunner] {err_msg}")
return ("", err_msg, -1, False, passthrough_out)
+2 -2
View File
@@ -99,7 +99,7 @@ class TextLoraFinder:
matches.append((m.start(), display_name, sys_path))
seen_paths.add(sys_path)
except Exception as e:
print(f"[LeafFlow] 🍃 Invalid custom regex pattern '{custom_regex}': {e}")
print(f"[SaturnNodes] 🍃 Invalid custom regex pattern '{custom_regex}': {e}")
else:
for display_name, sys_path in mapping.items():
if display_name in ["[ NONE ]", "[ RANDOM ]"] or not sys_path or sys_path == "[ NONE ]":
@@ -150,7 +150,7 @@ class TextLoraFinder:
)
increment_lora_usage(sys_path)
except Exception as e:
print(f"[LeafFlow] 🍃 Error loading LoRA '{sys_path}': {e}")
print(f"[SaturnNodes] 🍃 Error loading LoRA '{sys_path}': {e}")
continue
else:
increment_lora_usage(sys_path)
+11 -10
View File
@@ -18,12 +18,13 @@ from .utils import (
parse_pretty_name_with_version,
format_lora_output_name,
sanitize_folder_path,
get_saturnnodes_user_dir,
get_leafflow_user_dir,
is_local_request,
is_safe_path
)
LORA_CATEGORY = "🍃 LeafFlow/Loaders"
LORA_CATEGORY = "🪐 SaturnNodes/Loaders"
LORA_OUTPUT_FORMAT_CHOICES = [
"Parsed Name",
"Filename",
@@ -34,7 +35,7 @@ LORA_OUTPUT_FORMAT_CHOICES = [
]
CURRENT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
USER_DIR = get_leafflow_user_dir()
USER_DIR = get_saturnnodes_user_dir()
ENV_FILE = os.path.join(USER_DIR, ".env")
LORA_STATE_FILE = os.path.join(USER_DIR, "lora_loader_state.json")
@@ -62,7 +63,7 @@ def save_lora_cycle_state(state):
with open(LORA_STATE_FILE, "w", encoding="utf-8") as f:
json.dump(state, f, indent=4, ensure_ascii=False)
except Exception as e:
print(f"[LeafFlow] Error saving lora loader cycle state: {e}")
print(f"[SaturnNodes] Error saving lora loader cycle state: {e}")
def get_api_keys():
civitai_key = os.getenv("CIVITAI_API_KEY", "")
@@ -98,7 +99,7 @@ def load_usage_data():
else:
usage_data[k] = usage_data.get(k, 0) + v
except Exception as e:
print(f"[LeafFlow] Error loading usage data: {e}")
print(f"[SaturnNodes] Error loading usage data: {e}")
return usage_data
def increment_lora_usage(lora_path):
@@ -114,7 +115,7 @@ def increment_lora_usage(lora_path):
with open(usage_file, "w", encoding="utf-8") as f:
json.dump(usage_data, f, indent=4, ensure_ascii=False)
except Exception as e:
print(f"[LeafFlow] Error updating lora usage: {e}")
print(f"[SaturnNodes] Error updating lora usage: {e}")
def find_preview_image(lora_relative_path):
lora_full_path = folder_paths.get_full_path("loras", lora_relative_path)
@@ -232,10 +233,10 @@ def scrape_missing_images_sync():
try:
with open(dest_path, "wb") as f:
f.write(img_data)
print(f"[LeafFlow] Scraped Civitai preview for {pretty_name}")
print(f"[SaturnNodes] Scraped Civitai preview for {pretty_name}")
success = True
except (PermissionError, OSError) as write_err:
print(f"[LeafFlow] Warning: Could not save preview image to '{dest_path}': {write_err}")
print(f"[SaturnNodes] Warning: Could not save preview image to '{dest_path}': {write_err}")
except Exception:
pass
@@ -277,11 +278,11 @@ def scrape_missing_images_sync():
try:
with open(dest_path, "wb") as f:
f.write(img_data)
print(f"[LeafFlow] Scraped TMDB preview for {pretty_name} (via '{term}')")
print(f"[SaturnNodes] Scraped TMDB preview for {pretty_name} (via '{term}')")
success = True
break
except (PermissionError, OSError) as write_err:
print(f"[LeafFlow] Warning: Could not save preview image to '{dest_path}': {write_err}")
print(f"[SaturnNodes] Warning: Could not save preview image to '{dest_path}': {write_err}")
except Exception as tmdb_err:
pass
@@ -296,7 +297,7 @@ def scrape_missing_images_sync():
except Exception:
pass
except Exception as e:
print(f"[LeafFlow] Error in preview scraping thread: {e}")
print(f"[SaturnNodes] Error in preview scraping thread: {e}")
def start_async_scraping():
global scraping_thread
+1 -1
View File
@@ -1,4 +1,4 @@
PREVIEW_CATEGORY = "🍃 LeafFlow/Previews"
PREVIEW_CATEGORY = "🪐 SaturnNodes/Previews"
class PreviewLatentLiveNode:
@classmethod
+2 -2
View File
@@ -1,4 +1,4 @@
from .prompt_iterator import parse_prompt_blocks
from .prompt_iterator import parse_prompt_blocks
class PromptCounter:
"""
@@ -27,7 +27,7 @@ class PromptCounter:
RETURN_TYPES = ("STRING", "INT")
RETURN_NAMES = ("STRING", "count")
FUNCTION = "count_prompts"
CATEGORY = "🍃 LeafFlow/Utils"
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):
+40 -26
View File
@@ -9,10 +9,10 @@ import subprocess
from aiohttp import web
from server import PromptServer
import folder_paths
from .utils import get_leafflow_user_dir, is_authenticated_local_request
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,7 +83,7 @@ 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)
@@ -101,28 +101,28 @@ 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)
@@ -149,7 +149,7 @@ try:
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:
@@ -174,7 +174,7 @@ def parse_prompt_blocks(text_str, separator, custom_regex=""):
results.append(b_str)
return results
except Exception as e:
print(f"[LeafFlow Prompt] Invalid custom regex pattern '{pattern}': {e}. Falling back to empty line separator.")
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:
@@ -228,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
@@ -306,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]
@@ -335,7 +345,7 @@ class PromptQueueIterator:
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,
@@ -343,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}_"
@@ -355,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
+39 -38
View File
@@ -9,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, is_authenticated_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")
@@ -39,7 +39,7 @@ def is_process_management_enabled():
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")
@@ -57,11 +57,11 @@ class PauseQueueManager:
return
server = getattr(PromptServer, "instance", None)
if not server:
print("[LeafFlow] Queue control: Skipped pause patching (PromptServer instance not ready).")
print("[SaturnNodes] Queue control: Skipped pause patching (PromptServer instance not ready).")
return
if not hasattr(server, "prompt_queue") or not hasattr(server.prompt_queue, "get"):
print("[LeafFlow] Queue control: Skipped patching prompt_queue.get (incompatible ComfyUI version).")
print("[SaturnNodes] Queue control: Skipped patching prompt_queue.get (incompatible ComfyUI version).")
return
queue = server.prompt_queue
@@ -113,10 +113,10 @@ class PauseQueueManager:
server.send_sync = patched_send_sync
else:
print("[LeafFlow] Queue control: Skipped patching send_sync (not found on server).")
print("[SaturnNodes] Queue control: Skipped patching send_sync (not found on server).")
self._patched = True
print("[LeafFlow] Queue control: Pause manager initialized successfully.")
print("[SaturnNodes] Queue control: Pause manager initialized successfully.")
def is_currently_executing(self):
try:
@@ -281,16 +281,16 @@ class PersistentQueueManager:
if self._patched:
return
if not is_persistent_queue_enabled():
print("[LeafFlow] Persistent queue disabled in settings; core prompt_queue patching skipped.")
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("[LeafFlow] Queue control: Skipped persistent queue patching (prompt_queue not found on server).")
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("[LeafFlow] Queue control: Skipped persistent queue patching (incompatible queue interface).")
print("[SaturnNodes] Queue control: Skipped persistent queue patching (incompatible queue interface).")
return
# 1. Patch queue.put
@@ -361,7 +361,7 @@ class PersistentQueueManager:
server.send_sync = patched_send_sync
self._patched = True
print("[LeafFlow] Queue control: Persistent queue manager initialized successfully.")
print("[SaturnNodes] Queue control: Persistent queue manager initialized successfully.")
def restore_queue(self, active_client_id=None):
if self.has_claimed_once:
@@ -465,16 +465,16 @@ class PowerControlManager:
with self.lock:
if action in ["restart", "shutdown"]:
if not is_process_management_enabled():
print(f"[LeafFlow Power] Arm rejected: Process Management is disabled in LeafFlow settings.")
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
@@ -489,7 +489,8 @@ class PowerControlManager:
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
@@ -520,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()
@@ -529,12 +530,12 @@ class PowerControlManager:
def execute_restart(self):
if not is_process_management_enabled():
print("[LeafFlow Power] Rejected restart: Process Management is disabled in LeafFlow settings.")
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):
@@ -554,12 +555,12 @@ class PowerControlManager:
def execute_shutdown(self):
if not is_process_management_enabled():
print("[LeafFlow Power] Rejected shutdown: Process Management is disabled in LeafFlow settings.")
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()
@@ -612,19 +613,19 @@ 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_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 LeafFlow settings to use restart or shutdown.",
"error": "Process Management is disabled. Enable 'Allow Process Management' in SaturnNodes settings to use restart or shutdown.",
"enabled": False
}, status=403)
try:
@@ -635,13 +636,13 @@ def setup_queue_control_routes(server):
power_manager.arm(action)
return web.json_response(power_manager.get_status())
@routes.post("/leafflow/power/request_token")
@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 LeafFlow settings.",
"error": "Process Management is disabled. Enable 'Allow Process Management' in SaturnNodes settings.",
"enabled": False
}, status=403)
try:
@@ -659,13 +660,13 @@ def setup_queue_control_routes(server):
"expires_in": 30
})
@routes.post("/leafflow/power/confirm_action")
@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 LeafFlow settings.",
"error": "Process Management is disabled. Enable 'Allow Process Management' in SaturnNodes settings.",
"enabled": False
}, status=403)
try:
@@ -684,13 +685,13 @@ def setup_queue_control_routes(server):
return web.json_response({"status": "shutting_down", "success": True})
return web.json_response({"error": "Unknown action"}, status=400)
@routes.post("/leafflow/power/restart")
@routes.post("/saturnnodes/power/restart")
async def trigger_restart(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 LeafFlow settings to use restart.",
"error": "Process Management is disabled. Enable 'Allow Process Management' in SaturnNodes settings to use restart.",
"enabled": False
}, status=403)
try:
@@ -699,17 +700,17 @@ def setup_queue_control_routes(server):
data = {}
ticket = data.get("ticket")
if not _consume_power_ticket(ticket, "restart"):
return web.json_response({"error": "Confirmation ticket required. Use /leafflow/power/request_token first."}, status=403)
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_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 LeafFlow settings to use shutdown.",
"error": "Process Management is disabled. Enable 'Allow Process Management' in SaturnNodes settings to use shutdown.",
"enabled": False
}, status=403)
try:
@@ -718,11 +719,11 @@ def setup_queue_control_routes(server):
data = {}
ticket = data.get("ticket")
if not _consume_power_ticket(ticket, "shutdown"):
return web.json_response({"error": "Confirmation ticket required. Use /leafflow/power/request_token first."}, status=403)
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_authenticated_local_request(request):
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
@@ -735,19 +736,19 @@ 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_authenticated_local_request(request):
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
+26 -13
View File
@@ -1,18 +1,31 @@
import re
import csv
import io
def parse_find_targets(find_str):
if not find_str:
if not find_str or not find_str.strip():
return []
raw_items = []
# Split strictly by comma
parts = find_str.split(',')
for part in parts:
item = part.strip()
if (item.startswith('"') and item.endswith('"')) or (item.startswith("'") and item.endswith("'")):
item = item[1:-1].strip()
if item:
raw_items.append(item)
return raw_items
try:
reader = csv.reader(io.StringIO(find_str.strip()), skipinitialspace=True)
raw_items = []
for row in reader:
for item in row:
s = item.strip()
if (s.startswith('"') and s.endswith('"')) or (s.startswith("'") and s.endswith("'")):
s = s[1:-1].strip()
if s:
raw_items.append(s)
return raw_items
except Exception:
raw_items = []
parts = find_str.split(',')
for part in parts:
item = part.strip()
if (item.startswith('"') and item.endswith('"')) or (item.startswith("'") and item.endswith("'")):
item = item[1:-1].strip()
if item:
raw_items.append(item)
return raw_items
class MultiTextReplacer:
@classmethod
@@ -32,7 +45,7 @@ class MultiTextReplacer:
RETURN_TYPES = ("STRING", "INT")
RETURN_NAMES = ("text", "replaced_count")
FUNCTION = "replace_text"
CATEGORY = "🍃 LeafFlow/Utils"
CATEGORY = "🪐 SaturnNodes/Utils"
DESCRIPTION = "Searches input text for multiple search targets specified in a comma-separated list or regex, and replaces all matches with a replacement string."
def replace_text(
@@ -75,6 +88,6 @@ class MultiTextReplacer:
replaced_count += count
result_text = re.sub(pattern_str, lambda m: replace, result_text, flags=flags)
except Exception as e:
print(f"[LeafFlow] 🍃 Invalid regex pattern '{pattern_str}': {e}")
print(f"[SaturnNodes] 🍃 Invalid regex pattern '{pattern_str}': {e}")
return (result_text, replaced_count)
+7 -4
View File
@@ -1,8 +1,8 @@
import re
class LeafFlowTextSplit:
class SaturnTextSplit:
"""
LeafFlow Text Node:
SaturnNodes Text Split Node:
Splits input text into two parts (text1 and text2) by either a literal character sequence
or a regular expression pattern, supporting forward (from start) and backward (from end) splitting.
"""
@@ -23,7 +23,7 @@ class LeafFlowTextSplit:
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("text1", "text2")
FUNCTION = "split_text"
CATEGORY = "🍃 LeafFlow/Utils"
CATEGORY = "🪐 SaturnNodes/Utils"
DESCRIPTION = "Splits input text into two parts (text1 and text2) by a character sequence or regex pattern, with forward/backward splitting."
def split_text(
@@ -64,7 +64,7 @@ class LeafFlowTextSplit:
else:
t1, t2 = input_text, ""
except Exception as e:
print(f"[LeafFlow Text Split] Regex error: {e}. Falling back to literal string split.")
print(f"[SaturnNodes Text Split] Regex error: {e}. Falling back to literal string split.")
if is_forward:
parts = input_text.split(delimiter, 1)
else:
@@ -85,3 +85,6 @@ class LeafFlowTextSplit:
t2 = t2.strip()
return (t1, t2)
LeafFlowTextSplit = SaturnTextSplit
SaturnTextSplit = SaturnTextSplit
+13 -13
View File
@@ -3,9 +3,9 @@ import sys
import threading
import webbrowser
from PIL import Image, ImageDraw
from .utils import get_leafflow_user_dir
from .utils import get_saturnnodes_user_dir, get_leafflow_user_dir
USER_DIR = get_leafflow_user_dir()
USER_DIR = get_saturnnodes_user_dir()
ENV_FILE = os.path.join(USER_DIR, ".env")
def get_env_setting(key, default_val):
@@ -68,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
@@ -81,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
@@ -146,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()
@@ -178,7 +178,7 @@ class TrayIconManager:
try:
import pystray
except ImportError:
print("[LeafFlow] System tray icon disabled: 'pystray' is not installed. Install pystray ('pip install pystray') to enable system tray support.")
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()
@@ -186,7 +186,7 @@ class TrayIconManager:
menu = self.build_menu()
self._icon = pystray.Icon(
name="ComfyUI-LeafFlow",
name="ComfyUI-SaturnNodes",
icon=icon_img,
title=title,
menu=menu
@@ -195,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
@@ -215,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:
@@ -228,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:
+24 -10
View File
@@ -4,17 +4,16 @@ 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")
@@ -24,13 +23,28 @@ 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)
@@ -87,7 +101,7 @@ def is_authenticated_local_request(request):
return False
headers = getattr(request, "headers", {})
token = headers.get("X-LeafFlow-CSRF-Token")
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
@@ -203,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:
+5 -5
View File
@@ -1,7 +1,7 @@
[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.3.1"
version = "2.3.2"
readme = "README.md"
license = "MIT"
dependencies = ["pillow", "numpy", "piexif"]
@@ -10,9 +10,9 @@ dependencies = ["pillow", "numpy", "piexif"]
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"
+2 -2
View File
@@ -6,11 +6,11 @@ import threading
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import test_helper
from nodes.decision_node import DecisionManager, LeafFlowDecision
from nodes.decision_node import DecisionManager, SaturnDecision
class TestDecisionNode(unittest.TestCase):
def setUp(self):
self.node = LeafFlowDecision()
self.node = SaturnDecision()
def test_decision_manager_register_and_trigger(self):
uid = "test_decision_123"
+31
View File
@@ -235,5 +235,36 @@ class TestPromptQueueIterator(unittest.TestCase):
matching_keys = [k for k in state_db.keys() if k.startswith("node_prune_node_")]
self.assertLessEqual(len(matching_keys), 3)
def test_random_cycle_permutation(self):
"""
Verify that Random (Cycle) mode visits all items in a permutation before repeating.
"""
pack = "A\n\nB\n\nC\n\nD\n\nE"
state_db = {}
def mock_load():
return dict(state_db)
def mock_save(new_state):
nonlocal state_db
state_db = dict(new_state)
with patch("nodes.prompt_iterator.load_state", side_effect=mock_load), \
patch("nodes.prompt_iterator.save_state", side_effect=mock_save), \
patch("nodes.prompt_iterator.PromptServer.instance.send_sync"):
picked = []
for _ in range(5):
p, _, _ = self.node.process_queue(
pop_mode="Random (Cycle)",
text=pack,
unique_id="cycle_test_node"
)
picked.append(p)
# All 5 unique items must have been visited in 1 cycle
self.assertEqual(len(picked), 5)
self.assertEqual(set(picked), {"A", "B", "C", "D", "E"})
if __name__ == "__main__":
unittest.main()
+4
View File
@@ -19,6 +19,10 @@ class TestMultiTextReplacer(unittest.TestCase):
targets = parse_find_targets('"dog", "cat", "bird"')
self.assertEqual(targets, ["dog", "cat", "bird"])
def test_parse_find_targets_with_comma_inside_quotes(self):
targets = parse_find_targets('"hello, world", "foo, bar", baz')
self.assertEqual(targets, ["hello, world", "foo, bar", "baz"])
def test_replace_comma_separated_list(self):
text, count = self.node.replace_text(
find="dog, cat, bird",
+3 -3
View File
@@ -5,11 +5,11 @@ import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import test_helper
from nodes.text_split import LeafFlowTextSplit
from nodes.text_split import SaturnTextSplit
class TestLeafFlowTextSplit(unittest.TestCase):
class TestSaturnTextSplit(unittest.TestCase):
def setUp(self):
self.node = LeafFlowTextSplit()
self.node = SaturnTextSplit()
def test_forward_literal_split_basic(self):
# User standard case: "Hello --- Bye" with delimiter "--"
+41 -13
View File
@@ -42,10 +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 SETTING_SNAPSHOT_GUARD = "LeafFlow.BatchQueue.SnapshotGuard";
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();
@@ -54,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)) {
@@ -65,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);
}
}
@@ -198,11 +206,21 @@ 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;
@@ -210,8 +228,18 @@ function isBatchQueueEnabled() {
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) {
return app.extensionManager.setting.get(SETTING_SNAPSHOT_GUARD) !== 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;
@@ -280,7 +308,7 @@ function mergeDynamicSeeds(snapPrompt, livePrompt) {
// Sync with server if PersistentQueue is available
async function syncBatchToServer(promptId, batchInfo) {
try {
await authenticatedFetch("/leafflow/batch_queue/sync", {
await authenticatedFetch("/saturnnodes/batch_queue/sync", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -296,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") {
@@ -477,7 +505,7 @@ function setupQueueHooks() {
}
app.registerExtension({
name: "ComfyUI.LeafFlow.BatchQueue",
name: "ComfyUI.SaturnNodes.BatchQueue",
async setup() {
injectBatchStyles();
setupQueueHooks();
+8 -5
View File
@@ -12,14 +12,14 @@ export async function getCsrfToken() {
}
_tokenPromise = (async () => {
try {
const resp = await api.fetchApi("/leafflow/auth/token");
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("[LeafFlow Auth] Could not fetch CSRF token:", e);
console.warn("[SaturnNodes Auth] Could not fetch CSRF token:", e);
} finally {
_tokenPromise = null;
}
@@ -33,15 +33,18 @@ export async function authenticatedFetch(endpoint, options = {}) {
const opts = { ...options };
opts.headers = { ...(opts.headers || {}) };
if (token) {
opts.headers["X-LeafFlow-CSRF-Token"] = token;
opts.headers["X-SaturnNodes-CSRF-Token"] = token;
}
return api.fetchApi(endpoint, opts);
}
// Expose on window for all LeafFlow extensions
// Expose on window for all SaturnNodes extensions (with LeafFlow alias for backward compatibility)
if (typeof window !== "undefined") {
window._LeafFlowAuth = {
const authObj = {
getToken: getCsrfToken,
fetch: authenticatedFetch
};
window._SaturnNodesAuth = authObj;
window._LeafFlowAuth = authObj;
}
+28 -114
View File
@@ -36,11 +36,27 @@ async function copyToClipboard(text) {
function isCopyEnabled(settingKey, defaultVal = true) {
try {
let val;
const saturnNumberedKey = settingKey.replace(/^LeafFlow\./, "SaturnNodes.");
const saturnPlainKey = saturnNumberedKey.replace(/\d+\s*-\s*/, "");
const keysToCheck = [saturnPlainKey, saturnNumberedKey, settingKey];
if (app.extensionManager?.setting?.get) {
val = app.extensionManager.setting.get(settingKey);
for (const k of keysToCheck) {
val = app.extensionManager.setting.get(k);
if (val !== undefined && val !== null && val !== "") break;
}
}
if ((val === undefined || val === null || val === "") && app.ui?.settings?.getSettingValue) {
val = app.ui.settings.getSettingValue(settingKey);
for (const k of keysToCheck) {
val = app.ui.settings.getSettingValue(k);
if (val !== undefined && val !== null && val !== "") break;
}
}
if ((val === undefined || val === null || val === "") && app.ui?.settings?.get) {
for (const k of keysToCheck) {
val = app.ui.settings.get(k);
if (val !== undefined && val !== null && val !== "") break;
}
}
if (val === undefined || val === null || val === "") {
return defaultVal;
@@ -51,9 +67,6 @@ function isCopyEnabled(settingKey, defaultVal = true) {
}
}
const BOOKMARK_SVG = `<svg class="size-4 pointer-events-none" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z"/></svg>`;
const BOOKMARK_SUCCESS_SVG = `<svg class="size-4 text-emerald-600 pointer-events-none" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`;
const BOOKMARK_FAIL_SVG = `<svg class="size-4 text-rose-600 pointer-events-none" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
const INSPECT_SVG = `<svg class="size-4 pointer-events-none" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>`;
function updateButtonGroupBorders(container) {
@@ -248,8 +261,8 @@ async function getImagePromptAndMeta(imgSrc) {
if (!promptText && filename) {
try {
const promptUrl = `/leafflow/get_image_prompt?filename=${encodeURIComponent(filename)}&type=${encodeURIComponent(type)}&subfolder=${encodeURIComponent(subfolder)}`;
const response = await api.fetchApi(promptUrl);
const query = `filename=${encodeURIComponent(filename)}&type=${encodeURIComponent(type)}&subfolder=${encodeURIComponent(subfolder)}`;
const response = await api.fetchApi(`/saturnnodes/get_image_prompt?${query}`);
if (response.ok) {
const data = await response.json();
if (data && data.prompt) promptText = data.prompt;
@@ -259,32 +272,12 @@ async function getImagePromptAndMeta(imgSrc) {
return {
promptText: promptText || "",
filename: filename || "bookmark",
filename: filename || "image",
subfolder: subfolder || "",
type: type || "output"
};
}
async function saveImageToPromptBookmarks(imgSrc) {
const meta = await getImagePromptAndMeta(imgSrc);
if (!meta) return false;
const event = new CustomEvent("prompt-bookmarks-create", {
detail: {
name: meta.filename.replace(/\.[^/.]+$/, ""),
text: meta.promptText,
media: [{
filename: meta.filename,
subfolder: meta.subfolder,
type: meta.type,
media_type: "image"
}]
}
});
window.dispatchEvent(event);
return true;
}
async function copyImagePrompt(imgSrc) {
if (!imgSrc) return false;
let promptText = null;
@@ -308,8 +301,8 @@ async function copyImagePrompt(imgSrc) {
}
if (filename) {
const promptUrl = `/leafflow/get_image_prompt?filename=${encodeURIComponent(filename)}&type=${encodeURIComponent(type)}&subfolder=${encodeURIComponent(subfolder)}`;
const response = await api.fetchApi(promptUrl);
const query = `filename=${encodeURIComponent(filename)}&type=${encodeURIComponent(type)}&subfolder=${encodeURIComponent(subfolder)}`;
const response = await api.fetchApi(`/saturnnodes/get_image_prompt?${query}`);
if (response.ok) {
const data = await response.json();
if (data && data.prompt) {
@@ -318,7 +311,7 @@ async function copyImagePrompt(imgSrc) {
}
}
} catch (e) {
console.warn("[LeafFlow] Direct prompt fetch failed:", e);
console.warn("[SaturnNodes] Direct prompt fetch failed:", e);
}
}
@@ -339,7 +332,7 @@ async function copyImagePrompt(imgSrc) {
// 1. Hook into Node Context Menu (Right Click)
app.registerExtension({
name: "ComfyUI.LeafFlow.CopyPrompt",
name: "ComfyUI.SaturnNodes.CopyPrompt",
async beforeRegisterNodeDef(nodeType, nodeData) {
const origGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
@@ -371,18 +364,6 @@ app.registerExtension({
}
},
});
if (isCopyEnabled("LeafFlow.3 - 📋 Prompt Actions.03_EnableSaveToPromptSaver")) {
options.push({
content: "🔖 Bookmark in Prompt Saver",
callback: async () => {
const img = imgs[this.imageIndex || 0];
const src = typeof img === "string" ? img : (img?.src || img?.value);
if (!src) return;
await saveImageToPromptBookmarks(src);
},
});
}
}
};
}
@@ -437,7 +418,7 @@ function getActiveImageSrc() {
return null;
}
// 3. Inject Copy Prompt, Bookmark & Inspect Directly Next to Download Button on Asset Cards
// 3. Inject Copy Prompt & Inspect Directly Next to Download Button on Asset Cards
function injectCopyPromptNextToDownload(downloadBtn) {
if (!downloadBtn || !downloadBtn.parentElement) return;
@@ -455,10 +436,9 @@ function injectCopyPromptNextToDownload(downloadBtn) {
const parent = downloadBtn.parentElement;
const enableCopy = isCopyEnabled("LeafFlow.3 - 📋 Prompt Actions.01_EnableAssetsCopyPromptButton");
const enableBookmark = isCopyEnabled("LeafFlow.3 - 📋 Prompt Actions.03_EnableSaveToPromptSaver");
const enableInspect = isCopyEnabled("LeafFlow.3 - 📋 Prompt Actions.04_EnableInspectAssetButton", false);
if (!enableCopy && !enableBookmark && !enableInspect) return;
if (!enableCopy && !enableInspect) return;
let baseClasses = downloadBtn.className
.replace(/\brounded-[a-z0-9-]+\b/g, "")
@@ -522,35 +502,7 @@ function injectCopyPromptNextToDownload(downloadBtn) {
copyBtn.remove();
}
// 2. Bookmark Button
let bookmarkBtn = parent.querySelector(".leafflow-hover-bookmark");
if (enableBookmark) {
if (!bookmarkBtn) {
bookmarkBtn = document.createElement("button");
bookmarkBtn.type = "button";
bookmarkBtn.title = "Save to Prompt Bookmarks";
bookmarkBtn.setAttribute("aria-label", "Save to Prompt Bookmarks");
bookmarkBtn.className = `leafflow-hover-btn leafflow-hover-bookmark ${baseClasses} shrink-0`;
bookmarkBtn.innerHTML = BOOKMARK_SVG;
bookmarkBtn.onclick = async (e) => {
e.preventDefault();
e.stopPropagation();
const activeImg = card.querySelector("img") || img;
const success = await saveImageToPromptBookmarks(activeImg.src);
bookmarkBtn.innerHTML = success ? BOOKMARK_SUCCESS_SVG : BOOKMARK_FAIL_SVG;
setTimeout(() => {
bookmarkBtn.innerHTML = BOOKMARK_SVG;
}, 2000);
};
parent.insertBefore(bookmarkBtn, lastBtn.nextSibling);
}
lastBtn = bookmarkBtn;
} else if (bookmarkBtn) {
bookmarkBtn.remove();
}
// 3. Inspect Asset (Zoom) Button
// 2. Inspect Asset (Zoom) Button
let inspectBtn = parent.querySelector(".leafflow-hover-inspect");
if (enableInspect) {
if (!inspectBtn) {
@@ -649,44 +601,6 @@ function injectContextMenuCopy(contextMenu) {
});
downloadLi.parentElement.insertBefore(copyLi, downloadLi.nextSibling);
if (isCopyEnabled("LeafFlow.3 - 📋 Prompt Actions.03_EnableSaveToPromptSaver") && !contextMenu.querySelector(".leafflow-contextmenu-bookmark")) {
const bookmarkLi = document.createElement("li");
bookmarkLi.className = "p-contextmenu-item leafflow-contextmenu-bookmark";
bookmarkLi.setAttribute("role", "menuitem");
bookmarkLi.setAttribute("aria-label", "Save to prompt bookmarks");
bookmarkLi.setAttribute("data-pc-section", "item");
bookmarkLi.setAttribute("data-p-active", "false");
bookmarkLi.setAttribute("data-p-focused", "false");
const bIconHtml = BOOKMARK_SVG;
bookmarkLi.innerHTML = `
<div class="p-contextmenu-item-content" data-pc-section="itemcontent">
<button class="${btnClass}" tabindex="-1" data-pc-section="itemlink">
${bIconHtml}
<span>Save to prompt bookmarks</span>
</button>
</div>
`;
bookmarkLi.addEventListener("click", async (e) => {
e.preventDefault();
e.stopPropagation();
const span = bookmarkLi.querySelector("span");
const src = getActiveImageSrc();
if (src) {
const success = await saveImageToPromptBookmarks(src);
if (span) span.textContent = success ? "Bookmarked! ✅" : "Failed ❌";
}
setTimeout(() => {
contextMenu.style.display = "none";
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
}, 500);
});
downloadLi.parentElement.insertBefore(bookmarkLi, copyLi.nextSibling);
}
}
// 5. Fallback Hook for Older Frontend Action Bars
+11 -8
View File
@@ -39,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);
@@ -49,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);
@@ -59,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";
@@ -88,7 +91,7 @@ app.registerExtension({
const sendDecision = async (action) => {
node.disableDecisionButtons();
try {
await authenticatedFetch("/leafflow/decision", {
await authenticatedFetch("/saturnnodes/decision", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -97,7 +100,7 @@ app.registerExtension({
}),
});
} catch (e) {
console.error("[LeafFlow] Failed to send decision:", e);
console.error("[SaturnNodes] Failed to send decision:", e);
}
};
+149 -20
View File
@@ -1,5 +1,6 @@
import { app } from "/scripts/app.js";
import { api } from "/scripts/api.js";
import { isSaturnColorsEnabled } from "../saturnnodes_colors.js";
// Hash folder name to get a consistent color for the border
function getFolderBorderColor(folderName) {
@@ -550,10 +551,9 @@ app.registerExtension({
};
}
// Adjust sizing constraints dynamically
domWidget.computeSize = function() {
return [node.size[0] - 30, node.size[1] - 160];
};
// In ComfyUI LiteGraph (V1), computeLayoutSize handles dynamic widget space distribution.
// Removing computeSize avoids an infinite expansion loop in _arrangeWidgets.
delete domWidget.computeSize;
// Add Zoom slider widget
const zoomWidget = node.addWidget(
@@ -808,14 +808,6 @@ app.registerExtension({
if (node.comfyClass === "FolderLoraLoaderVisualPrettyV2" || node.comfyClass === "VisualLoraLoader" || node.type === "FolderLoraLoaderVisualPrettyV2" || node.type === "VisualLoraLoader") {
node.size = [380, 360];
node.computeSize = function() {
const displayModeWidget = node.widgets ? node.widgets.find(w => w.name === "display_mode") : null;
if (displayModeWidget && displayModeWidget.value === "Show All") {
const viewContainer = document.querySelector(".lora-visual-container");
}
return [node.size[0], Math.max(360, node.size[1])];
};
const folderWidget = node.widgets.find(w => w.name === "folder");
const getHiddenWidget = () => {
@@ -919,7 +911,7 @@ app.registerExtension({
viewContainer.classList.remove("show-all-mode");
viewContainer.classList.add("scrollable-mode");
const restoredH = node.userCustomHeight || userSavedHeight || 420;
const containerH = Math.max(200, restoredH - 100);
const containerH = Math.max(180, restoredH - 150);
viewContainer.style.setProperty("height", `${containerH}px`, "important");
viewContainer.style.setProperty("max-height", `${containerH}px`, "important");
@@ -938,9 +930,9 @@ app.registerExtension({
node.computeSize = function() {
const displayModeWidget = node.widgets ? node.widgets.find(w => w.name === "display_mode") : null;
if (displayModeWidget && displayModeWidget.value === "Show All" && gridContainer) {
return [node.size[0], Math.max(360, (gridContainer.scrollHeight || 0) + 160)];
return [Math.max(380, node.size[0]), Math.max(360, (gridContainer.scrollHeight || 0) + 160)];
}
return [node.size[0], Math.max(360, node.size[1])];
return [Math.max(380, node.size[0]), 360];
};
const updateNodeSize = () => {
@@ -1054,12 +1046,14 @@ app.registerExtension({
const domWidget = node.addDOMWidget("lora_visual_picker", "HTML", viewContainer, {
getValue() { return getHiddenWidget().value; },
setValue(val) { getHiddenWidget().value = val; },
serialize: false
serialize: false,
getMinHeight() { return 180; }
});
domWidget.computeSize = function() {
return [node.size[0] - 30, node.size[1] - 190];
};
// In ComfyUI LiteGraph (V1), computeLayoutSize handles dynamic widget space distribution.
// A computeSize returning (node.size[1] - offset) causes an infinite loop in _arrangeWidgets
// because LiteGraph sees (l > t) and expands node.size on every animation frame.
delete domWidget.computeSize;
const onResize = node.onResize;
node.onResize = function(size) {
@@ -1070,7 +1064,7 @@ app.registerExtension({
node.userCustomHeight = size[1];
userSavedHeight = size[1];
if (viewContainer && viewContainer.style) {
const h = Math.max(200, size[1] - 100);
const h = Math.max(180, size[1] - 150);
viewContainer.style.setProperty("height", `${h}px`, "important");
viewContainer.style.setProperty("max-height", `${h}px`, "important");
}
@@ -1091,6 +1085,78 @@ app.registerExtension({
);
zoomWidget.serialize = false;
// Live Selected LoRAs count preview widget matching ResolutionSelector styling
let loraCountWidget = node.widgets?.find(w => w.name === "lora_count_preview" || w._isLoraCount);
let loraBadgeSpan = node._loraBadgeSpan;
if (!loraCountWidget) {
const loraBadgeEl = document.createElement("div");
loraBadgeEl.className = "not-disabled:bg-component-node-widget-background not-disabled:text-component-node-foreground [[readonly]]:bg-component-node-widget-background-disabled border-none rounded-md flex w-full items-center justify-center gap-2 px-2 h-6 col-span-2";
loraBadgeEl.setAttribute("data-widget-name", "lora_count_preview");
loraBadgeEl.setAttribute("node-type", "VisualLoraLoader");
loraBadgeEl.style.cssText = "display: flex; align-items: center; justify-content: center; gap: 6px; padding: 0 8px; height: 24px !important; min-height: 24px !important; max-height: 24px !important; flex-grow: 0 !important; flex-shrink: 0 !important; align-self: center !important; overflow: hidden !important; border-radius: 6px; background: var(--component-node-widget-background, rgba(255, 255, 255, 0.06)); color: var(--component-node-foreground, #e4e4e7); font-size: 12px; font-weight: 500; width: 100%; box-sizing: border-box; user-select: none;";
loraBadgeSpan = document.createElement("span");
loraBadgeSpan.className = "text-xs";
loraBadgeSpan.style.cssText = "font-weight: 600;";
loraBadgeSpan.textContent = "0 LoRAs Selected";
loraBadgeEl.appendChild(loraBadgeSpan);
node._loraBadgeSpan = loraBadgeSpan;
node._loraBadgeEl = loraBadgeEl;
const enforceFixedLoraBadgeSize = () => {
loraBadgeEl.style.setProperty("height", "24px", "important");
loraBadgeEl.style.setProperty("min-height", "24px", "important");
loraBadgeEl.style.setProperty("max-height", "24px", "important");
loraBadgeEl.style.setProperty("flex-grow", "0", "important");
loraBadgeEl.style.setProperty("flex-shrink", "0", "important");
loraBadgeEl.style.setProperty("align-self", "center", "important");
const widgetContainer = loraBadgeEl.closest?.(".lg-node-widget");
if (widgetContainer) {
widgetContainer.style.setProperty("align-self", "center", "important");
widgetContainer.style.setProperty("flex-grow", "0", "important");
}
};
node._enforceFixedLoraBadgeSize = enforceFixedLoraBadgeSize;
loraCountWidget = node.addDOMWidget("lora_count_preview", "preview", loraBadgeEl, {
serialize: false,
getValue() { return loraBadgeSpan.textContent; },
setValue(v) { loraBadgeSpan.textContent = v; }
});
loraCountWidget._isLoraCount = true;
}
if (loraCountWidget) {
// Crucial: shadow computeLayoutSize so ComfyUI Vue deriveWidgetRenderState evaluates hasLayoutSize to false.
// This forces grid-template-rows to use 'min-content' (24px) instead of 'auto' (which was stealing half the node height).
Object.defineProperty(loraCountWidget, "computeLayoutSize", {
value: undefined,
writable: true,
configurable: true
});
loraCountWidget.hasLayoutSize = false;
loraCountWidget.computeSize = function() {
return [node.size ? (node.size[0] - 20) : 200, 24];
};
}
const updateLoraBadgeColor = () => {
const enabled = isSaturnColorsEnabled();
if (node._loraBadgeSpan) {
node._loraBadgeSpan.style.color = enabled ? "#f59e0b" : "var(--text-muted, #a1a1aa)";
}
const badgeEl = node._loraBadgeEl;
if (badgeEl) {
badgeEl.style.background = enabled
? "rgba(180, 140, 95, 0.12)"
: "var(--component-node-widget-background, rgba(255, 255, 255, 0.06))";
badgeEl.style.border = enabled
? "1px solid rgba(180, 140, 95, 0.35)"
: "none";
}
};
node._updateCounterBadgeColor = updateLoraBadgeColor;
let activeRequest = null;
let debounceTimer = null;
let foldersMap = {};
@@ -1115,6 +1181,21 @@ app.registerExtension({
return currentSelected;
};
const updateLoraCountDisplay = () => {
node._enforceFixedLoraBadgeSize?.();
const count = getSelectedLoras().length;
node._selectedLoraCount = count;
const label = `${count} LoRA${count === 1 ? "" : "s"} Selected`;
if (node._loraBadgeSpan) {
node._loraBadgeSpan.textContent = label;
}
if (loraCountWidget) {
loraCountWidget.value = label;
}
updateLoraBadgeColor();
app.graph?.setDirtyCanvas(true, true);
};
const updateFolderCheckboxes = () => {
const selectedList = getSelectedLoras();
@@ -1166,6 +1247,7 @@ app.registerExtension({
});
updateFolderCheckboxes();
updateLoraCountDisplay();
node.triggerSlotEvent?.(0);
};
@@ -1553,9 +1635,55 @@ app.registerExtension({
syncModeToggleUI();
syncScrapeToggleUI();
updateLoraCountDisplay();
setTimeout(() => updateVisualGrid(), 100);
};
const origOnDrawForeground = node.onDrawForeground;
node.onDrawForeground = function(ctx) {
if (node.flags?.collapsed) {
const isSaturn = isSaturnColorsEnabled();
const count = node._selectedLoraCount ?? getSelectedLoras().length;
const label = `${count} LoRA${count === 1 ? "" : "s"}`;
ctx.save();
ctx.font = "bold 11px Inter, system-ui, sans-serif";
const badgeText = label;
const tw = ctx.measureText(badgeText).width;
const bx = node.size[0] - tw - 16;
const by = -LiteGraph.NODE_TITLE_HEIGHT + 3;
if (isSaturn) {
ctx.fillStyle = count > 0 ? "rgba(180, 140, 95, 0.25)" : "rgba(255, 255, 255, 0.08)";
ctx.strokeStyle = count > 0 ? "rgba(180, 140, 95, 0.6)" : "rgba(255, 255, 255, 0.2)";
} else {
ctx.fillStyle = count > 0 ? "rgba(255, 255, 255, 0.12)" : "rgba(255, 255, 255, 0.08)";
ctx.strokeStyle = count > 0 ? "rgba(255, 255, 255, 0.25)" : "rgba(255, 255, 255, 0.2)";
}
ctx.lineWidth = 1;
ctx.beginPath();
if (ctx.roundRect) {
ctx.roundRect(bx, by, tw + 10, 18, 4);
} else {
ctx.rect(bx, by, tw + 10, 18);
}
ctx.fill();
ctx.stroke();
ctx.fillStyle = isSaturn ? (count > 0 ? "#decbb2" : "#cbd5e1") : (count > 0 ? "#e2e8f0" : "#cbd5e1");
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.fillText(badgeText, bx + 5, by + 9);
ctx.restore();
}
if (origOnDrawForeground) origOnDrawForeground.apply(this, arguments);
};
const origOnResize = node.onResize;
node.onResize = function() {
if (origOnResize) origOnResize.apply(this, arguments);
node._enforceFixedLoraBadgeSize?.();
};
syncModeToggleUI();
syncScrapeToggleUI();
@@ -1570,6 +1698,7 @@ app.registerExtension({
applyDisplayMode(displayModeWidget.value || "Scrollable");
}
updateVisualGrid();
updateLoraCountDisplay();
}, 100);
}
}
+10 -19
View File
@@ -216,14 +216,6 @@ app.registerExtension({
if (isVisualImageLoader) {
node.size = [380, 480];
node.computeSize = function() {
const displayModeWidget = node.widgets ? node.widgets.find(w => w.name === "display_mode") : null;
if (displayModeWidget && displayModeWidget.value === "Show All") {
// Logic added below
}
return [node.size[0], Math.max(480, node.size[1])];
};
const folderWidget = node.widgets ? node.widgets.find(w => w.name === "folder_path") : null;
const getHiddenWidget = (name, defaultVal) => {
@@ -288,8 +280,6 @@ app.registerExtension({
gridContainer.className = "img-grid-container";
viewContainer.appendChild(gridContainer);
node.computeSize = function() {
const displayModeWidget = node.widgets ? node.widgets.find(w => w.name === "display_mode") : null;
let userSavedHeight = 480;
const applyDisplayMode = (mode) => {
@@ -314,7 +304,7 @@ app.registerExtension({
viewContainer.classList.remove("show-all-mode");
viewContainer.classList.add("scrollable-mode");
const restoredH = node.userCustomHeight || userSavedHeight || 480;
const containerH = Math.max(200, restoredH - 100);
const containerH = Math.max(180, restoredH - 150);
viewContainer.style.setProperty("height", `${containerH}px`, "important");
viewContainer.style.setProperty("max-height", `${containerH}px`, "important");
@@ -333,9 +323,9 @@ app.registerExtension({
node.computeSize = function() {
const displayModeWidget = node.widgets ? node.widgets.find(w => w.name === "display_mode") : null;
if (displayModeWidget && displayModeWidget.value === "Show All" && gridContainer) {
return [node.size[0], Math.max(420, (gridContainer.scrollHeight || 0) + 160)];
return [Math.max(380, node.size[0]), Math.max(420, (gridContainer.scrollHeight || 0) + 160)];
}
return [node.size[0], Math.max(420, node.size[1])];
return [Math.max(380, node.size[0]), 420];
};
const updateNodeSize = () => {
@@ -355,9 +345,14 @@ app.registerExtension({
const domWidget = node.addDOMWidget("img_visual_picker", "HTML", viewContainer, {
getValue() { return getHiddenWidget("_selected_image", "").value; },
setValue(val) { getHiddenWidget("_selected_image", "").value = val; },
serialize: false
serialize: false,
getMinHeight() { return 180; }
});
// In ComfyUI LiteGraph (V1), computeLayoutSize handles dynamic widget space distribution.
// Removing computeSize avoids an infinite expansion loop in _arrangeWidgets.
delete domWidget.computeSize;
const onResize = node.onResize;
node.onResize = function(size) {
if (onResize) onResize.apply(this, arguments);
@@ -367,17 +362,13 @@ app.registerExtension({
node.userCustomHeight = size[1];
userSavedHeight = size[1];
if (viewContainer && viewContainer.style) {
const h = Math.max(200, size[1] - 100);
const h = Math.max(180, size[1] - 150);
viewContainer.style.setProperty("height", `${h}px`, "important");
viewContainer.style.setProperty("max-height", `${h}px`, "important");
}
}
};
domWidget.computeSize = function() {
return [node.size[0] - 30, node.size[1] - 140];
};
// Zoom Slider Widget
const zoomWidget = node.addWidget(
"slider",
+10 -10
View File
@@ -1,9 +1,9 @@
import { app } from "/scripts/app.js";
import { authenticatedFetch } from "./auth_helper.js";
// LeafFlow Local File Runner In-Memory Consent Gate Extension
// SaturnNodes Local File Runner In-Memory Consent Gate Extension
app.registerExtension({
name: "LeafFlow.LocalRunner.Security",
name: "SaturnNodes.LocalRunner.Security",
async nodeCreated(node) {
if (node.comfyClass !== "RunLocalFileNode") return;
@@ -19,7 +19,7 @@ app.registerExtension({
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.title = `🪐 ⚡ Run Local File [ARMED (${remainingMin}m)]`;
node.color = "#047857";
node.bgcolor = "#064e3b";
if (authButton) {
@@ -27,7 +27,7 @@ app.registerExtension({
}
} else {
node._isAuthorized = false;
node.title = "🍃 ⚡ Run Local File [DISARMED]";
node.title = "🪐 ⚡ Run Local File [DISARMED]";
node.color = "#7f1d1d";
node.bgcolor = "#18181b";
if (authButton) {
@@ -45,9 +45,9 @@ app.registerExtension({
async () => {
const scriptName = filePathWidget?.value?.trim() || "configured script";
const confirmed = confirm(
"🍃 LeafFlow Security Verification\n\n" +
"🪐 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" +
"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" +
@@ -57,7 +57,7 @@ app.registerExtension({
if (!confirmed) return;
try {
const resp = await authenticatedFetch("/leafflow/local_runner/authorize", {
const resp = await authenticatedFetch("/saturnnodes/local_runner/authorize", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -72,7 +72,7 @@ app.registerExtension({
if (app.extensionManager?.toast?.add) {
app.extensionManager.toast.add({
severity: "error",
summary: "🍃 Authorization Failed",
summary: "🪐 Authorization Failed",
detail: errMsg,
life: 6000
});
@@ -95,7 +95,7 @@ app.registerExtension({
});
}
} catch (e) {
console.error("[LeafFlow Security] Error authorizing script run:", e);
console.error("[SaturnNodes Security] Error authorizing script run:", e);
alert("❌ Error communicating with authorization service: " + e.message);
}
}
@@ -135,7 +135,7 @@ app.registerExtension({
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.fillText("Script Location: ComfyUI/scripts/", node.size[0] / 2, node.size[1] - 19);
ctx.font = "bold 10px sans-serif";
if (!isArmed) {
+26 -21
View File
@@ -2,7 +2,7 @@ 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 = {
@@ -190,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();
@@ -279,14 +279,14 @@ function checkProcessManagementEnabled() {
if (app.extensionManager?.toast?.add) {
app.extensionManager.toast.add({
severity: "warn",
summary: "🍃 Process Management Disabled",
detail: "Enable 'Allow Process Management' in LeafFlow settings to restart or shut down ComfyUI.",
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 ⚙️ -> LeafFlow -> Pause Controls -> Turn ON 'Allow Process Management (Restart / Shutdown)'.",
"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
);
@@ -302,6 +302,10 @@ function closePowerPopup() {
}
}
async function powerFetch(endpointSuffix, options = {}) {
return authenticatedFetch(`/saturnnodes/power/${endpointSuffix}`, options);
}
function showPowerSidePopup(anchorElement) {
if (activePopup) {
closePowerPopup();
@@ -341,7 +345,7 @@ function showPowerSidePopup(anchorElement) {
cancelBtn.onclick = async (e) => {
e.stopPropagation();
closePowerPopup();
await authenticatedFetch("/leafflow/power/arm", {
await powerFetch("arm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: null })
@@ -350,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
});
@@ -376,7 +380,7 @@ function showPowerSidePopup(anchorElement) {
"Restart ComfyUI Immediately?",
"Are you sure you want to restart ComfyUI immediately? Any active prompt generation will be interrupted.",
async () => {
const tokenResp = await authenticatedFetch("/leafflow/power/request_token", {
const tokenResp = await powerFetch("request_token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "restart" })
@@ -386,7 +390,7 @@ function showPowerSidePopup(anchorElement) {
if (app.extensionManager?.toast?.add) {
app.extensionManager.toast.add({
severity: "error",
summary: "🍃 Restart Denied",
summary: "🪐 Restart Denied",
detail: err.error || "Failed to obtain power action ticket.",
life: 6000
});
@@ -394,7 +398,7 @@ function showPowerSidePopup(anchorElement) {
return;
}
const tokenData = await tokenResp.json();
const resp = await authenticatedFetch("/leafflow/power/confirm_action", {
const resp = await powerFetch("confirm_action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ticket: tokenData.ticket, action: "restart" })
@@ -404,7 +408,7 @@ function showPowerSidePopup(anchorElement) {
if (app.extensionManager?.toast?.add) {
app.extensionManager.toast.add({
severity: "error",
summary: "🍃 Restart Failed",
summary: "🪐 Restart Failed",
detail: err.error || "Failed to trigger restart.",
life: 6000
});
@@ -443,7 +447,7 @@ function showPowerSidePopup(anchorElement) {
"Shutdown ComfyUI Server?",
"Are you sure you want to SHUT DOWN the ComfyUI server immediately? The process will exit.",
async () => {
const tokenResp = await authenticatedFetch("/leafflow/power/request_token", {
const tokenResp = await powerFetch("request_token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "shutdown" })
@@ -453,7 +457,7 @@ function showPowerSidePopup(anchorElement) {
if (app.extensionManager?.toast?.add) {
app.extensionManager.toast.add({
severity: "error",
summary: "🍃 Shutdown Denied",
summary: "🪐 Shutdown Denied",
detail: err.error || "Failed to obtain power action ticket.",
life: 6000
});
@@ -461,7 +465,7 @@ function showPowerSidePopup(anchorElement) {
return;
}
const tokenData = await tokenResp.json();
const resp = await authenticatedFetch("/leafflow/power/confirm_action", {
const resp = await powerFetch("confirm_action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ticket: tokenData.ticket, action: "shutdown" })
@@ -471,7 +475,7 @@ function showPowerSidePopup(anchorElement) {
if (app.extensionManager?.toast?.add) {
app.extensionManager.toast.add({
severity: "error",
summary: "🍃 Shutdown Failed",
summary: "🪐 Shutdown Failed",
detail: err.error || "Failed to trigger shutdown.",
life: 6000
});
@@ -514,7 +518,7 @@ function showPowerSidePopup(anchorElement) {
"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 () => {
const resp = await authenticatedFetch("/leafflow/power/arm", {
const resp = await powerFetch("arm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "restart" })
@@ -524,7 +528,7 @@ function showPowerSidePopup(anchorElement) {
if (app.extensionManager?.toast?.add) {
app.extensionManager.toast.add({
severity: "error",
summary: "🍃 Scheduling Failed",
summary: "🪐 Scheduling Failed",
detail: err.error || "Failed to schedule restart.",
life: 6000
});
@@ -562,7 +566,7 @@ function showPowerSidePopup(anchorElement) {
"Schedule Shutdown After Queue Finish?",
"ComfyUI will wait until all queued prompts complete and the system returns to idle before shutting down completely.",
async () => {
const resp = await authenticatedFetch("/leafflow/power/arm", {
const resp = await powerFetch("arm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "shutdown" })
@@ -572,7 +576,7 @@ function showPowerSidePopup(anchorElement) {
if (app.extensionManager?.toast?.add) {
app.extensionManager.toast.add({
severity: "error",
summary: "🍃 Scheduling Failed",
summary: "🪐 Scheduling Failed",
detail: err.error || "Failed to schedule shutdown.",
life: 6000
});
@@ -683,10 +687,11 @@ app.registerExtension({
}, 1000);
try {
api.addEventListener("leafflow_power_status", (e) => {
const handlePowerStatus = (e) => {
currentPowerState = e.detail || currentPowerState;
updateSidebarButtonVisuals();
});
};
api.addEventListener("saturnnodes_power_status", handlePowerStatus);
} catch (_) {}
setInterval(fetchPowerStatus, 3000);
+1 -1
View File
@@ -296,7 +296,7 @@ app.registerExtension({
}
});
api.addEventListener("leafflow_update_preview_aspect_ratio", (event) => {
api.addEventListener("saturnnodes_update_preview_aspect_ratio", (event) => {
const detail = event.detail;
if (!detail || !detail.node_id) return;
+91 -44
View File
@@ -1,4 +1,5 @@
import { app } from "/scripts/app.js";
import { app } from "/scripts/app.js";
import { isSaturnColorsEnabled } from "../saturnnodes_colors.js";
function countPromptsInText(text, separator, customRegex) {
if (!text || !text.trim()) return 0;
@@ -46,50 +47,77 @@ app.registerExtension({
const sepWidget = node.widgets?.find(w => w.name === "separator");
const regexWidget = node.widgets?.find(w => w.name === "custom_regex");
// Add the live count display widget
const countWidget = node.addWidget("text", "🍃 Prompts", "0 Prompts", () => {}, { serialize: false });
countWidget.disabled = true;
// 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;";
// Custom drawing for counter widget
countWidget.computeSize = function(width) {
return [width, 28];
};
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;
countWidget.draw = function(ctx, n, widget_width, y, widget_height) {
ctx.save();
const count = n._promptCount ?? 0;
const label = `${count} Prompt${count === 1 ? "" : "s"}`;
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;
const margin = 8;
const badgeX = margin;
const badgeY = y + 2;
const badgeW = widget_width - margin * 2;
const badgeH = Math.max(24, widget_height - 4);
const radius = 6;
countWidget = node.addDOMWidget("prompt_count_preview", "preview", badgeEl, {
serialize: false,
getValue() { return badgeSpan.textContent; },
setValue(v) { badgeSpan.textContent = v; }
});
countWidget._isPromptCount = true;
}
// Subtle dark background pill
ctx.fillStyle = count > 0 ? "rgba(34, 197, 94, 0.15)" : "rgba(255, 255, 255, 0.05)";
ctx.strokeStyle = count > 0 ? "rgba(34, 197, 94, 0.5)" : "rgba(255, 255, 255, 0.15)";
ctx.lineWidth = 1;
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];
};
}
ctx.beginPath();
if (ctx.roundRect) {
ctx.roundRect(badgeX, badgeY, badgeW, badgeH, radius);
} else {
ctx.rect(badgeX, badgeY, badgeW, badgeH);
function updatePromptBadgeColor() {
const enabled = isSaturnColorsEnabled();
if (node._promptBadgeSpan) {
node._promptBadgeSpan.style.color = enabled ? "#f59e0b" : "var(--text-muted, #a1a1aa)";
}
ctx.fill();
ctx.stroke();
// Text
ctx.font = "bold 13px Inter, system-ui, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillStyle = count > 0 ? "#4ade80" : "#94a3b8";
ctx.fillText(`🍃 ${label}`, badgeX + badgeW / 2, badgeY + badgeH / 2);
ctx.restore();
};
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) || "";
@@ -98,7 +126,13 @@ app.registerExtension({
const count = countPromptsInText(text, sep, regex);
node._promptCount = count;
const label = `${count} Prompt${count === 1 ? "" : "s"}`;
countWidget.value = label;
if (node._promptBadgeSpan) {
node._promptBadgeSpan.textContent = label;
}
if (countWidget) {
countWidget.value = label;
}
updatePromptBadgeColor();
// Manage custom_regex enable/disable
if (regexWidget) {
@@ -189,17 +223,23 @@ app.registerExtension({
}
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 badgeText = label;
const tw = ctx.measureText(badgeText).width;
const bx = node.size[0] - tw - 16;
const by = -LiteGraph.NODE_TITLE_HEIGHT + 3;
ctx.fillStyle = count > 0 ? "rgba(34, 197, 94, 0.2)" : "rgba(255, 255, 255, 0.08)";
ctx.strokeStyle = count > 0 ? "rgba(34, 197, 94, 0.6)" : "rgba(255, 255, 255, 0.2)";
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) {
@@ -210,7 +250,7 @@ app.registerExtension({
ctx.fill();
ctx.stroke();
ctx.fillStyle = count > 0 ? "#4ade80" : "#cbd5e1";
ctx.fillStyle = isSaturn ? (count > 0 ? "#decbb2" : "#cbd5e1") : (count > 0 ? "#e2e8f0" : "#cbd5e1");
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.fillText(badgeText, bx + 5, by + 9);
@@ -220,11 +260,18 @@ app.registerExtension({
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);
};
+16 -15
View File
@@ -3,35 +3,36 @@ 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 authenticatedFetch("/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) })
@@ -39,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 authenticatedFetch("/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!";
@@ -67,11 +68,11 @@ 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);
});
-48
View File
@@ -1,48 +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,
"PromptCounter": 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;
}
}
});
-674
View File
@@ -1,674 +0,0 @@
import { app } from "/scripts/app.js";
import { api } from "/scripts/api.js";
import { authenticatedFetch } from "./js/auth_helper.js";
async function postLeafFlowSettings(bodyObj) {
try {
return await authenticatedFetch("/leafflow/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(bodyObj)
});
} catch (e) {
console.warn("[LeafFlow Settings] Failed to save setting:", e);
}
}
// 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();
postLeafFlowSettings({ civitai_api_key: cleanKey });
}
});
// 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) {
postLeafFlowSettings({ enable_civitai_scraping: value ? "true" : "false" });
}
});
// 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();
postLeafFlowSettings({ tmdb_api_key: cleanKey });
}
});
// 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) {
postLeafFlowSettings({ enable_tmdb_scraping: value ? "true" : "false" });
}
});
// 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) {
postLeafFlowSettings({ enable_lora_usage: value ? "true" : "false" });
}
});
// 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 authenticatedFetch("/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 authenticatedFetch("/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) {
postLeafFlowSettings({ clear_prompt_iterator_on_launch: value ? "true" : "false" });
}
});
// 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 authenticatedFetch("/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 authenticatedFetch("/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) {
postLeafFlowSettings({ default_pause_state: value });
}
});
// 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";
postLeafFlowSettings({ default_pause_mode: modeKey });
}
});
// 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) {
postLeafFlowSettings({ enable_tray_icon: value ? "true" : "false" });
}
});
// 4.7 Allow Process Management (Restart / Shutdown)
app.ui.settings.addSetting({
id: "LeafFlow.4 - ⏸️ Pause Controls.07_AllowProcessManagement",
name: "Allow Process Management (Restart / Shutdown)",
type: "boolean",
defaultValue: false,
tooltip: "Enables server restart and shutdown actions from the LeafFlow power controls. Disabled by default for security.",
onChange(value) {
postLeafFlowSettings({ enable_process_management: value ? "true" : "false" });
}
});
// =========================================================================
// 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) {
postLeafFlowSettings({ enable_persistent_queue: value ? "true" : "false" });
}
});
// 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) {
postLeafFlowSettings({ persistent_queue_restored_state: value });
}
});
// =========================================================================
// 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) {
postLeafFlowSettings({ enable_assets_restore: value ? "true" : "false" });
}
});
// 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;
postLeafFlowSettings({ restore_assets_count: count });
}
});
// =========================================================================
// 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."
});
// 8.2 Batch Graph Snapshot Guard
app.ui.settings.addSetting({
id: "LeafFlow.BatchQueue.SnapshotGuard",
name: "Batch Graph Snapshot Guard",
type: "boolean",
defaultValue: true,
tooltip: "When queueing multi-item batches, snapshots prompt and node inputs (like text and LoRAs) in the background so mid-queue canvas edits do not corrupt queued items. Random seeds continue to randomize."
});
// =========================================================================
// GRUPPE 9: 9 - 🛡️ Security & Script Execution
// =========================================================================
// 9.1 Allow Local File Execution (Default Disabled)
app.ui.settings.addSetting({
id: "LeafFlow.9 - 🛡️ Security.01_AllowLocalFileExecution",
name: "Allow Local File Execution",
type: "boolean",
defaultValue: false,
tooltip: "Controls whether the '🍃 ⚡ Run Local File' node is permitted to run executable files (.bat, .ps1, .exe, .sh). Disabled by default for operator security. When enabled, scripts are strictly confined to the 'ComfyUI/scripts/' directory. Execution is blocked if this setting is disabled or if the node is not interactively authorized.",
onChange(value) {
postLeafFlowSettings({ enable_local_file_execution: value ? "true" : "false" });
}
});
}
});
+1 -1
View File
@@ -320,7 +320,7 @@ class PauseQueueUI {
}
app.registerExtension({
name: "ComfyUI.LeafFlow.PauseQueue",
name: "ComfyUI.SaturnNodes.PauseQueue",
async setup() {
new PauseQueueUI();
}
+2 -2
View File
@@ -3,7 +3,7 @@ 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 {
@@ -36,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) {
+157
View File
@@ -0,0 +1,157 @@
import { app } from "/scripts/app.js";
// Default Amber theme matching the Saturn theme (warm, deep amber, not blinding neon and not muddy brown)
export const DEFAULT_HEADER_COLOR = "#6a3f20";
export const DEFAULT_BG_COLOR = "#472a15";
export const SATURN_NODE_TYPES = new Set([
// Visual Loaders
"VisualLoraLoader",
"FolderLoraLoader",
"FolderLoraLoaderPretty",
"FolderLoraLoaderVisualPrettyV2",
"VisualImageLoader",
"ImageLoaderVisualPrettyV2",
"LoadRecentOutputs",
// Automation, Flow & Utilities
"LoadImageFromFolder",
"TextAspectRatioFinder",
"AspectRatioFinder",
"PreviewImageSizeAspectRatio",
"TextLoraFinder",
"LoraTextFinder",
"PromptQueueIterator",
"PromptCounter",
"MultiTextReplacer",
"SaturnTextSplit",
"SaturnDecision",
"RunLocalFileNode",
// Queue & Previews
"PreviewLatentLive",
"PauseQueueNode",
"PersistentQueueNode"
]);
export function getSaturnTheme() {
let headerColor = DEFAULT_HEADER_COLOR;
let bgColor = DEFAULT_BG_COLOR;
try {
const getSetting = (plainId, numId) => {
if (app.ui?.settings?.get) {
const val = app.ui.settings.get(plainId) || app.ui.settings.get(numId);
if (val) return val;
}
if (typeof localStorage !== "undefined") {
for (const k of [plainId, numId]) {
for (const prefix of ["Comfy.Settings.", ""]) {
const raw = localStorage.getItem(`${prefix}${k}`);
if (raw !== null && raw !== undefined) {
try { return JSON.parse(raw); } catch (_) { return raw; }
}
}
}
}
return null;
};
const hc = getSetting("SaturnNodes.🖼️ Visual Loaders.00_CustomNodeHeaderColor", "SaturnNodes.1 - 🖼️ Visual Loaders.00_CustomNodeHeaderColor");
if (hc) headerColor = hc;
const bg = getSetting("SaturnNodes.🖼️ Visual Loaders.00_CustomNodeBgColor", "SaturnNodes.1 - 🖼️ Visual Loaders.00_CustomNodeBgColor");
if (bg) bgColor = bg;
} catch (_) {}
return { color: headerColor, bgcolor: bgColor };
}
export function isSaturnColorsEnabled() {
try {
const checkVal = (k) => {
if (app.ui?.settings?.get) {
const val = app.ui.settings.get(k);
if (val !== undefined && val !== null) return Boolean(val);
}
if (app.ui?.settings?.getSettingValue) {
const val = app.ui.settings.getSettingValue(k);
if (val !== undefined && val !== null) return Boolean(val);
}
if (typeof localStorage !== "undefined") {
for (const prefix of ["Comfy.Settings.", ""]) {
const raw = localStorage.getItem(`${prefix}${k}`);
if (raw !== null && raw !== undefined) {
try { return JSON.parse(raw); } catch (_) { return Boolean(raw); }
}
}
}
return null;
};
for (const k of [
"SaturnNodes.🖼️ Visual Loaders.00_EnableCustomColors",
"SaturnNodes.1 - 🖼️ Visual Loaders.00_EnableCustomColors"
]) {
const res = checkVal(k);
if (res !== null) return res;
}
} catch (_) {}
return false; // Default is disabled (false)
}
export function applySaturnColorToNode(node) {
if (!node) return;
const type = node.comfyClass || node.type || node.constructor?.comfyClass || node.constructor?.type;
if (!SATURN_NODE_TYPES.has(type)) return;
if (isSaturnColorsEnabled()) {
const theme = getSaturnTheme();
node.color = theme.color;
node.bgcolor = theme.bgcolor;
} else {
node.color = undefined;
node.bgcolor = undefined;
}
}
export function updateAllSaturnNodeColors() {
if (!app.graph?._nodes) return;
for (const node of app.graph._nodes) {
applySaturnColorToNode(node);
if (typeof node._updateCounterBadgeColor === "function") {
node._updateCounterBadgeColor();
}
}
app.graph.setDirtyCanvas(true, true);
}
app.registerExtension({
name: "ComfyUI.SaturnNodes.Colors",
beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData && nodeData.name && SATURN_NODE_TYPES.has(nodeData.name)) {
if (isSaturnColorsEnabled()) {
const theme = getSaturnTheme();
nodeType.color = theme.color;
nodeType.bgcolor = theme.bgcolor;
nodeType.prototype.color = theme.color;
nodeType.prototype.bgcolor = theme.bgcolor;
} else {
nodeType.color = undefined;
nodeType.bgcolor = undefined;
nodeType.prototype.color = undefined;
nodeType.prototype.bgcolor = undefined;
}
}
},
async nodeCreated(node) {
applySaturnColorToNode(node);
},
async loadedGraphNode(node) {
applySaturnColorToNode(node);
},
afterConfigureGraph() {
updateAllSaturnNodeColors();
},
async setup() {
setTimeout(() => updateAllSaturnNodeColors(), 100);
setTimeout(() => updateAllSaturnNodeColors(), 500);
}
});
File diff suppressed because it is too large Load Diff