Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b37d90882b | ||
|
|
71608332ab | ||
|
|
48e452dd67 | ||
|
|
377b95681c | ||
|
|
b4f388f378 | ||
|
|
de815c5c63 | ||
|
|
88e73b81c3 | ||
|
|
4145f247b7 | ||
|
|
1e3999e32f | ||
|
|
5cdd808bd8 | ||
|
|
aa63fffb36 | ||
|
|
8727eab44e | ||
|
|
19c6084216 | ||
|
|
63747d9c9c | ||
|
|
f7ec8e5073 | ||
|
|
0e635cd37f | ||
|
|
e596c6de5a | ||
|
|
387b5ceb0b | ||
|
|
5369980d57 | ||
|
|
61011eea96 | ||
|
|
d706f0eb00 | ||
|
|
77637e0626 | ||
|
|
9c9917961c | ||
|
|
a32bb7f913 | ||
|
|
4c6493fe63 | ||
|
|
c12beff2d2 | ||
|
|
5e2872d5f8 | ||
|
|
5a8f23ea2b | ||
|
|
12492185d2 | ||
|
|
c99af98e09 | ||
|
|
a8a8946edc | ||
|
|
16d2ed916d | ||
|
|
31047dc9da | ||
|
|
e15f716563 | ||
|
|
a6fbfe06e0 | ||
|
|
11a8d9d218 | ||
|
|
ebc9903bf2 | ||
|
|
7c57ccaae4 | ||
|
|
3653d2883f | ||
|
|
9c5870f938 | ||
|
|
005efdd7c5 | ||
|
|
78682dd0cb | ||
|
|
29198a5fd7 | ||
|
|
5c09936c3a | ||
|
|
ba35d58e81 | ||
|
|
ef95266cf4 | ||
|
|
b4228dc340 | ||
|
|
88f1485cc9 | ||
|
|
16ef5bf5db | ||
|
|
983862eec3 | ||
|
|
acc1570629 | ||
|
|
30c0547009 | ||
|
|
13e24c3a57 | ||
|
|
cd00cce1f8 | ||
|
|
a26d060d5f |
@@ -0,0 +1,17 @@
|
||||
# Files and folders excluded from the ComfyUI Registry package archive.
|
||||
# Everything listed here stays in the GitHub repository but is not bundled
|
||||
# when publishing via .github/workflows/publish_action.yml.
|
||||
|
||||
# Development, tests & CI
|
||||
.github/
|
||||
tests/
|
||||
|
||||
# Contributor & agent documentation (not needed at runtime)
|
||||
AGENTS.md
|
||||
CLAUDE.md
|
||||
GEMINI.md
|
||||
CONTRIBUTING.md
|
||||
|
||||
# Python bytecode caches
|
||||
__pycache__/
|
||||
*.pyc
|
||||
+10
-2
@@ -1,10 +1,11 @@
|
||||
# ComfyUI-LeafFlow Environment Configuration Example
|
||||
# ComfyUI-SaturnNodes Environment Configuration Example
|
||||
# Copy or rename this file to .env to configure custom API keys and default behaviors.
|
||||
# Runtime location: ComfyUI/user/default/SaturnNodes/.env
|
||||
|
||||
# Optional Civitai API Key for auto-downloading LoRA preview thumbnails
|
||||
CIVITAI_API_KEY=
|
||||
|
||||
# Optional TMDB API Key / Read Access Token for celebrity image preview search
|
||||
# Optional TMDB API Key / Read Access Token for actor / media preview image search
|
||||
TMDB_API_KEY=
|
||||
|
||||
# Feature toggles
|
||||
@@ -20,3 +21,10 @@ RESTORE_ASSETS_COUNT=64
|
||||
DEFAULT_PAUSE_STATE=Paused
|
||||
DEFAULT_PAUSE_MODE=after_finish
|
||||
PERSISTENT_QUEUE_RESTORED_STATE=Match Default
|
||||
|
||||
# Prompt iterator privacy
|
||||
CLEAR_PROMPT_ITERATOR_ON_LAUNCH=false
|
||||
|
||||
# Security gates (opt-in, both disabled by default)
|
||||
ALLOW_PROCESS_MANAGEMENT=false
|
||||
ENABLE_LOCAL_FILE_EXECUTION=false
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for ComfyUI-LeafFlow
|
||||
about: Suggest an idea for ComfyUI-SaturnNodes
|
||||
title: '[FEAT] '
|
||||
labels: 'enhancement'
|
||||
assignees: ''
|
||||
|
||||
@@ -2,9 +2,9 @@ name: Lint and Syntax Check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
branches: [ "**" ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
branches: [ "**" ]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
name: Publish to ComfyUI Registry
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- 'pyproject.toml'
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Publish Custom Node
|
||||
uses: Comfy-Org/publish-node-action@main
|
||||
with:
|
||||
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
|
||||
@@ -12,9 +12,23 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Extract release notes from CHANGELOG.md
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import os, re, pathlib
|
||||
tag = os.environ["GITHUB_REF_NAME"].lstrip("v")
|
||||
changelog = pathlib.Path("CHANGELOG.md").read_text(encoding="utf-8")
|
||||
pattern = re.compile(rf"^## \[{re.escape(tag)}\][^\n]*\n(.*?)(?=^## \[|\Z)", re.M | re.S)
|
||||
match = pattern.search(changelog)
|
||||
body = match.group(1).strip() if match else ""
|
||||
if not body:
|
||||
body = f"See CHANGELOG.md for the full list of changes in v{tag}."
|
||||
pathlib.Path("release_notes.md").write_text(body + "\n", encoding="utf-8")
|
||||
PY
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
generate_release_notes: true
|
||||
body_path: release_notes.md
|
||||
generate_release_notes: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -23,3 +23,8 @@ user/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Downloaded models and checkpoints
|
||||
models/
|
||||
*.onnx
|
||||
*.onnx.tmp
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 🤖 Agent & Developer Standard Operating Procedure (SOP)
|
||||
## `ComfyUI-LeafFlow` Contributor & Automation Guide
|
||||
## `ComfyUI-SaturnNodes` Contributor & Automation Guide
|
||||
|
||||
This guide defines the engineering checklist and architecture standards for all AI agents (Antigravity, Claude, Gemini, Cursor, Copilot) and human developers contributing to **`ComfyUI-LeafFlow`**.
|
||||
This guide defines the engineering checklist and architecture standards for all AI agents (Antigravity, Claude, Gemini, Cursor, Copilot) and human developers contributing to **`ComfyUI-SaturnNodes`**.
|
||||
|
||||
For Git workflow, branch protection, and Pull Request guidelines, see **[`CONTRIBUTING.md`](./CONTRIBUTING.md)**.
|
||||
|
||||
@@ -17,9 +17,9 @@ For Git workflow, branch protection, and Pull Request guidelines, see **[`CONTRI
|
||||
| **`WALKTHROUGH.md`** | Step-by-step user tutorials, example workflows, visual load guides, and batch prompting patterns. | [WALKTHROUGH.md](./WALKTHROUGH.md) |
|
||||
| **`CHANGELOG.md`** | Strict Keep-A-Changelog semantic version release history across all updates. | [CHANGELOG.md](./CHANGELOG.md) |
|
||||
| **`SECURITY.md`** | Security policies, supported versions, and private vulnerability disclosure instructions. | [SECURITY.md](./SECURITY.md) |
|
||||
| **`SECURITY_AUDIT_PLAYBOOK.md`** | Authoritative ComfyUI custom node security rulebook, PR #3200 review precedents, threat model, and coding standards. | [SECURITY_AUDIT_PLAYBOOK.md](./SECURITY_AUDIT_PLAYBOOK.md) |
|
||||
| **`CLAUDE.md`** | AI agent instruction pointer. | [CLAUDE.md](./CLAUDE.md) |
|
||||
| **`GEMINI.md`** | AI agent instruction pointer. | [GEMINI.md](./GEMINI.md) |
|
||||
| **`AGENTS.md`** | AI agent instruction pointer. | [AGENTS.md](./AGENTS.md) |
|
||||
| **`bug_report.md`** | GitHub issue template for reporting reproducible defects. | [bug_report.md](./.github/ISSUE_TEMPLATE/bug_report.md) |
|
||||
| **`feature_request.md`** | GitHub issue template for proposing new nodes or UI features. | [feature_request.md](./.github/ISSUE_TEMPLATE/feature_request.md) |
|
||||
|
||||
@@ -31,7 +31,7 @@ For Git workflow, branch protection, and Pull Request guidelines, see **[`CONTRI
|
||||
3. [Checklist: Adding a New Setting / Feature Flag](#3-checklist-adding-a-new-setting--feature-flag)
|
||||
4. [Checklist: Updating Dependencies](#4-checklist-updating-dependencies)
|
||||
5. [Cross-Platform & Safe Coding Standards](#5-cross-platform--safe-coding-standards)
|
||||
6. [User Data & Storage Architecture (`ComfyUI/user/default/LeafFlow/`)](#7-user-data--storage-architecture-comfyuiuserdefaultleafflow)
|
||||
6. [User Data & Storage Architecture (`ComfyUI/user/default/SaturnNodes/`)](#7-user-data--storage-architecture-comfyuiuserdefaultsaturnnodes)
|
||||
7. [Git, Branching & Commit Conventions](#6-git-branching--commit-conventions)
|
||||
|
||||
---
|
||||
@@ -39,42 +39,55 @@ For Git workflow, branch protection, and Pull Request guidelines, see **[`CONTRI
|
||||
## 1. Architecture & Directory Layout
|
||||
|
||||
```
|
||||
ComfyUI-LeafFlow/
|
||||
├── __init__.py # Node registration (NODE_CLASS_MAPPINGS), web endpoints (/leafflow/settings)
|
||||
├── install.py # Dependency installer script for ComfyUI Manager
|
||||
├── pyproject.toml # Package metadata and pip dependencies
|
||||
ComfyUI-SaturnNodes/
|
||||
├── __init__.py # Node registration (NODE_CLASS_MAPPINGS), settings routes (/saturnnodes/*)
|
||||
├── pyproject.toml # Package metadata, dependencies & ComfyUI Registry configuration
|
||||
├── requirements.txt # Pip dependencies (auto-installed by ComfyUI Manager)
|
||||
├── .env.example # Template of all supported environment variables
|
||||
├── .gitignore # Exclusion rules for caches, states, and credentials
|
||||
├── .comfyignore # Files excluded from the ComfyUI Registry package archive
|
||||
├── nodes/ # Python backend node implementations
|
||||
│ ├── aspect_ratio.py # Text & preview aspect ratio calculation nodes
|
||||
│ ├── assets_restore.py # Startup output-folder history restoration
|
||||
│ ├── auto_watcher.py # Automated folder watch image loaders
|
||||
│ ├── decision_node.py # Inline pause/gate decision node & notifications
|
||||
│ ├── text_split.py # Forward/backward & regex text splitter node
|
||||
│ ├── image_loader.py # Visual image browser & EXIF/PNG metadata parser
|
||||
│ ├── load_recent.py # Recent outputs loader node
|
||||
│ ├── local_runner.py # Confined local script execution node & authorization routes
|
||||
│ ├── lora_finder.py # Text-based prompt LoRA scanner & patcher
|
||||
│ ├── lora_loader.py # Folder & visual LoRA loaders + Civitai/TMDB scrapers
|
||||
│ ├── preview_latent.py # Live latent WebSocket preview node
|
||||
│ ├── prompt_counter.py # Multiline prompt counter node
|
||||
│ ├── prompt_iterator.py # Multiline prompt queue batch popper
|
||||
│ ├── queue_control.py # Pause Queue, Persistent Queue hooks, and server routes
|
||||
│ ├── stereogram.py # Stereoscopic 3D / cross-eye generator nodes
|
||||
│ ├── text_replacer.py # Multiline regex & list text replacer node
|
||||
│ ├── text_split.py # Forward/backward & regex text splitter node
|
||||
│ ├── tray_icon.py # OS system tray icon manager & status indicator
|
||||
│ └── utils.py # Shared path sanitization, string parsing, and formatting helpers
|
||||
├── web/ # Frontend extensions & static assets
|
||||
│ ├── leafflow_colors.js # Node theme colors (LiteGraph canvas & Vue V2)
|
||||
│ ├── leafflow_settings.js# Native ComfyUI Settings panel registrations
|
||||
│ ├── saturnnodes_colors.js # Node theme colors (LiteGraph canvas & Vue V2)
|
||||
│ ├── saturnnodes_settings.js# Native ComfyUI Settings panel registrations
|
||||
│ ├── pause_queue.js # Top toolbar Pause/Continue button group & dropdown
|
||||
│ ├── pause_queue.css # Toolbar styling
|
||||
│ ├── persistent_queue.js # Queue crash-recovery client ownership claimer
|
||||
│ ├── batch_queue.js # 1D git-graph batch queue visualizer
|
||||
│ ├── floating_latent_dock.js# Floating live latent preview dock
|
||||
│ ├── preview_node.js # Live latent canvas preview widget
|
||||
│ ├── logo.png # Registry icon (400x400)
|
||||
│ └── js/ # Node-specific UI widgets (visual pickers, buttons, aspect preview)
|
||||
│ ├── decision_node.js
|
||||
│ ├── auth_helper.js # CSRF token helper shared by all frontend extensions
|
||||
│ ├── copy_prompt.js
|
||||
│ ├── decision_node.js
|
||||
│ ├── folder_lora_loader.js
|
||||
│ ├── image_loader.js
|
||||
│ ├── local_runner.js
|
||||
│ ├── power_sidebar.js
|
||||
│ ├── preview_aspect_ratio.js
|
||||
│ ├── preview_manager.js
|
||||
│ └── prompt_iterator.js
|
||||
│ ├── prompt_counter.js
|
||||
│ ├── prompt_iterator.js
|
||||
│ └── stereogram.js
|
||||
├── README.md # Main documentation & complete node reference
|
||||
├── WALKTHROUGH.md # End-user guide for workflows and features
|
||||
└── CHANGELOG.md # Version release history
|
||||
@@ -92,7 +105,7 @@ When implementing a new custom node, you **must complete all 5 steps**:
|
||||
- `RETURN_TYPES`: Tuple of output types (e.g. `("IMAGE", "STRING")`).
|
||||
- `RETURN_NAMES`: Tuple of human-readable output labels.
|
||||
- `FUNCTION`: Exact method name to execute.
|
||||
- `CATEGORY`: Categorized under `"🍃 LeafFlow/Loaders"`, `"🍃 LeafFlow/Utils"`, `"🍃 LeafFlow/Automation"`, or `"🍃 LeafFlow/Previews"`.
|
||||
- `CATEGORY`: Categorized under `"🪐 SaturnNodes/Loaders"`, `"🪐 SaturnNodes/Utils"`, `"🪐 SaturnNodes/Automation"`, `"🪐 SaturnNodes/Previews"`, or `"🪐 SaturnNodes/3D & Previews"`.
|
||||
- `DESCRIPTION`: Clear multi-line string explaining node functionality and parameters.
|
||||
- [ ] Use `sanitize_folder_path(folder_input, default_dir)` from `nodes.utils` for any path/directory arguments (handles Windows/Linux slashes, wildcards `*`, and relative paths cleanly).
|
||||
- [ ] Ensure dummy tensors are valid 4D float32 batches `torch.zeros((1, 64, 64, 3), dtype=torch.float32)` for empty image states.
|
||||
@@ -107,30 +120,29 @@ When implementing a new custom node, you **must complete all 5 steps**:
|
||||
# ...
|
||||
}
|
||||
```
|
||||
- [ ] Register in `NODE_DISPLAY_NAME_MAPPINGS` with leaf prefix and emoji:
|
||||
- [ ] Register in `NODE_DISPLAY_NAME_MAPPINGS` with the Saturn prefix and emoji:
|
||||
```python
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"YourNewNodeName": "🍃 🏷️ Your Display Name",
|
||||
"YourNewNodeName": "🪐 🏷️ Your Display Name",
|
||||
# ...
|
||||
}
|
||||
```
|
||||
- [ ] If renaming or replacing an existing node, **always preserve the old name in `NODE_CLASS_MAPPINGS` as an alias** to avoid breaking user workflows!
|
||||
- [ ] Avoid renaming or replacing existing node class names; if unavoidable, migrate known workflow files to the new class name instead of keeping permanent legacy aliases (see §7).
|
||||
|
||||
### Step 2.3: Frontend Extensions & Colors (`web/`)
|
||||
- [ ] If the node needs custom canvas/DOM widgets, implement the extension under `web/js/<feature>.js`.
|
||||
- [ ] In `nodeCreated(node)` or `beforeRegisterNodeDef(nodeType, nodeData)`, **always check both `node.comfyClass` and `node.type`**, and support aliases:
|
||||
- [ ] In `nodeCreated(node)` or `beforeRegisterNodeDef(nodeType, nodeData)`, **always check both `node.comfyClass` and `node.type`**:
|
||||
```javascript
|
||||
const isTargetNode = ["YourNodeName", "YourOldAlias"].includes(node.comfyClass) ||
|
||||
["YourNodeName", "YourOldAlias"].includes(node.type);
|
||||
const isTargetNode = node.comfyClass === "YourNodeName" ||
|
||||
node.type === "YourNodeName";
|
||||
```
|
||||
- [ ] Register node theme colors in [`web/leafflow_colors.js`](./web/leafflow_colors.js):
|
||||
- Loaders: Emerald Green (`color: "#059669"`, `bgcolor: "#047857"`)
|
||||
- Automation & Utils: Amber (`color: "#d97706"`, `bgcolor: "#b45309"`)
|
||||
- Previews & Decisions: Violet (`color: "#7c3aed"`, `bgcolor: "#6d28d9"`)
|
||||
- [ ] Register node theme colors in [`web/saturnnodes_colors.js`](./web/saturnnodes_colors.js):
|
||||
- The suite uses a single unified Saturn amber theme (`DEFAULT_HEADER_COLOR = "#6a3f20"`, `DEFAULT_BG_COLOR = "#472a15"`), user-overridable via the Custom Node Title/Body Color settings.
|
||||
- Add the new node class key to `SATURN_NODE_TYPES` so the custom colors apply to it.
|
||||
|
||||
### Step 2.4: Documentation (`README.md` & `WALKTHROUGH.md`)
|
||||
- [ ] Add a collapsible `<details>` section in [`README.md`](./README.md) under `## 📦 Individual Nodes Reference`:
|
||||
- Node summary tag: `<summary><b>🍃 🏷️ Display Name</b> (<code>ClassName</code>)</summary>`
|
||||
- [ ] Add a clean heading section in [`README.md`](./README.md) under `## 📦 Individual Nodes Reference`:
|
||||
- Heading: `### 🪐 🏷️ Display Name — \`ClassName\``
|
||||
- `#### Overview`
|
||||
- `#### Inputs & Widgets`
|
||||
- `#### Outputs`
|
||||
@@ -156,17 +168,17 @@ When introducing a configurable option or feature toggle:
|
||||
- [ ] In `get_settings()` route: parse and return the setting.
|
||||
- [ ] In `save_settings()` route: read value from incoming JSON, persist to `.env` file via `new_lines`, update `os.environ`, and trigger any dynamic runtime manager updates.
|
||||
|
||||
### Step 3.3: Frontend Settings Registration (`web/leafflow_settings.js`)
|
||||
### Step 3.3: Frontend Settings Registration (`web/saturnnodes_settings.js`)
|
||||
- [ ] Register via `app.ui.settings.addSetting({...})`:
|
||||
```javascript
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.YourSettingName",
|
||||
name: "🍃 LeafFlow: Your Setting Display Name",
|
||||
id: "SaturnNodes.YourSettingName",
|
||||
name: "🪐 SaturnNodes: Your Setting Display Name",
|
||||
type: "boolean" /* or "text", "combo" */,
|
||||
defaultValue: false,
|
||||
tooltip: "Descriptive tooltip explaining user impact.",
|
||||
onChange(value) {
|
||||
api.fetchApi("/leafflow/settings", {
|
||||
api.fetchApi("/saturnnodes/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ your_setting_key: value })
|
||||
@@ -184,16 +196,17 @@ When introducing a configurable option or feature toggle:
|
||||
## 4. Checklist: Updating Dependencies
|
||||
|
||||
If a feature requires a new Python package:
|
||||
- [ ] Add package to `dependencies` in [`pyproject.toml`](./pyproject.toml).
|
||||
- [ ] Add package to `dependencies` list in [`install.py`](./install.py).
|
||||
- [ ] Add the package to `dependencies` in [`pyproject.toml`](./pyproject.toml) (ComfyUI Registry packaging).
|
||||
- [ ] Keep [`requirements.txt`](./requirements.txt) in sync for ComfyUI Manager auto-install.
|
||||
- [ ] Check imports gracefully handle missing optional libraries (e.g. `try... except ImportError`).
|
||||
- [ ] **Never** add an `install.py` or any runtime `pip`/`subprocess` installation — the suite is ComfyUI Registry compliant and installs nothing dynamically.
|
||||
|
||||
---
|
||||
|
||||
## 5. Cross-Platform & Safe Coding Standards
|
||||
|
||||
1. **Terminal Console Safety (Windows `cp1252` encoding)**:
|
||||
- **Never include raw unicode emojis in backend `print()` calls** (e.g. write `print("[LeafFlow] Started")` instead of `print("[LeafFlow] 🍃 ...")`).
|
||||
- **Never include raw unicode emojis in backend `print()` calls** (e.g. write `print("[SaturnNodes] Started")` instead of `print("[SaturnNodes] 🪐 ...")`).
|
||||
- Windows consoles running on default codepages will raise fatal `UnicodeEncodeError: 'charmap' codec can't encode character` if raw emoji bytes hit standard output.
|
||||
2. **Path Sanitization**:
|
||||
- Never assume `/` or `\` separators; always use `os.path.join()`, `os.path.normpath()`, or `sanitize_folder_path()`.
|
||||
@@ -210,59 +223,71 @@ If a feature requires a new Python package:
|
||||
|
||||
---
|
||||
|
||||
## 7. User Data & Storage Architecture (`ComfyUI/user/default/LeafFlow/`)
|
||||
## 7. User Data & Storage Architecture (`ComfyUI/user/default/SaturnNodes/`)
|
||||
|
||||
All user-specific runtime states, API credentials, usage counters, and temporary caches **MUST** be stored centrally in `ComfyUI/user/default/LeafFlow/` using the helper `get_leafflow_user_dir()` in `nodes/utils.py`:
|
||||
All user-specific runtime states, API credentials, usage counters, and temporary caches **MUST** be stored centrally in `ComfyUI/user/default/SaturnNodes/` using the helper `get_saturnnodes_user_dir()` in `nodes/utils.py`:
|
||||
|
||||
```python
|
||||
from .utils import get_leafflow_user_dir
|
||||
from .utils import get_saturnnodes_user_dir
|
||||
|
||||
USER_DIR = get_leafflow_user_dir()
|
||||
USER_DIR = get_saturnnodes_user_dir()
|
||||
STATE_FILE = os.path.join(USER_DIR, "your_state.json")
|
||||
```
|
||||
|
||||
### Stored User Files:
|
||||
- `ComfyUI/user/default/LeafFlow/.env` — API keys and persistent settings
|
||||
- `ComfyUI/user/default/LeafFlow/lora_usage.json` — LoRA selection counts and usage badges
|
||||
- `ComfyUI/user/default/LeafFlow/image_prompts_cache.json` — Cached positive prompts extracted from images
|
||||
- `ComfyUI/user/default/LeafFlow/failed_scrapes.json` — Failed Civitai/TMDB scraping attempts cache
|
||||
- `ComfyUI/user/default/LeafFlow/prompt_iterator_state.json` — Current iteration pointers and popped prompt queues
|
||||
- `ComfyUI/user/default/LeafFlow/persistent_queue.json` — Real-time queue crash recovery state
|
||||
- `ComfyUI/user/default/SaturnNodes/.env` — API keys and persistent settings
|
||||
- `ComfyUI/user/default/SaturnNodes/lora_usage.json` — LoRA selection counts and usage badges
|
||||
- `ComfyUI/user/default/SaturnNodes/image_prompts_cache.json` — Cached positive prompts extracted from images
|
||||
- `ComfyUI/user/default/SaturnNodes/failed_scrapes.json` — Failed Civitai/TMDB scraping attempts cache
|
||||
- `ComfyUI/user/default/SaturnNodes/prompt_iterator_state.json` — Current iteration pointers and popped prompt queues
|
||||
- `ComfyUI/user/default/SaturnNodes/persistent_queue.json` — Real-time queue crash recovery state
|
||||
|
||||
### Future Migration Hooks SOP:
|
||||
If legacy files need to be migrated in future multi-user releases or version upgrades:
|
||||
1. In `nodes/utils.py`, define a startup check `migrate_legacy_user_data()`.
|
||||
2. Inspect `custom_nodes/ComfyUI-LeafFlow/` for legacy files (`.env`, `lora_usage.json`, etc.).
|
||||
3. Atomically move them to `get_leafflow_user_dir()` using `shutil.move()` or copy + delete.
|
||||
4. Log a single clean notification `[LeafFlow] 🍃 Migrated legacy user data to ComfyUI/user/default/LeafFlow/`.
|
||||
### Directory Rules & Compatibility Policy:
|
||||
- All state, caches, and credentials live in `ComfyUI/user/default/SaturnNodes/` only. No legacy directories are read, migrated, or referenced.
|
||||
- No pre-rename compatibility shims exist or may be reintroduced (old HTTP routes, old setting keys/IDs, old localStorage keys, old headers, old user directories, old node class-name aliases). Old workflow files are migrated to the current node names instead.
|
||||
|
||||
---
|
||||
|
||||
## 6. Git, Branching & Commit Conventions
|
||||
## 6. Git, Branching, Versioning & Release Strategy (MANDATORY)
|
||||
|
||||
### 6.1 Strict Branch Naming Classification
|
||||
Never create misleading or overly narrow branch names when multiple components, fixes, and features are bundled together!
|
||||
- **Atomic Feature Branches** (`feature/<feature-name>`):
|
||||
- Used **ONLY** when implementing a single, isolated new feature or node.
|
||||
- Examples: `feature/prompt-iterator-privacy`, `feature/visual-loaders-pathing`
|
||||
- **Atomic Bug Fix Branches** (`fix/<bug-name>`):
|
||||
- Used **ONLY** for targeted bug fixes.
|
||||
- Examples: `fix/preview-latent-insets`, `fix/tray-icon-persistence`
|
||||
- **Comprehensive Integration & Release Branches** (`release/vX.Y.Z-<summary>` or `dev/<summary>`):
|
||||
- **MANDATORY**: Whenever bundling multiple features, fixes, rebrands, or architectural updates into a combined testing or release candidate, use a `release/...` or `dev/...` branch name.
|
||||
- **STRICTLY PROHIBITED**: Never disguise a multi-feature/fix release bundle under a single isolated `feature/<narrow-name>`.
|
||||
- Examples: `release/v2.2.0-leafflow-update`, `dev/v2.2.0-full-integration`
|
||||
### 6.1 Version-Named Branching Workflow
|
||||
From version `v2.5.0` onward, all development work in this repository strictly adheres to a version-scoped branching model:
|
||||
1. **Active Work Occurs ONLY in the Current Version Branch**:
|
||||
- All active development, bug fixes, UI improvements, and code changes **MUST** occur on a dedicated branch named directly after the target version prefixed with `v` (e.g. `v2.5.0`).
|
||||
- Git and GitHub fully support dots in branch names (`v2.5.0`, `v2.6.0`, `v3.0.0`).
|
||||
- **STRICTLY PROHIBITED**: Never commit or push directly to `main` during active development.
|
||||
2. **Strict User-Approval Gate for Merges & Releases**:
|
||||
- Merging to `main` is **STRICTLY GATED** by explicit user approval.
|
||||
- **NEVER** merge a version branch into `main` unless the user explicitly commands: *"merge to main"* or *"make a release"*.
|
||||
3. **Release & Tagging Execution Steps (Upon User Command)**:
|
||||
When explicit permission to release is granted:
|
||||
- **Merge**: Merge the version branch (e.g. `v2.5.0`) into `main`.
|
||||
- **Tag**: Create an annotated Git tag prefixed with `v` (e.g. `git tag -a v2.5.0 -m "v2.5.0 ..."`).
|
||||
- *Rule*: **ALWAYS** use the `v` prefix (`v2.5.0`, `v2.6.0`). **NEVER** create bare tags without `v` (like `2.5.0`).
|
||||
- **GitHub Release**: Publish or update the GitHub Release attached to that `vX.Y.Z` tag.
|
||||
- *Rule*: Every release **MUST** include a clear, descriptive title (e.g. `v2.5.0 - <Feature/Theme Summary> 🪐`) and a concise markdown bullet-point explanation of what changed and what was fixed (matching the standard of `v2.3.2` and `v2.4.0`), never just bare automated compare links.
|
||||
- **Next Version Transition**:
|
||||
- Immediately checkout a new branch from `main` named after the next target version (e.g. `git checkout -b v2.6.0` or `git checkout -b v3.0.0`).
|
||||
- Update version identifiers in `pyproject.toml`, `__init__.py`, `CHANGELOG.md`, and frontend info to the new development version.
|
||||
- Continue all subsequent work exclusively on the new version branch.
|
||||
|
||||
### 6.2 Excluded Files & Privacy
|
||||
Never commit sensitive keys, runtime states, or caches:
|
||||
Never commit sensitive keys, personal paths, runtime states, or caches:
|
||||
- `.env`, `.env.local`
|
||||
- `persistent_queue.json`, `lora_usage.json`, `failed_scrapes.json`, `image_prompts_cache.json`
|
||||
- `user/` directory
|
||||
- Personal user names, machine paths (`C:\Users\...`), or private model names.
|
||||
|
||||
### 6.3 Commit Messages
|
||||
Use atomic, descriptive English commit headers:
|
||||
- `Feat: ...` (New features or nodes)
|
||||
- `Fix: ...` (Bug fixes, UI sync repairs)
|
||||
- `Fix: ...` (Bug fixes, UI repairs)
|
||||
- `Docs: ...` (Documentation, README, Walkthrough updates)
|
||||
- `Refactor: ...` (Code restructuring or rebrands)
|
||||
- `Release: ...` (Consolidated version releases)
|
||||
- `Refactor: ...` (Code restructuring or cleanup)
|
||||
- `Release: ...` (Consolidated version release merge)
|
||||
|
||||
### 6.4 Registry Publishing (ComfyUI Registry)
|
||||
- Registry node name: `saturnnodes` (Publisher `KOFiblto`, `DisplayName = ComfyUI-SaturnNodes`). **Published versions are immutable** — always bump the version before publishing a change.
|
||||
- `.github/workflows/publish_action.yml` publishes automatically when `pyproject.toml` changes on `main` (and can also be triggered manually via workflow dispatch). It requires the `REGISTRY_ACCESS_TOKEN` repository secret.
|
||||
- `.comfyignore` excludes development files (tests, CI, contributor docs) from the published package archive.
|
||||
- Icon (`web/logo.png`) must be square, 400x400 or smaller.
|
||||
|
||||
+122
-16
@@ -1,18 +1,124 @@
|
||||
# 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.6.1] - In Development
|
||||
|
||||
### Added
|
||||
- **Per-Resolution Dock Memory & Drag Resize Handles**: The Floating Latent Dock and the queue overlay now remember their size per resolution/monitor and expose on-screen drag resize handles.
|
||||
- **Visual LoRA Loader Maintenance Controls**: New `↺ Counter`, `🧹 Usage` and `🗑 Cache` reset buttons on the Visual LoRA Loader node (each with a confirmation prompt) to reset the per-node Sequential/Random LoRA cycle and prompt→LoRA assignments, clear usage badges, or delete cached thumbnails.
|
||||
- **ComfyUI Registry Packaging**: Complete registry metadata (`pyproject.toml` with `requires-comfyui`, classifiers and optional extras), a Saturn icon (`web/logo.png`), a `.comfyignore` manifest, and an automated `publish_action.yml` workflow.
|
||||
- **Curated GitHub Releases**: `release.yml` now publishes the matching `CHANGELOG.md` section as the GitHub Release body instead of raw auto-generated compare links.
|
||||
|
||||
### Changed
|
||||
- **Free-Form Dock Resize**: Dragging the Floating Latent Dock's corner now resizes width and height independently (no longer locked to the latent's aspect ratio) within sane 100–2000px bounds. The drag starts from the dock's current on-screen size (no jump to the larger saved settings box), shows the free-form box live, writes the dragged dimensions back into the `Floating Dock Max Width/Height` settings (now 1px-stepped sliders), and snaps the dock to the exact latent fit on release.
|
||||
- **Route Namespacing**: All HTTP routes live exclusively under `/saturnnodes/*`; the transitional pre-2.6 route aliases (`/pause_queue/*`, `/persistent_queue/*`, `/image_loader/*`, `/folder_lora_loader/*`) were removed entirely.
|
||||
- **Compatibility Surface Reduction**: All remaining pre-rename compatibility layers were purged — duplicate legacy route handlers, frontend settings migration and legacy settings-ID lookups, legacy localStorage/storage keys, the legacy CSRF header fallback, the legacy user-directory auto-migration, and all superseded node class-name aliases. No backward-compatibility surface remains.
|
||||
- **Naming Consistency**: Unified `ComfyUI.SaturnNodes.*` frontend extension names, `saturnnodes-*` CSS prefixes, and `[SaturnNodes ...]` console log tags.
|
||||
- **Documentation Rebrand**: `WALKTHROUGH.md`, the README settings reference, `.env.example`, `AGENTS.md` and `SECURITY.md` were fully rebranded and reconciled with the real setting labels, widget names and environment variables.
|
||||
- **Registry Identity**: Package name set to `saturnnodes` (per registry naming best practices); version bumped to `2.6.1`.
|
||||
|
||||
### Fixed
|
||||
- **Publishing Compliance**: Removed `install.py` (no runtime `pip` execution, matching the documented "Zero Runtime Package Installs" claim) and corrected a stale README claim about legacy route aliases.
|
||||
- **Audit Remediation Hardening**: Full audit remediation pass — pause queue stall/deadlock fixes, restart verification hardening, atomic `.env` writes with proper error responses, debug endpoint guards, subgraph-safe lookups, runner pipe streaming, IPv4-mapped loopback handling and more (119 unit tests passing).
|
||||
- **Floating Latent Dock Resize Crash**: Fixed a `this.renderImage is not a function` error thrown while dragging the dock's resize handles or when the screen profile changed; the resize paths now invoke the dock's actual render routine.
|
||||
- **Queue Overlay Resize Lag**: Resizing the queue overlay no longer rewrites a stylesheet on every pointermove (which forced a full-document style recalculation, layout and paint churn on every frame); heights are now applied as scoped, rAF-throttled inline styles.
|
||||
- **Dock Drag Performance & Stuck Drags**: Dragging the dock no longer rebuilds the full-resolution canvas backing store (GPU texture/layer churn, ~74% GPU load during drags) on every pointermove — only CSS sizes change while dragging, with one full-quality redraw on release. Pointer capture plus `pointercancel`/window-blur guards fix drags that "never stopped" when the pointer was released outside the window.
|
||||
- **Prompt Iterator Reset Clarity**: The Prompt Queue Iterator's `Reset Counter (0)` button now confirms before resetting and explains that it resets the run pointer to the first prompt without wiping the queue state file (use Settings → `Reset All Queues` for a full wipe).
|
||||
|
||||
### Removed
|
||||
- `install.py` — dependencies are declared in `pyproject.toml` / `requirements.txt` only, and ComfyUI Manager handles installation.
|
||||
|
||||
---
|
||||
|
||||
## [2.5.0] - 2026-10-10 [Stable]
|
||||
|
||||
### Added
|
||||
- **Floating Latent Live Preview Dock & Core Lightbox Live Inspection**:
|
||||
- Floating live latent preview dock with dynamic aspect ratio preservation and contain-scaling, eliminating letterbox bars and layout jitter.
|
||||
- Minimap tracking and alignment alongside canvas navigation controls.
|
||||
- **Native Core Lightbox Live Inspecting View**: Clicking the floating latent dock opens the live generation directly in ComfyUI's native inspecting lightbox view, updating live in real time as each diffusion sampling step arrives.
|
||||
- Minimalist design: removed redundant "Sampling..." badge overlays for distraction-free monitoring.
|
||||
- **High-Performance LoRA Loader Engine & Viewport WebP Lazy-Loading**:
|
||||
- **Disk-Cached WebP Generation**: High-resolution preview images are downscaled via bicubic fast-WebP encoding and cached in `ComfyUI/user/default/SaturnNodes/cache/lora_thumbs/` (~35KB per card).
|
||||
- **Non-Blocking Background Worker**: Thumbnail processing runs asynchronously on worker threads with `threading.Event`, eliminating canvas UI thread lockups during heavy directory indexing.
|
||||
- **Viewport IntersectionObserver**: Implemented native `IntersectionObserver` on visual LoRA cards so thumbnails only load as they scroll into view.
|
||||
- Added an explicit settings toggle to bypass thumbnail generation if desired.
|
||||
- **Stereoscopic 3D / Cross-Eye Generator (`SaturnStereo3D`)**:
|
||||
- Transform 2D images into stereoscopic 3D pairs for cross-eye viewing, parallel 3D viewers, VR displays, and red-cyan anaglyphs.
|
||||
- Hybrid depth execution: accepts any input `depth_map` (zero downloads, 1ms execution) or automatically falls back to built-in Depth Anything V2 Small ONNX model cached locally, with mathematical gradient fallback when ONNX is absent.
|
||||
- Stereonix-inspired controls: `depth_intensity`, `depth_gamma`, `invert_depth`, `convergence_plane`, `image_zoom`, `pan_horizontal`, `pan_vertical`.
|
||||
- Framing and focus controls: adjustable `gap_spacing`, `outer_border` padding, and high-contrast `alignment_dots`.
|
||||
- **Stereoscopic 3D Live Viewer (`SaturnStereo3DLive`)**:
|
||||
- Interactive live viewer node featuring a real-time **1s countdown timer** on parameter adjustment.
|
||||
- **Mid-Render Interruption**: Modifying sliders during generation immediately sends `/interrupt` to abort obsolete rendering and restarts the 1s countdown.
|
||||
- **In-Memory Depth Map Caching**: Cached depth predictions allow near-instant (~5ms) re-rendering when tweaking intensity, gamma, zoom, pan, and spacing on the same input image.
|
||||
- **Queue Execution Controls & Mid-Generation Pause**:
|
||||
- Added execution interception hook allowing the queue to pause before popping the next item from the queue.
|
||||
- Restored queue prompt `create_time` timestamps are refreshed upon restart to prevent deprioritization in history.
|
||||
- **Modernized Continuous Sliders Settings UI**:
|
||||
- Converted `Visible Queue Items (Height)` and `Live Latent Dock Size` from rigid dropdowns into smooth continuous sliders.
|
||||
- Cleaned settings categories and isolated context menus to prevent bleeding into top PrimeVue application menubars.
|
||||
- **CLI Terminal Branding & Clean Telemetry**:
|
||||
- Clean unified startup banner styled in Saturn amber theme.
|
||||
- Cleaned noisy internal debug logging, displaying concise status lines for node count, persistence status, and storage paths.
|
||||
|
||||
### Fixed
|
||||
- **Persistent Queue Restored Creation Timestamp**:
|
||||
- Fixed an issue where unfinished prompts resumed after a server restart sorted to the bottom of the history and assets list by updating `create_time` to the current timestamp on recovery.
|
||||
- **Live Viewer Widget Value Corruption**:
|
||||
- Fixed an issue where sliding widgets on `SaturnStereo3DLive` set them to `NaN` or `0` due to arrow-function callback scoping, preventing "wrong type" and "missing connection" validation errors.
|
||||
- **Top Application Menubar Injection**:
|
||||
- Fixed an issue where "Copy prompt" appeared in the main application menu (between File and Edit) on PrimeVue Frontend V2 by strictly excluding `.comfy-command-menu`, `.comfy-menu`, `.p-menubar`, and top-level menuitems.
|
||||
- **CI & Environment Resilience**:
|
||||
- Replaced OpenCV dependency with PIL and ensured graceful fallback when `onnxruntime` is not installed.
|
||||
- **Security & Path Confinement**:
|
||||
- Hardened directory traversal guards against UNC paths and relative escapes, passing 119 unit tests.
|
||||
|
||||
---
|
||||
|
||||
## [2.4.0] - 2026-09-24 [Stable]
|
||||
|
||||
### Fixed
|
||||
- **Frontend V1 Sizing & Layout Stability**:
|
||||
- Eliminated rapid runaway downward expansion loop in `VisualLoraLoader` and `ImageLoader` on LiteGraph classic (Frontend V1).
|
||||
- Cleaned duplicate function stubs and unified `computeSize()` and `computeLayoutSize()`.
|
||||
- **Core Stability & Logic**:
|
||||
- Implemented true cycle permutation for `Random (Cycle)` in `PromptQueueIterator`.
|
||||
- Upgraded comma parsing in `MultiTextReplacer` with `csv.reader` to preserve quoted phrases.
|
||||
- Removed legacy pre-rebrand node class mappings.
|
||||
- Added robust exception handling for unterminated regex in text splitting.
|
||||
- Optimized 60fps canvas draw loops by decoupling badge geometry enforcement from `onDrawForeground`.
|
||||
|
||||
---
|
||||
|
||||
## [2.3.2] - 2026-09-22
|
||||
|
||||
### Changed & Rebranded
|
||||
- **Full Suite Rebrand to SaturnNodes (`🪐`)**:
|
||||
- Renamed the entire suite 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 pre-rebrand class names preserved and aliased alongside the new class names in `NODE_CLASS_MAPPINGS`.
|
||||
- Dual HTTP route registration for full compatibility with existing scripts, extensions, and bookmarks.
|
||||
- Dual WebSocket event emission for full frontend compatibility.
|
||||
- Seamless settings migration: frontend settings and local storage automatically migrated to `SaturnNodes.*` keys with instant fallback.
|
||||
- Automatic user directory migration: `.env` and `prompt_iterator_state.json` migrated smoothly into `user/default/SaturnNodes/`.
|
||||
- Removed third-party PromptBookmarks coupling: cleaned UI bookmarks button from copy prompt node for modularity.
|
||||
|
||||
---
|
||||
|
||||
## [2.3.1] - 2026-09-22
|
||||
|
||||
### Security & Hardening
|
||||
- **Universal CSRF Protection Across All Mutation Routes**:
|
||||
- Protected `POST /leafflow/prompt_iterator/reset_node`, `POST /leafflow/prompt_iterator/clear`, and `POST /leafflow/prompt_iterator/open_file` with `is_authenticated_local_request`.
|
||||
- Protected `POST /leafflow/decision` with `is_authenticated_local_request`.
|
||||
- Protected queue-mutating endpoints (`POST /pause_queue/toggle`, `POST /pause_queue/mode`, `POST /pause_queue/continue`, `POST /persistent_queue/claim`, `POST /leafflow/batch_queue/sync`, `POST /leafflow/assets/restore`) with `is_authenticated_local_request`.
|
||||
- Updated all frontend callers in `web/` to send the `X-LeafFlow-CSRF-Token` session header via `authenticatedFetch`.
|
||||
- Protected the prompt iterator reset, clear, and open-file endpoints with `is_authenticated_local_request`.
|
||||
- Protected the decision node endpoint with `is_authenticated_local_request`.
|
||||
- Protected all queue-mutating endpoints (pause controls, persistent queue claim, batch queue sync, assets restore) with `is_authenticated_local_request`.
|
||||
- Updated all frontend callers in `web/` to send the session CSRF header via `authenticatedFetch`.
|
||||
- **SSRF Protection & Scheme Filtering for Preview Scrapers**:
|
||||
- Added `is_safe_external_image_url` validating that downloaded image URLs use standard `https` or `http` schemes.
|
||||
- Strictly blocked internal loopback (`127.0.0.1`, `localhost`), private networks (`10.*`, `172.16.*`, `192.168.*`), and link-local cloud metadata endpoints (`169.254.*`).
|
||||
@@ -45,18 +151,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Recursive process tree termination on timeout or cancellation (uses `taskkill /F /T` on Windows to prevent orphaned background processes).
|
||||
- **In-Memory Operator Authorization Gate**: Removed `security_consent` from `INPUT_TYPES` so consent never serializes into workflow JSON or dropped image metadata. Requires active canvas button click `[⚡ Authorize Run]` with 5-minute single-use memory token.
|
||||
- Visual node status indicator (`[ARMED]` in emerald green vs `[DISARMED]` in red).
|
||||
- Global toggle setting (`LeafFlow.9 - 🛡️ Security.01_AllowLocalFileExecution`, default `false`).
|
||||
- Global `Allow Local File Execution` toggle setting (default `false`).
|
||||
- **🛡️ CSRF Defense & Request Authentication**:
|
||||
- Added `is_authenticated_local_request` validating loopback origin, `Sec-Fetch-Site` header (blocking `cross-site` calls from visited web pages), and `Origin`/`Referer` headers.
|
||||
- Cryptographically secure per-session CSRF token (`X-LeafFlow-CSRF-Token`) generated via `secrets.token_hex(32)` and validated with `secrets.compare_digest`.
|
||||
- Cryptographically secure per-session CSRF token generated via `secrets.token_hex(32)` and validated with `secrets.compare_digest`.
|
||||
- Added frontend auth helper (`web/js/auth_helper.js`) automatically fetching and attaching CSRF tokens to administrative fetch requests.
|
||||
- **⚡ Two-Step Power Action Handshake**:
|
||||
- Server restart and shutdown endpoints require `ALLOW_PROCESS_MANAGEMENT=true` plus a two-step confirmation handshake (`POST /leafflow/power/request_token` to issue a 30s single-use ticket, followed by `POST /leafflow/power/confirm_action` to consume and execute).
|
||||
- Server restart and shutdown endpoints require `ALLOW_PROCESS_MANAGEMENT=true` plus a two-step confirmation handshake (requesting a 30-second single-use ticket, then confirming the action to consume and execute it).
|
||||
|
||||
### Fixed & Compliance
|
||||
- **Complete Removal of `ALLOW_ANY_SCRIPT_PATH`**: Eliminated all path escape settings to guarantee execution confinement to `ComfyUI/scripts/`.
|
||||
- **Dynamic Node Count**: Replaced hardcoded node count in startup banner with dynamic `len(NODE_CLASS_MAPPINGS)` (now reporting all 16 registered nodes).
|
||||
- **Universal Synchronized Versioning**: Version `2.3.0` synchronized across `__init__.py`, `pyproject.toml`, `/leafflow/debug/export`, `README.md`, and `CHANGELOG.md`.
|
||||
- **Universal Synchronized Versioning**: Version `2.3.0` synchronized across `__init__.py`, `pyproject.toml`, the debug export endpoint, `README.md`, and `CHANGELOG.md`.
|
||||
- **Process Management Security Toggle**: Server restart and shutdown controls are disabled by default (`ALLOW_PROCESS_MANAGEMENT=false`). If disabled, attempts to trigger restart/shutdown via the UI or API return HTTP 403 with clear instructions.
|
||||
- **Optional `pystray` Dependency**: Made `pystray` an optional extra (`[project.optional-dependencies] tray = ["pystray"]`) and removed all runtime `pip install` subprocess calls, ensuring zero crashes on headless cloud servers, Docker, or Google Colab.
|
||||
- **Universal Loopback Protection**: Added `is_local_request(request)` validation to all legacy queue endpoints (`/pause_queue/*` and `/persistent_queue/claim`).
|
||||
@@ -83,7 +189,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Restores the direct "Inspect asset" (zoom in) button directly onto image cards in the Assets pane hover action bar, placed seamlessly alongside Download, Copy Prompt, and Bookmarks (restoring access since newer ComfyUI versions tucked it away behind the 3-dots submenu). Default is `false` (opt-in).
|
||||
- **🔖 Robust Inline SVG Icons for "Save to Prompt Bookmarks"**:
|
||||
- Replaced runtime Tailwind icon class references with crisp inline SVG ribbons, fixing an issue in modern `comfyui_frontend_package` where the bookmark icon rendered with 0 dimensions (empty space).
|
||||
- **🎨 1D Git-Graph Batch Queue Visualizer (`ComfyUI.LeafFlow.BatchQueue`)**:
|
||||
- **🎨 1D Git-Graph Batch Queue Visualizer**:
|
||||
- Automatically identifies workflows queued together as batches in both ComfyUI Frontend V2 (`JobAssetsList`) and Classic V1 queue list.
|
||||
- Purely graphical 1D colored line along the left edge of queue items without any text or number clutter.
|
||||
- **36 Curated Cycling Colors**: Sequential assignment ensures adjacent batches have distinct, high-contrast colors.
|
||||
@@ -94,7 +200,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Single-item batches curve inward at both top and bottom (`(`).
|
||||
- **In-between Infiltration Handling**: When another prompt or batch is queued in-between items of an existing batch, the interrupted batch retains its open straight ends (no false start/end curves), while the inserted item is cleanly enclosed in its own bracket.
|
||||
- **PersistentQueue Interoperability**: Operates client-side via `localStorage` when standalone, and synchronizes with `PersistentQueueManager` so batch relationships restore automatically upon server restart.
|
||||
- **Settings Toggle**: Added `LeafFlow.BatchQueue.Enabled` setting in ComfyUI Settings menu.
|
||||
- **Settings Toggle**: Added an enable/disable batch queue setting in the ComfyUI Settings menu.
|
||||
|
||||
### Fixed
|
||||
- **📐 Independent Per-Node `tile_size` in Visual Loaders**:
|
||||
@@ -104,7 +210,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Hardened image card button injection guards to avoid injecting into queue item elements or small icon previews in modern virtual grids.
|
||||
|
||||
### Security & Registry Compliance
|
||||
- **Local-Only Route Enforcement**: All server control endpoints (`/leafflow/power/*`), settings updates (`/leafflow/settings`), and queue sync routes now strictly verify loopback origin (`127.0.0.1` / `::1`), rejecting remote calls with `403 Forbidden`.
|
||||
- **Local-Only Route Enforcement**: All server control endpoints (power actions, settings updates, queue sync) now strictly verify loopback origin (`127.0.0.1` / `::1`), rejecting remote calls with `403 Forbidden`.
|
||||
- **Directory Traversal Protection**: Image loading (`VisualImageLoader`) and thumbnail endpoints are strictly confined to ComfyUI `input`, `output`, and `temp` directories.
|
||||
- **Packaging & Installation Standards**: Removed runtime `install.py` in favor of standard `requirements.txt` (`Pillow`, `piexif`, `numpy`, `pystray`), and removed `"torch"` from `pyproject.toml` to prevent host CUDA environment corruption.
|
||||
- **Opt-In Preview Scraping**: Civitai and TMDB network scraping defaulted to disabled (`ENABLE_CIVITAI_SCRAPING=false`, `ENABLE_TMDB_SCRAPING=false`).
|
||||
@@ -119,7 +225,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [2.1.0] - 2026-08-24
|
||||
|
||||
### Added
|
||||
- **Centralized ComfyUI User Directory (`ComfyUI/user/default/LeafFlow/`)**:
|
||||
- **Centralized ComfyUI User Directory (`ComfyUI/user/default/SaturnNodes/`)**:
|
||||
- All runtime JSON state (`lora_usage.json`, `lora_loader_state.json`, `image_prompts_cache.json`, `failed_scrapes.json`, `prompt_iterator_state.json`, `persistent_queue.json`, `.env`) now strictly lives inside the ComfyUI user folder rather than polluting the node directory.
|
||||
- **Structured ComfyUI V2 Settings Menu**:
|
||||
- Restructured settings into 6 explicit subcategories with clear titles, info tooltips, and real styled action buttons (`🗑️ Reset Scrapes Cache`, `🔄 Reset All Queues`).
|
||||
@@ -130,7 +236,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
- Fixed empty folder matching edge case in `BackToPlaceholder` (`undo_placeholder.py`).
|
||||
- Fixed XML entity escaping for native Windows Toast Notifications in `LeafFlowDecision`.
|
||||
- Fixed XML entity escaping for native Windows Toast Notifications in the decision node.
|
||||
- Removed accidental scrape cache reset during minor settings updates in `__init__.py`.
|
||||
- Removed obsolete `playwright` dependency from `pyproject.toml` and `install.py`.
|
||||
|
||||
@@ -145,7 +251,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **🍃 🔄 Prompt Queue Iterator**: Multiline batch prompt queue iterator with Pop, Cycle, and Random modes.
|
||||
- **🍃 🔤 Multi Text Replacer**: High-performance multi-target find & replace node with protection against recursive replacement loops.
|
||||
- **🍃 ✂️ Text Split**: Split strings into two outputs using literal delimiters or regex patterns (forward/backward).
|
||||
- **🍃 ⏸️ LeafFlow Decision**: Interactive mid-workflow pause node with UI buttons (Continue / Cancel / Stop) and desktop notifications.
|
||||
- **🍃 ⏸️ Decision**: Interactive mid-workflow pause node with UI buttons (Continue / Cancel / Stop) and desktop notifications.
|
||||
- **🍃 🔔 System Tray Integration**: OS notification area tray icon with real-time queue status colors and outside-browser queue controls.
|
||||
- **🍃 🖼️ Assets & History Restore**: Automatic restoration of recent generations into the ComfyUI Assets / History pane on startup.
|
||||
|
||||
@@ -159,7 +265,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **Visual LoRA Loader**: Folder-filtered LoRA selector with formatted pretty names, usage tracking, and Civitai SHA256 API preview thumbnail downloading.
|
||||
- **Visual Image Loader**: Folder image picker with PNG parameters & EXIF user comments metadata parsing.
|
||||
- **Auto Watcher**: Folder watcher with non-blocking check (`wait_for_image = False`) and blocking poll (`wait_for_image = True`).
|
||||
- **Undo Placeholder**: Prompt placeholder restoration tool for LoRA/celebrity names.
|
||||
- **Undo Placeholder**: Prompt placeholder restoration tool for LoRA/subject names.
|
||||
- **Recent Outputs**: Output image loader by recent step index for assets/sidebar integration.
|
||||
- **Live Latent Preview**: Real-time sampler latent preview canvas node renderer.
|
||||
- **Settings Panel**: Native ComfyUI Settings menu controls for API keys and UI toggles.
|
||||
|
||||
+14
-12
@@ -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)
|
||||
@@ -22,22 +22,24 @@ Before opening a new issue:
|
||||
## 🛠️ Contribution Workflow (Feature Branches & PRs)
|
||||
|
||||
### Branch and Pull Request Policy
|
||||
Direct pushes to the `main` branch are restricted. All changes, bug fixes, and new features must be developed on a dedicated feature or fix branch and merged via Pull Request.
|
||||
Direct pushes to the `main` branch are strictly prohibited. Active development is organized into dedicated version branches (e.g. `v2.5.0`, `v2.6.0`). Community features or fixes should branch off the active version branch or submit Pull Requests targeting it.
|
||||
|
||||
All development follows a **Feature Branch $\rightarrow$ Pull Request** workflow:
|
||||
Merging into `main` and creating releases is strictly gated by repository maintainer explicit authorization. Every release is tagged with `v` prefix (`vX.Y.Z`) and accompanied by release notes detailing all changes.
|
||||
|
||||
All development follows a **Branch $\rightarrow$ Pull Request** workflow:
|
||||
|
||||
```
|
||||
1. Fork/Branch Repository --> 2. Create Feature Branch --> 3. Commit Changes --> 4. Open Pull Request
|
||||
1. Fork/Branch Repository --> 2. Create Working Branch --> 3. Commit Changes --> 4. Open Pull Request
|
||||
```
|
||||
|
||||
### Step 1: Fork and Clone
|
||||
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
|
||||
### Step 2: Create a Working Branch
|
||||
Create a descriptive branch for your change:
|
||||
```bash
|
||||
git checkout -b feature/short-description
|
||||
@@ -67,8 +69,8 @@ Push your branch to your fork on GitHub and open a Pull Request:
|
||||
```bash
|
||||
git push origin feature/short-description
|
||||
```
|
||||
1. Open [Pull Requests](https://github.com/KOFiblto/ComfyUI-LeafFlow/pulls) $\rightarrow$ Click **New pull request**.
|
||||
2. Select `base: main` $\leftarrow$ `compare: feature/short-description`.
|
||||
1. Open [Pull Requests](https://github.com/KOFiblto/ComfyUI-SaturnNodes/pulls) $\rightarrow$ Click **New pull request**.
|
||||
2. Select the active version branch (e.g. `v2.5.0`) or `main` as base $\leftarrow$ `compare: feature/short-description`.
|
||||
3. Provide a brief summary of what changed and how you verified your fix.
|
||||
4. Wait for code review approval and automated status check validation!
|
||||
|
||||
@@ -79,4 +81,4 @@ git push origin feature/short-description
|
||||
- Be respectful and constructive when commenting on issues and reviewing pull requests.
|
||||
- The official mascot of ComfyUI is a very cute anime girl with massive fennec ears, a big fluffy tail, long blonde wavy hair, and blue eyes. Treat her with respect!
|
||||
|
||||
Thank you for helping make `ComfyUI-LeafFlow` better for everyone!
|
||||
Thank you for helping make `ComfyUI-SaturnNodes` better for everyone!
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
# 🍃 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**. Both **Frontend V1 (LiteGraph)** and **Frontend V2 (Vue UI)** are supported, though `VisualLoraLoader` breaks on V1 for now (will be fixed).
|
||||
|
||||
> ⚠️ **Notice**: Fully compatible with both **ComfyUI Frontend V2 (Nodes 2.0 / Vue UI)** and **Classic ComfyUI Frontend (Nodes 1.0 / LiteGraph)**.
|
||||
> 📖 **New here?** Follow the step-by-step [Complete Walkthrough](WALKTHROUGH.md) for setup, node recipes, and batch-prompting patterns.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Key Capabilities & Overview
|
||||
|
||||
- **Live Latent Preview**: Watch generations materialize step-by-step directly inside your graph canvas nodes in real time.
|
||||
- **Stereoscopic 3D & Cross-Eye Generator**: Convert 2D artwork and renders into 3D stereograms, parallel views, and red-cyan anaglyphs with automatic Depth Anything V2 fallback.
|
||||
- **Batch Prompting & Iteration**: Queue multiple prompts automatically with delimiter splits, regex blocks, and persistent queue state tracking.
|
||||
- **Visual Thumbnail Browsing**: Browse LoRAs and image folders with auto-scraped Civitai & TMDB previews, ranking badges, and multi-selection.
|
||||
- **Real-Time Queue & Pause Controls**: Pause, resume, or cleanly cancel execution mid-generation, with automatic crash recovery for interrupted queues.
|
||||
- **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 +23,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.
|
||||
@@ -19,7 +31,7 @@ Loads a LoRA by folder path using raw filename matching or formatted names with
|
||||
#### Inputs & Widgets
|
||||
- **`model`** (`MODEL`): Input model.
|
||||
- **`clip`** (`CLIP`): Input CLIP.
|
||||
- **`folder`** (`STRING`): Subfolder filter (e.g. `Celebrities`, `PonyV6`, `*`).
|
||||
- **`folder`** (`STRING`): Subfolder filter (e.g. `Characters`, `Styles/Anime`, `*`).
|
||||
- **`lora_name`** (`COMBO`): Dropdown listing available LoRA files in the target folder.
|
||||
- **`strength_model`** (`FLOAT`): Model weight strength (default `1.0`).
|
||||
- **`strength_clip`** (`FLOAT`): CLIP weight strength (default `1.0`).
|
||||
@@ -33,10 +45,10 @@ 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`).
|
||||
Loads a LoRA using formatted pretty names (e.g. `Cyberpunk Character V1` instead of `char_Cyberpunk-Character_v1.safetensors`).
|
||||
|
||||
#### Inputs & Widgets
|
||||
- **`model`** (`MODEL`): Input model.
|
||||
@@ -53,11 +65,17 @@ 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>)</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.
|
||||
|
||||
#### Maintenance Reset Buttons
|
||||
The node's control bar exposes three maintenance buttons — each asks for confirmation before acting:
|
||||
- **`↺ Counter`**: Resets this node's `Sequential`/`Random` cycle position and clears its prompt→LoRA assignments (stored in `user/default/SaturnNodes/lora_loader_state.json`). ⚠️ Only use between batches: running/queued workflows will restart the cycle from the first selected LoRA and can mismatch their prompts.
|
||||
- **`🧹 Usage`**: Clears all LoRA usage counters and badges (`lora_usage.json`). Cosmetic only — affects badges and `Usage` sortings, not LoRA loading.
|
||||
- **`🗑 Cache`**: Deletes cached thumbnail images. They regenerate automatically when you view the grid.
|
||||
|
||||
#### Inputs & Widgets
|
||||
- **`model`** (`MODEL`): Input model.
|
||||
- **`clip`** (`CLIP`): Input CLIP.
|
||||
@@ -72,10 +90,13 @@ Visual thumbnail browser for LoRAs with Civitai SHA256 search & TMDB auto-scrapi
|
||||
- **`MODEL`**: Patched model.
|
||||
- **`CLIP`**: Patched CLIP.
|
||||
- **`lora_name`** (`STRING`): Comma-separated list of active LoRA names.
|
||||
|
||||
> [!NOTE]
|
||||
> Frontend V1 and V2 are supported, though `VisualLoraLoader` breaks on Frontend V1 for now (will be fixed). Frontend V2 (Vue UI) is recommended for visual LoRA browsing.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>🍃 📷 Visual Image Loader</b> (<code>VisualImageLoader</code> / alias <code>ImageLoaderVisualPrettyV2</code>)</summary>
|
||||
<summary><b>🪐 📷 Visual Image Loader</b> (<code>VisualImageLoader</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,10 +115,10 @@ 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.
|
||||
Monitors a folder for incoming images, loads the target image into a PyTorch tensor, with an optional toggle to delete the image after loading. Path access and deletion are strictly confined to ComfyUI `input` and `output` directories.
|
||||
|
||||
#### Inputs & Widgets
|
||||
- **`folder`** (`STRING`): Watch directory path (default `input/watch`).
|
||||
@@ -105,7 +126,7 @@ Monitors a folder for incoming images, loads the target image into a PyTorch ten
|
||||
- **`rescan_interval`** (`INT`): Polling frequency in seconds.
|
||||
- **`sort_by`** (`COMBO`): Sort by `date_modified`, `date_created`, or `name`.
|
||||
- **`regex_filter`** (`STRING`): Regex pattern to filter filenames.
|
||||
- **`delete_image`** (`BOOLEAN`, *Default: false*): If enabled, deletes the image file from disk after loading.
|
||||
- **`delete_image`** (`BOOLEAN`, *Default: false*): If enabled, deletes the image file from disk after loading (strictly inside `input` or `output`).
|
||||
|
||||
#### Outputs
|
||||
- **`image`** (`IMAGE`): Loaded image tensor.
|
||||
@@ -113,7 +134,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,18 +149,18 @@ 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>)</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.
|
||||
@@ -154,7 +175,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>)</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 +198,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 +213,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>)</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 +235,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 +254,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 +270,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 +287,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>)</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 +320,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.
|
||||
@@ -310,115 +330,178 @@ Safely executes a local script or executable (`.bat`, `.cmd`, `.ps1`, `.exe` on
|
||||
- **`passthrough`** (`*`): Passes input `trigger` data through untouched to downstream nodes.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>🪐 👓 Stereoscopic 3D / Cross-Eye Generator</b> (<code>SaturnStereo3D</code>)</summary>
|
||||
|
||||
#### Overview
|
||||
Inspired by Stereonix, this node transforms any 2D image into stereoscopic 3D pairs for cross-eye viewing, parallel 3D viewers, VR/3D displays, and red-cyan anaglyph 3D glasses using depth-based horizontal disparity mapping (DIBR). Features automatic on-demand Depth Anything V2 Small fallback, crop/zoom/pan controls, outer border framing, empty gap spacing, and focus alignment guide dots.
|
||||
|
||||
#### Inputs & Widgets
|
||||
- **`image`** (`IMAGE`): The source 2D image.
|
||||
- **`mode`** (`COMBO`): 3D viewing format (`Cross-Eye`, `Parallel (Wall-Eyed)`, `Red-Cyan Anaglyph`, `Side-by-Side (Parallel)`, `Top-Bottom (Over-Under)`, `Left Eye Only`, `Right Eye Only`).
|
||||
- **`depth_intensity`** (`FLOAT` slider, `0.0` - `100.0`, default `25.0`): Controls how much the left and right eye perspectives shift apart horizontally.
|
||||
- **`depth_gamma`** (`FLOAT` slider, `0.1` - `3.0`, default `0.50`): Adjusts the depth curve for realistic falloff.
|
||||
- **`invert_depth`** (`BOOLEAN`, default `False`): Flips near and far depth planes.
|
||||
- **`convergence_plane`** (`FLOAT` slider, `0.0` - `1.0`, default `0.50`): Focal depth plane where horizontal shift is zero (the screen surface). Depths closer pop out; depths farther sink in.
|
||||
- **`image_zoom`** (`FLOAT` slider, `10.0` - `300.0`, default `100.0`): Zoom into areas of interest before generating 3D disparity.
|
||||
- **`pan_horizontal`** (`FLOAT` slider, `-100.0` - `100.0`, default `0.0`): Horizontal pan offset.
|
||||
- **`pan_vertical`** (`FLOAT` slider, `-100.0` - `100.0`, default `0.0`): Vertical pan offset.
|
||||
- **`gap_spacing`** (`INT`, default `20`): Empty black space between the left and right eye panels.
|
||||
- **`outer_border`** (`INT`, default `0`): Black border padding around the canvas (allows shrinking images for easier cross-eye viewing on large monitors).
|
||||
- **`alignment_dots`** (`BOOLEAN`, default `True`): Renders focus guide dots centered above each eye view to easily lock into 3D cross-eye view.
|
||||
- **`fill_method`** (`COMBO`): Hole-filling method (`Smooth (Bilinear Grid)` vs `Nearest Neighbor (Stereonix)`).
|
||||
- **`preview_zoom`** (`COMBO`): Scale preview for comfortable canvas cross-eye viewing (`Fit`, `25%`, `33%`, `50%`, `75%`, `100%`).
|
||||
- **`depth_map`** (`IMAGE`, *Optional Input*): Optional depth map from any node. If not connected, automatically downloads and runs Depth Anything V2 Small ONNX model internally.
|
||||
|
||||
#### Outputs
|
||||
- **`stereogram`** (`IMAGE`): The composited 3D stereogram image.
|
||||
- **`left_eye`** (`IMAGE`): The warped left eye viewpoint.
|
||||
- **`right_eye`** (`IMAGE`): The warped right eye viewpoint.
|
||||
- **`depth_map`** (`IMAGE`): The normalized depth map used for disparity calculation.
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<a id="comfyui-settings-menu-reference"></a>
|
||||
## ⚙️ 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.
|
||||
- **`Enable TMDB Auto-Scraping`** (`boolean`, *Default: false*): Automatically download celebrity preview images from TMDB.
|
||||
- **`TMDB Access Token`** (`text`): Optional key or v4 Read Access Token (`eyJ...`) for actor / media preview search.
|
||||
- **`Enable TMDB Auto-Scraping`** (`boolean`, *Default: false*): Automatically download media preview images from TMDB.
|
||||
- **`Enable LoRA Usage Tracking`** (`boolean`, *Default: true*): Toggle LoRA usage counting and visual rank badges (🔥, Gold, Silver, Bronze) in the picker.
|
||||
- **`Reset Failed Scrapes Cache`** (*Button: `🗑️ Clear Scrapes Cache`*): Clears failed scrape history so Civitai/TMDB can retry downloading missing preview images.
|
||||
- **`Disable Thumbnail Downscaling (Serve Raw)`** (`boolean`, *Default: false*): Serves full-resolution original preview images instead of generating downscaled WebP thumbnails (downscaled WebP thumbnails render faster in the picker and are recommended).
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<summary><b>2. 🔄 Prompt Iterator</b></summary>
|
||||
<details>
|
||||
<summary><b>👁️ Live Preview (Floating Latent Dock)</b></summary>
|
||||
|
||||
- **`Enable Floating Latent Dock`** (`boolean`, *Default: true*): Adds the floating picture-in-picture Live Latent preview dock (bottom-right corner) plus a canvas toolbar toggle button.
|
||||
- **`Floating Dock Max Width`** (`slider`, *Default: 280*, 1px steps): Maximum dock width box in pixels (100–2000). Drag the dock's top-left corner to set a free-form width×height box; the dragged dimensions are saved back into this setting, and the dock then snaps to the exact latent aspect ratio without distortion.
|
||||
- **`Floating Dock Max Height`** (`slider`, *Default: 280*, 1px steps): Maximum dock height box in pixels (100–2000). Drag the dock's top-left corner to set a free-form width×height box; the dragged dimensions are saved back into this setting, and the dock then snaps to the exact latent aspect ratio without distortion.
|
||||
- **`Auto-Hide Dock When Idle`** (`boolean`, *Default: false*): Hides the dock while idle and reveals it automatically when sampling begins.
|
||||
</details>
|
||||
|
||||
<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.
|
||||
- **`Visible Queue Items (Height)`** (`slider`, *Default: automatic per-monitor*, 1px steps, 0–1200): Limits the visible height of the queue overlay panel in pixels (`0` = native). The overlay's bottom edge can also be dragged directly on screen; sizes are remembered per monitor.
|
||||
</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.
|
||||
- **`Enable 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.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>🧩 Node Management</b></summary>
|
||||
|
||||
- One enable/disable toggle per SaturnNodes node (e.g. `Loaders / LoRA Loader (Folder)`, `Utils / Prompt Counter`): hides individual nodes from the node search menu. Disabled nodes remain registered, so existing workflows keep loading unchanged — they are only hidden from the Add Node menu (requires a server restart to take effect).
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🔒 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.
|
||||
For the comprehensive engineering standard, threat model, and review precedents enforced by the ComfyUI-Manager maintainers, see the full **[ComfyUI Custom Node Security & Compliance Rulebook](SECURITY_AUDIT_PLAYBOOK.md)**.
|
||||
|
||||
- **Local Runner Sandbox & Operator Authorization Gate:** The `Run Local File` node enforces single-use interactive operator authorization (`⚡ Authorize Run (5 min)`), strictly confines execution to `ComfyUI/scripts/`, never uses `shell=True`, terminates whole process trees on timeout or cancellation, and **never persists authorization inside workflow JSON or image metadata**.
|
||||
- **CSRF Defense & Fetch Metadata Validation:** All administrative endpoints (`/saturnnodes/settings`, `/saturnnodes/power/*`, `/saturnnodes/local_runner/*`, `/saturnnodes/scrapes/clear`, `/saturnnodes/pause_queue/*`, `/saturnnodes/persistent_queue/*`, `/saturnnodes/image_loader/*`, `/saturnnodes/folder_lora_loader/*`) 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/`.
|
||||
- **Atomic File Writes:** Safe atomic writes (`.tmp` + `os.replace`) prevent JSON corruption during sudden crashes.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Installation & Setup
|
||||
|
||||
**Option A — ComfyUI Manager / Registry (recommended):**
|
||||
|
||||
1. Open ComfyUI Manager → **Install Custom Nodes** and search for `SaturnNodes`, or run:
|
||||
```bash
|
||||
comfy node install saturnnodes
|
||||
```
|
||||
2. Restart ComfyUI.
|
||||
|
||||
**Option B — Manual (git clone):**
|
||||
|
||||
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.
|
||||
|
||||
**After install (either option):** open ComfyUI Settings (⚙ gear menu) to configure API keys or feature toggles if desired, and see the [Complete Walkthrough](WALKTHROUGH.md) for usage recipes.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+5
-5
@@ -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 under `/saturnnodes/*` 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` 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.
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
# 🛡️ ComfyUI Custom Node Security, Compliance & Architecture Rulebook
|
||||
*The Authoritative Engineering Standard & Audit Playbook Derived from ComfyUI-Manager PR #3200 Review Precedents*
|
||||
|
||||
---
|
||||
|
||||
## 📑 Table of Contents
|
||||
1. [Core Threat Model & The Golden Rule](#1-core-threat-model--the-golden-rule)
|
||||
2. [Case Study: ComfyUI-Manager PR #3200 Review Precedents](#2-case-study-comfyui-manager-pr-3200-review-precedents)
|
||||
3. [The 8 Core Security Rules (With Code Examples)](#3-the-8-core-security-rules)
|
||||
- [Rule 1: Filesystem Confinement & Traversal Prevention](#rule-1-filesystem-confinement--traversal-prevention)
|
||||
- [Rule 2: Destructive File Actions & Deletion Guards](#rule-2-destructive-file-actions--deletion-guards)
|
||||
- [Rule 3: Process Execution & The Workflow Serialization Trap](#rule-3-process-execution--the-workflow-serialization-trap)
|
||||
- [Rule 4: Dynamic Code Evaluation & Deserialization](#rule-4-dynamic-code-evaluation--deserialization)
|
||||
- [Rule 5: Web Endpoints & The Same-Machine Browser Fallacy (CSRF)](#rule-5-web-endpoints--the-same-machine-browser-fallacy-csrf)
|
||||
- [Rule 6: Two-Step Ephemeral Ticket Handshakes for Power Actions](#rule-6-two-step-ephemeral-ticket-handshakes-for-power-actions)
|
||||
- [Rule 7: Outbound Network Requests & SSRF Prevention](#rule-7-outbound-network-requests--ssrf-prevention)
|
||||
- [Rule 8: Configuration Storage, Isolation & Injection Defense](#rule-8-configuration-storage-isolation--injection-defense)
|
||||
4. [Ecosystem & Registry Packaging Compliance](#4-ecosystem--registry-packaging-compliance)
|
||||
- [Rule 9: Zero Runtime Package Installations (`install.py` Prohibition)](#rule-9-zero-runtime-package-installations-installpy-prohibition)
|
||||
- [Rule 10: PyTorch Exclusion from Dependencies](#rule-10-pytorch-exclusion-from-dependencies)
|
||||
- [Rule 11: Headless & Cloud Container Resilience](#rule-11-headless--cloud-container-resilience)
|
||||
- [Rule 12: Core Monkey-Patching Isolation](#rule-12-core-monkey-patching-isolation)
|
||||
5. [Frontend Hygiene & Canvas Safety](#5-frontend-hygiene--canvas-safety)
|
||||
- [Rule 13: Dual Frontend Architecture (V1 LiteGraph & V2 Vue UI)](#rule-13-dual-frontend-architecture-v1-litegraph--v2-vue-ui)
|
||||
- [Rule 14: Canvas Resize Loops (`computeSize` & `node.size`)](#rule-14-canvas-resize-loops-computesize--nodesize)
|
||||
- [Rule 15: Non-Serialized UI Widgets (`{ serialize: false }`)](#rule-15-non-serialized-ui-widgets--serialize-false-)
|
||||
6. [Auditor & Maintainer Quick Conformance Matrix](#6-auditor--maintainer-quick-conformance-matrix)
|
||||
|
||||
---
|
||||
|
||||
## 1. Core Threat Model & The Golden Rule
|
||||
|
||||
### 1.1 The Golden Rule
|
||||
> **"Treat every input, widget values included, as attacker-controlled, because it can come from a shared workflow or a direct `/prompt` call."**
|
||||
> — *ltdrdata (ComfyUI-Manager Lead Maintainer, Comfy-Org)*
|
||||
|
||||
### 1.2 The 4 Attack Vectors Unique to ComfyUI
|
||||
1. **Shared Workflow Vectors (.json & PNG Metadata):**
|
||||
ComfyUI workflows are freely shared across community hubs (Civitai, Reddit, Discord, GitHub) as raw JSON files or embedded inside PNG image generation metadata. Loading a poisoned image instantly sets all node widgets on the canvas.
|
||||
2. **The Same-Machine Browser Exploit (Drive-by Localhost CSRF):**
|
||||
Any website an operator visits in their regular browser can execute background JavaScript (`fetch('http://localhost:8188/...')`). Because this request originates from the operator's local computer, primitive IP loopback checks (`127.0.0.1`) pass automatically unless strict fetch metadata and CSRF headers are validated.
|
||||
3. **Direct Headless API Injection (`POST /prompt`):**
|
||||
Attackers or automated scripts can bypass the graphical interface completely by sending malicious graph specifications directly to the server's `/prompt` endpoint.
|
||||
4. **Host User Privileges:**
|
||||
ComfyUI processes run with the full security privileges of the logged-in operating system user. Any sandbox breakout, unchecked script runner, or arbitrary file deletion results in immediate compromise of personal files or host systems.
|
||||
|
||||
---
|
||||
|
||||
## 2. Case Study: ComfyUI-Manager PR #3200 Review Precedents
|
||||
|
||||
During the official registration of **`ComfyUI-SaturnNodes`** in [Comfy-Org/ComfyUI-Manager#3200](https://github.com/Comfy-Org/ComfyUI-Manager/pull/3200), lead maintainer `@ltdrdata` conducted a three-phase security review. This exchange set definitive precedents for all modern custom nodes:
|
||||
|
||||
### Round 1: Unauthenticated Power Routes & Arbitrary File Reads
|
||||
* **Maintainer Finding:** Exposing network routes that restart/shutdown the server, stream files by absolute path, or write to `.env` without access controls is strictly forbidden.
|
||||
* **Precedent:** File reads must be confined to fixed base directories. Power management and settings routes must be local-only and strictly constrained.
|
||||
|
||||
### Round 2: The Same-Machine Fallacy & The Workflow Serialization Trap
|
||||
* **Maintainer Finding:**
|
||||
1. *Same-Machine Check Failure:* A basic IP loopback check (`request.remote == "127.0.0.1"`) is insufficient. Any open browser tab makes requests from `localhost`, allowing malicious web pages to trigger restarts, shutdowns, or settings changes.
|
||||
2. *Escape Hatches Forbidden:* Settings toggles such as `ALLOW_ANY_SCRIPT_PATH` that bypass folder confinement must be completely removed.
|
||||
3. *In-Workflow Consent Fallacy:* A `security_consent` boolean widget in `INPUT_TYPES` defaults to `False` in code, but a shared malicious workflow arrives with `security_consent: True` already serialized. Execution consent must never travel with the graph.
|
||||
* **Precedent:**
|
||||
- Routes must validate `Sec-Fetch-Site` (blocking `cross-site`) and verify cryptographic CSRF tokens.
|
||||
- Process execution must require interactive, in-memory human authorization on the canvas node via an ephemeral RAM token.
|
||||
|
||||
### Round 3: Folder Deletion Sandbox Bypass
|
||||
* **Maintainer Finding:** `sanitize_folder_path` allowed relative paths (`..`) and absolute paths. In a folder watcher node with an optional `delete_image` flag, an attacker could supply `folder: "C:/Windows"` or `../../` and delete arbitrary user files.
|
||||
* **Precedent:** All path-handling functions must reject absolute paths, reject `..`, and verify canonical containment against approved ComfyUI directories using `os.path.commonpath`.
|
||||
|
||||
---
|
||||
|
||||
## 3. The 8 Core Security Rules
|
||||
|
||||
### Rule 1: Filesystem Confinement & Traversal Prevention
|
||||
|
||||
Nodes must never read, write, or list files outside designated ComfyUI directories (`input/`, `output/`, `temp/`, and `models/`).
|
||||
|
||||
#### ❌ Non-Compliant Patterns
|
||||
```python
|
||||
# VULNERABLE: Accepts arbitrary absolute paths or directory traversal
|
||||
def load_image(self, folder, filename):
|
||||
filepath = os.path.join(folder, filename)
|
||||
with open(filepath, "rb") as f: # Attacker loads C:\Users\user\.ssh\id_rsa
|
||||
return f.read()
|
||||
```
|
||||
|
||||
#### ✅ Compliant Pattern
|
||||
```python
|
||||
import os
|
||||
import re
|
||||
|
||||
def sanitize_folder_path(folder_input: str, allowed_base_dir: str) -> str:
|
||||
"""
|
||||
Strictly confines a folder to an allowed base directory.
|
||||
Rejects absolute paths, drive letters, UNC shares, and parent traversal.
|
||||
"""
|
||||
if not folder_input or not folder_input.strip():
|
||||
return allowed_base_dir
|
||||
|
||||
raw_path = folder_input.strip()
|
||||
|
||||
# Reject Windows drive letters (C:) and UNC network paths (// or \\)
|
||||
if re.match(r'^[A-Za-z]:', raw_path) or raw_path.startswith(("\\\\", "//")):
|
||||
raise ValueError(f"Absolute or network paths are forbidden: {raw_path}")
|
||||
|
||||
# Reject root slashes and directory traversal
|
||||
if raw_path.startswith(("/", "\\")) or ".." in raw_path.replace("\\", "/").split("/"):
|
||||
raise ValueError(f"Directory traversal sequences are forbidden: {raw_path}")
|
||||
|
||||
# Canonicalize and verify commonpath boundary
|
||||
canonical_base = os.path.realpath(allowed_base_dir)
|
||||
target_path = os.path.realpath(os.path.join(canonical_base, raw_path))
|
||||
|
||||
if os.path.commonpath([canonical_base, target_path]) != canonical_base:
|
||||
raise ValueError(f"Path escapes allowed sandbox: {target_path}")
|
||||
|
||||
return target_path
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Rule 2: Destructive File Actions & Deletion Guards
|
||||
|
||||
Any node that deletes or overwrites files (`os.remove`, `os.unlink`, `shutil.rmtree`) must verify containment immediately prior to invocation.
|
||||
|
||||
#### ❌ Non-Compliant Patterns
|
||||
```python
|
||||
# VULNERABLE: Trusting earlier sanitization or allowing unverified deletion
|
||||
def cleanup_cache(self, user_supplied_path):
|
||||
if os.path.exists(user_supplied_path):
|
||||
os.remove(user_supplied_path) # Arbitrary file deletion
|
||||
```
|
||||
|
||||
#### ✅ Compliant Pattern
|
||||
```python
|
||||
def safe_delete_image(target_file_path: str, allowed_folders: list[str]) -> bool:
|
||||
"""
|
||||
Verifies target file is strictly inside input/output before deletion.
|
||||
"""
|
||||
real_target = os.path.realpath(target_file_path)
|
||||
if not os.path.isfile(real_target):
|
||||
return False
|
||||
|
||||
is_confined = any(
|
||||
os.path.commonpath([os.path.realpath(base), real_target]) == os.path.realpath(base)
|
||||
for base in allowed_folders
|
||||
)
|
||||
|
||||
if not is_confined:
|
||||
raise PermissionError(f"Security Violation: Refusing to delete file outside sandbox: {real_target}")
|
||||
|
||||
os.remove(real_target)
|
||||
return True
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Rule 3: Process Execution & The Workflow Serialization Trap
|
||||
|
||||
Executing external binaries or scripts is high risk. You must adhere to the following principles:
|
||||
|
||||
1. **The Workflow Serialization Trap:** Never include an authorization checkbox (e.g. `"security_consent": ("BOOLEAN", {"default": False})`) in `INPUT_TYPES`. Attackers distribute workflows with `"security_consent": True` already serialized.
|
||||
2. **Ephemeral RAM Authorization:** Authorization must occur via an in-memory session token generated when the user physically clicks a canvas UI button (e.g. `[⚡ Authorize Run]`).
|
||||
3. **Execution Confinement:** Scripts must reside strictly within a dedicated directory (e.g., `ComfyUI/scripts/`).
|
||||
4. **Never Use `shell=True`:** Always tokenize arguments (`shlex.split` or list) and execute with `shell=False`.
|
||||
|
||||
#### ❌ Non-Compliant Patterns
|
||||
```python
|
||||
# VULNERABLE: shell=True, arbitrary paths, and consent stored in workflow JSON
|
||||
class VulnerableRunner:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"command": ("STRING", {"default": "python script.py"}),
|
||||
"user_consent": ("BOOLEAN", {"default": False}), # Poisoned via JSON!
|
||||
}
|
||||
}
|
||||
def run(self, command, user_consent):
|
||||
if user_consent:
|
||||
subprocess.Popen(command, shell=True) # Remote Code Execution
|
||||
```
|
||||
|
||||
#### ✅ Compliant Pattern
|
||||
```python
|
||||
import subprocess
|
||||
import shlex
|
||||
import time
|
||||
import os
|
||||
|
||||
# Stored strictly in volatile Python memory (RAM), never serialized to disk
|
||||
ACTIVE_RUN_AUTHORIZATIONS: dict[str, float] = {}
|
||||
|
||||
def authorize_node_execution(node_id: str, ttl_seconds: int = 300) -> str:
|
||||
"""Invoked only by interactive UI button via authenticated CSRF route."""
|
||||
token = os.urandom(16).hex()
|
||||
ACTIVE_RUN_AUTHORIZATIONS[node_id] = time.time() + ttl_seconds
|
||||
return token
|
||||
|
||||
def execute_confined_script(script_name: str, args_list: list[str], node_id: str, scripts_dir: str):
|
||||
# 1. Verify in-memory authorization
|
||||
expiry = ACTIVE_RUN_AUTHORIZATIONS.pop(node_id, 0)
|
||||
if time.time() > expiry:
|
||||
raise PermissionError("Execution denied: Interactive operator authorization required on canvas.")
|
||||
|
||||
# 2. Confine target executable to scripts/ directory
|
||||
real_scripts_base = os.path.realpath(scripts_dir)
|
||||
target_script = os.path.realpath(os.path.join(real_scripts_base, script_name))
|
||||
|
||||
if os.path.commonpath([real_scripts_base, target_script]) != real_scripts_base:
|
||||
raise ValueError("Script escape detected.")
|
||||
|
||||
if not os.path.isfile(target_script):
|
||||
raise FileNotFoundError(f"Script not found: {script_name}")
|
||||
|
||||
# 3. Safe execution with shell=False
|
||||
cmd = ["python", target_script] + args_list
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
shell=False,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True
|
||||
)
|
||||
return proc.communicate(timeout=60)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Rule 4: Dynamic Code Evaluation & Deserialization
|
||||
|
||||
Never pass widget string inputs into `eval()`, `exec()`, or dynamic `importlib` calls.
|
||||
|
||||
#### ❌ Non-Compliant Patterns
|
||||
```python
|
||||
# VULNERABLE: Direct eval allows __import__('os').system('...')
|
||||
def calculate_aspect(self, formula_string):
|
||||
return eval(formula_string)
|
||||
```
|
||||
|
||||
#### ✅ Compliant Pattern
|
||||
```python
|
||||
import ast
|
||||
import operator
|
||||
|
||||
SAFE_OPERATORS = {
|
||||
ast.Add: operator.add,
|
||||
ast.Sub: operator.sub,
|
||||
ast.Mult: operator.mul,
|
||||
ast.Div: operator.truediv,
|
||||
}
|
||||
|
||||
def safe_eval_math(expression: str) -> float:
|
||||
"""Parses mathematical expression using an Abstract Syntax Tree (AST)."""
|
||||
node = ast.parse(expression, mode='eval').body
|
||||
def _eval(node):
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
|
||||
return node.value
|
||||
if isinstance(node, ast.BinOp) and type(node.op) in SAFE_OPERATORS:
|
||||
return SAFE_OPERATORS[type(node.op)](_eval(node.left), _eval(node.right))
|
||||
raise ValueError(f"Unsafe expression: {expression}")
|
||||
return float(_eval(node))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Rule 5: Web Endpoints & The Same-Machine Browser Fallacy (CSRF)
|
||||
|
||||
Because any malicious web page the operator visits in another tab can target `http://127.0.0.1:8188`, endpoints that mutate state or trigger actions must defend against cross-site requests.
|
||||
|
||||
#### Required Multi-Layer Defense:
|
||||
1. **Loopback IP Verification:** Peer address must be `127.0.0.1`, `::1`, or `localhost`.
|
||||
2. **Fetch Metadata Inspection:** `Sec-Fetch-Site` header must **not** be `cross-site`.
|
||||
3. **Cryptographic CSRF Header:** Require a per-session secret token in a custom header (e.g. `X-SaturnNodes-CSRF-Token`). Browsers forbid cross-origin pages from sending custom headers without CORS preflight approval.
|
||||
|
||||
#### ✅ Compliant Pattern
|
||||
```python
|
||||
import secrets
|
||||
from aiohttp import web
|
||||
|
||||
SESSION_CSRF_TOKEN = secrets.token_hex(32)
|
||||
|
||||
def is_authenticated_local_request(request: web.Request) -> bool:
|
||||
"""
|
||||
Blocks remote network callers AND malicious cross-site browser requests.
|
||||
"""
|
||||
# 1. IP Loopback Check
|
||||
peername = request.transport.get_extra_info('peername')
|
||||
client_ip = peername[0] if peername else ""
|
||||
if client_ip not in ("127.0.0.1", "::1", "localhost"):
|
||||
return False
|
||||
|
||||
# 2. Browser Fetch Metadata Defense (blocks drive-by background fetches)
|
||||
sec_fetch_site = request.headers.get("Sec-Fetch-Site", "")
|
||||
if sec_fetch_site in ("cross-site",):
|
||||
return False
|
||||
|
||||
# 3. Custom Header & Secret Verification (triggers CORS preflight rejection)
|
||||
token = request.headers.get("X-SaturnNodes-CSRF-Token", "")
|
||||
if not token or not secrets.compare_digest(token, SESSION_CSRF_TOKEN):
|
||||
return False
|
||||
|
||||
return True
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Rule 6: Two-Step Ephemeral Ticket Handshakes for Power Actions
|
||||
|
||||
High-impact administrative actions (restarting ComfyUI, shutting down the host, mass cache purges) must **never** trigger from a single `POST` endpoint.
|
||||
|
||||
#### Handshake Protocol:
|
||||
1. **Disabled by Default:** Master setting `ALLOW_PROCESS_MANAGEMENT = False`.
|
||||
2. **Step 1 (`POST /power/request_token`):** Server validates local CSRF session, generates a single-use token in RAM with a 30-second TTL, and returns it to the client.
|
||||
3. **Step 2 (`POST /power/confirm_action`):** Client provides the single-use token. Server validates, burns the token, and schedules the action.
|
||||
|
||||
---
|
||||
|
||||
### Rule 7: Outbound Network Requests & SSRF Prevention
|
||||
|
||||
If your custom node scrapes images, models, or metadata from the internet (Civitai, HuggingFace, TMDB):
|
||||
|
||||
1. **Opt-In Only:** Background scraping must be disabled by default in settings.
|
||||
2. **Protocol Confinement:** Allow only `http://` and `https://`. Reject `file://`, `ftp://`, and `gopher://`.
|
||||
3. **Private IP & Cloud Metadata Blocking (SSRF):** Reject requests resolving to:
|
||||
- Loopback: `127.0.0.0/8`, `::1`
|
||||
- Private LAN: `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`
|
||||
- Link-Local / Cloud Metadata: `169.254.169.254`
|
||||
|
||||
#### ✅ Compliant Pattern
|
||||
```python
|
||||
import ipaddress
|
||||
import socket
|
||||
from urllib.parse import urlparse
|
||||
|
||||
def is_safe_external_url(url: str) -> bool:
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
return False
|
||||
|
||||
hostname = parsed.hostname
|
||||
if not hostname:
|
||||
return False
|
||||
|
||||
# Resolve IP and check for private / loopback / metadata ranges
|
||||
ip_addr = socket.gethostbyname(hostname)
|
||||
ip = ipaddress.ip_address(ip_addr)
|
||||
|
||||
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
|
||||
return False
|
||||
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Rule 8: Configuration Storage, Isolation & Injection Defense
|
||||
|
||||
All user runtime state, custom settings, and caches must be stored cleanly inside ComfyUI's user directory:
|
||||
`ComfyUI/user/default/<YourCustomNodeName>/`
|
||||
|
||||
- **Newline Injection Defense:** When writing to `.env` or config files, strip `\r` and `\n` from both keys and values to prevent setting forgery.
|
||||
- **Atomic Disk Writes:** Write data to a temporary file (`.tmp`) first, then commit via `os.replace` to prevent corrupted files on unexpected shutdowns.
|
||||
- **Log Masking:** Strip or mask API keys (`sk-***`, token hashes) in console output.
|
||||
|
||||
---
|
||||
|
||||
## 4. Ecosystem & Registry Packaging Compliance
|
||||
|
||||
To pass automated and manual review in the official ComfyUI Registry and ComfyUI-Manager:
|
||||
|
||||
### Rule 9: Zero Runtime Package Installations (`install.py` Prohibition)
|
||||
- **Prohibited:** Creating an `install.py` script that invokes `subprocess.run(["pip", "install", ...])` during ComfyUI startup.
|
||||
- **Mandated:** Declare all dependencies cleanly in `requirements.txt` and `pyproject.toml`.
|
||||
|
||||
### Rule 10: PyTorch Exclusion from Dependencies
|
||||
- **Prohibited:** Listing `torch` or `torchvision` in your custom node's `requirements.txt` or `pyproject.toml`.
|
||||
- **Reason:** ComfyUI manages its own PyTorch/CUDA environment. Specifying `torch` causes pip to downgrade or corrupt the user's CUDA acceleration binaries.
|
||||
|
||||
### Rule 11: Headless & Cloud Container Resilience
|
||||
- If using desktop GUI dependencies (e.g. `pystray` system tray icons, Tkinter windows), wrap imports in `try/except ImportError` and provide silent headless fallbacks so cloud instances (RunPod, Colab, Docker) do not crash on launch.
|
||||
|
||||
### Rule 12: Core Monkey-Patching Isolation
|
||||
- If overriding core ComfyUI objects (such as `PromptQueue`), the monkey-patch must be conditionally executed **only** if the corresponding feature flag is enabled in settings. If the user disables the feature, core classes must remain untouched.
|
||||
|
||||
---
|
||||
|
||||
## 5. Frontend Hygiene & Canvas Safety
|
||||
|
||||
### Rule 13: Dual Frontend Architecture (V1 LiteGraph & V2 Vue UI)
|
||||
- All custom UI extensions must verify compatibility with both the classic LiteGraph canvas and the modern ComfyUI Vue V2 interface.
|
||||
- In hooks (`nodeCreated`, `beforeRegisterNodeDef`), verify both `node.comfyClass` and `node.type`.
|
||||
|
||||
### Rule 14: Canvas Resize Loops (`computeSize` & `node.size`)
|
||||
- Never compute widget heights dynamically based on `this.size[1]` inside `computeSize()`. In LiteGraph, this creates an infinite resize loop where the node expands downwards continuously on every draw frame.
|
||||
|
||||
### Rule 15: Non-Serialized UI Widgets (`{ serialize: false }`)
|
||||
- Dynamic preview widgets, action buttons, ranking badges, and status labels must be configured with:
|
||||
```javascript
|
||||
widget.serialize = false;
|
||||
// or widget.options = { serialize: false };
|
||||
```
|
||||
This prevents transient UI states from cluttering the user's saved workflow JSON.
|
||||
|
||||
---
|
||||
|
||||
## 6. Auditor & Maintainer Quick Conformance Matrix
|
||||
|
||||
Use this matrix when auditing your own or third-party ComfyUI custom nodes:
|
||||
|
||||
| Domain | Mandatory Security Requirement | Verification Method |
|
||||
| :--- | :--- | :--- |
|
||||
| **Filesystem** | Absolute paths (`C:`, `/`, `\\`) rejected | Test with `C:\Windows\System32` & `/etc/passwd` |
|
||||
| **Filesystem** | Directory traversal (`..`) rejected | Test with `../../` |
|
||||
| **Filesystem** | Canonical path confinement via `commonpath` | Verify `commonpath([base, target]) == base` |
|
||||
| **Deletion** | Deletions strictly confined to `input/` and `output/` | Verify boundary check immediately before `os.remove` |
|
||||
| **Execution** | `shell=False` enforced on all subprocesses | Inspect all `subprocess.Popen` / `subprocess.run` calls |
|
||||
| **Execution** | Scripts confined strictly to `ComfyUI/scripts/` | Verify realpath check against scripts directory |
|
||||
| **Execution** | Execution consent **absent** from `INPUT_TYPES` | Verify no authorization widgets in workflow JSON |
|
||||
| **Execution** | In-memory interactive operator authorization | Confirm single-use RAM token generated via UI button |
|
||||
| **Network** | Loopback checks on administrative routes | Test request from non-local IP (expects 403) |
|
||||
| **Network** | CSRF defense: `Sec-Fetch-Site` + custom token header | Test request with `Sec-Fetch-Site: cross-site` |
|
||||
| **Network** | Two-step ephemeral ticket for restart/shutdown | Verify token generation + confirmation handshake |
|
||||
| **Network** | SSRF: Private IPs & cloud metadata blocked | Test with `127.0.0.1`, `192.168.1.1`, `169.254.169.254` |
|
||||
| **Network** | Scraping features opt-in (disabled by default) | Verify settings default to `false` |
|
||||
| **Packaging** | No `install.py` / dynamic pip execution | Verify absence of `install.py` |
|
||||
| **Packaging** | `torch` excluded from `requirements.txt` | Verify dependencies list |
|
||||
| **Data** | Data stored in `user/default/<pack>/` | Inspect state and cache file paths |
|
||||
| **Frontend** | UI widgets marked `{ serialize: false }` | Inspect exported workflow JSON for UI clutter |
|
||||
|
||||
---
|
||||
*Maintained under the ComfyUI-SaturnNodes security framework. Conforms strictly to Comfy-Org registry audit policies.*
|
||||
+32
-30
@@ -1,6 +1,6 @@
|
||||
# 🍃 ComfyUI-LeafFlow Complete Walkthrough
|
||||
# 🪐 ComfyUI-SaturnNodes Complete Walkthrough
|
||||
|
||||
Welcome to the full guide for ComfyUI-LeafFlow! This custom node suite is designed to give you unprecedented control over your generations, visual asset management, and queue resilience.
|
||||
Welcome to the full guide for ComfyUI-SaturnNodes! This custom node suite is designed to give you unprecedented control over your generations, visual asset management, and queue resilience.
|
||||
|
||||
This walkthrough covers everything from basic setup to advanced workflows across both the new ComfyUI V2 (Vue) and the classic ComfyUI (LiteGraph).
|
||||
|
||||
@@ -9,7 +9,7 @@ This walkthrough covers everything from basic setup to advanced workflows across
|
||||
## 1. Global Features
|
||||
|
||||
### The Pause Queue Toolbar
|
||||
Upon installing the extension, you will notice a new **Green/Orange Pause Button** nestled next to your standard "Queue Prompt" button.
|
||||
Upon installing the extension, you will notice a new **Green/Orange Pause Button** nestled next to your standard "Queue Prompt" button.
|
||||
- **Green (Running)**: ComfyUI operates normally.
|
||||
- **Orange (Pausing/Paused)**: ComfyUI is holding the queue.
|
||||
|
||||
@@ -19,8 +19,8 @@ You have two pause modes, accessible by clicking the small dropdown arrow next t
|
||||
|
||||
### The System Tray Icon (Outside-Browser Queue Control)
|
||||
Want to check your queue status or pause/resume generations while working in other apps (Photoshop, Blender, games) without opening ComfyUI in your browser?
|
||||
1. Open ComfyUI Settings (⚙️ gear icon) and enable **🍃 LeafFlow: Enable System Tray Icon** (or set `ENABLE_TRAY_ICON=true` in `.env`).
|
||||
2. LeafFlow will spawn an OS system tray icon in your Windows/macOS/Linux taskbar notification area:
|
||||
1. Open ComfyUI Settings (⚙️ gear icon) and enable **`Enable System Tray Icon`** (search for `SaturnNodes` in the settings search box, or set `ENABLE_TRAY_ICON=true` in `ComfyUI/user/default/SaturnNodes/.env`).
|
||||
2. SaturnNodes will spawn an OS system tray icon in your Windows/macOS/Linux taskbar notification area:
|
||||
- **Green Icon (❚❚)**: Indicates ComfyUI queue is actively running.
|
||||
- **Orange Icon (▶)**: Indicates ComfyUI queue is paused and waiting for you to continue.
|
||||
- **Orange Icon (❚❚)**: Indicates ComfyUI is in the middle of a generation and will pause as soon as the active workflow/node finishes.
|
||||
@@ -28,72 +28,73 @@ Want to check your queue status or pause/resume generations while working in oth
|
||||
4. Right-click the tray icon to choose explicit actions: **Pause (Finish)**, **Pause (Instant)**, **Continue / Resume**, switch default pause modes, or quickly **Open ComfyUI in Browser**.
|
||||
|
||||
### The 1D Git-Graph Batch Queue Visualizer
|
||||
LeafFlow automatically tracks which workflows are submitted together as a batch (whether through "Batch count" or sequential submission) and renders a clean, purely graphical 1D git-graph line directly to the left of your queued items in the queue list:
|
||||
SaturnNodes automatically tracks which workflows are submitted together as a batch (whether through "Batch count" or sequential submission) and renders a clean, purely graphical 1D git-graph line directly to the left of your queued items in the queue list:
|
||||
- **36 High-Contrast Cycling Colors**: Every new batch gets a distinct color from an accessible 36-color sequence so adjacent batches are visually distinguishable at a glance.
|
||||
- **Purely Graphical (No Clutter)**: No numbers or text labels.
|
||||
- **Purely Graphical (No Clutter)**: No numbers or text labels.
|
||||
- **Batch Start (`┌`)**: The top of the line curves inward towards the card and runs straight down.
|
||||
- **Batch Middle (`│`)**: Seamless, continuous straight vertical line.
|
||||
- **Batch End (`└`)**: Runs straight from above and curves inward towards the card at the bottom.
|
||||
- **Single Item (`(`)**: A standalone 1-item batch curves inward at both top and bottom like a bracket.
|
||||
- **In-between Infiltration**: If you use "Queue Front" or insert another prompt between items of an active batch, the original batch maintains its open ends (no false start/end curves), while the inserted prompt is enclosed in its own bracket.
|
||||
- **Works Standalone or with PersistentQueue**: Runs locally via `localStorage`, and synchronizes with `PersistentQueue` so batch groupings survive server restarts.
|
||||
- **Works Standalone or with Persistent Queue**: Runs locally via `localStorage`, and synchronizes with the Persistent Queue so batch groupings survive server restarts.
|
||||
- **V1 & V2 Frontend Compatible**: Works in the modern V2 queue sidebar (`JobAssetsList`) and classic V1 queue dialog.
|
||||
|
||||
### The Persistent Queue (Crash Recovery)
|
||||
You don't need to interact with this—it runs silently in the background!
|
||||
Every time you queue a prompt, LeafFlow saves the entire queue state to your hard drive in real time. If your computer crashes, power goes out, or you accidentally close the terminal, ComfyUI will automatically restore your unfinished queue upon next boot (in a paused state, so it doesn't overwhelm your GPU before you're ready).
|
||||
You don't need to interact with this—it runs silently in the background!
|
||||
Every time you queue a prompt, SaturnNodes saves the entire queue state to your hard drive in real time. If your computer crashes, power goes out, or you accidentally close the terminal, ComfyUI will automatically restore your unfinished queue upon next boot (in a paused state, so it doesn't overwhelm your GPU before you're ready).
|
||||
|
||||
### Automatic Assets Pane & History Restore
|
||||
In standard ComfyUI, restarting your server wipes the in-memory execution history, leaving your sidebar Assets Pane / History tab completely blank on launch.
|
||||
- LeafFlow automatically scans your `output` folder (read-only) on server startup and pre-populates the history with your latest **64 images** (or custom count).
|
||||
- SaturnNodes automatically scans your `output` folder (read-only) on server startup and pre-populates the history with your latest **64 images** (or custom count).
|
||||
- All images are loaded in strict chronological order with embedded workflow and prompt graphs intact.
|
||||
- Features like **Copy Prompt** and **Copy Workflow** immediately work with restored images as if they were generated in your current session!
|
||||
- You can adjust the restored image count or toggle this feature in **ComfyUI Settings (⚙️)** under **`🍃 LeafFlow: Restore Assets on Launch`** and **`🍃 LeafFlow: Restored Assets Count`**.
|
||||
- You can adjust the restored image count or toggle this feature in **ComfyUI Settings (⚙️)** under **`Restore Assets on Launch`** and **`Restored Assets Count`** (search for `SaturnNodes`).
|
||||
|
||||
### Image Overlay Actions & Prompt Bookmarks
|
||||
Hovering over any image in the ComfyUI Assets / History pane or preview nodes provides powerful quick actions directly on the card:
|
||||
- **📋 Copy Prompt**: Instantly copies the positive prompt text/graph to your clipboard.
|
||||
- **🔖 Save to Prompt Bookmarks**: One-click saves prompt text straight into your Prompt Bookmarks list with robust SVG bookmark iconography.
|
||||
- **🔍 Inspect Asset (Zoom)**: When enabled in settings (`04_EnableInspectAssetButton`), restores the direct full-size zoom inspect button right on the card hover pill bar alongside Download and Copy Prompt (bringing back quick one-click access that newer ComfyUI frontend versions moved into the 3-dots submenu).
|
||||
- **🔍 Inspect Asset (Zoom)**: When enabled via the **Show "Inspect Asset" (Zoom) Button on Images** setting, restores the direct full-size zoom inspect button right on the card hover pill bar alongside Download and Copy Prompt (bringing back quick one-click access that newer ComfyUI frontend versions moved into the 3-dots submenu).
|
||||
- **Dynamic Button Pill Styling**: The action button bar dynamically calculates borders and corner rounding (`rounded-l-lg`, `rounded-r-lg`) depending on which actions are enabled in settings, maintaining a seamless native appearance.
|
||||
|
||||
---
|
||||
|
||||
## 2. Advanced Nodes
|
||||
|
||||
### 🍃 ⏸️ LeafFlow Decision
|
||||
### 🪐 ⏸️ Saturn Decision
|
||||
Sometimes you run a complex workflow that takes hours (e.g., upscaling), but you want to check a low-res preview first to ensure it's worth the compute time.
|
||||
1. Drop the **`🍃 ⏸️ LeafFlow Decision`** node into your workflow between the low-res and high-res stages.
|
||||
1. Drop the **`🪐 ⏸️ Saturn Decision`** node into your workflow between the low-res and high-res stages.
|
||||
2. When the execution reaches this node, your workflow will pause, and a native desktop notification (Windows/macOS/Linux) will alert you!
|
||||
3. You will see a popup UI on your ComfyUI canvas giving you three choices:
|
||||
- **Continue**: Proceeds with the workflow normally.
|
||||
- **Cancel**: Outputs a `True` signal. You can route this into a Switch node to bypass the high-res stage entirely.
|
||||
- **Stop Workflow**: Instantly aborts the generation queue, exactly like clicking the red X in ComfyUI.
|
||||
|
||||
### 🍃 📂 Load Image From Folder
|
||||
### 🪐 📂 Load Image From Folder
|
||||
An absolute powerhouse for automation. Point this node at any folder on your hard drive, and it will automatically ingest incoming images, convert them into tensors, and delete the original files to keep the folder clean.
|
||||
- **wait_if_folder_is_empty**: If set to `True`, the workflow will patiently pause and poll the folder at your `rescan_interval` until an image arrives. Perfect for linking ComfyUI to external tools like Photoshop or Blender auto-exports!
|
||||
- **Regex Filtering**: Only want it to grab images that start with `render_`? Just use standard regex like `^render_.*\.png$`.
|
||||
|
||||
### 🍃 🖼️ Visual LoRA & Image Loaders
|
||||
### 🪐 🖼️ Visual LoRA & Image Loaders
|
||||
Tired of guessing what a LoRA does based on its filename?
|
||||
1. Use the **`🍃 🖼️ Visual LoRA Loader`**. It natively extracts preview images if you place an identically named `.png`/`.jpg` next to your `.safetensors` file.
|
||||
2. If you enable the **Civitai Auto-Scraper** in your ComfyUI Settings, the node will calculate the SHA256 hash of your LoRA and automatically download the official preview thumbnail directly from Civitai!
|
||||
3. **Pretty Name String Output**: The node outputs the parsed, human-readable name of the loaded LoRA (e.g. "Addison Rae V1") by default, allowing you to route it into a text prompt or watermark node. You can easily switch this via the `output_name` toggle on the node to output the raw filename without extension instead.
|
||||
4. **Independent Per-Node Zoom**: Use the `tile_size` slider to adjust preview card sizes to your preference. Zoom levels are saved per node directly inside your workflow JSON, so different visual loaders maintain independent tile sizes across browser reloads.
|
||||
1. Use the **`🪐 🖼️ Visual LoRA Loader`**. It natively extracts preview images if you place an identically named `.png`/`.jpg` next to your `.safetensors` file.
|
||||
2. If you enable the **Civitai Auto-Scraper** in your ComfyUI Settings, the node will calculate the SHA256 hash of your LoRA and automatically download the official preview thumbnail directly from Civitai!
|
||||
3. **Pretty Name String Output**: The node outputs the parsed, human-readable name of the loaded LoRA (e.g. "Addison Rae V1") by default, allowing you to route it into a text prompt or watermark node. You can easily switch this via the `output_name` toggle on the node to output the raw filename without extension instead.
|
||||
4. **Independent Per-Node Zoom**: Use the `tile_size` slider to adjust preview card sizes to your preference. Zoom levels are saved per node directly inside your workflow JSON, so different visual loaders maintain independent tile sizes across browser reloads.
|
||||
5. **Maintenance Reset Buttons**: The control bar offers `↺ Counter` (reset this node's Sequential/Random LoRA cycle + prompt→LoRA assignments), `🧹 Usage` (clear usage badges), and `🗑 Cache` (delete cached thumbnails). Each asks for confirmation. ⚠️ Only use `↺ Counter` when the queue is empty — resetting mid-batch restarts the LoRA cycle from the first selected LoRA and can mismatch prompts against LoRAs.
|
||||
|
||||
### 🍃 ⏱️ Recent Outputs
|
||||
Want to quickly reference your recent generations without digging through Windows Explorer? The **`🍃 ⏱️ Recent Outputs`** node cycles chronologically through your `output/` directory, pulling your freshest creations right back into the workflow canvas.
|
||||
### 🪐 ⏱️ Recent Outputs
|
||||
Want to quickly reference your recent generations without digging through Windows Explorer? The **`🪐 ⏱️ Recent Outputs`** node cycles chronologically through your `output/` directory, pulling your freshest creations right back into the workflow canvas.
|
||||
|
||||
### 🍃 📝 Prompt Counter & 🔄 Prompt Queue Iterator
|
||||
### 🪐 📝 Prompt Counter & 🪐 🔄 Prompt Queue Iterator
|
||||
Managing multi-prompt queues and setting the exact ComfyUI batch size has never been easier:
|
||||
1. **`🍃 📝 Prompt Counter`**: Place this multiline text node in your workflow and paste all your prompts.
|
||||
- As you type or paste, the live badge instantly updates (e.g. `🍃 24 Prompts`), so you know exactly what batch size to set before clicking Queue!
|
||||
1. **`🪐 📝 Prompt Counter`**: Place this multiline text node in your workflow and paste all your prompts.
|
||||
- As you type or paste, the live badge instantly updates (e.g. `24 Prompts`), so you know exactly what batch size to set before clicking Queue!
|
||||
- Under Advanced Options, select your delimiter (`>1 Empty Line`, `Newline`, `>2 Empty Lines`, or `Custom Regex`).
|
||||
- If `Custom Regex` is selected, enter any custom delimiter pattern (such as `\n---\n`). The regex field is dynamically enabled only when Custom Regex is active.
|
||||
2. **`🍃 🔄 Prompt Queue Iterator`**: Connect the `STRING` output of the Prompt Counter into the Prompt Queue Iterator to sequentially pop and execute each prompt run-by-run.
|
||||
2. **`🪐 🔄 Prompt Queue Iterator`**: Connect the `STRING` output of the Prompt Counter into the Prompt Queue Iterator to sequentially pop and execute each prompt run-by-run.
|
||||
- Now supports `Custom Regex` in addition to empty line and newline separators.
|
||||
- Includes live progress counter widget (`🍃 Run X / Y`), instant index reset button (`🔄 Reset Counter (0)`), and direct state file access (`📂 Open Queue File`).
|
||||
- Includes live progress counter widget (`Run X / Y`), an index reset button (`Reset Counter (0)`, now with a confirmation explaining that it only restarts the run pointer — it does **not** empty the queue state file; use Settings → `🔄 Reset All Queues` for a full wipe), and direct state file access (`Open Queue File`).
|
||||
|
||||
---
|
||||
|
||||
@@ -103,7 +104,7 @@ Several nodes across this suite (e.g. *Load Image From Folder*, *Recent Outputs*
|
||||
- **Empty String**: Leaving a field completely empty (or spaces) will default to the sensible native directory for that node (e.g., leaving it empty on *Recent Outputs* will automatically target ComfyUI's root `output/` directory).
|
||||
- **Safe Directory Containment**: For security, image loading and visual thumbnail nodes are confined to ComfyUI `input`, `output`, and `temp` directories with path traversal protection.
|
||||
- **Subfolder Paths**: You can specify subdirectories like `batch_01` or `renders/portraits` within the allowed ComfyUI directories.
|
||||
- **Wildcard Filters (`*`)**:
|
||||
- **Wildcard Filters (`*`)**:
|
||||
- **For the LoRA Loader**: Ending a path with `*` acts as a wildcard directory filter. For example, if you input `style/*`, the loader will recursively display all LoRAs located inside any subfolder that begins with `style/`.
|
||||
- **For File Loaders**: If you accidentally copy a path from Windows Explorer that ends in `\*` or `*`, these nodes are smart enough to automatically strip the trailing asterisk so the folder path still resolves cleanly without throwing an OS Error.
|
||||
|
||||
@@ -111,9 +112,10 @@ Several nodes across this suite (e.g. *Load Image From Folder*, *Recent Outputs*
|
||||
|
||||
## 4. Settings & Configuration
|
||||
|
||||
Click the standard ComfyUI Settings gear ⚙️ to access LeafFlow's configurations. Here you can:
|
||||
Click the standard ComfyUI Settings gear ⚙️ and search for `SaturnNodes` to access the suite's configurations. Here you can:
|
||||
- Safely enter and mask API Keys for Civitai and TMDB scraping.
|
||||
- Change the colors of the Pause Queue buttons via built-in color pickers.
|
||||
- Adjust the default boot behavior for the Persistent Queue (e.g., forcing ComfyUI to always boot in a Paused state).
|
||||
- Enable the floating Live Latent dock, drag-resize the queue overlay, and hide entire nodes from the Add Node menu under **Node Management**.
|
||||
|
||||
> **Cross-Platform Note**: All of the visual nodes, pause features, and OS notifications are fully compatible with Windows, macOS, and Linux, and seamlessly adapt to both the modern ComfyUI V2 interface and the classic LiteGraph UI!
|
||||
|
||||
+236
-58
@@ -4,29 +4,39 @@ import subprocess
|
||||
from aiohttp import web
|
||||
from server import PromptServer
|
||||
|
||||
__version__ = "2.3.1"
|
||||
__version__ = "2.6.1"
|
||||
|
||||
from .nodes.queue_control import setup_queue_control_routes, tray_manager
|
||||
from .nodes.queue_control import (
|
||||
setup_queue_control_routes,
|
||||
tray_manager,
|
||||
persistent_queue_manager,
|
||||
is_persistent_queue_enabled
|
||||
)
|
||||
from .nodes.tray_icon import is_tray_icon_enabled
|
||||
from .nodes.assets_restore import (
|
||||
assets_restore_manager,
|
||||
is_assets_restore_enabled
|
||||
)
|
||||
from .nodes.lora_loader import (
|
||||
FolderLoraLoader,
|
||||
FolderLoraLoaderPretty,
|
||||
VisualLoraLoader,
|
||||
FolderLoraLoaderVisualPrettyV2
|
||||
VisualLoraLoader
|
||||
)
|
||||
from .nodes.image_loader import VisualImageLoader, ImageLoaderVisualPrettyV2
|
||||
from .nodes.image_loader import VisualImageLoader
|
||||
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.aspect_ratio import TextAspectRatioFinder, AspectRatioFinder, PreviewImageSizeAspectRatio
|
||||
from .nodes.lora_finder import TextLoraFinder, LoraTextFinder
|
||||
from .nodes.decision_node import SaturnDecision
|
||||
from .nodes.aspect_ratio import TextAspectRatioFinder, PreviewImageSizeAspectRatio
|
||||
from .nodes.lora_finder import TextLoraFinder
|
||||
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.stereogram import SaturnStereo3D
|
||||
from .nodes.utils import (
|
||||
get_leafflow_user_dir,
|
||||
get_saturnnodes_user_dir,
|
||||
get_env_setting,
|
||||
is_local_request,
|
||||
is_safe_path,
|
||||
@@ -42,68 +52,203 @@ 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,
|
||||
"SaturnStereo3D": SaturnStereo3D,
|
||||
}
|
||||
|
||||
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",
|
||||
"SaturnStereo3D": "🪐 👓 Stereoscopic 3D Generator",
|
||||
}
|
||||
|
||||
ALL_SATURN_NODE_CLASSES = dict(NODE_CLASS_MAPPINGS)
|
||||
ALL_SATURN_NODE_DISPLAY_NAMES = dict(NODE_DISPLAY_NAME_MAPPINGS)
|
||||
ALL_SATURN_NODE_KEYS = list(NODE_CLASS_MAPPINGS)
|
||||
|
||||
def get_saturn_nodes_info():
|
||||
"""Returns sorted node metadata auto-generated from NODE_CLASS_MAPPINGS and NODE_DISPLAY_NAME_MAPPINGS."""
|
||||
import re
|
||||
info_list = []
|
||||
category_order = ["Loaders", "Automation", "Previews", "3D & Previews", "Utils"]
|
||||
|
||||
for key in ALL_SATURN_NODE_KEYS:
|
||||
cls = ALL_SATURN_NODE_CLASSES.get(key)
|
||||
cat = getattr(cls, "CATEGORY", "🪐 SaturnNodes/Utils") if cls else "🪐 SaturnNodes/Utils"
|
||||
subcat = cat.split("/")[-1].strip() if "/" in cat else "Utils"
|
||||
disp = ALL_SATURN_NODE_DISPLAY_NAMES.get(key, key)
|
||||
clean_name = re.sub(r'^[^a-zA-Z0-9\(]+', '', disp).strip()
|
||||
if not clean_name:
|
||||
clean_name = key
|
||||
|
||||
info_list.append({
|
||||
"key": key,
|
||||
"category": subcat,
|
||||
"clean_name": clean_name,
|
||||
"name": f"{subcat} / {clean_name}",
|
||||
"desc": disp
|
||||
})
|
||||
|
||||
def sort_key(item):
|
||||
cat = item["category"]
|
||||
cat_idx = category_order.index(cat) if cat in category_order else 999
|
||||
return (cat_idx, item["name"])
|
||||
|
||||
info_list.sort(key=sort_key)
|
||||
return info_list
|
||||
|
||||
def load_disabled_nodes():
|
||||
"""Loads set of user-disabled SaturnNodes from user settings directory."""
|
||||
try:
|
||||
import json
|
||||
user_dir = get_saturnnodes_user_dir()
|
||||
path = os.path.join(user_dir, "disabled_nodes.json")
|
||||
if os.path.isfile(path):
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, list):
|
||||
return set(data)
|
||||
except Exception as e:
|
||||
print(f"[SaturnNodes] Notice: Unable to load disabled_nodes.json: {e}")
|
||||
return set()
|
||||
|
||||
def save_disabled_nodes(disabled_set):
|
||||
"""Saves set of user-disabled SaturnNodes atomically."""
|
||||
import json
|
||||
user_dir = get_saturnnodes_user_dir()
|
||||
os.makedirs(user_dir, exist_ok=True)
|
||||
path = os.path.join(user_dir, "disabled_nodes.json")
|
||||
temp_path = path + ".tmp"
|
||||
with open(temp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(sorted(list(disabled_set)), f, indent=2)
|
||||
os.replace(temp_path, path)
|
||||
|
||||
# Filter disabled nodes (all enabled by default when disabled_nodes.json is empty)
|
||||
_active_disabled_nodes = load_disabled_nodes()
|
||||
if _active_disabled_nodes:
|
||||
for _node_name in _active_disabled_nodes:
|
||||
NODE_CLASS_MAPPINGS.pop(_node_name, None)
|
||||
NODE_DISPLAY_NAME_MAPPINGS.pop(_node_name, None)
|
||||
|
||||
WEB_DIRECTORY = "./web"
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
|
||||
|
||||
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
|
||||
server = getattr(PromptServer, "instance", None)
|
||||
if server is not None and hasattr(server, "routes"):
|
||||
setup_queue_control_routes(server)
|
||||
setup_local_runner_routes(server)
|
||||
routes = server.routes
|
||||
else:
|
||||
routes = web.RouteTableDef()
|
||||
|
||||
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
|
||||
def print_startup_banner():
|
||||
# Enable ANSI escape sequences on Windows console if available
|
||||
if os.name == "nt":
|
||||
try:
|
||||
import ctypes
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Saturn Brand Colors (Amber, Gold, Warm Sand, White)
|
||||
AMBER = "\033[1;38;2;245;158;11m"
|
||||
GOLD = "\033[38;2;251;191;36m"
|
||||
SAND = "\033[38;2;222;203;178m"
|
||||
WHITE = "\033[97m"
|
||||
RESET = "\033[0m"
|
||||
|
||||
active_count = len(NODE_DISPLAY_NAME_MAPPINGS)
|
||||
total_count = len(ALL_SATURN_NODE_KEYS)
|
||||
disabled_count = len(_active_disabled_nodes) if _active_disabled_nodes else 0
|
||||
total_str = f"{active_count}/{total_count} nodes loaded"
|
||||
if disabled_count > 0:
|
||||
total_str += f" ({disabled_count} disabled in settings)"
|
||||
|
||||
print()
|
||||
print(f"{AMBER}[ComfyUI-SaturnNodes]{RESET} {SAND}Version:{RESET} {WHITE}{__version__}{RESET}")
|
||||
print(f" {GOLD}*{RESET} {SAND}Loaded:{RESET} {WHITE}Visual LoRA & Image Loaders, LoRA Folder Loader{RESET}")
|
||||
print(f" {GOLD}*{RESET} {SAND}Loaded:{RESET} {WHITE}Live Latent Preview & Floating Dock{RESET}")
|
||||
print(f" {GOLD}*{RESET} {SAND}Loaded:{RESET} {WHITE}Stereoscopic 3D Generator{RESET}")
|
||||
print(f" {GOLD}*{RESET} {SAND}Loaded:{RESET} {WHITE}Prompt Queue Iterator, Prompt Counter, Text Tools{RESET}")
|
||||
print(f" {GOLD}*{RESET} {SAND}Loaded:{RESET} {WHITE}Queue Pause, Persistent Queue, Local Runner{RESET}")
|
||||
print()
|
||||
|
||||
# System status lines
|
||||
try:
|
||||
if is_tray_icon_enabled() and getattr(tray_manager, "_is_running", False):
|
||||
tray_str = "System tray icon started."
|
||||
elif not is_tray_icon_enabled():
|
||||
tray_str = "System tray icon disabled in settings."
|
||||
else:
|
||||
tray_str = "System tray icon not started."
|
||||
except Exception:
|
||||
tray_str = "System tray icon disabled in settings."
|
||||
|
||||
try:
|
||||
if is_assets_restore_enabled():
|
||||
r_count = getattr(assets_restore_manager, "restored_count", 0)
|
||||
assets_str = f"Restored {r_count} recent image(s) into Assets pane."
|
||||
else:
|
||||
assets_str = "No images restored into Assets pane (disabled in settings)."
|
||||
except Exception:
|
||||
assets_str = "Assets pane restore ready."
|
||||
|
||||
try:
|
||||
if is_persistent_queue_enabled():
|
||||
p_items = getattr(persistent_queue_manager, "persistent_items", [])
|
||||
q_count = len(p_items) if p_items else 0
|
||||
queue_str = f"Loaded {q_count} saved queue item(s)."
|
||||
else:
|
||||
queue_str = "No saved queue items loaded (disabled in settings)."
|
||||
except Exception:
|
||||
queue_str = "Persistent queue manager ready."
|
||||
|
||||
print(f" {GOLD}*{RESET} {WHITE}{tray_str}{RESET}")
|
||||
print(f" {GOLD}*{RESET} {WHITE}{assets_str}{RESET}")
|
||||
print(f" {GOLD}*{RESET} {WHITE}{queue_str}{RESET}")
|
||||
print(f"{AMBER}Total: {total_str}{RESET}")
|
||||
print()
|
||||
|
||||
try:
|
||||
print(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__}).")
|
||||
print_startup_banner()
|
||||
except Exception:
|
||||
print(f"\n[ComfyUI-SaturnNodes] Version: {__version__} - Total: {len(NODE_DISPLAY_NAME_MAPPINGS)}/{len(ALL_SATURN_NODE_KEYS)} nodes loaded\n")
|
||||
|
||||
@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 +290,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 +340,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)
|
||||
@@ -327,8 +470,12 @@ async def save_settings(request):
|
||||
if not has_local_file_exec and enable_local_file_execution is not None:
|
||||
new_lines.append(f"ENABLE_LOCAL_FILE_EXECUTION={enable_local_file_execution}\n")
|
||||
|
||||
with open(ENV_FILE, "w", encoding="utf-8") as f:
|
||||
temp_env = ENV_FILE + ".tmp"
|
||||
with open(temp_env, "w", encoding="utf-8") as f:
|
||||
f.writelines(new_lines)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(temp_env, ENV_FILE)
|
||||
|
||||
if civitai_key is not None:
|
||||
os.environ["CIVITAI_API_KEY"] = civitai_key
|
||||
@@ -352,25 +499,56 @@ 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": "error", "message": str(e)}, status=500)
|
||||
|
||||
return web.json_response({"status": "ok"})
|
||||
|
||||
@routes.post("/leafflow/scrapes/clear")
|
||||
@routes.get("/saturnnodes/disabled_nodes")
|
||||
async def get_disabled_nodes_endpoint(request):
|
||||
if not is_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local access only"}, status=403)
|
||||
return web.json_response({
|
||||
"disabled_nodes": sorted(list(load_disabled_nodes())),
|
||||
"all_nodes": ALL_SATURN_NODE_KEYS,
|
||||
"nodes_info": get_saturn_nodes_info()
|
||||
})
|
||||
|
||||
@routes.post("/saturnnodes/disabled_nodes")
|
||||
async def save_disabled_nodes_endpoint(request):
|
||||
if not is_authenticated_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
|
||||
try:
|
||||
data = await request.json()
|
||||
disabled_list = data.get("disabled_nodes", [])
|
||||
if isinstance(disabled_list, list):
|
||||
valid_keys = set(ALL_SATURN_NODE_KEYS)
|
||||
cleaned = {str(k) for k in disabled_list if str(k) in valid_keys}
|
||||
save_disabled_nodes(cleaned)
|
||||
return web.json_response({"status": "ok", "disabled_nodes": sorted(list(cleaned))})
|
||||
return web.json_response({"error": "Invalid format"}, status=400)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
@routes.post("/saturnnodes/scrapes/clear")
|
||||
async def clear_scrapes_endpoint(request):
|
||||
if not is_authenticated_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
|
||||
try:
|
||||
failed_file = os.path.join(USER_DIR, "failed_scrapes.json")
|
||||
with open(failed_file, "w", encoding="utf-8") as f:
|
||||
temp_failed = failed_file + ".tmp"
|
||||
with open(temp_failed, "w", encoding="utf-8") as f:
|
||||
f.write("{}")
|
||||
os.replace(temp_failed, failed_file)
|
||||
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):
|
||||
if not is_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local access only"}, status=403)
|
||||
try:
|
||||
import platform
|
||||
import json
|
||||
@@ -390,7 +568,7 @@ async def export_debug_profile(request):
|
||||
for line in f:
|
||||
if line.strip().startswith("CIVITAI_API_KEY=") and not civitai_key:
|
||||
civitai_key = line.split("=", 1)[1].strip()
|
||||
elif line.startswith("TMDB_API_KEY=") and not tmdb_key:
|
||||
elif line.strip().startswith("TMDB_API_KEY=") and not tmdb_key:
|
||||
tmdb_key = line.split("=", 1)[1].strip()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -409,7 +587,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 +602,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
@@ -1 +1 @@
|
||||
# Package marker for LeafFlow nodes
|
||||
# Package marker for SaturnNodes nodes
|
||||
|
||||
@@ -59,7 +59,7 @@ class TextAspectRatioFinder:
|
||||
RETURN_TYPES = ("INT", "INT", "STRING")
|
||||
RETURN_NAMES = ("width", "height", "aspect_ratio")
|
||||
FUNCTION = "find_aspect_ratio"
|
||||
CATEGORY = "🍃 LeafFlow/Utils"
|
||||
CATEGORY = "🪐 SaturnNodes/Utils"
|
||||
DESCRIPTION = "Searches text for aspect ratios (e.g. 16:9), syntax checks them, and calculates width & height for target megapixels."
|
||||
|
||||
def find_aspect_ratio(
|
||||
@@ -147,11 +147,11 @@ class TextAspectRatioFinder:
|
||||
raise ValueError(f"Cannot parse aspect ratio: {found_ratio}")
|
||||
except Exception as e:
|
||||
err_msg = f"Aspect ratio '{found_ratio}' is invalid. Falling back to '1:1'."
|
||||
print(f"[LeafFlow] Warning: {err_msg} ({e})")
|
||||
print(f"[SaturnNodes] Warning: {err_msg} ({e})")
|
||||
try:
|
||||
from server import PromptServer
|
||||
if hasattr(PromptServer, "instance") and PromptServer.instance:
|
||||
PromptServer.instance.send_sync("leafflow_node_error_state", {
|
||||
PromptServer.instance.send_sync("saturnnodes_node_error_state", {
|
||||
"node_id": str(unique_id or ""),
|
||||
"title": "Aspect Ratio Warning",
|
||||
"message": err_msg,
|
||||
@@ -196,7 +196,7 @@ class PreviewImageSizeAspectRatio:
|
||||
RETURN_TYPES = ()
|
||||
RETURN_NAMES = ()
|
||||
FUNCTION = "process_preview"
|
||||
CATEGORY = "🍃 LeafFlow/Utils"
|
||||
CATEGORY = "🪐 SaturnNodes/Utils"
|
||||
OUTPUT_NODE = True
|
||||
DESCRIPTION = "Visual preview node that displays image aspect ratio and dimension summary."
|
||||
|
||||
@@ -241,7 +241,7 @@ class PreviewImageSizeAspectRatio:
|
||||
display_text = f"1 x {h_disp}"
|
||||
|
||||
try:
|
||||
PromptServer.instance.send_sync("leafflow_update_preview_aspect_ratio", {
|
||||
PromptServer.instance.send_sync("saturnnodes_update_preview_aspect_ratio", {
|
||||
"node_id": str(unique_id),
|
||||
"ratio": calc_ratio,
|
||||
"display_text": display_text
|
||||
@@ -251,5 +251,3 @@ class PreviewImageSizeAspectRatio:
|
||||
|
||||
return {}
|
||||
|
||||
# Alias for backward compatibility
|
||||
AspectRatioFinder = TextAspectRatioFinder
|
||||
|
||||
+10
-6
@@ -9,9 +9,9 @@ import logging
|
||||
from PIL import Image
|
||||
|
||||
import folder_paths
|
||||
from .utils import get_leafflow_user_dir
|
||||
from .utils import get_saturnnodes_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):
|
||||
@@ -137,15 +137,19 @@ class AssetsRestoreManager:
|
||||
subfolder = item["subfolder"]
|
||||
|
||||
rel_key = f"{subfolder}/{filename}" if subfolder else filename
|
||||
prompt_id = str(uuid.uuid5(uuid.NAMESPACE_URL, f"leafflow:{rel_key}"))
|
||||
prompt_id = str(uuid.uuid5(uuid.NAMESPACE_URL, f"saturnnodes:{rel_key}"))
|
||||
|
||||
prompt_dict, workflow_dict, positive_prompt = self.extract_image_metadata(filepath)
|
||||
output_node_id = "9"
|
||||
output_node_id = None
|
||||
if prompt_dict:
|
||||
for nid, nval in prompt_dict.items():
|
||||
if isinstance(nval, dict) and "SaveImage" in nval.get("class_type", ""):
|
||||
output_node_id = str(nid)
|
||||
break
|
||||
if not output_node_id:
|
||||
output_node_id = str(next(iter(prompt_dict.keys())))
|
||||
if not output_node_id:
|
||||
output_node_id = "0"
|
||||
|
||||
extra_pnginfo = {}
|
||||
if workflow_dict:
|
||||
@@ -162,7 +166,7 @@ class AssetsRestoreManager:
|
||||
{
|
||||
"create_time": mtime_ms,
|
||||
"extra_pnginfo": extra_pnginfo,
|
||||
"client_id": "leafflow_restored"
|
||||
"client_id": "saturnnodes_restored"
|
||||
},
|
||||
[output_node_id]
|
||||
],
|
||||
@@ -229,9 +233,9 @@ class AssetsRestoreManager:
|
||||
"total_scanned_images": len(images),
|
||||
"restored_count": restored_count
|
||||
}
|
||||
self.restored_count = restored_count
|
||||
|
||||
if restored_count > 0:
|
||||
print(f"[LeafFlow] Restored {restored_count} recent image(s) into Assets / History pane.")
|
||||
if server:
|
||||
try:
|
||||
server.queue_updated()
|
||||
|
||||
+25
-14
@@ -5,16 +5,24 @@ import numpy as np
|
||||
import re
|
||||
from PIL import Image, ImageOps
|
||||
import comfy.model_management
|
||||
from .utils import sanitize_folder_path
|
||||
from .utils import sanitize_folder_path, is_safe_path, get_allowed_folder_watch_directories
|
||||
|
||||
def get_current_prompt_id():
|
||||
try:
|
||||
from server import PromptServer
|
||||
server = PromptServer.instance
|
||||
if hasattr(server, "prompt_queue") and server.prompt_queue.currently_running:
|
||||
for item in server.prompt_queue.currently_running.values():
|
||||
if isinstance(item, (tuple, list)) and len(item) > 1:
|
||||
return str(item[1])
|
||||
server = getattr(PromptServer, "instance", None)
|
||||
if server and hasattr(server, "prompt_queue"):
|
||||
queue = server.prompt_queue
|
||||
if hasattr(queue, "currently_running") and queue.currently_running:
|
||||
mutex = getattr(queue, "mutex", None)
|
||||
if mutex:
|
||||
with mutex:
|
||||
items = list(queue.currently_running.values())
|
||||
else:
|
||||
items = list(queue.currently_running.values())
|
||||
for item in items:
|
||||
if isinstance(item, (tuple, list)) and len(item) > 1:
|
||||
return str(item[1])
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
@@ -51,7 +59,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
|
||||
@@ -70,10 +78,13 @@ class LoadImageFromFolder:
|
||||
img_tensor = torch.from_numpy(image_array)[None,]
|
||||
|
||||
if delete_image:
|
||||
if not is_safe_path(filepath, allowed_bases=get_allowed_folder_watch_directories()):
|
||||
print(f"[SaturnNodes Security] Refusing to delete '{filepath}': file is outside ComfyUI input/output directories.")
|
||||
return img_tensor
|
||||
try:
|
||||
os.remove(filepath)
|
||||
except Exception as e:
|
||||
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 +93,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 = []
|
||||
@@ -148,7 +159,7 @@ class LoadImageFromFolder:
|
||||
return files[idx]
|
||||
|
||||
def watch(self, folder, wait_if_folder_is_empty, rescan_interval, sort_by, regex_filter, delete_image=False, unique_id="default", **kwargs):
|
||||
folder = sanitize_folder_path(folder, default_dir="input/watch")
|
||||
folder = sanitize_folder_path(folder, default_dir="watch")
|
||||
|
||||
if wait_if_folder_is_empty:
|
||||
while True:
|
||||
@@ -164,9 +175,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 +191,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)
|
||||
|
||||
+35
-20
@@ -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,41 +135,56 @@ $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.")
|
||||
stop_dispatched = False
|
||||
try:
|
||||
# Attempt to cancel current execution queue
|
||||
PromptServer.instance.prompt_queue.cancel_current_execution()
|
||||
except Exception:
|
||||
pass
|
||||
if hasattr(PromptServer, "instance") and hasattr(PromptServer.instance, "prompt_queue"):
|
||||
PromptServer.instance.prompt_queue.cancel_current_execution()
|
||||
stop_dispatched = True
|
||||
except Exception as e:
|
||||
print(f"[SaturnNodes] Notice: cancel_current_execution: {e}")
|
||||
try:
|
||||
import nodes
|
||||
if hasattr(nodes, "interrupt_processing"):
|
||||
nodes.interrupt_processing()
|
||||
stop_dispatched = True
|
||||
except Exception as e:
|
||||
print(f"[SaturnNodes] Notice: nodes.interrupt_processing: {e}")
|
||||
try:
|
||||
comfy.model_management.interrupt_current_processing()
|
||||
except Exception:
|
||||
pass
|
||||
stop_dispatched = True
|
||||
except Exception as e:
|
||||
print(f"[SaturnNodes] Notice: interrupt_current_processing: {e}")
|
||||
|
||||
if not stop_dispatched:
|
||||
print(f"[SaturnNodes] Notice: Core cancellation hooks bypassed; halting via InterruptProcessingException.")
|
||||
|
||||
# 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 RuntimeError("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)
|
||||
|
||||
+27
-22
@@ -11,15 +11,15 @@ from server import PromptServer
|
||||
import folder_paths
|
||||
from .utils import (
|
||||
sanitize_folder_path,
|
||||
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 +148,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():
|
||||
@@ -162,14 +162,20 @@ def load_prompts_cache():
|
||||
|
||||
def save_prompts_cache(cache):
|
||||
try:
|
||||
with open(CACHE_FILE, "w", encoding="utf-8") as f:
|
||||
temp_file = CACHE_FILE + ".tmp"
|
||||
with open(temp_file, "w", encoding="utf-8") as f:
|
||||
json.dump(cache, f, indent=4, ensure_ascii=False)
|
||||
os.replace(temp_file, CACHE_FILE)
|
||||
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()
|
||||
if hasattr(PromptServer, "instance") and PromptServer.instance is not None:
|
||||
routes = PromptServer.instance.routes
|
||||
else:
|
||||
routes = web.RouteTableDef()
|
||||
print("[SaturnNodes] Notice: PromptServer instance not available during image_loader import; using standalone route table.")
|
||||
|
||||
@routes.get("/image_loader/get_images")
|
||||
@routes.get("/saturnnodes/image_loader/get_images")
|
||||
async def get_images_endpoint(request):
|
||||
if not is_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local access only"}, status=403)
|
||||
@@ -222,6 +228,7 @@ async def get_images_endpoint(request):
|
||||
cached_entry = cache.get(full_path)
|
||||
if cached_entry and cached_entry.get("mtime") == mtime:
|
||||
prompt_text = cached_entry.get("prompt", "")
|
||||
cache[full_path] = cache.pop(full_path)
|
||||
else:
|
||||
prompt_text, _, _ = extract_metadata_from_image(full_path)
|
||||
cache[full_path] = {
|
||||
@@ -250,7 +257,7 @@ async def get_images_endpoint(request):
|
||||
"prompts": prompts
|
||||
})
|
||||
|
||||
@routes.get("/image_loader/get_thumbnail")
|
||||
@routes.get("/saturnnodes/image_loader/get_thumbnail")
|
||||
async def get_thumbnail_endpoint(request):
|
||||
if not is_local_request(request):
|
||||
return web.Response(status=403)
|
||||
@@ -281,7 +288,7 @@ async def get_thumbnail_endpoint(request):
|
||||
except Exception:
|
||||
return web.FileResponse(full_path)
|
||||
|
||||
@routes.get("/image_loader/get_full_image")
|
||||
@routes.get("/saturnnodes/image_loader/get_full_image")
|
||||
async def get_full_image_endpoint(request):
|
||||
if not is_local_request(request):
|
||||
return web.Response(status=403)
|
||||
@@ -342,18 +349,16 @@ class VisualImageLoader:
|
||||
empty_image = torch.zeros((1, 512, 512, 3), dtype=torch.float32)
|
||||
return (empty_image, "", 0, 0)
|
||||
|
||||
i = Image.open(full_path)
|
||||
i = ImageOps.exif_transpose(i)
|
||||
|
||||
positive_prompt, width, height = extract_metadata_from_image(full_path)
|
||||
if width == 0 or height == 0:
|
||||
width, height = i.size
|
||||
with Image.open(full_path) as i:
|
||||
i = ImageOps.exif_transpose(i)
|
||||
|
||||
positive_prompt, width, height = extract_metadata_from_image(full_path)
|
||||
if width == 0 or height == 0:
|
||||
width, height = i.size
|
||||
|
||||
image = i.convert("RGB")
|
||||
image = np.array(image).astype(np.float32) / 255.0
|
||||
image = torch.from_numpy(image)[None,]
|
||||
image = i.convert("RGB")
|
||||
image = np.array(image).astype(np.float32) / 255.0
|
||||
image = torch.from_numpy(image)[None,]
|
||||
|
||||
return (image, positive_prompt, width, height)
|
||||
|
||||
# Alias for backward compatibility
|
||||
ImageLoaderVisualPrettyV2 = VisualImageLoader
|
||||
|
||||
@@ -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,)
|
||||
|
||||
+38
-26
@@ -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)
|
||||
@@ -385,6 +390,8 @@ class RunLocalFileNode:
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
creationflags=creationflags
|
||||
)
|
||||
|
||||
@@ -406,32 +413,37 @@ class RunLocalFileNode:
|
||||
pass
|
||||
|
||||
effective_timeout = timeout if timeout > 0 else None
|
||||
stdout_data = ""
|
||||
stderr_data = ""
|
||||
|
||||
# Poll for completion while honoring ComfyUI cancellation
|
||||
while proc.poll() is None:
|
||||
# Continuously drain pipes to prevent OS buffer deadlocks while honoring cancellation
|
||||
while True:
|
||||
try:
|
||||
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)
|
||||
try:
|
||||
stdout_data, stderr_data = proc.communicate(timeout=0.1)
|
||||
break
|
||||
except subprocess.TimeoutExpired:
|
||||
continue
|
||||
|
||||
stdout_data, stderr_data = proc.communicate()
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
@@ -161,5 +161,3 @@ class TextLoraFinder:
|
||||
|
||||
return (current_model, current_clip, ", ".join(formatted_names))
|
||||
|
||||
# Alias for backward compatibility
|
||||
LoraTextFinder = TextLoraFinder
|
||||
|
||||
+153
-23
@@ -4,6 +4,7 @@ import fnmatch
|
||||
import json
|
||||
import time
|
||||
import hashlib
|
||||
import asyncio
|
||||
import folder_paths
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
@@ -18,12 +19,13 @@ from .utils import (
|
||||
parse_pretty_name_with_version,
|
||||
format_lora_output_name,
|
||||
sanitize_folder_path,
|
||||
get_leafflow_user_dir,
|
||||
get_saturnnodes_user_dir,
|
||||
is_local_request,
|
||||
is_authenticated_local_request,
|
||||
is_safe_path
|
||||
)
|
||||
|
||||
LORA_CATEGORY = "🍃 LeafFlow/Loaders"
|
||||
LORA_CATEGORY = "🪐 SaturnNodes/Loaders"
|
||||
LORA_OUTPUT_FORMAT_CHOICES = [
|
||||
"Parsed Name",
|
||||
"Filename",
|
||||
@@ -34,7 +36,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")
|
||||
|
||||
@@ -59,10 +61,12 @@ def save_lora_cycle_state(state):
|
||||
excess_keys = list(state.keys())[:-100]
|
||||
for k in excess_keys:
|
||||
del state[k]
|
||||
with open(LORA_STATE_FILE, "w", encoding="utf-8") as f:
|
||||
temp_file = LORA_STATE_FILE + ".tmp"
|
||||
with open(temp_file, "w", encoding="utf-8") as f:
|
||||
json.dump(state, f, indent=4, ensure_ascii=False)
|
||||
os.replace(temp_file, LORA_STATE_FILE)
|
||||
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 +102,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):
|
||||
@@ -111,10 +115,12 @@ def increment_lora_usage(lora_path):
|
||||
usage_data = load_usage_data()
|
||||
pretty_key = parse_pretty_name(lora_path)
|
||||
usage_data[pretty_key] = usage_data.get(pretty_key, 0) + 1
|
||||
with open(usage_file, "w", encoding="utf-8") as f:
|
||||
temp_file = usage_file + ".tmp"
|
||||
with open(temp_file, "w", encoding="utf-8") as f:
|
||||
json.dump(usage_data, f, indent=4, ensure_ascii=False)
|
||||
os.replace(temp_file, usage_file)
|
||||
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 +238,13 @@ 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 urllib.error.HTTPError as http_err:
|
||||
if http_err.code in (401, 403):
|
||||
print(f"[SaturnNodes] Notice: Civitai API authentication failed (HTTP {http_err.code}). Check your CIVITAI_API_KEY in settings.")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -277,12 +286,15 @@ 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}")
|
||||
except Exception as tmdb_err:
|
||||
print(f"[SaturnNodes] Warning: Could not save preview image to '{dest_path}': {write_err}")
|
||||
except urllib.error.HTTPError as http_err:
|
||||
if http_err.code in (401, 403):
|
||||
print(f"[SaturnNodes] Notice: TMDB API authentication failed (HTTP {http_err.code}). Check your TMDB_API_KEY in settings.")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not success:
|
||||
@@ -291,12 +303,14 @@ def scrape_missing_images_sync():
|
||||
|
||||
if modified:
|
||||
try:
|
||||
with open(failed_scrapes_file, "w", encoding="utf-8") as f:
|
||||
temp_file = failed_scrapes_file + ".tmp"
|
||||
with open(temp_file, "w", encoding="utf-8") as f:
|
||||
json.dump(failed_scrapes, f, indent=4, ensure_ascii=False)
|
||||
os.replace(temp_file, failed_scrapes_file)
|
||||
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
|
||||
@@ -351,7 +365,7 @@ def get_filtered_loras_mapping(folder_filter, pretty=False):
|
||||
server = PromptServer.instance
|
||||
routes = server.routes
|
||||
|
||||
@routes.get("/folder_lora_loader/get_loras")
|
||||
@routes.get("/saturnnodes/folder_lora_loader/get_loras")
|
||||
async def get_loras_endpoint(request):
|
||||
if not is_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local access only"}, status=403)
|
||||
@@ -389,7 +403,46 @@ async def get_loras_endpoint(request):
|
||||
"mtime": mtime_by_path
|
||||
})
|
||||
|
||||
@routes.get("/folder_lora_loader/get_preview")
|
||||
THUMBNAILS_DIR = os.path.join(USER_DIR, "thumbnails")
|
||||
|
||||
def get_or_create_thumbnail(img_path, max_size=256):
|
||||
"""
|
||||
Returns path to a high-speed cached WebP thumbnail of img_path with max dimension max_size.
|
||||
Cached on disk and invalidated if source file mtime changes.
|
||||
"""
|
||||
try:
|
||||
from PIL import Image
|
||||
mtime = os.path.getmtime(img_path)
|
||||
hash_src = f"{img_path}_{mtime}_{max_size}".encode("utf-8")
|
||||
cache_key = hashlib.md5(hash_src).hexdigest()
|
||||
os.makedirs(THUMBNAILS_DIR, exist_ok=True)
|
||||
thumb_path = os.path.join(THUMBNAILS_DIR, f"thumb_{cache_key}.webp")
|
||||
|
||||
if os.path.exists(thumb_path) and os.path.getsize(thumb_path) > 0:
|
||||
return thumb_path
|
||||
|
||||
with Image.open(img_path) as img:
|
||||
w, h = img.size
|
||||
if w <= max_size and h <= max_size:
|
||||
return img_path
|
||||
|
||||
if img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info):
|
||||
img = img.convert("RGBA")
|
||||
else:
|
||||
img = img.convert("RGB")
|
||||
|
||||
# BICUBIC produces crisp, smooth, high-quality thumbnails from 4K/6MP sources
|
||||
# while executing significantly faster than LANCZOS to avoid CPU contention.
|
||||
img.thumbnail((max_size, max_size), Image.Resampling.BICUBIC)
|
||||
temp_path = thumb_path + ".tmp"
|
||||
# method=1 gives high quality WebP at ~10x the speed of method=4, eliminating CPU spikes
|
||||
img.save(temp_path, format="WEBP", quality=85, method=1)
|
||||
os.replace(temp_path, thumb_path)
|
||||
return thumb_path
|
||||
except Exception:
|
||||
return img_path
|
||||
|
||||
@routes.get("/saturnnodes/folder_lora_loader/get_preview")
|
||||
async def get_preview_endpoint(request):
|
||||
if not is_local_request(request):
|
||||
return web.Response(status=403)
|
||||
@@ -397,18 +450,97 @@ async def get_preview_endpoint(request):
|
||||
lora_name = request.query.get("lora", "")
|
||||
folder = request.query.get("folder", "")
|
||||
pretty = request.query.get("pretty", "true").lower() == "true"
|
||||
|
||||
full = request.query.get("full", "false").lower() == "true"
|
||||
|
||||
if not system_path and lora_name:
|
||||
mapping = get_filtered_loras_mapping(folder, pretty=pretty)
|
||||
system_path = mapping.get(lora_name, lora_name)
|
||||
|
||||
|
||||
if system_path and system_path != "[ NONE ]":
|
||||
img_path = find_preview_image(system_path)
|
||||
if img_path and os.path.exists(img_path) and os.path.isfile(img_path):
|
||||
return web.FileResponse(img_path)
|
||||
|
||||
if full:
|
||||
return web.FileResponse(img_path)
|
||||
try:
|
||||
thumb_size = int(request.query.get("size", 256))
|
||||
thumb_size = max(64, min(1024, thumb_size))
|
||||
except Exception:
|
||||
thumb_size = 256
|
||||
serve_path = await asyncio.to_thread(get_or_create_thumbnail, img_path, max_size=thumb_size)
|
||||
return web.FileResponse(serve_path)
|
||||
|
||||
return web.Response(status=404)
|
||||
|
||||
@routes.post("/saturnnodes/folder_lora_loader/reset_cycle")
|
||||
async def reset_lora_cycle_endpoint(request):
|
||||
"""Clears Sequential/Random cycle counters + prompt->LoRA assignments for one node (or all)."""
|
||||
if not is_authenticated_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
data = {}
|
||||
node_id = str(data.get("node_id", "")).strip()
|
||||
try:
|
||||
state = load_lora_cycle_state()
|
||||
removed = 0
|
||||
if node_id and node_id != "all":
|
||||
prefixes = (f"seq_{node_id}_", f"rand_{node_id}_", f"noreplace_{node_id}_")
|
||||
for k in list(state.keys()):
|
||||
if k.startswith(prefixes):
|
||||
del state[k]
|
||||
removed += 1
|
||||
else:
|
||||
removed = len(state)
|
||||
state = {}
|
||||
save_lora_cycle_state(state)
|
||||
return web.json_response({"status": "ok", "removed": removed})
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
@routes.post("/saturnnodes/folder_lora_loader/reset_usage")
|
||||
async def reset_lora_usage_endpoint(request):
|
||||
"""Empties lora_usage.json (usage badges + usage-based sortings)."""
|
||||
if not is_authenticated_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
|
||||
try:
|
||||
ensure_user_dir()
|
||||
usage_file = os.path.join(USER_DIR, "lora_usage.json")
|
||||
tmp_file = usage_file + ".tmp"
|
||||
with open(tmp_file, "w", encoding="utf-8") as f:
|
||||
json.dump({}, f, indent=4)
|
||||
os.replace(tmp_file, usage_file)
|
||||
return web.json_response({"status": "ok"})
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
@routes.post("/saturnnodes/folder_lora_loader/clear_cache")
|
||||
async def clear_lora_cache_endpoint(request):
|
||||
"""Deletes cached WebP thumbnails (regenerated on demand)."""
|
||||
if not is_authenticated_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
|
||||
removed = 0
|
||||
try:
|
||||
cache_dir = os.path.realpath(THUMBNAILS_DIR)
|
||||
canonical_user = os.path.realpath(USER_DIR)
|
||||
try:
|
||||
if os.path.commonpath([canonical_user, cache_dir]) != canonical_user:
|
||||
return web.json_response({"status": "error", "message": "Security: cache path escapes user directory"}, status=403)
|
||||
except Exception:
|
||||
return web.json_response({"status": "error", "message": "Security path validation error"}, status=403)
|
||||
if os.path.isdir(cache_dir):
|
||||
for name in os.listdir(cache_dir):
|
||||
entry = os.path.join(cache_dir, name)
|
||||
if os.path.isfile(entry):
|
||||
try:
|
||||
os.remove(entry)
|
||||
removed += 1
|
||||
except Exception:
|
||||
pass
|
||||
return web.json_response({"status": "ok", "removed": removed})
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
class FolderLoraLoader:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
@@ -746,5 +878,3 @@ class VisualLoraLoader(FolderLoraLoaderPretty):
|
||||
pretty_name_str = ", ".join(loaded_names) if loaded_names else ""
|
||||
return (current_model, current_clip, pretty_name_str)
|
||||
|
||||
# Alias for backward compatibility
|
||||
FolderLoraLoaderVisualPrettyV2 = VisualLoraLoader
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
PREVIEW_CATEGORY = "🍃 LeafFlow/Previews"
|
||||
PREVIEW_CATEGORY = "🪐 SaturnNodes/Previews"
|
||||
|
||||
class PreviewLatentLiveNode:
|
||||
@classmethod
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from .prompt_iterator import parse_prompt_blocks
|
||||
from .prompt_iterator import parse_prompt_blocks
|
||||
|
||||
class PromptCounter:
|
||||
"""
|
||||
LeafFlow Prompt Counter:
|
||||
SaturnNodes Prompt Counter:
|
||||
Multiline text prompt input node with real-time prompt counting according to
|
||||
selectable separator ("Newline", ">1 Empty Line", ">2 Empty Lines", "Custom Regex").
|
||||
Outputs STRING (unmodified text) and count (integer count of prompts).
|
||||
@@ -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):
|
||||
|
||||
+59
-38
@@ -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, 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):
|
||||
@@ -57,22 +57,21 @@ def save_state(state):
|
||||
tmp_file = STATE_FILE + ".tmp"
|
||||
with open(tmp_file, "w", encoding="utf-8") as f:
|
||||
json.dump(state, f, indent=4, ensure_ascii=False)
|
||||
if os.path.exists(STATE_FILE):
|
||||
os.replace(tmp_file, STATE_FILE)
|
||||
else:
|
||||
os.rename(tmp_file, STATE_FILE)
|
||||
os.replace(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:
|
||||
tmp_file = STATE_FILE + ".tmp"
|
||||
with open(tmp_file, "w", encoding="utf-8") as f:
|
||||
json.dump({}, f, indent=4)
|
||||
print("[LeafFlow] 🍃 Prompt iterator state cleared.")
|
||||
os.replace(tmp_file, STATE_FILE)
|
||||
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 +82,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 +100,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,11 +148,11 @@ 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:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"[SaturnNodes] Notice: Skipped registering prompt iterator endpoints: {e}")
|
||||
|
||||
def parse_prompt_blocks(text_str, separator, custom_regex=""):
|
||||
if not text_str or not text_str.strip():
|
||||
@@ -174,7 +173,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:
|
||||
@@ -188,11 +187,19 @@ def parse_prompt_blocks(text_str, separator, custom_regex=""):
|
||||
|
||||
def get_current_prompt_id():
|
||||
try:
|
||||
server = PromptServer.instance
|
||||
if hasattr(server, "prompt_queue") and server.prompt_queue.currently_running:
|
||||
for item in server.prompt_queue.currently_running.values():
|
||||
if isinstance(item, (tuple, list)) and len(item) > 1:
|
||||
return str(item[1])
|
||||
server = getattr(PromptServer, "instance", None)
|
||||
if server and hasattr(server, "prompt_queue"):
|
||||
queue = server.prompt_queue
|
||||
if hasattr(queue, "currently_running") and queue.currently_running:
|
||||
mutex = getattr(queue, "mutex", None)
|
||||
if mutex:
|
||||
with mutex:
|
||||
items = list(queue.currently_running.values())
|
||||
else:
|
||||
items = list(queue.currently_running.values())
|
||||
for item in items:
|
||||
if isinstance(item, (tuple, list)) and len(item) > 1:
|
||||
return str(item[1])
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
@@ -228,7 +235,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 +313,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 +352,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 +360,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 +375,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
|
||||
|
||||
|
||||
+158
-84
@@ -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, 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
|
||||
@@ -69,29 +69,35 @@ class PauseQueueManager:
|
||||
|
||||
def patched_get(*args, **kwargs):
|
||||
while True:
|
||||
# 1. If queue is paused, wait BEFORE popping any item from the queue!
|
||||
# This guarantees that ComfyUI never sets currently_running, never sends
|
||||
# execution_start, and never starts the elapsed timer while waiting.
|
||||
if self.paused:
|
||||
if not self.is_waiting:
|
||||
self.is_waiting = True
|
||||
print("[PauseQueue] Workflow paused after this run: Current workflow finished, queue paused.")
|
||||
print("[SaturnNodes PauseQueue] Workflow paused: Queue is paused, waiting before next workflow.")
|
||||
self.notify_clients()
|
||||
while self.paused:
|
||||
try:
|
||||
import comfy.model_management as mm
|
||||
if hasattr(mm, "processing_interrupted") and mm.processing_interrupted():
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
self.event.wait(0.2)
|
||||
self.is_waiting = False
|
||||
self.notify_clients()
|
||||
|
||||
item = original_get(*args, **kwargs)
|
||||
# 2. Use a short timeout (0.5s) when polling for items so that if the user
|
||||
# enables pause while the queue is idle, we don't remain stuck inside
|
||||
# a blocking original_get() that would prematurely pop an incoming workflow.
|
||||
kwargs_copy = dict(kwargs)
|
||||
if "timeout" not in kwargs_copy and len(args) == 0:
|
||||
kwargs_copy["timeout"] = 0.5
|
||||
|
||||
if self.paused:
|
||||
if not self.is_waiting:
|
||||
self.is_waiting = True
|
||||
print("[PauseQueue] Workflow paused after this run: Current workflow finished, queue paused.")
|
||||
self.notify_clients()
|
||||
while self.paused:
|
||||
self.event.wait(0.2)
|
||||
self.is_waiting = False
|
||||
self.notify_clients()
|
||||
|
||||
return item
|
||||
item = original_get(*args, **kwargs_copy)
|
||||
if item is not None:
|
||||
return item
|
||||
|
||||
queue.get = patched_get
|
||||
|
||||
@@ -99,24 +105,44 @@ class PauseQueueManager:
|
||||
original_send_sync = server.send_sync
|
||||
|
||||
def patched_send_sync(event, data, sid=None):
|
||||
result = original_send_sync(event, data, sid)
|
||||
if event == "executing" and isinstance(data, dict) and data.get("node") is not None:
|
||||
if self.paused and self.mode == "instantly":
|
||||
if not self.is_waiting:
|
||||
self.is_waiting = True
|
||||
print(f"[PauseQueue] Workflow Paused Instant: Paused before executing node '{data.get('node')}'.")
|
||||
print(f"[SaturnNodes PauseQueue] Workflow Paused Instant: Paused before executing node '{data.get('node')}'.")
|
||||
self.notify_clients()
|
||||
while self.paused and self.mode == "instantly":
|
||||
# Check for user cancellation or interrupt while paused
|
||||
try:
|
||||
import comfy.model_management as mm
|
||||
if hasattr(mm, "processing_interrupted") and mm.processing_interrupted():
|
||||
self.paused = False
|
||||
self.is_waiting = False
|
||||
self.event.set()
|
||||
self.notify_clients()
|
||||
if hasattr(mm, "throw_exception_if_processing_interrupted"):
|
||||
mm.throw_exception_if_processing_interrupted()
|
||||
break
|
||||
except Exception as err:
|
||||
if "InterruptProcessingException" in type(err).__name__:
|
||||
raise
|
||||
|
||||
if not self.is_currently_executing():
|
||||
self.is_waiting = False
|
||||
self.notify_clients()
|
||||
break
|
||||
|
||||
self.event.wait(0.2)
|
||||
self.is_waiting = False
|
||||
self.notify_clients()
|
||||
return original_send_sync(event, data, sid)
|
||||
return result
|
||||
|
||||
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.")
|
||||
|
||||
def is_currently_executing(self):
|
||||
try:
|
||||
@@ -136,26 +162,49 @@ class PauseQueueManager:
|
||||
if not self.is_currently_executing():
|
||||
self.is_waiting = True
|
||||
if self.mode == "instantly":
|
||||
print("[PauseQueue] Workflow Paused Instant: Queue is empty/idle, pause active.")
|
||||
print("[SaturnNodes PauseQueue] Workflow Paused Instant: Queue is empty/idle, pause active.")
|
||||
else:
|
||||
print("[PauseQueue] Workflow paused after this run: Queue is empty/idle, pause active.")
|
||||
print("[SaturnNodes PauseQueue] Workflow paused after this run: Queue is empty/idle, pause active.")
|
||||
else:
|
||||
self.is_waiting = False
|
||||
if self.mode == "instantly":
|
||||
print("[PauseQueue] Workflow Paused Instant requested. Waiting for current node to complete...")
|
||||
print("[SaturnNodes PauseQueue] Workflow Paused Instant requested. Waiting for current node to complete...")
|
||||
else:
|
||||
print("[PauseQueue] Workflow paused after this run requested. Will pause when current workflow completes...")
|
||||
print("[SaturnNodes PauseQueue] Workflow paused after this run requested. Will pause when current workflow completes...")
|
||||
self.event.clear()
|
||||
else:
|
||||
self.is_waiting = False
|
||||
self.event.set()
|
||||
print("[PauseQueue] Continue pressed: Resuming execution.")
|
||||
print("[SaturnNodes PauseQueue] Continue pressed: Resuming execution.")
|
||||
|
||||
self.notify_clients()
|
||||
|
||||
try:
|
||||
server = getattr(PromptServer, "instance", None)
|
||||
if server and hasattr(server, "prompt_queue"):
|
||||
pq = server.prompt_queue
|
||||
if hasattr(pq, "not_empty") and hasattr(pq, "mutex"):
|
||||
# Use non-blocking acquire to avoid deadlock if worker thread is locked
|
||||
acquired = pq.mutex.acquire(blocking=False)
|
||||
if acquired:
|
||||
try:
|
||||
pq.not_empty.notify_all()
|
||||
finally:
|
||||
pq.mutex.release()
|
||||
else:
|
||||
def _notify_async():
|
||||
try:
|
||||
with pq.mutex:
|
||||
pq.not_empty.notify_all()
|
||||
except Exception:
|
||||
pass
|
||||
threading.Thread(target=_notify_async, daemon=True).start()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def set_mode(self, mode):
|
||||
self.mode = mode
|
||||
print(f"[PauseQueue] Selected pause mode: '{self.mode}'")
|
||||
print(f"[SaturnNodes PauseQueue] Selected pause mode: '{self.mode}'")
|
||||
self.notify_clients()
|
||||
|
||||
def notify_clients(self):
|
||||
@@ -198,11 +247,10 @@ class PersistentQueueManager:
|
||||
for x in self.persistent_items:
|
||||
if "batch_info" in x and "prompt_id" in x:
|
||||
self.batch_meta[str(x["prompt_id"])] = x["batch_info"]
|
||||
print(f"[PersistentQueue] Loaded {len(self.persistent_items)} saved queue item(s).")
|
||||
else:
|
||||
self.persistent_items = []
|
||||
except Exception as e:
|
||||
print(f"[PersistentQueue] Error loading persistent queue file: {e}")
|
||||
print(f"[SaturnNodes PersistentQueue] Error loading persistent queue file: {e}")
|
||||
self.persistent_items = []
|
||||
else:
|
||||
self.persistent_items = []
|
||||
@@ -230,7 +278,7 @@ class PersistentQueueManager:
|
||||
json.dump(self.persistent_items, f, indent=2, default=str)
|
||||
os.replace(temp_file, PERSISTENT_FILE)
|
||||
except Exception as e:
|
||||
print(f"[PersistentQueue] Error saving persistent queue file: {e}")
|
||||
print(f"[SaturnNodes PersistentQueue] Error saving persistent queue file: {e}")
|
||||
|
||||
def add_item(self, item_tuple):
|
||||
if self.is_restoring or not is_persistent_queue_enabled():
|
||||
@@ -252,9 +300,9 @@ class PersistentQueueManager:
|
||||
self.persistent_items = [x for x in self.persistent_items if str(x.get("prompt_id")) != pid_str]
|
||||
self.persistent_items.append(entry)
|
||||
self.save_to_file()
|
||||
print(f"[PersistentQueue] Persisted prompt {pid_str} to disk.")
|
||||
print(f"[SaturnNodes PersistentQueue] Persisted prompt {pid_str} to disk.")
|
||||
except Exception as e:
|
||||
print(f"[PersistentQueue] Error formatting queue item for persistence: {e}")
|
||||
print(f"[SaturnNodes PersistentQueue] Error formatting queue item for persistence: {e}")
|
||||
|
||||
def remove_item(self, prompt_id):
|
||||
if not prompt_id:
|
||||
@@ -267,7 +315,7 @@ class PersistentQueueManager:
|
||||
changed = len(self.persistent_items) != initial_count
|
||||
if changed and is_persistent_queue_enabled():
|
||||
self.save_to_file()
|
||||
print(f"[PersistentQueue] Removed prompt {pid_str} from disk.")
|
||||
print(f"[SaturnNodes PersistentQueue] Removed prompt {pid_str} from disk.")
|
||||
|
||||
def wipe_all(self):
|
||||
with self.lock:
|
||||
@@ -275,22 +323,22 @@ class PersistentQueueManager:
|
||||
self.batch_meta = {}
|
||||
if is_persistent_queue_enabled():
|
||||
self.save_to_file()
|
||||
print("[PersistentQueue] Cleared all saved queue items.")
|
||||
print("[SaturnNodes PersistentQueue] Cleared all saved queue items.")
|
||||
|
||||
def patch_server(self):
|
||||
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,14 +409,13 @@ class PersistentQueueManager:
|
||||
server.send_sync = patched_send_sync
|
||||
|
||||
self._patched = True
|
||||
print("[LeafFlow] Queue control: Persistent queue manager initialized successfully.")
|
||||
|
||||
def restore_queue(self, active_client_id=None):
|
||||
if self.has_claimed_once:
|
||||
return
|
||||
|
||||
if not is_persistent_queue_enabled():
|
||||
print("[PersistentQueue] Auto-recovery is disabled in settings.")
|
||||
print("[SaturnNodes PersistentQueue] Auto-recovery is disabled in settings.")
|
||||
self.has_claimed_once = True
|
||||
return
|
||||
|
||||
@@ -383,7 +430,7 @@ class PersistentQueueManager:
|
||||
elif restored_state_setting == "Force Running":
|
||||
pause_manager.set_pause(False)
|
||||
|
||||
print(f"[PersistentQueue] Restoring {len(self.persistent_items)} saved queue item(s) to client '{active_client_id}'...")
|
||||
print(f"[SaturnNodes PersistentQueue] Restoring {len(self.persistent_items)} saved queue item(s) to client '{active_client_id}'...")
|
||||
self.is_restoring = True
|
||||
max_number = 0
|
||||
restored_count = 0
|
||||
@@ -404,8 +451,11 @@ class PersistentQueueManager:
|
||||
self.remove_item(pid)
|
||||
continue
|
||||
|
||||
if active_client_id and len(item_list) > 3 and isinstance(item_list[3], dict):
|
||||
item_list[3]["client_id"] = active_client_id
|
||||
if len(item_list) > 3 and isinstance(item_list[3], dict):
|
||||
if active_client_id:
|
||||
item_list[3]["client_id"] = active_client_id
|
||||
# Update create_time to current timestamp so resumed jobs sort as newest in history & assets
|
||||
item_list[3]["create_time"] = int(time.time() * 1000)
|
||||
|
||||
if isinstance(item_list[0], (int, float)):
|
||||
max_number = max(max_number, int(item_list[0]))
|
||||
@@ -413,7 +463,7 @@ class PersistentQueueManager:
|
||||
server.prompt_queue.put(tuple(item_list))
|
||||
restored_count += 1
|
||||
except Exception as e:
|
||||
print(f"[PersistentQueue] Error restoring prompt {entry.get('prompt_id')}: {e}")
|
||||
print(f"[SaturnNodes PersistentQueue] Error restoring prompt {entry.get('prompt_id')}: {e}")
|
||||
finally:
|
||||
self.is_restoring = False
|
||||
self.has_claimed_once = True
|
||||
@@ -465,16 +515,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 SaturnNodes 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 +539,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 +571,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 +580,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 SaturnNodes 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):
|
||||
@@ -542,25 +593,47 @@ class PowerControlManager:
|
||||
cmd.extend(sys.argv)
|
||||
cwd = os.getcwd()
|
||||
env = os.environ.copy()
|
||||
if sys.platform.startswith("win"):
|
||||
creationflags = subprocess.CREATE_NEW_CONSOLE if hasattr(subprocess, "CREATE_NEW_CONSOLE") else 0
|
||||
subprocess.Popen(cmd, cwd=cwd, env=env, creationflags=creationflags)
|
||||
else:
|
||||
subprocess.Popen(cmd, cwd=cwd, env=env, start_new_session=True)
|
||||
time.sleep(0.4)
|
||||
proc = None
|
||||
try:
|
||||
if sys.platform.startswith("win"):
|
||||
creationflags = subprocess.CREATE_NEW_CONSOLE if hasattr(subprocess, "CREATE_NEW_CONSOLE") else 0
|
||||
proc = subprocess.Popen(cmd, cwd=cwd, env=env, creationflags=creationflags)
|
||||
else:
|
||||
proc = subprocess.Popen(cmd, cwd=cwd, env=env, start_new_session=True)
|
||||
except Exception as e:
|
||||
print(f"[SaturnNodes Power] Error spawning restart process: {e}")
|
||||
self._executing = False
|
||||
return
|
||||
|
||||
time.sleep(0.5)
|
||||
if proc is not None and proc.poll() is not None:
|
||||
print(f"[SaturnNodes Power] Child process exited prematurely with code {proc.returncode}. Aborting restart.")
|
||||
self._executing = False
|
||||
return
|
||||
|
||||
try:
|
||||
sys.stdout.flush()
|
||||
sys.stderr.flush()
|
||||
except Exception:
|
||||
pass
|
||||
os._exit(0)
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
return True
|
||||
|
||||
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 SaturnNodes settings.")
|
||||
return False
|
||||
|
||||
def _run():
|
||||
import os, time
|
||||
print("[LeafFlow Power] Shutting down ComfyUI server...")
|
||||
import sys, os, time
|
||||
print("[SaturnNodes Power] Shutting down ComfyUI server...")
|
||||
time.sleep(0.6)
|
||||
try:
|
||||
sys.stdout.flush()
|
||||
sys.stderr.flush()
|
||||
except Exception:
|
||||
pass
|
||||
os._exit(0)
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
return True
|
||||
@@ -596,6 +669,7 @@ def _consume_power_ticket(ticket, expected_action=None):
|
||||
|
||||
pause_manager = PauseQueueManager()
|
||||
persistent_manager = PersistentQueueManager()
|
||||
persistent_queue_manager = persistent_manager
|
||||
tray_manager = TrayIconManager(pause_manager)
|
||||
power_manager = PowerControlManager(pause_manager)
|
||||
|
||||
@@ -612,19 +686,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 +709,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 +733,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 +758,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 +773,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 +792,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 +809,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)
|
||||
@@ -762,7 +836,7 @@ def setup_queue_control_routes(server):
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
return web.json_response({"success": False}, status=400)
|
||||
|
||||
@routes.get("/pause_queue/status")
|
||||
@routes.get("/saturnnodes/pause_queue/status")
|
||||
async def get_status(request):
|
||||
if not is_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local access only"}, status=403)
|
||||
@@ -773,7 +847,7 @@ def setup_queue_control_routes(server):
|
||||
"waiting": pause_manager.is_waiting
|
||||
})
|
||||
|
||||
@routes.post("/pause_queue/toggle")
|
||||
@routes.post("/saturnnodes/pause_queue/toggle")
|
||||
async def toggle_pause(request):
|
||||
if not is_authenticated_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
|
||||
@@ -790,7 +864,7 @@ def setup_queue_control_routes(server):
|
||||
"waiting": pause_manager.is_waiting
|
||||
})
|
||||
|
||||
@routes.post("/pause_queue/mode")
|
||||
@routes.post("/saturnnodes/pause_queue/mode")
|
||||
async def set_mode_route(request):
|
||||
if not is_authenticated_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
|
||||
@@ -806,7 +880,7 @@ def setup_queue_control_routes(server):
|
||||
"waiting": pause_manager.is_waiting
|
||||
})
|
||||
|
||||
@routes.post("/pause_queue/continue")
|
||||
@routes.post("/saturnnodes/pause_queue/continue")
|
||||
async def continue_queue(request):
|
||||
if not is_authenticated_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
|
||||
@@ -817,7 +891,7 @@ def setup_queue_control_routes(server):
|
||||
"waiting": pause_manager.is_waiting
|
||||
})
|
||||
|
||||
@routes.post("/persistent_queue/claim")
|
||||
@routes.post("/saturnnodes/persistent_queue/claim")
|
||||
async def claim_queue(request):
|
||||
if not is_authenticated_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
|
||||
@@ -827,5 +901,5 @@ def setup_queue_control_routes(server):
|
||||
if client_id and not persistent_manager.has_claimed_once:
|
||||
persistent_manager.restore_queue(client_id)
|
||||
except Exception as e:
|
||||
print(f"[PersistentQueue] Error in claim endpoint: {e}")
|
||||
print(f"[SaturnNodes PersistentQueue] Error in claim endpoint: {e}")
|
||||
return web.json_response({"status": "ok"})
|
||||
|
||||
@@ -0,0 +1,476 @@
|
||||
import os
|
||||
import urllib.request
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
import folder_paths
|
||||
except ImportError:
|
||||
folder_paths = None
|
||||
|
||||
# Global ONNX runtime session cache & predicted depth cache
|
||||
_DEPTH_ORT_SESSION = None
|
||||
_PREDICTED_DEPTH_CACHE = {}
|
||||
|
||||
def get_depth_model_path():
|
||||
"""Returns local path to Depth Anything V2 Small ONNX model."""
|
||||
if folder_paths and hasattr(folder_paths, "models_dir") and folder_paths.models_dir:
|
||||
base_dir = os.path.join(folder_paths.models_dir, "depth")
|
||||
else:
|
||||
base_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "models")
|
||||
os.makedirs(base_dir, exist_ok=True)
|
||||
return os.path.join(base_dir, "depth-anything-v2-small-518.onnx")
|
||||
|
||||
def ensure_depth_model():
|
||||
"""Downloads Depth Anything V2 Small ONNX model if not already present."""
|
||||
target_path = get_depth_model_path()
|
||||
if os.path.exists(target_path) and os.path.getsize(target_path) > 10 * 1024 * 1024:
|
||||
return target_path
|
||||
|
||||
temp_path = target_path + ".tmp"
|
||||
urls = [
|
||||
"https://huggingface.co/onnx-community/depth-anything-v2-small/resolve/main/onnx/model.onnx",
|
||||
"https://nqrlabs.com/Stereonix/models/depth-anything-v2-small-518.onnx"
|
||||
]
|
||||
|
||||
print("[SaturnNodes 3D] Downloading Depth Anything V2 Small model (~94 MB)...")
|
||||
for url in urls:
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "ComfyUI-SaturnNodes"})
|
||||
with urllib.request.urlopen(req, timeout=60) as resp, open(temp_path, "wb") as f:
|
||||
total_size = int(resp.headers.get("Content-Length", 0))
|
||||
downloaded = 0
|
||||
last_pct = 0
|
||||
while True:
|
||||
chunk = resp.read(1024 * 1024 * 2)
|
||||
if not chunk:
|
||||
break
|
||||
f.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
if total_size > 0:
|
||||
pct = int((downloaded / total_size) * 100)
|
||||
if pct >= last_pct + 25 or pct == 100:
|
||||
print(f"[SaturnNodes 3D] Model download: {pct}% ({downloaded // (1024*1024)}MB / {total_size // (1024*1024)}MB)")
|
||||
last_pct = pct
|
||||
|
||||
if os.path.exists(temp_path) and os.path.getsize(temp_path) > 10 * 1024 * 1024:
|
||||
os.replace(temp_path, target_path)
|
||||
print(f"[SaturnNodes 3D] Successfully downloaded Depth Anything V2 to {target_path}")
|
||||
return target_path
|
||||
except Exception as e:
|
||||
print(f"[SaturnNodes 3D] Download error from {url}: {e}")
|
||||
if os.path.exists(temp_path):
|
||||
try:
|
||||
os.remove(temp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
raise RuntimeError("Failed to download Depth Anything V2 Small ONNX model.")
|
||||
|
||||
def get_ort_session():
|
||||
"""Retrieves or loads the ONNX runtime session for depth estimation."""
|
||||
global _DEPTH_ORT_SESSION
|
||||
if _DEPTH_ORT_SESSION is not None:
|
||||
return _DEPTH_ORT_SESSION
|
||||
|
||||
try:
|
||||
import onnxruntime as ort
|
||||
except ImportError:
|
||||
raise RuntimeError("onnxruntime is required for built-in depth estimation. Install via: pip install onnxruntime")
|
||||
|
||||
model_path = ensure_depth_model()
|
||||
available_providers = ort.get_available_providers()
|
||||
providers = ["CUDAExecutionProvider", "CPUExecutionProvider"] if "CUDAExecutionProvider" in available_providers else ["CPUExecutionProvider"]
|
||||
|
||||
_DEPTH_ORT_SESSION = ort.InferenceSession(model_path, providers=providers)
|
||||
return _DEPTH_ORT_SESSION
|
||||
|
||||
def predict_depth_map(image_np):
|
||||
"""
|
||||
Runs Depth Anything V2 Small on a single [H, W, 3] float32 image (0.0 to 1.0).
|
||||
Returns [H, W] normalized float32 depth map (0.0=far, 1.0=near).
|
||||
If onnxruntime or the model is unavailable, gracefully falls back to a pseudo-depth map.
|
||||
"""
|
||||
H, W, _ = image_np.shape
|
||||
try:
|
||||
session = get_ort_session()
|
||||
if session is None:
|
||||
raise RuntimeError("ONNX runtime session could not be initialized.")
|
||||
|
||||
target_size = 518
|
||||
scale = min(target_size / W, target_size / H)
|
||||
scaled_w = max(1, int(round(W * scale)))
|
||||
scaled_h = max(1, int(round(H * scale)))
|
||||
offset_x = (target_size - scaled_w) // 2
|
||||
offset_y = (target_size - scaled_h) // 2
|
||||
|
||||
# Letterbox to 518x518 with black padding using PIL
|
||||
from PIL import Image
|
||||
uint_img = (image_np * 255.0).clip(0, 255).astype(np.uint8)
|
||||
scaled_pil = Image.fromarray(uint_img).resize((scaled_w, scaled_h), Image.Resampling.BILINEAR)
|
||||
scaled_img = np.array(scaled_pil, dtype=np.uint8)
|
||||
|
||||
canvas = np.zeros((target_size, target_size, 3), dtype=np.uint8)
|
||||
canvas[offset_y:offset_y + scaled_h, offset_x:offset_x + scaled_w] = scaled_img
|
||||
|
||||
# Normalize with ImageNet constants
|
||||
mean = np.array([0.485, 0.456, 0.406], dtype=np.float32).reshape(1, 1, 3)
|
||||
std = np.array([0.229, 0.224, 0.225], dtype=np.float32).reshape(1, 1, 3)
|
||||
norm = ((canvas.astype(np.float32) / 255.0) - mean) / std
|
||||
input_tensor = np.transpose(norm, (2, 0, 1))[np.newaxis, ...].astype(np.float32)
|
||||
|
||||
# Run inference
|
||||
input_name = session.get_inputs()[0].name
|
||||
raw_depth = session.run(None, {input_name: input_tensor})[0][0] # [518, 518]
|
||||
|
||||
# Crop out the letterbox padding and resize back to original (W, H)
|
||||
valid_depth = raw_depth[offset_y:offset_y + scaled_h, offset_x:offset_x + scaled_w]
|
||||
depth_pil = Image.fromarray(valid_depth.astype(np.float32), mode="F").resize((W, H), Image.Resampling.BILINEAR)
|
||||
depth_full = np.array(depth_pil, dtype=np.float32)
|
||||
|
||||
# Normalize with p2 and p98 histogram percentiles
|
||||
p2 = np.percentile(depth_full, 2)
|
||||
p98 = np.percentile(depth_full, 98)
|
||||
denom = max(float(p98 - p2), 1e-6)
|
||||
depth_norm = np.clip((depth_full - p2) / denom, 0.0, 1.0).astype(np.float32)
|
||||
return depth_norm
|
||||
except Exception as e:
|
||||
print(f"[SaturnNodes 3D] Notice: Automatic depth model unavailable ({e}). Using pseudo-depth gradient fallback.")
|
||||
y_gradient = np.linspace(0.2, 1.0, H, dtype=np.float32).reshape(H, 1)
|
||||
luminance = np.dot(image_np[..., :3], [0.299, 0.587, 0.114])
|
||||
fallback = 0.7 * y_gradient + 0.3 * luminance
|
||||
denom = max(float(fallback.max() - fallback.min()), 1e-6)
|
||||
return ((fallback - fallback.min()) / denom).astype(np.float32)
|
||||
|
||||
def draw_alignment_dot(target_tensor, cx, cy, radius=5):
|
||||
"""Draws a high-contrast white dot with black outline at (cx, cy) on [H, W, 3] tensor."""
|
||||
H, W, _ = target_tensor.shape
|
||||
y_min = max(0, cy - radius - 2)
|
||||
y_max = min(H, cy + radius + 3)
|
||||
x_min = max(0, cx - radius - 2)
|
||||
x_max = min(W, cx + radius + 3)
|
||||
|
||||
if y_max <= y_min or x_max <= x_min:
|
||||
return
|
||||
|
||||
yy, xx = torch.meshgrid(
|
||||
torch.arange(y_min, y_max, device=target_tensor.device),
|
||||
torch.arange(x_min, x_max, device=target_tensor.device),
|
||||
indexing="ij"
|
||||
)
|
||||
dist_sq = (xx - cx) ** 2 + (yy - cy) ** 2
|
||||
inner = dist_sq <= (radius ** 2)
|
||||
outer = (dist_sq <= ((radius + 1.5) ** 2)) & (~inner)
|
||||
|
||||
target_tensor[yy[outer], xx[outer]] = 0.0
|
||||
target_tensor[yy[inner], xx[inner]] = 1.0
|
||||
|
||||
|
||||
class SaturnStereo3D:
|
||||
"""
|
||||
🪐 Stereoscopic 3D / Cross-Eye Generator
|
||||
Generates side-by-side 3D stereograms, parallel views, and red-cyan anaglyphs
|
||||
from 2D images using depth-based horizontal disparity mapping.
|
||||
Includes built-in Depth Anything V2 Small auto-inference, zoom & pan cropping,
|
||||
spacing/border padding, and alignment guide dots.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"mode": ([
|
||||
"Cross-Eye",
|
||||
"Parallel (Wall-Eyed)",
|
||||
"Red-Cyan Anaglyph",
|
||||
"Side-by-Side (Parallel)",
|
||||
"Top-Bottom (Over-Under)",
|
||||
"Left Eye Only",
|
||||
"Right Eye Only"
|
||||
], {"default": "Cross-Eye"}),
|
||||
"depth_intensity": ("FLOAT", {
|
||||
"default": 25.0, "min": 0.0, "max": 100.0, "step": 1.0,
|
||||
"display": "slider",
|
||||
"tooltip": "Controls how much the left and right perspectives shift apart."
|
||||
}),
|
||||
"depth_gamma": ("FLOAT", {
|
||||
"default": 0.50, "min": 0.1, "max": 3.0, "step": 0.05,
|
||||
"display": "slider",
|
||||
"tooltip": "Adjust depth curve for realistic 3D falloff (Stereonix default is 0.50)."
|
||||
}),
|
||||
"invert_depth": ("BOOLEAN", {
|
||||
"default": False,
|
||||
"tooltip": "Flip which parts appear closer or further away."
|
||||
}),
|
||||
"convergence_plane": ("FLOAT", {
|
||||
"default": 0.50, "min": 0.0, "max": 1.0, "step": 0.02,
|
||||
"display": "slider",
|
||||
"tooltip": "Depth level where parallax is zero (screen surface). Above pops out, below sinks in."
|
||||
}),
|
||||
"image_zoom": ("FLOAT", {
|
||||
"default": 100.0, "min": 10.0, "max": 300.0, "step": 1.0,
|
||||
"display": "slider",
|
||||
"tooltip": "Zoom into areas of interest before generating 3D disparity."
|
||||
}),
|
||||
"pan_horizontal": ("FLOAT", {
|
||||
"default": 0.0, "min": -100.0, "max": 100.0, "step": 1.0,
|
||||
"display": "slider",
|
||||
"tooltip": "Horizontal pan offset (-100 to 100)."
|
||||
}),
|
||||
"pan_vertical": ("FLOAT", {
|
||||
"default": 0.0, "min": -100.0, "max": 100.0, "step": 1.0,
|
||||
"display": "slider",
|
||||
"tooltip": "Vertical pan offset (-100 to 100)."
|
||||
}),
|
||||
"gap_spacing": ("INT", {
|
||||
"default": 20, "min": 0, "max": 1000, "step": 2,
|
||||
"display": "number",
|
||||
"tooltip": "Empty black space between the left and right images."
|
||||
}),
|
||||
"outer_border": ("INT", {
|
||||
"default": 0, "min": 0, "max": 1000, "step": 2,
|
||||
"display": "number",
|
||||
"tooltip": "Black border padding around the canvas (allows shrinking images for easier cross-eye viewing)."
|
||||
}),
|
||||
"alignment_dots": ("BOOLEAN", {
|
||||
"default": True,
|
||||
"tooltip": "Place focus guide dots above each eye view to easily lock into 3D cross-eye view."
|
||||
}),
|
||||
"fill_method": ([
|
||||
"Smooth (Bilinear Grid)",
|
||||
"Nearest Neighbor (Stereonix)"
|
||||
], {
|
||||
"default": "Smooth (Bilinear Grid)",
|
||||
"tooltip": "Hole-filling interpolation for occlusion gaps."
|
||||
}),
|
||||
"preview_zoom": ([
|
||||
"Fit",
|
||||
"25%",
|
||||
"33%",
|
||||
"50%",
|
||||
"75%",
|
||||
"100%"
|
||||
], {
|
||||
"default": "50%",
|
||||
"tooltip": "Scale preview for easier cross-eye viewing on canvas (does not affect full output)."
|
||||
}),
|
||||
},
|
||||
"optional": {
|
||||
"depth_map": ("IMAGE", {
|
||||
"tooltip": "Optional depth map. If disconnected, automatically runs Depth Anything V2."
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "IMAGE", "IMAGE", "IMAGE")
|
||||
RETURN_NAMES = ("stereogram", "left_eye", "right_eye", "depth_map")
|
||||
FUNCTION = "generate"
|
||||
CATEGORY = "🪐 SaturnNodes/3D & Previews"
|
||||
DESCRIPTION = (
|
||||
"Creates side-by-side cross-eye stereograms, parallel 3D views, and red-cyan anaglyphs "
|
||||
"from 2D images. Inspired by Stereonix, it includes automatic Depth Anything V2 Small fallback, "
|
||||
"custom depth gamma/inversion, zoom & pan cropping, adjustable gap/border spacing, and focus alignment dots."
|
||||
)
|
||||
|
||||
@torch.no_grad()
|
||||
def generate(
|
||||
self,
|
||||
image,
|
||||
mode="Cross-Eye",
|
||||
depth_intensity=25.0,
|
||||
depth_gamma=0.50,
|
||||
invert_depth=False,
|
||||
convergence_plane=0.50,
|
||||
image_zoom=100.0,
|
||||
pan_horizontal=0.0,
|
||||
pan_vertical=0.0,
|
||||
gap_spacing=20,
|
||||
outer_border=0,
|
||||
alignment_dots=True,
|
||||
fill_method="Smooth (Bilinear Grid)",
|
||||
preview_zoom="50%",
|
||||
depth_map=None,
|
||||
**kwargs
|
||||
):
|
||||
device = image.device
|
||||
B, H, W, C = image.shape
|
||||
|
||||
# 1. Obtain or generate depth map
|
||||
depth_tensors = []
|
||||
if depth_map is not None:
|
||||
# Handle incoming depth map tensor
|
||||
d_map = depth_map.to(device=device, dtype=torch.float32)
|
||||
if d_map.shape[1:3] != (H, W):
|
||||
d_perm = d_map.permute(0, 3, 1, 2) if d_map.dim() == 4 else d_map.unsqueeze(1)
|
||||
d_resized = F.interpolate(d_perm, size=(H, W), mode="bilinear", align_corners=False)
|
||||
d_map = d_resized.permute(0, 2, 3, 1) if d_map.dim() == 4 else d_resized.squeeze(1)
|
||||
|
||||
if d_map.dim() == 4:
|
||||
d_tensor = d_map[..., 0] # take single channel
|
||||
else:
|
||||
d_tensor = d_map
|
||||
|
||||
# Normalize 0 to 1
|
||||
for b in range(B):
|
||||
b_depth = d_tensor[b if b < d_tensor.shape[0] else 0]
|
||||
min_v = b_depth.min()
|
||||
max_v = b_depth.max()
|
||||
denom = max(float(max_v - min_v), 1e-6)
|
||||
depth_norm = torch.clamp((b_depth - min_v) / denom, 0.0, 1.0)
|
||||
depth_tensors.append(depth_norm)
|
||||
else:
|
||||
# Automatic Depth Estimation via Depth Anything V2 Small ONNX (cached for instant slider responsiveness)
|
||||
cpu_img = image.detach().cpu().numpy()
|
||||
for b in range(B):
|
||||
# Fast signature based on shape, sum, and corner samples
|
||||
img_slice = cpu_img[b]
|
||||
cache_key = (
|
||||
img_slice.shape,
|
||||
round(float(img_slice.sum()), 2),
|
||||
round(float(img_slice[0, 0, 0]), 4),
|
||||
round(float(img_slice[-1, -1, -1]), 4)
|
||||
)
|
||||
if cache_key in _PREDICTED_DEPTH_CACHE:
|
||||
d_np = _PREDICTED_DEPTH_CACHE[cache_key]
|
||||
else:
|
||||
d_np = predict_depth_map(img_slice)
|
||||
while len(_PREDICTED_DEPTH_CACHE) >= 16:
|
||||
_PREDICTED_DEPTH_CACHE.pop(next(iter(_PREDICTED_DEPTH_CACHE)))
|
||||
_PREDICTED_DEPTH_CACHE[cache_key] = d_np
|
||||
depth_tensors.append(torch.from_numpy(d_np).to(device=device, dtype=torch.float32))
|
||||
|
||||
depth_batch = torch.stack(depth_tensors, dim=0) # [B, H, W]
|
||||
|
||||
# 2. Apply Invert & Depth Gamma
|
||||
if invert_depth:
|
||||
depth_batch = 1.0 - depth_batch
|
||||
|
||||
if abs(depth_gamma - 1.0) > 1e-4:
|
||||
depth_batch = torch.pow(torch.clamp(depth_batch, 1e-6, 1.0), depth_gamma)
|
||||
|
||||
# 3. Apply Zoom & Pan cropping if requested
|
||||
zoom_factor = max(0.1, float(image_zoom) / 100.0)
|
||||
pan_x_norm = float(pan_horizontal) / 100.0
|
||||
pan_y_norm = float(pan_vertical) / 100.0
|
||||
|
||||
grid_y = torch.linspace(-1, 1, H, device=device).view(1, H, 1, 1).expand(B, H, W, 1)
|
||||
grid_x = torch.linspace(-1, 1, W, device=device).view(1, 1, W, 1).expand(B, H, W, 1)
|
||||
|
||||
if abs(zoom_factor - 1.0) > 1e-4 or abs(pan_x_norm) > 1e-4 or abs(pan_y_norm) > 1e-4:
|
||||
crop_x = (grid_x - pan_x_norm) / zoom_factor
|
||||
crop_y = (grid_y - pan_y_norm) / zoom_factor
|
||||
crop_grid = torch.cat([crop_x, crop_y], dim=-1)
|
||||
|
||||
img_perm = image.permute(0, 3, 1, 2)
|
||||
d_perm = depth_batch.unsqueeze(1)
|
||||
image_cropped = F.grid_sample(img_perm, crop_grid, mode="bilinear", padding_mode="zeros", align_corners=True).permute(0, 2, 3, 1)
|
||||
depth_cropped = F.grid_sample(d_perm, crop_grid, mode="bilinear", padding_mode="border", align_corners=True).squeeze(1)
|
||||
else:
|
||||
image_cropped = image
|
||||
depth_cropped = depth_batch
|
||||
|
||||
# 4. Compute Parallax Shift (Horizontal Disparity)
|
||||
# Shift magnitude scales with image width: at intensity 100, maximum shift is 3.5% of width
|
||||
max_shift = (float(depth_intensity) / 100.0) * (W * 0.035)
|
||||
# Shift relative to convergence plane
|
||||
shift_px = (depth_cropped - float(convergence_plane)) * max_shift
|
||||
shift_norm = (shift_px * (2.0 / W)).unsqueeze(-1) # [B, H, W, 1]
|
||||
|
||||
# 5. Generate Left and Right eye views
|
||||
grid_left = torch.cat([grid_x - shift_norm, grid_y], dim=-1)
|
||||
grid_right = torch.cat([grid_x + shift_norm, grid_y], dim=-1)
|
||||
|
||||
img_in = image_cropped.permute(0, 3, 1, 2)
|
||||
|
||||
if fill_method == "Nearest Neighbor (Stereonix)":
|
||||
# Backward warp with nearest-neighbor lookup matching classic Stereonix
|
||||
left_eye = F.grid_sample(img_in, grid_left, mode="nearest", padding_mode="border", align_corners=True).permute(0, 2, 3, 1)
|
||||
right_eye = F.grid_sample(img_in, grid_right, mode="nearest", padding_mode="border", align_corners=True).permute(0, 2, 3, 1)
|
||||
else:
|
||||
# Smooth bilinear grid sampling
|
||||
left_eye = F.grid_sample(img_in, grid_left, mode="bilinear", padding_mode="border", align_corners=True).permute(0, 2, 3, 1)
|
||||
right_eye = F.grid_sample(img_in, grid_right, mode="bilinear", padding_mode="border", align_corners=True).permute(0, 2, 3, 1)
|
||||
|
||||
# 6. Composite according to Selected Mode
|
||||
if mode == "Left Eye Only":
|
||||
stereogram = left_eye
|
||||
elif mode == "Right Eye Only":
|
||||
stereogram = right_eye
|
||||
elif mode == "Red-Cyan Anaglyph":
|
||||
# Red channel from Left Eye, Cyan (Green + Blue) channels from Right Eye
|
||||
stereogram = torch.zeros_like(left_eye)
|
||||
stereogram[..., 0] = left_eye[..., 0]
|
||||
stereogram[..., 1] = right_eye[..., 1]
|
||||
stereogram[..., 2] = right_eye[..., 2]
|
||||
elif mode == "Top-Bottom (Over-Under)":
|
||||
gap_h = max(0, int(gap_spacing))
|
||||
border = max(0, int(outer_border))
|
||||
|
||||
top_panel = left_eye
|
||||
bot_panel = right_eye
|
||||
|
||||
if gap_h > 0:
|
||||
gap_row = torch.zeros((B, gap_h, W, C), device=device, dtype=torch.float32)
|
||||
stacked = torch.cat([top_panel, gap_row, bot_panel], dim=1)
|
||||
else:
|
||||
stacked = torch.cat([top_panel, bot_panel], dim=1)
|
||||
|
||||
if border > 0:
|
||||
H_st, W_st = stacked.shape[1], stacked.shape[2]
|
||||
stereogram = torch.zeros((B, H_st + border * 2, W_st + border * 2, C), device=device, dtype=torch.float32)
|
||||
stereogram[:, border:border + H_st, border:border + W_st, :] = stacked
|
||||
else:
|
||||
stereogram = stacked
|
||||
else:
|
||||
# Side-by-Side: Cross-Eye vs Parallel
|
||||
# In Cross-Eye: Left panel is Right-Eye, Right panel is Left-Eye
|
||||
# In Parallel / Side-by-Side: Left panel is Left-Eye, Right panel is Right-Eye
|
||||
if mode == "Cross-Eye":
|
||||
panel_left = right_eye
|
||||
panel_right = left_eye
|
||||
else:
|
||||
panel_left = left_eye
|
||||
panel_right = right_eye
|
||||
|
||||
gap_w = max(0, int(gap_spacing))
|
||||
border = max(0, int(outer_border))
|
||||
|
||||
if gap_w > 0:
|
||||
gap_col = torch.zeros((B, H, gap_w, C), device=device, dtype=torch.float32)
|
||||
side_by_side = torch.cat([panel_left, gap_col, panel_right], dim=2)
|
||||
else:
|
||||
side_by_side = torch.cat([panel_left, panel_right], dim=2)
|
||||
|
||||
# Apply outer border framing
|
||||
if border > 0:
|
||||
H_sbs, W_sbs = side_by_side.shape[1], side_by_side.shape[2]
|
||||
stereogram = torch.zeros((B, H_sbs + border * 2, W_sbs + border * 2, C), device=device, dtype=torch.float32)
|
||||
stereogram[:, border:border + H_sbs, border:border + W_sbs, :] = side_by_side
|
||||
else:
|
||||
stereogram = side_by_side
|
||||
|
||||
# Add alignment guide dots
|
||||
if alignment_dots and (mode in ("Cross-Eye", "Parallel (Wall-Eyed)", "Side-by-Side (Parallel)")):
|
||||
# Center coordinates above left and right panels
|
||||
cx_left = border + (W // 2)
|
||||
cx_right = border + W + gap_w + (W // 2)
|
||||
cy_dot = border // 2 if border >= 18 else min(16, H // 12)
|
||||
|
||||
dot_radius = max(4, min(8, W // 100))
|
||||
for b in range(B):
|
||||
draw_alignment_dot(stereogram[b], cx_left, cy_dot, radius=dot_radius)
|
||||
draw_alignment_dot(stereogram[b], cx_right, cy_dot, radius=dot_radius)
|
||||
|
||||
# 7. Convert Depth to 3-channel image for output
|
||||
depth_output = depth_cropped.unsqueeze(-1).repeat(1, 1, 1, 3)
|
||||
|
||||
try:
|
||||
import comfy.model_management
|
||||
comfy.model_management.soft_empty_cache()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return (stereogram, left_eye, right_eye, depth_output)
|
||||
|
||||
+26
-13
@@ -1,18 +1,31 @@
|
||||
import re
|
||||
import csv
|
||||
import io
|
||||
|
||||
def parse_find_targets(find_str):
|
||||
if not find_str:
|
||||
if not find_str or not find_str.strip():
|
||||
return []
|
||||
raw_items = []
|
||||
# Split strictly by comma
|
||||
parts = find_str.split(',')
|
||||
for part in parts:
|
||||
item = part.strip()
|
||||
if (item.startswith('"') and item.endswith('"')) or (item.startswith("'") and item.endswith("'")):
|
||||
item = item[1:-1].strip()
|
||||
if item:
|
||||
raw_items.append(item)
|
||||
return raw_items
|
||||
try:
|
||||
reader = csv.reader(io.StringIO(find_str.strip()), skipinitialspace=True)
|
||||
raw_items = []
|
||||
for row in reader:
|
||||
for item in row:
|
||||
s = item.strip()
|
||||
if (s.startswith('"') and s.endswith('"')) or (s.startswith("'") and s.endswith("'")):
|
||||
s = s[1:-1].strip()
|
||||
if s:
|
||||
raw_items.append(s)
|
||||
return raw_items
|
||||
except Exception:
|
||||
raw_items = []
|
||||
parts = find_str.split(',')
|
||||
for part in parts:
|
||||
item = part.strip()
|
||||
if (item.startswith('"') and item.endswith('"')) or (item.startswith("'") and item.endswith("'")):
|
||||
item = item[1:-1].strip()
|
||||
if item:
|
||||
raw_items.append(item)
|
||||
return raw_items
|
||||
|
||||
class MultiTextReplacer:
|
||||
@classmethod
|
||||
@@ -32,7 +45,7 @@ class MultiTextReplacer:
|
||||
RETURN_TYPES = ("STRING", "INT")
|
||||
RETURN_NAMES = ("text", "replaced_count")
|
||||
FUNCTION = "replace_text"
|
||||
CATEGORY = "🍃 LeafFlow/Utils"
|
||||
CATEGORY = "🪐 SaturnNodes/Utils"
|
||||
DESCRIPTION = "Searches input text for multiple search targets specified in a comma-separated list or regex, and replaces all matches with a replacement string."
|
||||
|
||||
def replace_text(
|
||||
@@ -75,6 +88,6 @@ class MultiTextReplacer:
|
||||
replaced_count += count
|
||||
result_text = re.sub(pattern_str, lambda m: replace, result_text, flags=flags)
|
||||
except Exception as e:
|
||||
print(f"[LeafFlow] 🍃 Invalid regex pattern '{pattern_str}': {e}")
|
||||
print(f"[SaturnNodes] Invalid regex pattern '{pattern_str}': {e}")
|
||||
|
||||
return (result_text, replaced_count)
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
import re
|
||||
|
||||
class LeafFlowTextSplit:
|
||||
class SaturnTextSplit:
|
||||
"""
|
||||
LeafFlow Text Node:
|
||||
SaturnNodes Text Split Node:
|
||||
Splits input text into two parts (text1 and text2) by either a literal character sequence
|
||||
or a regular expression pattern, supporting forward (from start) and backward (from end) splitting.
|
||||
"""
|
||||
@@ -23,7 +23,7 @@ class LeafFlowTextSplit:
|
||||
RETURN_TYPES = ("STRING", "STRING")
|
||||
RETURN_NAMES = ("text1", "text2")
|
||||
FUNCTION = "split_text"
|
||||
CATEGORY = "🍃 LeafFlow/Utils"
|
||||
CATEGORY = "🪐 SaturnNodes/Utils"
|
||||
DESCRIPTION = "Splits input text into two parts (text1 and text2) by a character sequence or regex pattern, with forward/backward splitting."
|
||||
|
||||
def split_text(
|
||||
@@ -64,7 +64,7 @@ class LeafFlowTextSplit:
|
||||
else:
|
||||
t1, t2 = input_text, ""
|
||||
except Exception as e:
|
||||
print(f"[LeafFlow Text Split] Regex error: {e}. Falling back to literal string split.")
|
||||
print(f"[SaturnNodes Text Split] Regex error: {e}. Falling back to literal string split.")
|
||||
if is_forward:
|
||||
parts = input_text.split(delimiter, 1)
|
||||
else:
|
||||
|
||||
+12
-13
@@ -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
|
||||
|
||||
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,9 @@ class TrayIconManager:
|
||||
def _run():
|
||||
try:
|
||||
self._is_running = True
|
||||
print("[LeafFlow] 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 +214,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 +227,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:
|
||||
|
||||
+148
-72
@@ -4,17 +4,15 @@ 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/
|
||||
"""
|
||||
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 +22,12 @@ 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)
|
||||
return saturn_dir
|
||||
|
||||
_SESSION_CSRF_TOKEN = secrets.token_hex(32)
|
||||
|
||||
@@ -67,7 +64,7 @@ def is_same_origin_or_direct(request):
|
||||
try:
|
||||
parsed_ref = urlparse(referer)
|
||||
ref_host = parsed_ref.hostname
|
||||
if ref_host not in ["127.0.0.1", "localhost", "::1", "0.0.0.0", "testclient"]:
|
||||
if ref_host not in ["127.0.0.1", "localhost", "::1", "0.0.0.0", "testclient", "::ffff:127.0.0.1"]:
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
@@ -79,7 +76,7 @@ def is_authenticated_local_request(request):
|
||||
Guarantees user-initiated actions and protects against CSRF / drive-by requests from open browser tabs:
|
||||
1. Originates strictly from loopback IP (is_local_request)
|
||||
2. Not a cross-site drive-by request (is_same_origin_or_direct)
|
||||
3. Contains a valid X-LeafFlow-CSRF-Token header matching the session secret.
|
||||
3. Contains a valid X-SaturnNodes-CSRF-Token header matching the session secret.
|
||||
"""
|
||||
if not is_local_request(request):
|
||||
return False
|
||||
@@ -87,7 +84,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")
|
||||
if not token or not secrets.compare_digest(token, _SESSION_CSRF_TOKEN):
|
||||
return False
|
||||
|
||||
@@ -95,7 +92,7 @@ def is_authenticated_local_request(request):
|
||||
|
||||
def is_local_request(request):
|
||||
"""
|
||||
Returns True if the HTTP request originates strictly from loopback (localhost / 127.0.0.1 / ::1).
|
||||
Returns True if the HTTP request originates strictly from loopback (localhost / 127.0.0.1 / ::1 / ::ffff:127.0.0.1).
|
||||
Used to protect sensitive endpoints (power control, system settings, file browsing).
|
||||
"""
|
||||
if request is None:
|
||||
@@ -109,12 +106,15 @@ def is_local_request(request):
|
||||
remote = peername[0]
|
||||
if not remote:
|
||||
return False
|
||||
local_hosts = {"127.0.0.1", "::1", "localhost", "0.0.0.0", "testclient"}
|
||||
if remote in local_hosts:
|
||||
clean_remote = str(remote).strip("[]")
|
||||
if ":" in clean_remote and clean_remote.count(":") == 1:
|
||||
clean_remote = clean_remote.split(":")[0]
|
||||
local_hosts = {"127.0.0.1", "::1", "localhost", "0.0.0.0", "testclient", "::ffff:127.0.0.1"}
|
||||
if clean_remote in local_hosts or remote in local_hosts:
|
||||
return True
|
||||
try:
|
||||
import ipaddress
|
||||
ip = ipaddress.ip_address(remote)
|
||||
ip = ipaddress.ip_address(clean_remote)
|
||||
return ip.is_loopback
|
||||
except Exception:
|
||||
return False
|
||||
@@ -166,26 +166,43 @@ def is_safe_path(target_path, allowed_bases=None):
|
||||
def sanitize_image_loader_folder(folder_input, default_to_output=True):
|
||||
"""
|
||||
Resolves folder input strictly within ComfyUI input/output/temp directories.
|
||||
If folder_input attempts to escape or points outside allowed bases, falls back safely.
|
||||
Rejects directory traversal ('..') and unapproved absolute paths, strictly verifying confinement via commonpath.
|
||||
"""
|
||||
allowed_bases = get_allowed_image_directories()
|
||||
input_dir = folder_paths.get_input_directory()
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
default_dir = output_dir if default_to_output else input_dir
|
||||
input_dir = folder_paths.get_input_directory() if hasattr(folder_paths, "get_input_directory") else "."
|
||||
output_dir = folder_paths.get_output_directory() if hasattr(folder_paths, "get_output_directory") else "."
|
||||
default_dir = os.path.abspath(os.path.realpath(output_dir if default_to_output else input_dir))
|
||||
|
||||
if not folder_input or not str(folder_input).strip():
|
||||
return default_dir
|
||||
|
||||
clean = str(folder_input).strip().rstrip("\\/")
|
||||
|
||||
|
||||
# 1. Reject directory traversal sequences ('..')
|
||||
norm_slashes = clean.replace("\\", "/")
|
||||
if any(p == ".." for p in norm_slashes.split("/")):
|
||||
print(f"[SaturnNodes Security] Blocked directory traversal in image folder path '{clean}'. Confining to ComfyUI directories.")
|
||||
return default_dir
|
||||
|
||||
# 2. Reject absolute paths (drive letters C:\, POSIX root /, UNC \\server)
|
||||
is_absolute = (
|
||||
os.path.isabs(clean)
|
||||
or bool(re.match(r'^[A-Za-z]:', clean))
|
||||
or clean.startswith(("//", "\\\\"))
|
||||
)
|
||||
if is_absolute:
|
||||
if not is_safe_path(clean, allowed_bases):
|
||||
print(f"[SaturnNodes Security] Blocked absolute image folder path '{clean}'. Confining to ComfyUI directories.")
|
||||
return default_dir
|
||||
return os.path.abspath(os.path.realpath(clean))
|
||||
|
||||
# 3. Handle relative 'input' or 'output' prefixes
|
||||
if clean.lower() == "input" or clean.lower().startswith("input/") or clean.lower().startswith("input\\"):
|
||||
sub = clean[5:].lstrip("\\/")
|
||||
resolved = os.path.join(input_dir, sub) if sub else input_dir
|
||||
elif clean.lower() == "output" or clean.lower().startswith("output/") or clean.lower().startswith("output\\"):
|
||||
sub = clean[6:].lstrip("\\/")
|
||||
resolved = os.path.join(output_dir, sub) if sub else output_dir
|
||||
elif os.path.isabs(clean):
|
||||
resolved = clean
|
||||
else:
|
||||
cand_out = os.path.join(output_dir, clean)
|
||||
cand_inp = os.path.join(input_dir, clean)
|
||||
@@ -195,15 +212,15 @@ def sanitize_image_loader_folder(folder_input, default_to_output=True):
|
||||
resolved = cand_out
|
||||
|
||||
if is_safe_path(resolved, allowed_bases):
|
||||
return os.path.normpath(resolved)
|
||||
return os.path.abspath(os.path.realpath(resolved))
|
||||
|
||||
return default_dir
|
||||
|
||||
def get_env_setting(key, default_val):
|
||||
"""
|
||||
Reads a key setting from ComfyUI/user/default/LeafFlow/.env safely.
|
||||
Reads a key setting from ComfyUI/user/default/SaturnNodes/.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:
|
||||
@@ -248,74 +265,133 @@ def parse_pretty_name_with_version(filepath):
|
||||
return f"{pretty_name} {version_candidate.upper()}"
|
||||
return pretty_name
|
||||
|
||||
def get_allowed_folder_watch_directories():
|
||||
"""
|
||||
Returns approved base directories for folder watching and recent output scanning:
|
||||
strictly ComfyUI input and output directories.
|
||||
"""
|
||||
allowed = []
|
||||
try:
|
||||
inp = folder_paths.get_input_directory()
|
||||
if inp:
|
||||
allowed.append(os.path.abspath(os.path.realpath(inp)))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
out = folder_paths.get_output_directory()
|
||||
if out:
|
||||
allowed.append(os.path.abspath(os.path.realpath(out)))
|
||||
except Exception:
|
||||
pass
|
||||
return allowed
|
||||
|
||||
def sanitize_folder_path(folder_input, default_dir=None):
|
||||
"""
|
||||
Confines folder paths strictly within ComfyUI's input and output directories.
|
||||
Rejects absolute paths and '..' directory traversal sequences.
|
||||
Resolves canonical realpath and verifies commonpath containment.
|
||||
"""
|
||||
allowed_bases = get_allowed_folder_watch_directories()
|
||||
input_dir = folder_paths.get_input_directory() if hasattr(folder_paths, "get_input_directory") else None
|
||||
safe_fallback = os.path.join(input_dir, "watch") if input_dir else "watch"
|
||||
if default_dir:
|
||||
if os.path.isabs(default_dir):
|
||||
try:
|
||||
real_def = os.path.abspath(os.path.realpath(default_dir))
|
||||
for base in allowed_bases:
|
||||
if os.path.commonpath([base, real_def]) == base:
|
||||
safe_fallback = real_def
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
safe_fallback = default_dir
|
||||
|
||||
if folder_input is None:
|
||||
folder_input = ""
|
||||
clean_folder = str(folder_input).strip()
|
||||
|
||||
# Strip any trailing wildcards (e.g. "krea2\*" -> "krea2", "krea2/*" -> "krea2")
|
||||
# Strip any trailing wildcards (e.g. "subfolder\*" -> "subfolder", "subfolder/*" -> "subfolder")
|
||||
clean_folder = re.sub(r'[\*\?]+$', '', clean_folder).rstrip("\\/")
|
||||
|
||||
if not clean_folder and default_dir:
|
||||
clean_folder = default_dir
|
||||
|
||||
if not clean_folder:
|
||||
return ""
|
||||
clean_folder = safe_fallback
|
||||
|
||||
# 1. If absolute path, verify directly with case-insensitive check
|
||||
# 1. Reject absolute paths (drive letters C:\, POSIX root /, UNC \\server)
|
||||
is_absolute = (
|
||||
os.path.isabs(clean_folder)
|
||||
or bool(re.match(r'^[A-Za-z]:', clean_folder))
|
||||
or clean_folder.startswith(("//", "\\\\"))
|
||||
)
|
||||
|
||||
# 2. Reject directory traversal sequences ('..')
|
||||
normalized_slashes = clean_folder.replace("\\", "/")
|
||||
parts = normalized_slashes.split("/")
|
||||
has_traversal = any(p == ".." for p in parts)
|
||||
|
||||
if is_absolute or has_traversal:
|
||||
is_safe_default = False
|
||||
if is_absolute and safe_fallback and clean_folder == safe_fallback:
|
||||
try:
|
||||
real_clean = os.path.abspath(os.path.realpath(clean_folder))
|
||||
for base in allowed_bases:
|
||||
if os.path.commonpath([base, real_clean]) == base:
|
||||
is_safe_default = True
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not is_safe_default:
|
||||
print(f"[SaturnNodes Security] Blocked unsafe folder path '{clean_folder}' (absolute={is_absolute}, traversal={has_traversal}). Confining to ComfyUI input/output directories.")
|
||||
clean_folder = safe_fallback
|
||||
|
||||
# 3. If clean_folder is an already-confined absolute path (e.g. from internal safe_fallback)
|
||||
if os.path.isabs(clean_folder):
|
||||
norm_path = os.path.normpath(clean_folder)
|
||||
if os.path.exists(norm_path):
|
||||
return norm_path
|
||||
parent_dir = os.path.dirname(norm_path)
|
||||
base_name = os.path.basename(norm_path)
|
||||
if os.path.exists(parent_dir):
|
||||
try:
|
||||
for entry in os.listdir(parent_dir):
|
||||
if entry.lower() == base_name.lower():
|
||||
return os.path.join(parent_dir, entry)
|
||||
except Exception:
|
||||
pass
|
||||
return norm_path
|
||||
try:
|
||||
real_cand = os.path.abspath(os.path.realpath(clean_folder))
|
||||
for base in allowed_bases:
|
||||
if os.path.commonpath([base, real_cand]) == base:
|
||||
return real_cand
|
||||
except Exception:
|
||||
pass
|
||||
return os.path.abspath(os.path.realpath(safe_fallback))
|
||||
|
||||
# 2. If relative path, check output directory, input directory, then base path
|
||||
# 4. For relative paths, evaluate candidates strictly within allowed_bases
|
||||
candidates = []
|
||||
try:
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
if output_dir:
|
||||
candidates.append(os.path.normpath(os.path.join(output_dir, clean_folder)))
|
||||
except Exception:
|
||||
pass
|
||||
for base in allowed_bases:
|
||||
target = os.path.abspath(os.path.realpath(os.path.join(base, clean_folder)))
|
||||
try:
|
||||
if os.path.commonpath([base, target]) == base:
|
||||
candidates.append((target, os.path.exists(target)))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
try:
|
||||
input_dir = folder_paths.get_input_directory()
|
||||
if input_dir:
|
||||
candidates.append(os.path.normpath(os.path.join(input_dir, clean_folder)))
|
||||
except Exception:
|
||||
pass
|
||||
# Return existing candidate if present on disk
|
||||
for target, exists in candidates:
|
||||
if exists:
|
||||
return target
|
||||
|
||||
try:
|
||||
if folder_paths.base_path:
|
||||
candidates.append(os.path.normpath(os.path.join(folder_paths.base_path, clean_folder)))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for cand in candidates:
|
||||
if os.path.exists(cand):
|
||||
return cand
|
||||
|
||||
for cand in candidates:
|
||||
parent_dir = os.path.dirname(cand)
|
||||
base_name = os.path.basename(cand)
|
||||
# Case-insensitive resolution within candidate parent folders
|
||||
for target, _ in candidates:
|
||||
parent_dir = os.path.dirname(target)
|
||||
base_name = os.path.basename(target)
|
||||
if os.path.exists(parent_dir):
|
||||
try:
|
||||
for entry in os.listdir(parent_dir):
|
||||
if entry.lower() == base_name.lower():
|
||||
return os.path.join(parent_dir, entry)
|
||||
match_path = os.path.join(parent_dir, entry)
|
||||
real_match = os.path.abspath(os.path.realpath(match_path))
|
||||
for base in allowed_bases:
|
||||
if os.path.commonpath([base, real_match]) == base:
|
||||
return real_match
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return candidates[0] if candidates else os.path.normpath(os.path.join(folder_paths.base_path, clean_folder))
|
||||
if candidates:
|
||||
return candidates[0][0]
|
||||
|
||||
default_input = folder_paths.get_input_directory() if hasattr(folder_paths, "get_input_directory") else "."
|
||||
return os.path.abspath(os.path.realpath(os.path.join(default_input, "watch")))
|
||||
|
||||
def format_lora_output_name(resolved_path, display_name, output_format="Parsed Name", custom_regex=""):
|
||||
if not resolved_path or resolved_path in ["[ NONE ]", "[ RANDOM ]"]:
|
||||
|
||||
+15
-6
@@ -1,18 +1,27 @@
|
||||
[project]
|
||||
name = "comfyui-leafflow"
|
||||
name = "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.6.1"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
license = { file = "LICENSE" }
|
||||
requires-python = ">=3.10"
|
||||
dependencies = ["pillow", "numpy", "piexif"]
|
||||
classifiers = [
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
tray = ["pystray"]
|
||||
stereogram = ["onnxruntime"]
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/KOFiblto/ComfyUI-LeafFlow"
|
||||
Repository = "https://github.com/KOFiblto/ComfyUI-SaturnNodes"
|
||||
"Bug Tracker" = "https://github.com/KOFiblto/ComfyUI-SaturnNodes/issues"
|
||||
Documentation = "https://github.com/KOFiblto/ComfyUI-SaturnNodes#readme"
|
||||
|
||||
[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"
|
||||
requires-comfyui = ">=0.3.0"
|
||||
|
||||
@@ -2,3 +2,5 @@ Pillow
|
||||
piexif
|
||||
numpy
|
||||
# pystray # Optional: install manually if desktop system tray icon integration is enabled
|
||||
# onnxruntime # Optional: install manually for built-in Depth Anything V2 estimation in 3D stereogram nodes
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Ensure LeafFlow root is at index 0 in sys.path
|
||||
# Ensure SaturnNodes root is at index 0 in sys.path
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if ROOT in sys.path:
|
||||
sys.path.remove(ROOT)
|
||||
|
||||
@@ -12,8 +12,12 @@ from nodes.auto_watcher import LoadImageFromFolder
|
||||
|
||||
class TestAutoWatcher(unittest.TestCase):
|
||||
def setUp(self):
|
||||
import folder_paths
|
||||
self.node = LoadImageFromFolder()
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
inp_dir = folder_paths.get_input_directory()
|
||||
os.makedirs(inp_dir, exist_ok=True)
|
||||
self.temp_dir = tempfile.mkdtemp(dir=inp_dir)
|
||||
self.rel_dir = os.path.relpath(self.temp_dir, inp_dir)
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
@@ -44,8 +48,9 @@ class TestAutoWatcher(unittest.TestCase):
|
||||
def test_empty_folder_returns_dummy(self):
|
||||
empty_dir = os.path.join(self.temp_dir, "empty")
|
||||
os.makedirs(empty_dir, exist_ok=True)
|
||||
empty_rel = os.path.join(self.rel_dir, "empty")
|
||||
img_tensor, has_image = self.node.watch(
|
||||
folder=empty_dir,
|
||||
folder=empty_rel,
|
||||
wait_if_folder_is_empty=False,
|
||||
rescan_interval=1,
|
||||
sort_by="date_modified",
|
||||
@@ -62,15 +67,15 @@ class TestAutoWatcher(unittest.TestCase):
|
||||
Image.new("RGB", (64, 64), color="green").save(f2)
|
||||
|
||||
# Run 1 -> loads img_a
|
||||
t1, has1 = self.node.watch(folder=self.temp_dir, wait_if_folder_is_empty=False, rescan_interval=1, sort_by="name", regex_filter=".*", delete_image=False, unique_id="cycle_test")
|
||||
t1, has1 = self.node.watch(folder=self.rel_dir, wait_if_folder_is_empty=False, rescan_interval=1, sort_by="name", regex_filter=".*", delete_image=False, unique_id="cycle_test")
|
||||
self.assertTrue(has1)
|
||||
|
||||
# Run 2 -> loads img_b (cycled!)
|
||||
t2, has2 = self.node.watch(folder=self.temp_dir, wait_if_folder_is_empty=False, rescan_interval=1, sort_by="name", regex_filter=".*", delete_image=False, unique_id="cycle_test")
|
||||
t2, has2 = self.node.watch(folder=self.rel_dir, wait_if_folder_is_empty=False, rescan_interval=1, sort_by="name", regex_filter=".*", delete_image=False, unique_id="cycle_test")
|
||||
self.assertTrue(has2)
|
||||
|
||||
# Run 3 -> loops back to img_a
|
||||
t3, has3 = self.node.watch(folder=self.temp_dir, wait_if_folder_is_empty=False, rescan_interval=1, sort_by="name", regex_filter=".*", delete_image=False, unique_id="cycle_test")
|
||||
t3, has3 = self.node.watch(folder=self.rel_dir, wait_if_folder_is_empty=False, rescan_interval=1, sort_by="name", regex_filter=".*", delete_image=False, unique_id="cycle_test")
|
||||
self.assertTrue(has3)
|
||||
|
||||
def test_load_and_remove_image_with_delete_toggle(self):
|
||||
|
||||
@@ -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"
|
||||
|
||||
+11
-11
@@ -3,17 +3,17 @@ import sys
|
||||
import types
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
LEAFFLOW_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
SATURNNODES_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
# Ensure LeafFlow root is prioritized at the very start of sys.path
|
||||
while LEAFFLOW_ROOT in sys.path:
|
||||
sys.path.remove(LEAFFLOW_ROOT)
|
||||
sys.path.insert(0, LEAFFLOW_ROOT)
|
||||
# Ensure SaturnNodes root is prioritized at the very start of sys.path
|
||||
while SATURNNODES_ROOT in sys.path:
|
||||
sys.path.remove(SATURNNODES_ROOT)
|
||||
sys.path.insert(0, SATURNNODES_ROOT)
|
||||
|
||||
# If 'nodes' in sys.modules is ComfyUI's root nodes.py file, replace it with LeafFlow's nodes package
|
||||
# If 'nodes' in sys.modules is ComfyUI's root nodes.py file, replace it with SaturnNodes' nodes package
|
||||
if "nodes" in sys.modules:
|
||||
nodes_mod = sys.modules["nodes"]
|
||||
if not hasattr(nodes_mod, "__path__") or not getattr(nodes_mod, "__file__", "").startswith(LEAFFLOW_ROOT):
|
||||
if not hasattr(nodes_mod, "__path__") or not getattr(nodes_mod, "__file__", "").startswith(SATURNNODES_ROOT):
|
||||
del sys.modules["nodes"]
|
||||
|
||||
# Provide lightweight mock stubs for ComfyUI environment if running in standalone CI / test runner
|
||||
@@ -67,12 +67,12 @@ if "comfy" not in sys.modules:
|
||||
|
||||
if "folder_paths" not in sys.modules:
|
||||
fp_mock = types.ModuleType("folder_paths")
|
||||
fp_mock.get_input_directory = MagicMock(return_value=os.path.join(LEAFFLOW_ROOT, "input"))
|
||||
fp_mock.get_output_directory = MagicMock(return_value=os.path.join(LEAFFLOW_ROOT, "output"))
|
||||
fp_mock.get_temp_directory = MagicMock(return_value=os.path.join(LEAFFLOW_ROOT, "temp"))
|
||||
fp_mock.get_input_directory = MagicMock(return_value=os.path.join(SATURNNODES_ROOT, "input"))
|
||||
fp_mock.get_output_directory = MagicMock(return_value=os.path.join(SATURNNODES_ROOT, "output"))
|
||||
fp_mock.get_temp_directory = MagicMock(return_value=os.path.join(SATURNNODES_ROOT, "temp"))
|
||||
fp_mock.get_filename_list = MagicMock(return_value=[])
|
||||
fp_mock.get_full_path = MagicMock(return_value="")
|
||||
fp_mock.base_path = LEAFFLOW_ROOT
|
||||
fp_mock.base_path = SATURNNODES_ROOT
|
||||
sys.modules["folder_paths"] = fp_mock
|
||||
|
||||
if "server" not in sys.modules:
|
||||
|
||||
@@ -13,8 +13,12 @@ from nodes.load_recent import LoadRecentOutputs
|
||||
|
||||
class TestLoadRecent(unittest.TestCase):
|
||||
def setUp(self):
|
||||
import folder_paths
|
||||
self.node = LoadRecentOutputs()
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
out_dir = folder_paths.get_output_directory()
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
self.temp_dir = tempfile.mkdtemp(dir=out_dir)
|
||||
self.rel_dir = os.path.relpath(self.temp_dir, out_dir)
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
@@ -26,16 +30,14 @@ class TestLoadRecent(unittest.TestCase):
|
||||
img.save(f1)
|
||||
img.save(f2)
|
||||
|
||||
with patch("nodes.load_recent.folder_paths.get_output_directory", return_value=self.temp_dir):
|
||||
out_tensor, = self.node.load_single_image(output_folder=self.temp_dir, amount=2, index=0)
|
||||
self.assertEqual(out_tensor.shape[0], 1)
|
||||
self.assertEqual(out_tensor.shape[1], 64)
|
||||
self.assertEqual(out_tensor.shape[2], 64)
|
||||
out_tensor, = self.node.load_single_image(output_folder=self.rel_dir, amount=2, index=0)
|
||||
self.assertEqual(out_tensor.shape[0], 1)
|
||||
self.assertEqual(out_tensor.shape[1], 64)
|
||||
self.assertEqual(out_tensor.shape[2], 64)
|
||||
|
||||
def test_load_recent_empty_directory_returns_dummy(self):
|
||||
with patch("nodes.load_recent.folder_paths.get_output_directory", return_value=self.temp_dir):
|
||||
out_tensor, = self.node.load_single_image(output_folder=self.temp_dir, amount=5, index=0)
|
||||
self.assertEqual(out_tensor.shape, (1, 512, 512, 3))
|
||||
out_tensor, = self.node.load_single_image(output_folder=self.rel_dir, amount=5, index=0)
|
||||
self.assertEqual(out_tensor.shape, (1, 512, 512, 3))
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -36,9 +36,9 @@ class TestLoraFinder(unittest.TestCase):
|
||||
@patch("nodes.lora_finder.comfy.sd.load_lora_for_models")
|
||||
def test_find_and_load_lora_match(self, mock_load_lora, mock_load_torch, mock_exists, mock_get_path, mock_mapping):
|
||||
mock_mapping.return_value = {
|
||||
"Ana De Armas": "celebrities/krea2_ana-de-armas_v1.safetensors"
|
||||
"Cyberpunk Character": "characters/char_cyberpunk-character_v1.safetensors"
|
||||
}
|
||||
mock_get_path.return_value = "/models/loras/celebrities/krea2_ana-de-armas_v1.safetensors"
|
||||
mock_get_path.return_value = "/models/loras/characters/char_cyberpunk-character_v1.safetensors"
|
||||
mock_exists.return_value = True
|
||||
mock_load_torch.return_value = {}
|
||||
mock_load_lora.return_value = ("PATCHED_MODEL", "PATCHED_CLIP")
|
||||
@@ -46,13 +46,13 @@ class TestLoraFinder(unittest.TestCase):
|
||||
model, clip, loras = self.finder.find_and_load_loras(
|
||||
model="MODEL",
|
||||
clip="CLIP",
|
||||
text="A photo of Ana De Armas in evening dress",
|
||||
text="A digital painting of a Cyberpunk Character at neon midnight",
|
||||
search_for="Parsed Name",
|
||||
output_format="Parsed Name"
|
||||
)
|
||||
self.assertEqual(model, "PATCHED_MODEL")
|
||||
self.assertEqual(clip, "PATCHED_CLIP")
|
||||
self.assertIn("Ana De Armas", loras)
|
||||
self.assertIn("Cyberpunk Character", loras)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
import torch
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import test_helper
|
||||
|
||||
from nodes.stereogram import SaturnStereo3D
|
||||
|
||||
class TestSaturnStereo3D(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.node = SaturnStereo3D()
|
||||
self.img = torch.rand((1, 128, 128, 3), dtype=torch.float32)
|
||||
self.depth = torch.linspace(0, 1, 128).view(1, 1, 128).expand(1, 128, 128).unsqueeze(-1).repeat(1, 1, 1, 3)
|
||||
|
||||
def test_cross_eye_mode_dimensions(self):
|
||||
# Image is 128x128. With gap=10, border=5:
|
||||
# Side by side: 128 + 10 + 128 = 266 width.
|
||||
# With border=5 on each side: Width = 266 + 10 = 276. Height = 128 + 10 = 138.
|
||||
res = self.node.generate(
|
||||
self.img,
|
||||
depth_map=self.depth,
|
||||
mode="Cross-Eye",
|
||||
depth_intensity=30.0,
|
||||
gap_spacing=10,
|
||||
outer_border=5,
|
||||
alignment_dots=True
|
||||
)
|
||||
stereogram, left_eye, right_eye, depth_out = res
|
||||
self.assertEqual(stereogram.shape, (1, 138, 276, 3))
|
||||
self.assertEqual(left_eye.shape, (1, 128, 128, 3))
|
||||
self.assertEqual(right_eye.shape, (1, 128, 128, 3))
|
||||
self.assertEqual(depth_out.shape, (1, 128, 128, 3))
|
||||
|
||||
def test_parallel_mode(self):
|
||||
res = self.node.generate(
|
||||
self.img,
|
||||
depth_map=self.depth,
|
||||
mode="Parallel (Wall-Eyed)",
|
||||
depth_intensity=25.0,
|
||||
gap_spacing=0,
|
||||
outer_border=0,
|
||||
alignment_dots=False
|
||||
)
|
||||
stereogram, _, _, _ = res
|
||||
self.assertEqual(stereogram.shape, (1, 128, 256, 3))
|
||||
|
||||
def test_anaglyph_mode(self):
|
||||
res = self.node.generate(
|
||||
self.img,
|
||||
depth_map=self.depth,
|
||||
mode="Red-Cyan Anaglyph",
|
||||
depth_intensity=25.0
|
||||
)
|
||||
stereogram, left_eye, right_eye, _ = res
|
||||
self.assertEqual(stereogram.shape, (1, 128, 128, 3))
|
||||
np.testing.assert_allclose(stereogram[..., 0].cpu().numpy(), left_eye[..., 0].cpu().numpy(), atol=1e-5)
|
||||
np.testing.assert_allclose(stereogram[..., 1].cpu().numpy(), right_eye[..., 1].cpu().numpy(), atol=1e-5)
|
||||
np.testing.assert_allclose(stereogram[..., 2].cpu().numpy(), right_eye[..., 2].cpu().numpy(), atol=1e-5)
|
||||
|
||||
def test_top_bottom_mode(self):
|
||||
res = self.node.generate(
|
||||
self.img,
|
||||
depth_map=self.depth,
|
||||
mode="Top-Bottom (Over-Under)",
|
||||
gap_spacing=6,
|
||||
outer_border=0
|
||||
)
|
||||
stereogram, _, _, _ = res
|
||||
self.assertEqual(stereogram.shape, (1, 262, 128, 3))
|
||||
|
||||
def test_single_eye_modes(self):
|
||||
res_left = self.node.generate(self.img, depth_map=self.depth, mode="Left Eye Only")
|
||||
res_right = self.node.generate(self.img, depth_map=self.depth, mode="Right Eye Only")
|
||||
self.assertEqual(res_left[0].shape, (1, 128, 128, 3))
|
||||
self.assertEqual(res_right[0].shape, (1, 128, 128, 3))
|
||||
|
||||
def test_zoom_and_pan(self):
|
||||
res = self.node.generate(
|
||||
self.img,
|
||||
depth_map=self.depth,
|
||||
image_zoom=150.0,
|
||||
pan_horizontal=20.0,
|
||||
pan_vertical=-10.0
|
||||
)
|
||||
stereogram = res[0]
|
||||
self.assertIsNotNone(stereogram)
|
||||
self.assertEqual(stereogram.dim(), 4)
|
||||
|
||||
def test_auto_depth_fallback(self):
|
||||
from unittest.mock import patch
|
||||
with patch("nodes.stereogram.get_ort_session", side_effect=RuntimeError("onnxruntime unavailable")):
|
||||
res = self.node.generate(
|
||||
self.img,
|
||||
depth_map=None,
|
||||
mode="Cross-Eye",
|
||||
depth_intensity=20.0
|
||||
)
|
||||
stereogram, _, _, depth_out = res
|
||||
self.assertEqual(stereogram.shape[0], 1)
|
||||
self.assertEqual(depth_out.shape, (1, 128, 128, 3))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -19,6 +19,10 @@ class TestMultiTextReplacer(unittest.TestCase):
|
||||
targets = parse_find_targets('"dog", "cat", "bird"')
|
||||
self.assertEqual(targets, ["dog", "cat", "bird"])
|
||||
|
||||
def test_parse_find_targets_with_comma_inside_quotes(self):
|
||||
targets = parse_find_targets('"hello, world", "foo, bar", baz')
|
||||
self.assertEqual(targets, ["hello, world", "foo, bar", "baz"])
|
||||
|
||||
def test_replace_comma_separated_list(self):
|
||||
text, count = self.node.replace_text(
|
||||
find="dog, cat, bird",
|
||||
|
||||
@@ -5,11 +5,11 @@ import unittest
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import test_helper
|
||||
|
||||
from nodes.text_split import LeafFlowTextSplit
|
||||
from nodes.text_split import SaturnTextSplit
|
||||
|
||||
class TestLeafFlowTextSplit(unittest.TestCase):
|
||||
class TestSaturnTextSplit(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.node = LeafFlowTextSplit()
|
||||
self.node = SaturnTextSplit()
|
||||
|
||||
def test_forward_literal_split_basic(self):
|
||||
# User standard case: "Hello --- Bye" with delimiter "--"
|
||||
|
||||
+67
-18
@@ -26,6 +26,8 @@ class TestUtils(unittest.TestCase):
|
||||
self.assertTrue(is_local_request(DummyReq("127.0.0.1")))
|
||||
self.assertTrue(is_local_request(DummyReq("::1")))
|
||||
self.assertTrue(is_local_request(DummyReq("localhost")))
|
||||
self.assertTrue(is_local_request(DummyReq("::ffff:127.0.0.1")))
|
||||
self.assertTrue(is_local_request(DummyReq("[::ffff:127.0.0.1]")))
|
||||
self.assertFalse(is_local_request(DummyReq("192.168.1.50")))
|
||||
self.assertFalse(is_local_request(DummyReq("10.0.0.1")))
|
||||
self.assertFalse(is_local_request(DummyReq("8.8.8.8")))
|
||||
@@ -65,7 +67,7 @@ class TestUtils(unittest.TestCase):
|
||||
# Valid local request with matching token
|
||||
valid_req = DummyReq("127.0.0.1", {
|
||||
"Origin": "http://127.0.0.1:8188",
|
||||
"X-LeafFlow-CSRF-Token": valid_token
|
||||
"X-SaturnNodes-CSRF-Token": valid_token
|
||||
})
|
||||
self.assertTrue(is_authenticated_local_request(valid_req))
|
||||
|
||||
@@ -76,21 +78,21 @@ class TestUtils(unittest.TestCase):
|
||||
# Wrong token
|
||||
wrong_token_req = DummyReq("127.0.0.1", {
|
||||
"Origin": "http://127.0.0.1:8188",
|
||||
"X-LeafFlow-CSRF-Token": "invalid_forged_token"
|
||||
"X-SaturnNodes-CSRF-Token": "invalid_forged_token"
|
||||
})
|
||||
self.assertFalse(is_authenticated_local_request(wrong_token_req))
|
||||
|
||||
# Remote IP attempting to forge or replay token
|
||||
remote_req = DummyReq("192.168.1.100", {
|
||||
"Origin": "http://127.0.0.1:8188",
|
||||
"X-LeafFlow-CSRF-Token": valid_token
|
||||
"X-SaturnNodes-CSRF-Token": valid_token
|
||||
})
|
||||
self.assertFalse(is_authenticated_local_request(remote_req))
|
||||
|
||||
# Cross-site drive-by request (Sec-Fetch-Site: cross-site)
|
||||
drive_by_req = DummyReq("127.0.0.1", {
|
||||
"Sec-Fetch-Site": "cross-site",
|
||||
"X-LeafFlow-CSRF-Token": valid_token
|
||||
"X-SaturnNodes-CSRF-Token": valid_token
|
||||
})
|
||||
self.assertFalse(is_authenticated_local_request(drive_by_req))
|
||||
|
||||
@@ -105,8 +107,8 @@ class TestUtils(unittest.TestCase):
|
||||
shutil.rmtree(base1, ignore_errors=True)
|
||||
|
||||
def test_parse_pretty_name(self):
|
||||
name = parse_pretty_name("krea2_ana-de-armas_v1.safetensors")
|
||||
self.assertEqual(name, "Ana De Armas")
|
||||
name = parse_pretty_name("char_cyberpunk-character_v1.safetensors")
|
||||
self.assertEqual(name, "Cyberpunk Character")
|
||||
|
||||
def test_parse_pretty_name_special_keywords(self):
|
||||
name = parse_pretty_name("tag_cyberpunk-nsfw-v2.safetensors")
|
||||
@@ -114,28 +116,63 @@ class TestUtils(unittest.TestCase):
|
||||
self.assertIn("V2", name)
|
||||
|
||||
def test_parse_pretty_name_with_version(self):
|
||||
name_v = parse_pretty_name_with_version("krea2_ana-de-armas_v1.safetensors")
|
||||
self.assertEqual(name_v, "Ana De Armas V1")
|
||||
name_v = parse_pretty_name_with_version("char_cyberpunk-character_v1.safetensors")
|
||||
self.assertEqual(name_v, "Cyberpunk Character V1")
|
||||
|
||||
def test_format_lora_output_name_modes(self):
|
||||
rel_path = os.path.join("celebrities", "ana_de_armas.safetensors")
|
||||
rel_path = os.path.join("characters", "cyberpunk_character.safetensors")
|
||||
|
||||
parsed = format_lora_output_name(rel_path, "Ana De Armas", "Parsed Name")
|
||||
self.assertEqual(parsed, "Ana De Armas")
|
||||
parsed = format_lora_output_name(rel_path, "Cyberpunk Character", "Parsed Name")
|
||||
self.assertEqual(parsed, "Cyberpunk Character")
|
||||
|
||||
filename = format_lora_output_name(rel_path, "Ana De Armas", "Filename")
|
||||
self.assertEqual(filename, "ana_de_armas.safetensors")
|
||||
filename = format_lora_output_name(rel_path, "Cyberpunk Character", "Filename")
|
||||
self.assertEqual(filename, "cyberpunk_character.safetensors")
|
||||
|
||||
no_ext = format_lora_output_name(rel_path, "Ana De Armas", "Filename without extension")
|
||||
self.assertEqual(no_ext, "ana_de_armas")
|
||||
no_ext = format_lora_output_name(rel_path, "Cyberpunk Character", "Filename without extension")
|
||||
self.assertEqual(no_ext, "cyberpunk_character")
|
||||
|
||||
custom_reg = format_lora_output_name(rel_path, "Ana De Armas", "Custom Regex", custom_regex=r"ana_([a-z]+)")
|
||||
self.assertEqual(custom_reg, "ana_de")
|
||||
custom_reg = format_lora_output_name(rel_path, "Cyberpunk Character", "Custom Regex", custom_regex=r"cyberpunk_([a-z]+)")
|
||||
self.assertEqual(custom_reg, "cyberpunk_character")
|
||||
|
||||
def test_sanitize_folder_path_wildcard_stripping(self):
|
||||
clean = sanitize_folder_path("input/watch/*")
|
||||
clean = sanitize_folder_path("watch/*")
|
||||
self.assertNotIn("*", clean)
|
||||
|
||||
def test_sanitize_folder_path_blocks_absolute_paths(self):
|
||||
import folder_paths
|
||||
inp = os.path.abspath(os.path.realpath(folder_paths.get_input_directory()))
|
||||
out = os.path.abspath(os.path.realpath(folder_paths.get_output_directory()))
|
||||
|
||||
# Test Windows drive letters and root paths
|
||||
for bad_path in ["C:\\Windows\\System32", "D:\\SecretData", "/etc/passwd", "\\\\server\\share"]:
|
||||
res = sanitize_folder_path(bad_path)
|
||||
res_norm = os.path.abspath(os.path.realpath(res))
|
||||
# Must be confined strictly within input or output directory
|
||||
is_confined = (os.path.commonpath([inp, res_norm]) == inp) or (os.path.commonpath([out, res_norm]) == out)
|
||||
self.assertTrue(is_confined, f"Path '{bad_path}' escaped confinement! Got: '{res_norm}'")
|
||||
|
||||
def test_sanitize_folder_path_blocks_directory_traversal(self):
|
||||
import folder_paths
|
||||
inp = os.path.abspath(os.path.realpath(folder_paths.get_input_directory()))
|
||||
out = os.path.abspath(os.path.realpath(folder_paths.get_output_directory()))
|
||||
|
||||
for bad_traversal in ["../../etc", "watch/../../secret", "..\\..\\Windows", "sub/../../../root"]:
|
||||
res = sanitize_folder_path(bad_traversal)
|
||||
res_norm = os.path.abspath(os.path.realpath(res))
|
||||
is_confined = (os.path.commonpath([inp, res_norm]) == inp) or (os.path.commonpath([out, res_norm]) == out)
|
||||
self.assertTrue(is_confined, f"Traversal '{bad_traversal}' escaped confinement! Got: '{res_norm}'")
|
||||
|
||||
def test_sanitize_folder_path_allows_safe_subfolders(self):
|
||||
import folder_paths
|
||||
inp = os.path.abspath(os.path.realpath(folder_paths.get_input_directory()))
|
||||
out = os.path.abspath(os.path.realpath(folder_paths.get_output_directory()))
|
||||
|
||||
for safe_sub in ["watch", "my_images", "subfolder/nested"]:
|
||||
res = sanitize_folder_path(safe_sub)
|
||||
res_norm = os.path.abspath(os.path.realpath(res))
|
||||
is_confined = (os.path.commonpath([inp, res_norm]) == inp) or (os.path.commonpath([out, res_norm]) == out)
|
||||
self.assertTrue(is_confined, f"Safe subfolder '{safe_sub}' failed confinement! Got: '{res_norm}'")
|
||||
|
||||
def test_is_safe_external_image_url(self):
|
||||
from nodes.lora_loader import is_safe_external_image_url
|
||||
self.assertTrue(is_safe_external_image_url("https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/test.jpg"))
|
||||
@@ -155,5 +192,17 @@ class TestUtils(unittest.TestCase):
|
||||
self.assertEqual(parse_pretty_name("[ NONE ]"), "")
|
||||
self.assertEqual(parse_pretty_name("[ RANDOM ]"), "")
|
||||
|
||||
def test_sanitize_image_loader_folder_confinement(self):
|
||||
from nodes.utils import sanitize_image_loader_folder
|
||||
import folder_paths
|
||||
inp = os.path.abspath(os.path.realpath(folder_paths.get_input_directory()))
|
||||
out = os.path.abspath(os.path.realpath(folder_paths.get_output_directory()))
|
||||
|
||||
for bad in ["C:\\Windows", "/etc", "../../escape", "output/../../secret", "\\\\server\\share"]:
|
||||
res = sanitize_image_loader_folder(bad)
|
||||
res_norm = os.path.abspath(os.path.realpath(res))
|
||||
is_confined = (os.path.commonpath([inp, res_norm]) == inp) or (os.path.commonpath([out, res_norm]) == out)
|
||||
self.assertTrue(is_confined, f"Image folder '{bad}' escaped confinement: '{res_norm}'")
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
+49
-23
@@ -42,10 +42,10 @@ 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 BATCH_COUNTER_KEY = "saturnnodes_batch_counter";
|
||||
const SETTING_ID = "SaturnNodes.🎨 Batch Queue.01_ShowBatchLines";
|
||||
const SETTING_SNAPSHOT_GUARD = "SaturnNodes.🎨 Batch Queue.02_SnapshotGuard";
|
||||
|
||||
// In-memory registry mapping prompt_id -> batch metadata
|
||||
let batchRegistry = new Map();
|
||||
@@ -70,7 +70,7 @@ function loadStorage() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,14 +107,14 @@ function getNextBatch(batchCount = 1) {
|
||||
// Inject CSS for the 1D git-graph style line
|
||||
function injectBatchStyles() {
|
||||
if (typeof document === "undefined") return;
|
||||
const styleId = "leafflow-batch-queue-styles";
|
||||
const styleId = "saturnnodes-batch-queue-styles";
|
||||
if (document.getElementById(styleId)) return;
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.id = styleId;
|
||||
style.textContent = `
|
||||
/* LeafFlow 1D Git-Graph Batch Line Container */
|
||||
.leafflow-batch-line-wrap {
|
||||
/* SaturnNodes 1D Git-Graph Batch Line Container */
|
||||
.saturnnodes-batch-line-wrap {
|
||||
position: absolute;
|
||||
left: -9px;
|
||||
top: 0;
|
||||
@@ -128,7 +128,7 @@ function injectBatchStyles() {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.leafflow-batch-line-svg {
|
||||
.saturnnodes-batch-line-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
@@ -175,16 +175,16 @@ function buildSvgPath(segmentType) {
|
||||
function renderBatchLineOnElement(element, segmentType, color) {
|
||||
if (!element) return;
|
||||
|
||||
let wrap = element.querySelector(".leafflow-batch-line-wrap");
|
||||
let wrap = element.querySelector(".saturnnodes-batch-line-wrap");
|
||||
if (!wrap) {
|
||||
wrap = document.createElement("div");
|
||||
wrap.className = "leafflow-batch-line-wrap";
|
||||
wrap.className = "saturnnodes-batch-line-wrap";
|
||||
element.appendChild(wrap);
|
||||
}
|
||||
|
||||
const dPath = buildSvgPath(segmentType);
|
||||
wrap.innerHTML = `
|
||||
<svg class="leafflow-batch-line-svg" viewBox="0 0 10 48" preserveAspectRatio="none">
|
||||
<svg class="saturnnodes-batch-line-svg" viewBox="0 0 10 48" preserveAspectRatio="none">
|
||||
<path d="${dPath}" stroke="${color}" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" fill="none" />
|
||||
</svg>
|
||||
`;
|
||||
@@ -192,17 +192,22 @@ function renderBatchLineOnElement(element, segmentType, color) {
|
||||
|
||||
function removeBatchLineFromElement(element) {
|
||||
if (!element) return;
|
||||
const wrap = element.querySelector(".leafflow-batch-line-wrap");
|
||||
const wrap = element.querySelector(".saturnnodes-batch-line-wrap");
|
||||
if (wrap) {
|
||||
wrap.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Check if LeafFlow Batch Queue is enabled in settings
|
||||
// Check if SaturnNodes Batch Queue is enabled in settings
|
||||
function isBatchQueueEnabled() {
|
||||
try {
|
||||
if (app.extensionManager?.setting?.get) {
|
||||
return app.extensionManager.setting.get(SETTING_ID) !== false;
|
||||
const val = app.extensionManager.setting.get(SETTING_ID);
|
||||
if (val !== undefined) return val !== false;
|
||||
}
|
||||
if (app.ui?.settings?.get) {
|
||||
const val = app.ui.settings.get(SETTING_ID);
|
||||
if (val !== undefined) return val !== false;
|
||||
}
|
||||
} catch (e) {}
|
||||
return true;
|
||||
@@ -211,7 +216,12 @@ function isBatchQueueEnabled() {
|
||||
function isSnapshotGuardEnabled() {
|
||||
try {
|
||||
if (app.extensionManager?.setting?.get) {
|
||||
return app.extensionManager.setting.get(SETTING_SNAPSHOT_GUARD) !== false;
|
||||
const val = app.extensionManager.setting.get(SETTING_SNAPSHOT_GUARD);
|
||||
if (val !== undefined) return val !== false;
|
||||
}
|
||||
if (app.ui?.settings?.get) {
|
||||
const val = app.ui.settings.get(SETTING_SNAPSHOT_GUARD);
|
||||
if (val !== undefined) return val !== false;
|
||||
}
|
||||
} catch (e) {}
|
||||
return true;
|
||||
@@ -260,11 +270,27 @@ function mergeDynamicSeeds(snapPrompt, livePrompt) {
|
||||
if (!snapNode || snapNode.id === undefined) continue;
|
||||
const liveWfNode = liveWorkflowMap.get(String(snapNode.id));
|
||||
if (liveWfNode && Array.isArray(liveWfNode.widgets_values) && Array.isArray(snapNode.widgets_values)) {
|
||||
const graphNode = app.graph?.getNodeById?.(snapNode.id) || app.graph?.getNodeById?.(Number(snapNode.id));
|
||||
for (let i = 0; i < liveWfNode.widgets_values.length; i++) {
|
||||
const liveVal = liveWfNode.widgets_values[i];
|
||||
const snapVal = snapNode.widgets_values[i];
|
||||
if (typeof liveVal === "number" && typeof snapVal === "number" && liveVal !== snapVal) {
|
||||
snapNode.widgets_values[i] = liveVal;
|
||||
const widgetName = graphNode?.widgets?.[i]?.name;
|
||||
if (widgetName) {
|
||||
if (isSeedInput(widgetName, liveVal)) {
|
||||
snapNode.widgets_values[i] = liveVal;
|
||||
}
|
||||
} else {
|
||||
const apiInputs = cloned.output?.[String(snapNode.id)]?.inputs;
|
||||
if (apiInputs) {
|
||||
for (const [k, v] of Object.entries(apiInputs)) {
|
||||
if (v === liveVal && isSeedInput(k, v)) {
|
||||
snapNode.widgets_values[i] = liveVal;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -272,7 +298,7 @@ function mergeDynamicSeeds(snapPrompt, livePrompt) {
|
||||
}
|
||||
return cloned;
|
||||
} catch (e) {
|
||||
console.warn("[LeafFlow BatchQueue] Error merging dynamic seeds into snapshot:", e);
|
||||
console.warn("[SaturnNodes BatchQueue] Error merging dynamic seeds into snapshot:", e);
|
||||
return snapPrompt;
|
||||
}
|
||||
}
|
||||
@@ -280,7 +306,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 +322,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") {
|
||||
@@ -314,7 +340,7 @@ async function loadBatchFromServer() {
|
||||
// Refresh visual lines in DOM for both v1 and v2
|
||||
async function updateQueueBatchVisuals() {
|
||||
if (!isBatchQueueEnabled()) {
|
||||
document.querySelectorAll(".leafflow-batch-line-wrap").forEach(el => el.remove());
|
||||
document.querySelectorAll(".saturnnodes-batch-line-wrap").forEach(el => el.remove());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -432,7 +458,7 @@ function setupQueueHooks() {
|
||||
try {
|
||||
activeBatchContext.snapshotPrompt = JSON.parse(JSON.stringify(prompt));
|
||||
} catch (e) {
|
||||
console.warn("[LeafFlow BatchQueue] Failed to snapshot prompt:", e);
|
||||
console.warn("[SaturnNodes BatchQueue] Failed to snapshot prompt:", e);
|
||||
}
|
||||
} else if (activeBatchContext.snapshotPrompt) {
|
||||
promptToSend = mergeDynamicSeeds(activeBatchContext.snapshotPrompt, prompt);
|
||||
@@ -477,7 +503,7 @@ function setupQueueHooks() {
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfyUI.LeafFlow.BatchQueue",
|
||||
name: "ComfyUI.SaturnNodes.BatchQueue",
|
||||
async setup() {
|
||||
injectBatchStyles();
|
||||
setupQueueHooks();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,17 @@ 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
|
||||
if (typeof window !== "undefined") {
|
||||
window._LeafFlowAuth = {
|
||||
const authObj = {
|
||||
getToken: getCsrfToken,
|
||||
fetch: authenticatedFetch
|
||||
};
|
||||
window._SaturnNodesAuth = authObj;
|
||||
}
|
||||
|
||||
|
||||
+63
-145
@@ -11,7 +11,7 @@ async function copyToClipboard(text) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.warn("[LeafFlow] Navigator clipboard failed, trying textarea fallback:", e);
|
||||
console.warn("[SaturnNodes CopyPrompt] Navigator clipboard failed, trying textarea fallback:", e);
|
||||
}
|
||||
}
|
||||
try {
|
||||
@@ -28,7 +28,7 @@ async function copyToClipboard(text) {
|
||||
document.body.removeChild(textarea);
|
||||
return successful;
|
||||
} catch (err) {
|
||||
console.error("[LeafFlow] Clipboard copy fallback failed:", err);
|
||||
console.error("[SaturnNodes CopyPrompt] Clipboard copy fallback failed:", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,9 @@ function isCopyEnabled(settingKey, defaultVal = true) {
|
||||
if ((val === undefined || val === null || val === "") && app.ui?.settings?.getSettingValue) {
|
||||
val = app.ui.settings.getSettingValue(settingKey);
|
||||
}
|
||||
if ((val === undefined || val === null || val === "") && app.ui?.settings?.get) {
|
||||
val = app.ui.settings.get(settingKey);
|
||||
}
|
||||
if (val === undefined || val === null || val === "") {
|
||||
return defaultVal;
|
||||
}
|
||||
@@ -51,9 +54,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) {
|
||||
@@ -220,7 +220,7 @@ async function extractPromptFromImageUrl(imgSrc) {
|
||||
} catch (_) {}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[LeafFlow] Direct PNG metadata extraction failed:", e);
|
||||
console.warn("[SaturnNodes CopyPrompt] Direct PNG metadata extraction failed:", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -248,8 +248,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 +259,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 +288,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 +298,7 @@ async function copyImagePrompt(imgSrc) {
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[LeafFlow] Direct prompt fetch failed:", e);
|
||||
console.warn("[SaturnNodes] Direct prompt fetch failed:", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,14 +319,14 @@ 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;
|
||||
nodeType.prototype.getExtraMenuOptions = function (_, options) {
|
||||
if (origGetExtraMenuOptions) origGetExtraMenuOptions.apply(this, arguments);
|
||||
|
||||
if (!isCopyEnabled("LeafFlow.3 - 📋 Prompt Actions.02_EnableContextMenuCopyPrompt")) return;
|
||||
if (!isCopyEnabled("SaturnNodes.📋 Prompt Actions.02_EnableContextMenuCopyPrompt")) return;
|
||||
|
||||
const imgs = this.imgs || this.images || (this.widgets && this.widgets.filter(w => w.type === "image" || w.name === "image"));
|
||||
if (imgs && imgs.length > 0) {
|
||||
@@ -361,28 +341,16 @@ app.registerExtension({
|
||||
if (app.extensionManager?.toast?.add) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: "warn",
|
||||
summary: "LeafFlow Copy Prompt",
|
||||
summary: "SaturnNodes Copy Prompt",
|
||||
detail: "Could not extract positive prompt from this image.",
|
||||
life: 3000
|
||||
});
|
||||
} else {
|
||||
alert("LeafFlow: Could not extract positive prompt from this image.");
|
||||
alert("SaturnNodes: Could not extract positive prompt from this image.");
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
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 +405,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;
|
||||
|
||||
@@ -454,11 +422,10 @@ function injectCopyPromptNextToDownload(downloadBtn) {
|
||||
if (img.classList.contains("size-8") || img.closest(".size-8, .size-10, .h-12")) return;
|
||||
|
||||
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);
|
||||
const enableCopy = isCopyEnabled("SaturnNodes.📋 Prompt Actions.01_EnableAssetsCopyPromptButton");
|
||||
const enableInspect = isCopyEnabled("SaturnNodes.📋 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, "")
|
||||
@@ -479,14 +446,14 @@ function injectCopyPromptNextToDownload(downloadBtn) {
|
||||
let lastBtn = downloadBtn;
|
||||
|
||||
// 1. Copy Prompt Button
|
||||
let copyBtn = parent.querySelector(".leafflow-hover-copy");
|
||||
let copyBtn = parent.querySelector(".saturnnodes-hover-copy");
|
||||
if (enableCopy) {
|
||||
if (!copyBtn) {
|
||||
copyBtn = document.createElement("button");
|
||||
copyBtn.type = "button";
|
||||
copyBtn.title = "Copy Prompt";
|
||||
copyBtn.setAttribute("aria-label", "Copy Prompt");
|
||||
copyBtn.className = `leafflow-hover-btn leafflow-hover-copy ${baseClasses} shrink-0`;
|
||||
copyBtn.className = `saturnnodes-hover-btn saturnnodes-hover-copy ${baseClasses} shrink-0`;
|
||||
|
||||
if (hasModernIcons) {
|
||||
copyBtn.innerHTML = `<i class="icon-[lucide--copy] size-4 pointer-events-none"></i>`;
|
||||
@@ -522,43 +489,15 @@ 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
|
||||
let inspectBtn = parent.querySelector(".leafflow-hover-inspect");
|
||||
// 2. Inspect Asset (Zoom) Button
|
||||
let inspectBtn = parent.querySelector(".saturnnodes-hover-inspect");
|
||||
if (enableInspect) {
|
||||
if (!inspectBtn) {
|
||||
inspectBtn = document.createElement("button");
|
||||
inspectBtn.type = "button";
|
||||
inspectBtn.title = "Inspect asset";
|
||||
inspectBtn.setAttribute("aria-label", "Inspect asset");
|
||||
inspectBtn.className = `leafflow-hover-btn leafflow-hover-inspect ${baseClasses} shrink-0`;
|
||||
inspectBtn.className = `saturnnodes-hover-btn saturnnodes-hover-inspect ${baseClasses} shrink-0`;
|
||||
inspectBtn.innerHTML = INSPECT_SVG;
|
||||
|
||||
inspectBtn.onclick = (e) => {
|
||||
@@ -582,20 +521,37 @@ function injectCopyPromptNextToDownload(downloadBtn) {
|
||||
// 4. Inject Copy Prompt into PrimeVue Context Menu Next to Download Item
|
||||
function injectContextMenuCopy(contextMenu) {
|
||||
if (!contextMenu) return;
|
||||
if (contextMenu.querySelector(".leafflow-contextmenu-copy")) return;
|
||||
if (!isCopyEnabled("LeafFlow.3 - 📋 Prompt Actions.02_EnableContextMenuCopyPrompt")) return;
|
||||
if (contextMenu.querySelector(".saturnnodes-contextmenu-copy")) return;
|
||||
if (!isCopyEnabled("SaturnNodes.📋 Prompt Actions.02_EnableContextMenuCopyPrompt")) return;
|
||||
|
||||
let downloadLi = contextMenu.querySelector(
|
||||
'li[aria-label="Download"], li[aria-label*="ownload" i], [data-pc-section="item"][aria-label*="ownload" i]'
|
||||
);
|
||||
if (!downloadLi) {
|
||||
const items = contextMenu.querySelectorAll('li, [data-pc-section="item"]');
|
||||
for (const item of items) {
|
||||
const text = item.textContent?.trim().toLowerCase() || "";
|
||||
if (text.includes("download") || item.querySelector('.icon-[lucide--download], [class*="download" i]')) {
|
||||
downloadLi = item;
|
||||
break;
|
||||
}
|
||||
// Guard: strictly ignore top menu, menubar, command menus, and main application menus!
|
||||
if (
|
||||
contextMenu.closest?.(".comfy-command-menu, .comfy-menu, .p-menubar, [data-pc-name='menubar'], [data-pc-name='tieredmenu']") ||
|
||||
contextMenu.classList?.contains("comfy-command-menu") ||
|
||||
contextMenu.classList?.contains("p-tieredmenu") ||
|
||||
contextMenu.querySelector?.(".p-menubar-root-list, .p-tieredmenu-root-list, li[aria-label='File'], li[aria-label='New'], li[aria-label='Edit'], li[aria-label='View']")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let downloadLi = null;
|
||||
const candidates = contextMenu.querySelectorAll('li[role="menuitem"], [data-pc-section="item"]');
|
||||
for (const item of candidates) {
|
||||
const label = (item.getAttribute("aria-label") || "").trim().toLowerCase();
|
||||
const text = (item.textContent || "").trim().toLowerCase();
|
||||
|
||||
// Strictly ignore workflow export / save actions
|
||||
if (label.includes("export") || text.includes("export") || label.includes("save as") || text.includes("save as")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (label === "download" || label === "download image" || text === "download" || text === "download image") {
|
||||
downloadLi = item;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!downloadLi && (label.includes("download") || text.includes("download"))) {
|
||||
downloadLi = item;
|
||||
}
|
||||
}
|
||||
if (!downloadLi || !downloadLi.parentElement) return;
|
||||
@@ -611,7 +567,7 @@ function injectContextMenuCopy(contextMenu) {
|
||||
);
|
||||
|
||||
const copyLi = document.createElement("li");
|
||||
copyLi.className = "p-contextmenu-item leafflow-contextmenu-copy";
|
||||
copyLi.className = "p-contextmenu-item saturnnodes-contextmenu-copy";
|
||||
copyLi.setAttribute("role", "menuitem");
|
||||
copyLi.setAttribute("aria-label", "Copy prompt");
|
||||
copyLi.setAttribute("data-pc-section", "item");
|
||||
@@ -649,50 +605,12 @@ 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
|
||||
function injectHoverCopyAction(overlayBar) {
|
||||
if (!overlayBar || overlayBar.querySelector(".leafflow-hover-copy")) return;
|
||||
if (!isCopyEnabled("LeafFlow.3 - 📋 Prompt Actions.01_EnableAssetsCopyPromptButton")) return;
|
||||
if (!overlayBar || overlayBar.querySelector(".saturnnodes-hover-copy")) return;
|
||||
if (!isCopyEnabled("SaturnNodes.📋 Prompt Actions.01_EnableAssetsCopyPromptButton")) return;
|
||||
|
||||
if (isQueueItemElement(overlayBar)) return;
|
||||
|
||||
@@ -720,7 +638,7 @@ function injectHoverCopyAction(overlayBar) {
|
||||
|
||||
const copyBtn = document.createElement("button");
|
||||
copyBtn.className =
|
||||
"leafflow-hover-btn leafflow-hover-copy relative inline-flex items-center justify-center cursor-pointer touch-manipulation appearance-none border-none text-xs font-medium font-inter transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-white text-gray-700 hover:bg-gray-100 size-8 p-0 rounded-none pointer-events-auto border-r border-gray-200 shrink-0";
|
||||
"saturnnodes-hover-btn saturnnodes-hover-copy relative inline-flex items-center justify-center cursor-pointer touch-manipulation appearance-none border-none text-xs font-medium font-inter transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-white text-gray-700 hover:bg-gray-100 size-8 p-0 rounded-none pointer-events-auto border-r border-gray-200 shrink-0";
|
||||
copyBtn.title = "Copy Prompt";
|
||||
copyBtn.setAttribute("aria-label", "Copy Prompt");
|
||||
copyBtn.setAttribute("data-pd-tooltip", "true");
|
||||
@@ -758,7 +676,7 @@ function scanAndInject() {
|
||||
);
|
||||
downloadBtns.forEach(injectCopyPromptNextToDownload);
|
||||
|
||||
const menus = document.querySelectorAll('.p-contextmenu, .p-menu, .p-tieredmenu, [data-pc-name="contextmenu"], [data-pc-name="menu"], [data-pc-name="tieredmenu"]');
|
||||
const menus = document.querySelectorAll('.p-contextmenu, [data-pc-name="contextmenu"]');
|
||||
menus.forEach(injectContextMenuCopy);
|
||||
|
||||
const olderOverlays = document.querySelectorAll(
|
||||
@@ -780,7 +698,7 @@ document.addEventListener("pointerover", (e) => {
|
||||
const cardDl = card.querySelector('button[aria-label="Download"], button[aria-label*="ownload" i]');
|
||||
if (cardDl) injectCopyPromptNextToDownload(cardDl);
|
||||
}
|
||||
const menu = e.target.closest?.('.p-contextmenu, .p-menu, .p-tieredmenu, [data-pc-name="contextmenu"], [data-pc-name="menu"], [data-pc-name="tieredmenu"]');
|
||||
const menu = e.target.closest?.('.p-contextmenu, [data-pc-name="contextmenu"]');
|
||||
if (menu) {
|
||||
injectContextMenuCopy(menu);
|
||||
}
|
||||
@@ -796,7 +714,7 @@ const observer = new MutationObserver((mutations) => {
|
||||
|
||||
if (node.matches?.('button[aria-label="Download"], button[aria-label*="ownload" i]')) {
|
||||
injectCopyPromptNextToDownload(node);
|
||||
} else if (node.matches?.('.p-contextmenu, .p-menu, .p-tieredmenu, [data-pc-name="contextmenu"], [data-pc-name="menu"], [data-pc-name="tieredmenu"]')) {
|
||||
} else if (node.matches?.('.p-contextmenu, [data-pc-name="contextmenu"]')) {
|
||||
injectContextMenuCopy(node);
|
||||
} else if (node.matches?.('[data-testid="asset-card-actions"], .asset-card-overlay, .asset-item-overlay')) {
|
||||
injectHoverCopyAction(node);
|
||||
@@ -804,7 +722,7 @@ const observer = new MutationObserver((mutations) => {
|
||||
const dlBtns = node.querySelectorAll('button[aria-label="Download"], button[aria-label*="ownload" i]');
|
||||
dlBtns.forEach(injectCopyPromptNextToDownload);
|
||||
|
||||
const ctxMenus = node.querySelectorAll('.p-contextmenu, .p-menu, .p-tieredmenu, [data-pc-name="contextmenu"], [data-pc-name="menu"], [data-pc-name="tieredmenu"]');
|
||||
const ctxMenus = node.querySelectorAll('.p-contextmenu, [data-pc-name="contextmenu"]');
|
||||
ctxMenus.forEach(injectContextMenuCopy);
|
||||
|
||||
const overlays = node.querySelectorAll('[data-testid="asset-card-actions"], .asset-card-overlay, .asset-item-overlay');
|
||||
|
||||
+61
-23
@@ -5,7 +5,7 @@ import { authenticatedFetch } from "./auth_helper.js";
|
||||
// Create global styles for the decision buttons
|
||||
const styles = document.createElement("style");
|
||||
styles.textContent = `
|
||||
.leafflow-decision-btn {
|
||||
.saturnnodes-decision-btn {
|
||||
margin: 4px;
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
@@ -15,19 +15,19 @@ styles.textContent = `
|
||||
color: white;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.leafflow-decision-btn:disabled {
|
||||
.saturnnodes-decision-btn:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
filter: grayscale(100%);
|
||||
}
|
||||
.leafflow-btn-continue { background-color: #2e7d32; }
|
||||
.leafflow-btn-continue:hover:not(:disabled) { background-color: #388e3c; }
|
||||
.leafflow-btn-cancel { background-color: #f57c00; }
|
||||
.leafflow-btn-cancel:hover:not(:disabled) { background-color: #fb8c00; }
|
||||
.leafflow-btn-stop { background-color: #d32f2f; }
|
||||
.leafflow-btn-stop:hover:not(:disabled) { background-color: #f44336; }
|
||||
.saturnnodes-btn-continue { background-color: #2e7d32; }
|
||||
.saturnnodes-btn-continue:hover:not(:disabled) { background-color: #388e3c; }
|
||||
.saturnnodes-btn-cancel { background-color: #f57c00; }
|
||||
.saturnnodes-btn-cancel:hover:not(:disabled) { background-color: #fb8c00; }
|
||||
.saturnnodes-btn-stop { background-color: #d32f2f; }
|
||||
.saturnnodes-btn-stop:hover:not(:disabled) { background-color: #f44336; }
|
||||
|
||||
.leafflow-decision-container {
|
||||
.saturnnodes-decision-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -38,46 +38,84 @@ styles.textContent = `
|
||||
`;
|
||||
document.head.appendChild(styles);
|
||||
|
||||
function findGraphNode(id) {
|
||||
if (!id && id !== 0) return null;
|
||||
const strId = String(id);
|
||||
const numId = Number(id);
|
||||
|
||||
// 1. Direct root graph lookup
|
||||
let node = app.graph?.getNodeById?.(id) || app.graph?.getNodeById?.(numId) || app.graph?.getNodeById?.(strId);
|
||||
if (node) return node;
|
||||
|
||||
// 2. Search root graph nodes and nested subgraphs
|
||||
if (app.graph && Array.isArray(app.graph._nodes)) {
|
||||
for (const n of app.graph._nodes) {
|
||||
if (String(n.id) === strId) return n;
|
||||
if (n.subgraph && typeof n.subgraph.getNodeById === "function") {
|
||||
const sub = n.subgraph.getNodeById(id) || n.subgraph.getNodeById(numId) || n.subgraph.getNodeById(strId);
|
||||
if (sub) return sub;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Search graph._subgraphs registry (v0.37+)
|
||||
if (app.graph && app.graph._subgraphs) {
|
||||
const subgraphs = Array.isArray(app.graph._subgraphs)
|
||||
? app.graph._subgraphs
|
||||
: Object.values(app.graph._subgraphs);
|
||||
for (const sg of subgraphs) {
|
||||
if (sg && typeof sg.getNodeById === "function") {
|
||||
const sub = sg.getNodeById(id) || sg.getNodeById(numId) || sg.getNodeById(strId);
|
||||
if (sub) return sub;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "Comfy.LeafFlowDecision",
|
||||
name: "ComfyUI.SaturnNodes.Decision",
|
||||
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);
|
||||
const node = findGraphNode(data.node_id);
|
||||
if (node && node.enableDecisionButtons) {
|
||||
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);
|
||||
const node = findGraphNode(data.node_id);
|
||||
if (node && node.disableDecisionButtons) {
|
||||
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") {
|
||||
const container = document.createElement("div");
|
||||
container.className = "leafflow-decision-container";
|
||||
container.className = "saturnnodes-decision-container";
|
||||
|
||||
const btnContinue = document.createElement("button");
|
||||
btnContinue.className = "leafflow-decision-btn leafflow-btn-continue";
|
||||
btnContinue.className = "saturnnodes-decision-btn saturnnodes-btn-continue";
|
||||
btnContinue.innerText = "Continue Workflow";
|
||||
btnContinue.disabled = true;
|
||||
|
||||
const btnCancel = document.createElement("button");
|
||||
btnCancel.className = "leafflow-decision-btn leafflow-btn-cancel";
|
||||
btnCancel.className = "saturnnodes-decision-btn saturnnodes-btn-cancel";
|
||||
btnCancel.innerText = "Cancel (Return True)";
|
||||
btnCancel.disabled = true;
|
||||
|
||||
const btnStop = document.createElement("button");
|
||||
btnStop.className = "leafflow-decision-btn leafflow-btn-stop";
|
||||
btnStop.className = "saturnnodes-decision-btn saturnnodes-btn-stop";
|
||||
btnStop.innerText = "Stop Workflow";
|
||||
btnStop.disabled = true;
|
||||
|
||||
@@ -88,7 +126,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 +135,7 @@ app.registerExtension({
|
||||
}),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[LeafFlow] Failed to send decision:", e);
|
||||
console.error("[SaturnNodes] Failed to send decision:", e);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+314
-51
@@ -1,5 +1,7 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { api } from "/scripts/api.js";
|
||||
import { isSaturnColorsEnabled } from "../saturnnodes_colors.js";
|
||||
import { authenticatedFetch } from "./auth_helper.js";
|
||||
|
||||
// Hash folder name to get a consistent color for the border
|
||||
function getFolderBorderColor(folderName) {
|
||||
@@ -95,7 +97,9 @@ visualStyles.textContent = `
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
row-gap: 6px;
|
||||
padding: 4px 6px;
|
||||
background: #181818;
|
||||
border: 1px solid #282828;
|
||||
@@ -396,7 +400,7 @@ visualStyles.textContent = `
|
||||
document.head.appendChild(visualStyles);
|
||||
|
||||
app.registerExtension({
|
||||
name: "Comfy.FolderLoraLoader",
|
||||
name: "ComfyUI.SaturnNodes.FolderLoraLoader",
|
||||
async nodeCreated(node) {
|
||||
// --- 1. ORIGINAL DROPDOWN NODES ---
|
||||
if (node.comfyClass === "FolderLoraLoader" || node.comfyClass === "FolderLoraLoaderPretty" || node.type === "FolderLoraLoader" || node.type === "FolderLoraLoaderPretty") {
|
||||
@@ -422,7 +426,7 @@ app.registerExtension({
|
||||
activeRequest = currentRequest;
|
||||
|
||||
try {
|
||||
const response = await api.fetchApi(`/folder_lora_loader/get_loras?folder=${encodeURIComponent(folder)}&pretty=${pretty}`);
|
||||
const response = await api.fetchApi(`/saturnnodes/folder_lora_loader/get_loras?folder=${encodeURIComponent(folder)}&pretty=${pretty}`);
|
||||
if (activeRequest !== currentRequest) return;
|
||||
const data = await response.json();
|
||||
|
||||
@@ -529,30 +533,26 @@ app.registerExtension({
|
||||
|
||||
// Embed DOM Widget inside the node container
|
||||
const domWidget = node.addDOMWidget("lora_visual_picker", "HTML", viewContainer, {
|
||||
getValue() { return getHiddenWidget().value; },
|
||||
setValue(val) { getHiddenWidget().value = val; },
|
||||
getValue() { return hiddenWidget.value; },
|
||||
setValue(val) { hiddenWidget.value = val; },
|
||||
serialize: false
|
||||
});
|
||||
|
||||
// Detect Vue UI vs LiteGraph by checking for the Vue container class eventually
|
||||
const isVueUI = !!document.querySelector(".lg-node");
|
||||
if (isVueUI) {
|
||||
viewContainer.classList.add("vue-mode");
|
||||
viewContainer.style.height = "160px";
|
||||
} else {
|
||||
viewContainer.classList.add("litegraph-mode");
|
||||
const onResize = node.onResize;
|
||||
node.onResize = function(size) {
|
||||
if (onResize) onResize.apply(this, arguments);
|
||||
if (viewContainer.style) {
|
||||
viewContainer.style.height = `${size[1] - 160}px`;
|
||||
}
|
||||
};
|
||||
}
|
||||
viewContainer.style.setProperty("height", "100%", "important");
|
||||
viewContainer.style.setProperty("max-height", "100%", "important");
|
||||
viewContainer.style.setProperty("box-sizing", "border-box", "important");
|
||||
|
||||
// Adjust sizing constraints dynamically
|
||||
domWidget.computeSize = function() {
|
||||
return [node.size[0] - 30, node.size[1] - 160];
|
||||
domWidget.computeLayoutSize = function() {
|
||||
return { minHeight: 160, maxHeight: undefined, minWidth: 300 };
|
||||
};
|
||||
delete domWidget.computeSize;
|
||||
|
||||
const onResize = node.onResize;
|
||||
node.onResize = function(size) {
|
||||
if (onResize) onResize.apply(this, arguments);
|
||||
if (size) {
|
||||
node.userCustomHeight = size[1];
|
||||
}
|
||||
};
|
||||
|
||||
// Add Zoom slider widget
|
||||
@@ -579,11 +579,27 @@ app.registerExtension({
|
||||
activeRequest = currentRequest;
|
||||
|
||||
try {
|
||||
const response = await api.fetchApi(`/folder_lora_loader/get_loras?folder=${encodeURIComponent(folder)}&pretty=true`);
|
||||
const response = await api.fetchApi(`/saturnnodes/folder_lora_loader/get_loras?folder=${encodeURIComponent(folder)}&pretty=true`);
|
||||
if (activeRequest !== currentRequest) return;
|
||||
const data = await response.json();
|
||||
|
||||
viewContainer.innerHTML = "";
|
||||
if (viewContainer._tileObserver) {
|
||||
try { viewContainer._tileObserver.disconnect(); } catch (_) {}
|
||||
}
|
||||
const tileObserver = new IntersectionObserver((entries, observer) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const img = entry.target;
|
||||
if (img.dataset.src) {
|
||||
img.src = img.dataset.src;
|
||||
img.removeAttribute("data-src");
|
||||
}
|
||||
observer.unobserve(img);
|
||||
}
|
||||
});
|
||||
}, { root: viewContainer, rootMargin: "150px" });
|
||||
viewContainer._tileObserver = tileObserver;
|
||||
|
||||
// Calculate Top 3 Ranks by versionless pretty name
|
||||
const getTop3Ranks = () => {
|
||||
@@ -689,7 +705,10 @@ app.registerExtension({
|
||||
}
|
||||
|
||||
const img = document.createElement("img");
|
||||
img.src = `/folder_lora_loader/get_preview?lora=${encodeURIComponent(loraName)}&folder=${encodeURIComponent(folder)}&pretty=true`;
|
||||
img.loading = "lazy";
|
||||
const bypassThumbs = app.ui.settings?.getSettingValue?.("SaturnNodes.🖼️ Visual Loaders.07_BypassThumbnailDownscaling", false);
|
||||
img.dataset.src = `/saturnnodes/folder_lora_loader/get_preview?lora=${encodeURIComponent(loraName)}&folder=${encodeURIComponent(folder)}&pretty=true` + (bypassThumbs ? "&full=true" : "&size=200");
|
||||
tileObserver.observe(img);
|
||||
|
||||
img.onerror = () => {
|
||||
img.remove();
|
||||
@@ -776,6 +795,10 @@ app.registerExtension({
|
||||
node.onConfigure = function(config) {
|
||||
if (originalOnConfigure) originalOnConfigure.apply(this, arguments);
|
||||
|
||||
if (config && config.size && config.size[1]) {
|
||||
node.userCustomHeight = config.size[1];
|
||||
}
|
||||
|
||||
const savedTileSize = (config && config.properties && config.properties["tile_size"] != null)
|
||||
? config.properties["tile_size"]
|
||||
: (node.properties && node.properties["tile_size"] != null ? node.properties["tile_size"] : null);
|
||||
@@ -791,7 +814,7 @@ app.registerExtension({
|
||||
}
|
||||
}
|
||||
|
||||
const curWidget = getHiddenWidget();
|
||||
const curWidget = hiddenWidget;
|
||||
const widgetIndex = node.widgets.indexOf(curWidget);
|
||||
const savedValue = (config.widgets_values && widgetIndex !== -1) ? config.widgets_values[widgetIndex] : curWidget.value;
|
||||
|
||||
@@ -805,17 +828,9 @@ app.registerExtension({
|
||||
}
|
||||
|
||||
// --- 3. VISUAL PRETTY PICKER V2 (Collapsible folders & Multi-selection) ---
|
||||
if (node.comfyClass === "FolderLoraLoaderVisualPrettyV2" || node.comfyClass === "VisualLoraLoader" || node.type === "FolderLoraLoaderVisualPrettyV2" || node.type === "VisualLoraLoader") {
|
||||
if (node.comfyClass === "VisualLoraLoader" || 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 = () => {
|
||||
@@ -918,11 +933,9 @@ app.registerExtension({
|
||||
} else {
|
||||
viewContainer.classList.remove("show-all-mode");
|
||||
viewContainer.classList.add("scrollable-mode");
|
||||
const restoredH = node.userCustomHeight || userSavedHeight || 420;
|
||||
const containerH = Math.max(200, restoredH - 100);
|
||||
|
||||
viewContainer.style.setProperty("height", `${containerH}px`, "important");
|
||||
viewContainer.style.setProperty("max-height", `${containerH}px`, "important");
|
||||
viewContainer.style.setProperty("height", "100%", "important");
|
||||
viewContainer.style.setProperty("max-height", "100%", "important");
|
||||
viewContainer.style.setProperty("overflow-y", "hidden", "important");
|
||||
|
||||
gridContainer.style.setProperty("height", "100%", "important");
|
||||
@@ -930,7 +943,10 @@ app.registerExtension({
|
||||
gridContainer.style.setProperty("overflow-y", "auto", "important");
|
||||
gridContainer.style.setProperty("flex", "1 1 0px", "important");
|
||||
|
||||
node.setSize([node.size[0], Math.max(360, restoredH)]);
|
||||
const targetH = node.userCustomHeight || userSavedHeight || (node.size ? node.size[1] : 420);
|
||||
if (targetH && (!node.size || node.size[1] !== targetH)) {
|
||||
node.setSize([node.size ? node.size[0] : 380, Math.max(360, targetH)]);
|
||||
}
|
||||
if (app.graph) app.graph.setDirtyCanvas(true, true);
|
||||
}
|
||||
};
|
||||
@@ -938,9 +954,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]), Math.max(360, node.size ? node.size[1] : 360)];
|
||||
};
|
||||
|
||||
const updateNodeSize = () => {
|
||||
@@ -1048,18 +1064,129 @@ app.registerExtension({
|
||||
scrapeToggleContainer.appendChild(scrapeOffBtn);
|
||||
controlBar.appendChild(scrapeToggleContainer);
|
||||
|
||||
// --- Maintenance Reset Container (cycle counter / usage counters / thumbnail cache) ---
|
||||
const maintContainer = document.createElement("div");
|
||||
maintContainer.className = "lora-toggle-container";
|
||||
|
||||
const maintLabel = document.createElement("span");
|
||||
maintLabel.className = "lora-toggle-label";
|
||||
maintLabel.innerText = "Reset:";
|
||||
maintContainer.appendChild(maintLabel);
|
||||
|
||||
const postMaintenance = async (route, body) => {
|
||||
const resp = await authenticatedFetch(route, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body || {})
|
||||
});
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok || (data && data.status === "error")) {
|
||||
throw new Error((data && data.message) || `Request failed (${resp.status})`);
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
const makeMaintButton = (label, title, onClick) => {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "lora-toggle-btn";
|
||||
btn.innerText = label;
|
||||
btn.title = title;
|
||||
btn.dataset.defaultLabel = label;
|
||||
btn.addEventListener("click", async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (btn.disabled) return;
|
||||
try {
|
||||
const result = await onClick();
|
||||
if (result === false) return;
|
||||
btn.innerText = "✅ Done";
|
||||
} catch (err) {
|
||||
console.error(`[SaturnNodes] '${label}' maintenance action failed:`, err);
|
||||
btn.innerText = "⚠️ Failed";
|
||||
}
|
||||
btn.disabled = true;
|
||||
app.graph?.setDirtyCanvas(true, true);
|
||||
setTimeout(() => {
|
||||
btn.disabled = false;
|
||||
btn.innerText = btn.dataset.defaultLabel;
|
||||
app.graph?.setDirtyCanvas(true, true);
|
||||
}, 1600);
|
||||
app.graph?.setDirtyCanvas(true, true);
|
||||
});
|
||||
return btn;
|
||||
};
|
||||
|
||||
// Cycle counter: resets Sequential/Random position + prompt->LoRA assignments for this node
|
||||
const cycleResetBtn = makeMaintButton(
|
||||
"↺ Counter",
|
||||
"Reset this loader's Sequential/Random cycle counter and prompt-to-LoRA assignments",
|
||||
async () => {
|
||||
const ok = confirm(
|
||||
"Reset this LoRA loader's cycle counter?\n\n" +
|
||||
"• The next run starts again from the FIRST selected LoRA.\n" +
|
||||
"• All prompt→LoRA assignments for this node are cleared.\n\n" +
|
||||
"⚠️ Running and queued workflows will pick LoRAs from the start again,\n" +
|
||||
"which can mismatch them against the prompt iterator mid-batch.\n" +
|
||||
"Only do this between batches (when the queue is empty).\n\nContinue?"
|
||||
);
|
||||
if (!ok) return false;
|
||||
await postMaintenance("/saturnnodes/folder_lora_loader/reset_cycle", { node_id: String(node.id) });
|
||||
}
|
||||
);
|
||||
maintContainer.appendChild(cycleResetBtn);
|
||||
|
||||
// Usage counters: clears badges + 'Usage' sortings (cosmetic)
|
||||
const usageResetBtn = makeMaintButton(
|
||||
"🧹 Usage",
|
||||
"Reset all LoRA usage counters (badges and 'Usage' sortings)",
|
||||
async () => {
|
||||
const ok = confirm(
|
||||
"Reset all LoRA usage counters?\n\n" +
|
||||
"• Every usage badge and 'Usage' sorting starts from 0.\n" +
|
||||
"• Cosmetic only - LoRA loading itself is not affected.\n\nContinue?"
|
||||
);
|
||||
if (!ok) return false;
|
||||
await postMaintenance("/saturnnodes/folder_lora_loader/reset_usage");
|
||||
updateVisualGrid();
|
||||
}
|
||||
);
|
||||
maintContainer.appendChild(usageResetBtn);
|
||||
|
||||
// Thumbnail cache: deletes cached WebP previews (regenerated automatically)
|
||||
const cacheResetBtn = makeMaintButton(
|
||||
"🗑 Cache",
|
||||
"Delete cached LoRA thumbnail images (they regenerate automatically)",
|
||||
async () => {
|
||||
const ok = confirm(
|
||||
"Clear the cached LoRA thumbnails?\n\n" +
|
||||
"• Cached preview thumbnails are deleted from disk.\n" +
|
||||
"• They regenerate automatically when you view the grid.\n\nContinue?"
|
||||
);
|
||||
if (!ok) return false;
|
||||
await postMaintenance("/saturnnodes/folder_lora_loader/clear_cache");
|
||||
}
|
||||
);
|
||||
maintContainer.appendChild(cacheResetBtn);
|
||||
|
||||
controlBar.appendChild(maintContainer);
|
||||
|
||||
viewContainer.appendChild(controlBar);
|
||||
viewContainer.appendChild(gridContainer);
|
||||
|
||||
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.
|
||||
// Removing computeSize avoids an infinite expansion loop in _arrangeWidgets.
|
||||
domWidget.computeLayoutSize = function() {
|
||||
return { minHeight: 180, maxHeight: undefined, minWidth: 300 };
|
||||
};
|
||||
delete domWidget.computeSize;
|
||||
|
||||
const onResize = node.onResize;
|
||||
node.onResize = function(size) {
|
||||
@@ -1069,11 +1196,6 @@ app.registerExtension({
|
||||
if (!isShowAll && size) {
|
||||
node.userCustomHeight = size[1];
|
||||
userSavedHeight = size[1];
|
||||
if (viewContainer && viewContainer.style) {
|
||||
const h = Math.max(200, size[1] - 100);
|
||||
viewContainer.style.setProperty("height", `${h}px`, "important");
|
||||
viewContainer.style.setProperty("max-height", `${h}px`, "important");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1091,6 +1213,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 +1309,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 +1375,7 @@ app.registerExtension({
|
||||
});
|
||||
|
||||
updateFolderCheckboxes();
|
||||
updateLoraCountDisplay();
|
||||
node.triggerSlotEvent?.(0);
|
||||
};
|
||||
|
||||
@@ -1176,7 +1386,7 @@ app.registerExtension({
|
||||
activeRequest = currentRequest;
|
||||
|
||||
try {
|
||||
const response = await api.fetchApi(`/folder_lora_loader/get_loras?folder=${encodeURIComponent(folder)}&pretty=true&scrape_on_new=${encodeURIComponent(scrapeOnNew)}`);
|
||||
const response = await api.fetchApi(`/saturnnodes/folder_lora_loader/get_loras?folder=${encodeURIComponent(folder)}&pretty=true&scrape_on_new=${encodeURIComponent(scrapeOnNew)}`);
|
||||
if (activeRequest !== currentRequest) return;
|
||||
const data = await response.json();
|
||||
|
||||
@@ -1414,7 +1624,8 @@ app.registerExtension({
|
||||
|
||||
const img = document.createElement("img");
|
||||
img.loading = "lazy";
|
||||
img.dataset.src = `/folder_lora_loader/get_preview?system_path=${encodeURIComponent(item.systemPath)}`;
|
||||
const bypassThumbs = app.ui.settings?.getSettingValue?.("SaturnNodes.🖼️ Visual Loaders.07_BypassThumbnailDownscaling", false);
|
||||
img.dataset.src = `/saturnnodes/folder_lora_loader/get_preview?system_path=${encodeURIComponent(item.systemPath)}` + (bypassThumbs ? "&full=true" : "&size=200");
|
||||
imageObserver.observe(img);
|
||||
|
||||
img.onerror = () => {
|
||||
@@ -1514,6 +1725,11 @@ app.registerExtension({
|
||||
const originalOnConfigure = node.onConfigure;
|
||||
node.onConfigure = function(config) {
|
||||
if (originalOnConfigure) originalOnConfigure.apply(this, arguments);
|
||||
|
||||
if (config && config.size && config.size[1]) {
|
||||
node.userCustomHeight = config.size[1];
|
||||
userSavedHeight = config.size[1];
|
||||
}
|
||||
|
||||
const savedTileSize = (config && config.properties && config.properties["tile_size"] != null)
|
||||
? config.properties["tile_size"]
|
||||
@@ -1553,9 +1769,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 +1832,7 @@ app.registerExtension({
|
||||
applyDisplayMode(displayModeWidget.value || "Scrollable");
|
||||
}
|
||||
updateVisualGrid();
|
||||
updateLoraCountDisplay();
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
+25
-32
@@ -201,29 +201,19 @@ visualStyles.textContent = `
|
||||
document.head.appendChild(visualStyles);
|
||||
|
||||
app.registerExtension({
|
||||
name: "Comfy.ImageLoaderCustom",
|
||||
name: "ComfyUI.SaturnNodes.VisualImageLoader",
|
||||
async nodeCreated(node) {
|
||||
const isVisualImageLoader = [
|
||||
"VisualImageLoader",
|
||||
"ImageLoaderVisualPrettyV2",
|
||||
"ImageLoaderCustom",
|
||||
].includes(node.comfyClass) || [
|
||||
"VisualImageLoader",
|
||||
"ImageLoaderVisualPrettyV2",
|
||||
"ImageLoaderCustom",
|
||||
].includes(node.type);
|
||||
|
||||
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 +278,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) => {
|
||||
@@ -313,11 +301,9 @@ app.registerExtension({
|
||||
} else {
|
||||
viewContainer.classList.remove("show-all-mode");
|
||||
viewContainer.classList.add("scrollable-mode");
|
||||
const restoredH = node.userCustomHeight || userSavedHeight || 480;
|
||||
const containerH = Math.max(200, restoredH - 100);
|
||||
|
||||
viewContainer.style.setProperty("height", `${containerH}px`, "important");
|
||||
viewContainer.style.setProperty("max-height", `${containerH}px`, "important");
|
||||
viewContainer.style.setProperty("height", "100%", "important");
|
||||
viewContainer.style.setProperty("max-height", "100%", "important");
|
||||
viewContainer.style.setProperty("overflow-y", "hidden", "important");
|
||||
|
||||
gridContainer.style.setProperty("height", "100%", "important");
|
||||
@@ -325,7 +311,10 @@ app.registerExtension({
|
||||
gridContainer.style.setProperty("overflow-y", "auto", "important");
|
||||
gridContainer.style.setProperty("flex", "1 1 0px", "important");
|
||||
|
||||
node.setSize([node.size[0], Math.max(420, restoredH)]);
|
||||
const targetH = node.userCustomHeight || userSavedHeight || (node.size ? node.size[1] : 480);
|
||||
if (targetH && (!node.size || node.size[1] !== targetH)) {
|
||||
node.setSize([node.size ? node.size[0] : 380, Math.max(420, targetH)]);
|
||||
}
|
||||
if (app.graph) app.graph.setDirtyCanvas(true, true);
|
||||
}
|
||||
};
|
||||
@@ -333,9 +322,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]), Math.max(420, node.size ? node.size[1] : 420)];
|
||||
};
|
||||
|
||||
const updateNodeSize = () => {
|
||||
@@ -355,9 +344,17 @@ 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.
|
||||
domWidget.computeLayoutSize = function() {
|
||||
return { minHeight: 180, maxHeight: undefined, minWidth: 300 };
|
||||
};
|
||||
delete domWidget.computeSize;
|
||||
|
||||
const onResize = node.onResize;
|
||||
node.onResize = function(size) {
|
||||
if (onResize) onResize.apply(this, arguments);
|
||||
@@ -366,18 +363,9 @@ app.registerExtension({
|
||||
if (!isShowAll && size) {
|
||||
node.userCustomHeight = size[1];
|
||||
userSavedHeight = size[1];
|
||||
if (viewContainer && viewContainer.style) {
|
||||
const h = Math.max(200, size[1] - 100);
|
||||
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",
|
||||
@@ -520,7 +508,7 @@ app.registerExtension({
|
||||
|
||||
const img = document.createElement("img");
|
||||
let fetchFolder = folder;
|
||||
img.dataset.src = `/image_loader/get_thumbnail?folder=${encodeURIComponent(fetchFolder)}&image=${encodeURIComponent(item.relPath)}`;
|
||||
img.dataset.src = `/saturnnodes/image_loader/get_thumbnail?folder=${encodeURIComponent(fetchFolder)}&image=${encodeURIComponent(item.relPath)}`;
|
||||
img.onload = () => img.classList.add("loaded");
|
||||
|
||||
img.onerror = () => {
|
||||
@@ -571,7 +559,7 @@ app.registerExtension({
|
||||
activeRequest = currentRequest;
|
||||
|
||||
try {
|
||||
const response = await api.fetchApi(`/image_loader/get_images?folder=${encodeURIComponent(folder)}`);
|
||||
const response = await api.fetchApi(`/saturnnodes/image_loader/get_images?folder=${encodeURIComponent(folder)}`);
|
||||
if (activeRequest !== currentRequest) return;
|
||||
|
||||
imagesData = await response.json();
|
||||
@@ -608,6 +596,11 @@ app.registerExtension({
|
||||
node.onConfigure = function(config) {
|
||||
if (originalOnConfigure) originalOnConfigure.apply(this, arguments);
|
||||
|
||||
if (config && config.size && config.size[1]) {
|
||||
node.userCustomHeight = config.size[1];
|
||||
userSavedHeight = config.size[1];
|
||||
}
|
||||
|
||||
const savedTileSize = (config && config.properties && config.properties["tile_size"] != null)
|
||||
? config.properties["tile_size"]
|
||||
: (node.properties && node.properties["tile_size"] != null ? node.properties["tile_size"] : null);
|
||||
|
||||
+10
-10
@@ -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: "ComfyUI.SaturnNodes.LocalRunner",
|
||||
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) {
|
||||
|
||||
+66
-60
@@ -2,8 +2,8 @@ import { app } from "/scripts/app.js";
|
||||
import { api } from "/scripts/api.js";
|
||||
import { authenticatedFetch } from "./auth_helper.js";
|
||||
|
||||
const EXTENSION_NAME = "LeafFlow.PowerControlSidebar";
|
||||
const STYLE_ID = "leafflow-power-sidebar-styles";
|
||||
const EXTENSION_NAME = "SaturnNodes.PowerControlSidebar";
|
||||
const STYLE_ID = "saturnnodes-power-sidebar-styles";
|
||||
|
||||
let currentPowerState = {
|
||||
pending_action: null,
|
||||
@@ -19,7 +19,7 @@ function injectStyles() {
|
||||
style.id = STYLE_ID;
|
||||
style.textContent = `
|
||||
/* Power Sidebar Button Layout (Matching standard full-width sidebar button) */
|
||||
#leafflow-power-bottom-btn {
|
||||
#saturnnodes-power-bottom-btn {
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
min-height: 48px !important;
|
||||
@@ -29,7 +29,7 @@ function injectStyles() {
|
||||
align-self: stretch !important;
|
||||
}
|
||||
|
||||
#leafflow-power-bottom-btn .side-bar-button-content {
|
||||
#saturnnodes-power-bottom-btn .side-bar-button-content {
|
||||
width: 100% !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
@@ -38,33 +38,33 @@ function injectStyles() {
|
||||
}
|
||||
|
||||
/* Power Sidebar Button Armed Red Warning State */
|
||||
#leafflow-power-bottom-btn.leafflow-power-btn-armed,
|
||||
.comfy-power-btn.leafflow-power-btn-armed,
|
||||
.side-bar-button.leafflow-power-btn-armed,
|
||||
.side-bar-button:has(.leafflow-power-icon-armed),
|
||||
button[aria-label*="Power"].leafflow-power-btn-armed {
|
||||
#saturnnodes-power-bottom-btn.saturnnodes-power-btn-armed,
|
||||
.comfy-power-btn.saturnnodes-power-btn-armed,
|
||||
.side-bar-button.saturnnodes-power-btn-armed,
|
||||
.side-bar-button:has(.saturnnodes-power-icon-armed),
|
||||
button[aria-label*="Power"].saturnnodes-power-btn-armed {
|
||||
background: linear-gradient(135deg, #dc2626 0%, #991b1b 100%) !important;
|
||||
color: #ffffff !important;
|
||||
border: 1px solid #f87171 !important;
|
||||
box-shadow: 0 0 14px rgba(239, 68, 68, 0.8) !important;
|
||||
animation: leafflow-power-pulse 1.8s infinite ease-in-out !important;
|
||||
animation: saturnnodes-power-pulse 1.8s infinite ease-in-out !important;
|
||||
}
|
||||
|
||||
.leafflow-power-btn-armed svg,
|
||||
.leafflow-power-btn-armed .side-bar-button-icon,
|
||||
.leafflow-power-btn-armed span {
|
||||
.saturnnodes-power-btn-armed svg,
|
||||
.saturnnodes-power-btn-armed .side-bar-button-icon,
|
||||
.saturnnodes-power-btn-armed span {
|
||||
color: #ffffff !important;
|
||||
stroke: #ffffff !important;
|
||||
}
|
||||
|
||||
@keyframes leafflow-power-pulse {
|
||||
@keyframes saturnnodes-power-pulse {
|
||||
0% { box-shadow: 0 0 6px rgba(239, 68, 68, 0.5); transform: scale(1); }
|
||||
50% { box-shadow: 0 0 16px rgba(239, 68, 68, 0.95); transform: scale(1.03); }
|
||||
100% { box-shadow: 0 0 6px rgba(239, 68, 68, 0.5); transform: scale(1); }
|
||||
}
|
||||
|
||||
/* Power Side Popup Menu (like Help Center popup) */
|
||||
.leafflow-power-popup {
|
||||
.saturnnodes-power-popup {
|
||||
position: fixed;
|
||||
z-index: 10005;
|
||||
min-width: 250px;
|
||||
@@ -76,15 +76,15 @@ function injectStyles() {
|
||||
padding: 8px;
|
||||
color: var(--fg-color, #f4f4f5);
|
||||
font-family: inherit;
|
||||
animation: leafflow-popup-fade 0.15s ease-out;
|
||||
animation: saturnnodes-popup-fade 0.15s ease-out;
|
||||
}
|
||||
|
||||
@keyframes leafflow-popup-fade {
|
||||
@keyframes saturnnodes-popup-fade {
|
||||
from { opacity: 0; transform: translateX(-6px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
.leafflow-power-popup .help-menu-item {
|
||||
.saturnnodes-power-popup .help-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
@@ -101,27 +101,27 @@ function injectStyles() {
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.leafflow-power-popup .help-menu-item:hover {
|
||||
.saturnnodes-power-popup .help-menu-item:hover {
|
||||
background: var(--comfy-menu-secondary-bg, rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
|
||||
.leafflow-power-popup .help-menu-item.danger-item:hover {
|
||||
.saturnnodes-power-popup .help-menu-item.danger-item:hover {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.leafflow-power-popup .help-menu-item.armed-cancel-item {
|
||||
.saturnnodes-power-popup .help-menu-item.armed-cancel-item {
|
||||
background: rgba(239, 68, 68, 0.85);
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.leafflow-power-popup .help-menu-item.armed-cancel-item:hover {
|
||||
.saturnnodes-power-popup .help-menu-item.armed-cancel-item:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.leafflow-power-popup .help-menu-icon-container {
|
||||
.saturnnodes-power-popup .help-menu-icon-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -131,18 +131,18 @@ function injectStyles() {
|
||||
color: var(--descrip-text, #a1a1aa);
|
||||
}
|
||||
|
||||
.leafflow-power-popup .help-menu-item:hover .help-menu-icon-container {
|
||||
.saturnnodes-power-popup .help-menu-item:hover .help-menu-icon-container {
|
||||
color: var(--fg-color, #f4f4f5);
|
||||
}
|
||||
|
||||
.leafflow-power-popup .menu-label {
|
||||
.saturnnodes-power-popup .menu-label {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.leafflow-power-popup .menu-divider {
|
||||
.saturnnodes-power-popup .menu-divider {
|
||||
height: 1px;
|
||||
background: var(--border-color, #27272a);
|
||||
margin: 4px 0;
|
||||
@@ -150,7 +150,7 @@ function injectStyles() {
|
||||
}
|
||||
|
||||
/* Power Confirm Modal */
|
||||
.leafflow-power-modal {
|
||||
.saturnnodes-power-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
@@ -172,17 +172,17 @@ function getPowerIconSvg(size = 18) {
|
||||
|
||||
function updateSidebarButtonVisuals() {
|
||||
const isArmed = Boolean(currentPowerState.pending_action);
|
||||
const btns = document.querySelectorAll('#leafflow-power-bottom-btn, .comfy-power-btn, [aria-label*="Power"], .side-bar-button:has(.pi-power)');
|
||||
const btns = document.querySelectorAll('#saturnnodes-power-bottom-btn, .comfy-power-btn, [aria-label*="Power"], .side-bar-button:has(.pi-power)');
|
||||
btns.forEach(btn => {
|
||||
if (isArmed) {
|
||||
btn.classList.add("leafflow-power-btn-armed");
|
||||
btn.classList.add("saturnnodes-power-btn-armed");
|
||||
const icon = btn.querySelector(".side-bar-button-icon, i, svg");
|
||||
if (icon) icon.classList.add("leafflow-power-icon-armed");
|
||||
if (icon) icon.classList.add("saturnnodes-power-icon-armed");
|
||||
btn.setAttribute("title", `⚠️ ComfyUI Power: Armed to ${currentPowerState.pending_action.toUpperCase()} after queue finish! Click to cancel.`);
|
||||
} else {
|
||||
btn.classList.remove("leafflow-power-btn-armed");
|
||||
btn.classList.remove("saturnnodes-power-btn-armed");
|
||||
const icon = btn.querySelector(".side-bar-button-icon, i, svg");
|
||||
if (icon) icon.classList.remove("leafflow-power-icon-armed");
|
||||
if (icon) icon.classList.remove("saturnnodes-power-icon-armed");
|
||||
btn.setAttribute("title", "Power (Restart / Shutdown)");
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
@@ -210,7 +210,7 @@ function openConfirmModal(title, message, onConfirm, isDanger = false) {
|
||||
overlay.style.backdropFilter = "blur(3px)";
|
||||
|
||||
const modal = document.createElement("div");
|
||||
modal.className = "leafflow-power-modal";
|
||||
modal.className = "saturnnodes-power-modal";
|
||||
modal.style.border = isDanger ? "1px solid rgba(239, 68, 68, 0.5)" : "1px solid rgba(59, 130, 246, 0.5)";
|
||||
modal.style.boxShadow = isDanger ? "0 0 25px rgba(239, 68, 68, 0.35)" : "0 0 25px rgba(59, 130, 246, 0.35)";
|
||||
|
||||
@@ -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();
|
||||
@@ -309,7 +313,7 @@ function showPowerSidePopup(anchorElement) {
|
||||
}
|
||||
|
||||
const popup = document.createElement("div");
|
||||
popup.className = "help-center-popup sidebar-left leafflow-power-popup";
|
||||
popup.className = "help-center-popup sidebar-left saturnnodes-power-popup";
|
||||
popup.setAttribute("data-testid", "power-popup");
|
||||
|
||||
const rect = anchorElement.getBoundingClientRect();
|
||||
@@ -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
|
||||
});
|
||||
@@ -621,16 +625,17 @@ function showPowerSidePopup(anchorElement) {
|
||||
}
|
||||
|
||||
function attachBottomSidebarButton() {
|
||||
// Fast path for the DOM MutationObserver: bail out before any querySelector
|
||||
// work when our button is already mounted (this callback fires for every
|
||||
// mutation in the app while dragging/resizing UI elements).
|
||||
const existingBtn = document.getElementById("saturnnodes-power-bottom-btn");
|
||||
if (existingBtn && existingBtn.isConnected) return;
|
||||
|
||||
const bottomGroup = document.querySelector(".sidebar-item-group.mt-auto, .side-bar .mt-auto, [data-testid='bottom-sidebar']");
|
||||
if (!bottomGroup) return;
|
||||
|
||||
if (document.getElementById("leafflow-power-bottom-btn")) {
|
||||
updateSidebarButtonVisuals();
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.createElement("button");
|
||||
btn.id = "leafflow-power-bottom-btn";
|
||||
btn.id = "saturnnodes-power-bottom-btn";
|
||||
btn.type = "button";
|
||||
btn.className = "relative inline-flex items-center justify-center gap-2 touch-manipulation whitespace-nowrap appearance-none font-medium font-inter transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([width]):not([height])]:size-4 [&_svg]:shrink-0 bg-transparent text-muted-foreground hover:bg-secondary-background-hover h-8 rounded-lg p-2 text-xs side-bar-button cursor-pointer border-none comfy-power-btn";
|
||||
btn.setAttribute("aria-label", "Power");
|
||||
@@ -683,10 +688,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);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { app } from "/scripts/app.js";
|
||||
import { api } from "/scripts/api.js";
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfyUI.LeafFlow.PreviewImageSizeAspectRatio",
|
||||
name: "ComfyUI.SaturnNodes.PreviewImageSizeAspectRatio",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name !== "PreviewImageSizeAspectRatio" && nodeData.name !== "TextAspectRatioFinder") return;
|
||||
@@ -35,7 +35,7 @@ app.registerExtension({
|
||||
|
||||
// DOM Container for ComfyUI V2 Vue UI
|
||||
const viewContainer = document.createElement("div");
|
||||
viewContainer.className = "leafflow-ar-preview-container";
|
||||
viewContainer.className = "saturnnodes-ar-preview-container";
|
||||
viewContainer.style.width = "100%";
|
||||
viewContainer.style.height = "calc(100% - 110px)";
|
||||
viewContainer.style.minHeight = "80px";
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -66,8 +66,8 @@ class PreviewManagerClass {
|
||||
const images = document.querySelectorAll('img[alt*="Live sampling preview"]');
|
||||
images.forEach(imgEl => {
|
||||
const src = imgEl.src;
|
||||
if (src && src.startsWith("blob:") && imgEl.dataset.lastLeafFlowSrc !== src) {
|
||||
imgEl.dataset.lastLeafFlowSrc = src;
|
||||
if (src && src.startsWith("blob:") && imgEl.dataset.lastSaturnNodesSrc !== src) {
|
||||
imgEl.dataset.lastSaturnNodesSrc = src;
|
||||
|
||||
const nodeEl = imgEl.closest('[data-node-id], [data-widgets-grid-node-id], .lg-node');
|
||||
const nodeId = nodeEl ? (nodeEl.dataset.nodeId || nodeEl.dataset.widgetsGridNodeId || nodeEl.getAttribute('data-node-id')) : null;
|
||||
|
||||
+92
-45
@@ -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;
|
||||
@@ -39,57 +40,84 @@ function countPromptsInText(text, separator, customRegex) {
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfyUI.LeafFlow.PromptCounter",
|
||||
name: "ComfyUI.SaturnNodes.PromptCounter",
|
||||
async nodeCreated(node) {
|
||||
if (node.comfyClass === "PromptCounter") {
|
||||
const textWidget = node.widgets?.find(w => w.name === "text");
|
||||
const sepWidget = node.widgets?.find(w => w.name === "separator");
|
||||
const regexWidget = node.widgets?.find(w => w.name === "custom_regex");
|
||||
|
||||
// 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);
|
||||
};
|
||||
|
||||
+56
-16
@@ -2,36 +2,76 @@ import { app } from "/scripts/app.js";
|
||||
import { api } from "/scripts/api.js";
|
||||
import { authenticatedFetch } from "./auth_helper.js";
|
||||
|
||||
function findGraphNode(id) {
|
||||
if (!id && id !== 0) return null;
|
||||
const strId = String(id);
|
||||
const numId = Number(id);
|
||||
|
||||
let node = app.graph?.getNodeById?.(id) || app.graph?.getNodeById?.(numId) || app.graph?.getNodeById?.(strId);
|
||||
if (node) return node;
|
||||
|
||||
if (app.graph && Array.isArray(app.graph._nodes)) {
|
||||
for (const n of app.graph._nodes) {
|
||||
if (String(n.id) === strId) return n;
|
||||
if (n.subgraph && typeof n.subgraph.getNodeById === "function") {
|
||||
const sub = n.subgraph.getNodeById(id) || n.subgraph.getNodeById(numId) || n.subgraph.getNodeById(strId);
|
||||
if (sub) return sub;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (app.graph && app.graph._subgraphs) {
|
||||
const subgraphs = Array.isArray(app.graph._subgraphs)
|
||||
? app.graph._subgraphs
|
||||
: Object.values(app.graph._subgraphs);
|
||||
for (const sg of subgraphs) {
|
||||
if (sg && typeof sg.getNodeById === "function") {
|
||||
const sub = sg.getNodeById(id) || sg.getNodeById(numId) || sg.getNodeById(strId);
|
||||
if (sub) return sub;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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);
|
||||
const node = findGraphNode(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 () => {
|
||||
const proceed = confirm(
|
||||
"Reset this node's prompt counter to 0?\n\n" +
|
||||
"• New queued runs start again from the FIRST prompt.\n" +
|
||||
"• Already-assigned running/queued items keep their prompt (crash recovery).\n" +
|
||||
"• The state file is NOT emptied - use Settings → \"Reset All Queues\" for a full wipe.\n\nContinue?"
|
||||
);
|
||||
if (!proceed) return;
|
||||
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 +79,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 +107,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);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
|
||||
/**
|
||||
* 🪐 SaturnNodes — Stereoscopic 3D Extension
|
||||
*
|
||||
* Configures the static 3D stereogram generator node (SaturnStereo3D).
|
||||
*/
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfyUI.SaturnNodes.SaturnStereo3D",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name !== "SaturnStereo3D") return;
|
||||
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
if (onNodeCreated) onNodeCreated.apply(this, arguments);
|
||||
|
||||
if (!this.size || this.size[0] < 340) {
|
||||
this.size = [360, 500];
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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" });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
+1
-1
@@ -1,4 +1,4 @@
|
||||
/* LeafFlow PauseQueue CSS */
|
||||
/* SaturnNodes PauseQueue CSS */
|
||||
|
||||
:root {
|
||||
--pq-unpaused-color: #059669;
|
||||
|
||||
+9
-9
@@ -33,13 +33,13 @@ class PauseQueueUI {
|
||||
|
||||
async fetchStatus() {
|
||||
try {
|
||||
const res = await api.fetchApi("/pause_queue/status");
|
||||
const res = await api.fetchApi("/saturnnodes/pause_queue/status");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
this.updateState(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[LeafFlow] Error fetching status:", e);
|
||||
console.error("[SaturnNodes PauseQueue] Error fetching status:", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ class PauseQueueUI {
|
||||
|
||||
async togglePause(paused) {
|
||||
try {
|
||||
const res = await authenticatedFetch("/pause_queue/toggle", {
|
||||
const res = await authenticatedFetch("/saturnnodes/pause_queue/toggle", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ paused, mode: this.state.mode })
|
||||
@@ -68,13 +68,13 @@ class PauseQueueUI {
|
||||
this.updateState(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[LeafFlow] Error toggling pause:", e);
|
||||
console.error("[SaturnNodes PauseQueue] Error toggling pause:", e);
|
||||
}
|
||||
}
|
||||
|
||||
async setMode(mode) {
|
||||
try {
|
||||
const res = await authenticatedFetch("/pause_queue/mode", {
|
||||
const res = await authenticatedFetch("/saturnnodes/pause_queue/mode", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mode })
|
||||
@@ -84,19 +84,19 @@ class PauseQueueUI {
|
||||
this.updateState(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[LeafFlow] Error setting mode:", e);
|
||||
console.error("[SaturnNodes PauseQueue] Error setting mode:", e);
|
||||
}
|
||||
}
|
||||
|
||||
async continueQueue() {
|
||||
try {
|
||||
const res = await authenticatedFetch("/pause_queue/continue", { method: "POST" });
|
||||
const res = await authenticatedFetch("/saturnnodes/pause_queue/continue", { method: "POST" });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
this.updateState(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[LeafFlow] Error continuing queue:", e);
|
||||
console.error("[SaturnNodes PauseQueue] Error continuing queue:", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,7 +320,7 @@ class PauseQueueUI {
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfyUI.LeafFlow.PauseQueue",
|
||||
name: "ComfyUI.SaturnNodes.PauseQueue",
|
||||
async setup() {
|
||||
new PauseQueueUI();
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
@@ -47,7 +47,7 @@ app.registerExtension({
|
||||
const claimQueueOwnership = async () => {
|
||||
if (api && api.clientId) {
|
||||
try {
|
||||
await authenticatedFetch("/persistent_queue/claim", {
|
||||
await authenticatedFetch("/saturnnodes/persistent_queue/claim", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ client_id: api.clientId })
|
||||
@@ -57,7 +57,7 @@ app.registerExtension({
|
||||
setTimeout(patchRgthree, 150);
|
||||
setTimeout(patchRgthree, 600);
|
||||
} catch (e) {
|
||||
console.error("[LeafFlow] Error claiming queue ownership:", e);
|
||||
console.error("[SaturnNodes PersistentQueue] Error claiming queue ownership:", e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
+10
-10
@@ -4,12 +4,12 @@ import { PreviewManager } from "./js/preview_manager.js";
|
||||
|
||||
// Global CSS injection to guarantee zero-expansion in Vue Node 2.0 and LiteGraph
|
||||
if (typeof document !== "undefined") {
|
||||
const styleId = "leafflow-live-preview-styles";
|
||||
const styleId = "saturnnodes-live-preview-styles";
|
||||
if (!document.getElementById(styleId)) {
|
||||
const style = document.createElement("style");
|
||||
style.id = styleId;
|
||||
style.textContent = `
|
||||
.leafflow-live-preview-container {
|
||||
.saturnnodes-live-preview-container {
|
||||
position: absolute !important;
|
||||
inset: 0 !important;
|
||||
top: 70px !important;
|
||||
@@ -29,7 +29,7 @@ if (typeof document !== "undefined") {
|
||||
box-sizing: border-box !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
.leafflow-live-preview-img {
|
||||
.saturnnodes-live-preview-img {
|
||||
position: absolute !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
@@ -48,7 +48,7 @@ if (typeof document !== "undefined") {
|
||||
min-height: 0 !important;
|
||||
min-width: 0 !important;
|
||||
}
|
||||
[node-type="PreviewLatentLive"] .leafflow-live-preview-container {
|
||||
[node-type="PreviewLatentLive"] .saturnnodes-live-preview-container {
|
||||
position: absolute !important;
|
||||
}
|
||||
`;
|
||||
@@ -176,7 +176,7 @@ function updateAllPreviewNodeDropdowns() {
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfyUI.LeafFlow.PreviewLatentLive",
|
||||
name: "ComfyUI.SaturnNodes.PreviewLatentLive",
|
||||
async setup() {
|
||||
PreviewManager.init(app, api);
|
||||
|
||||
@@ -234,7 +234,7 @@ app.registerExtension({
|
||||
this.imageIndex = 0;
|
||||
|
||||
const viewContainer = document.createElement("div");
|
||||
viewContainer.className = "leafflow-live-preview-container";
|
||||
viewContainer.className = "saturnnodes-live-preview-container";
|
||||
viewContainer.style.position = "absolute";
|
||||
viewContainer.style.inset = "0";
|
||||
viewContainer.style.top = "70px";
|
||||
@@ -253,12 +253,12 @@ app.registerExtension({
|
||||
viewContainer.style.pointerEvents = "none";
|
||||
|
||||
const fallbackText = document.createElement("div");
|
||||
fallbackText.className = "leafflow-live-preview-fallback";
|
||||
fallbackText.className = "saturnnodes-live-preview-fallback";
|
||||
fallbackText.innerText = "Waiting for live preview...";
|
||||
viewContainer.appendChild(fallbackText);
|
||||
|
||||
const imgElement = document.createElement("img");
|
||||
imgElement.className = "leafflow-live-preview-img";
|
||||
imgElement.className = "saturnnodes-live-preview-img";
|
||||
imgElement.style.position = "absolute";
|
||||
imgElement.style.top = "0";
|
||||
imgElement.style.left = "0";
|
||||
@@ -358,12 +358,12 @@ app.registerExtension({
|
||||
this.imageIndex = 0;
|
||||
|
||||
if (typeof document !== "undefined") {
|
||||
const domImgs = document.querySelectorAll(`[data-node-id="${this.id}"] .leafflow-live-preview-img, [data-widgets-grid-node-id="${this.id}"] .leafflow-live-preview-img`);
|
||||
const domImgs = document.querySelectorAll(`[data-node-id="${this.id}"] .saturnnodes-live-preview-img, [data-widgets-grid-node-id="${this.id}"] .saturnnodes-live-preview-img`);
|
||||
if (domImgs.length > 0) {
|
||||
domImgs.forEach(imgEl => {
|
||||
imgEl.src = img.src;
|
||||
imgEl.style.display = "block";
|
||||
const fallback = imgEl.parentElement?.querySelector(".leafflow-live-preview-fallback");
|
||||
const fallback = imgEl.parentElement?.querySelector(".saturnnodes-live-preview-fallback");
|
||||
if (fallback) fallback.style.display = "none";
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
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",
|
||||
"VisualImageLoader",
|
||||
"LoadRecentOutputs",
|
||||
|
||||
// Automation, Flow & Utilities
|
||||
"LoadImageFromFolder",
|
||||
"TextAspectRatioFinder",
|
||||
"PreviewImageSizeAspectRatio",
|
||||
"TextLoraFinder",
|
||||
"PromptQueueIterator",
|
||||
"PromptCounter",
|
||||
"MultiTextReplacer",
|
||||
"SaturnTextSplit",
|
||||
"SaturnDecision",
|
||||
"RunLocalFileNode",
|
||||
|
||||
// Queue & Previews
|
||||
"PreviewLatentLive",
|
||||
"SaturnStereo3D",
|
||||
"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) {
|
||||
try {
|
||||
if (nodeData && nodeData.name && SATURN_NODE_TYPES.has(nodeData.name)) {
|
||||
if (isSaturnColorsEnabled()) {
|
||||
const theme = getSaturnTheme();
|
||||
if (nodeType) {
|
||||
nodeType.color = theme.color;
|
||||
nodeType.bgcolor = theme.bgcolor;
|
||||
}
|
||||
} else {
|
||||
if (nodeType) {
|
||||
nodeType.color = undefined;
|
||||
nodeType.bgcolor = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
},
|
||||
async nodeCreated(node) {
|
||||
applySaturnColorToNode(node);
|
||||
},
|
||||
async loadedGraphNode(node) {
|
||||
applySaturnColorToNode(node);
|
||||
},
|
||||
afterConfigureGraph() {
|
||||
updateAllSaturnNodeColors();
|
||||
},
|
||||
async setup() {
|
||||
setTimeout(() => updateAllSaturnNodeColors(), 100);
|
||||
setTimeout(() => updateAllSaturnNodeColors(), 500);
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user