Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b37d90882b | ||
|
|
71608332ab | ||
|
|
48e452dd67 | ||
|
|
377b95681c | ||
|
|
b4f388f378 | ||
|
|
de815c5c63 | ||
|
|
88e73b81c3 | ||
|
|
4145f247b7 | ||
|
|
1e3999e32f | ||
|
|
5cdd808bd8 | ||
|
|
aa63fffb36 | ||
|
|
8727eab44e | ||
|
|
19c6084216 | ||
|
|
63747d9c9c | ||
|
|
f7ec8e5073 | ||
|
|
0e635cd37f |
@@ -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
|
||||
+9
-1
@@ -1,5 +1,6 @@
|
||||
# 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=
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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)**.
|
||||
|
||||
@@ -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,26 +120,25 @@ 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 clean heading section in [`README.md`](./README.md) under `## 📦 Individual Nodes Reference`:
|
||||
@@ -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,31 +223,28 @@ 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -275,3 +285,9 @@ Use atomic, descriptive English commit headers:
|
||||
- `Docs: ...` (Documentation, README, Walkthrough updates)
|
||||
- `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.
|
||||
|
||||
+50
-21
@@ -5,6 +5,35 @@ 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
|
||||
@@ -60,7 +89,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **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 node mappings (`LeafFlowDecision`, `LeafFlowTextSplit`).
|
||||
- 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`.
|
||||
|
||||
@@ -70,14 +99,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Changed & Rebranded
|
||||
- **Full Suite Rebrand to SaturnNodes (`🪐`)**:
|
||||
- Rebranded the entire suite from `ComfyUI-LeafFlow` to **`ComfyUI-SaturnNodes`** with signature emoji `🪐`.
|
||||
- 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 class names (`LeafFlowDecision`, `LeafFlowTextSplit`) preserved and aliased alongside new class names in `NODE_CLASS_MAPPINGS`.
|
||||
- Dual HTTP route registration: both `/saturnnodes/*` and `/leafflow/*` routes are active, guaranteeing compatibility with existing scripts, extensions, and bookmarks.
|
||||
- Dual WebSocket event emission: both `saturnnodes_*` and `leafflow_*` events are broadcast to the frontend.
|
||||
- Seamless settings migration: frontend settings and local storage automatically migrate from `LeafFlow.*` to `SaturnNodes.*` with instant fallback.
|
||||
- Automatic user directory migration: `.env` and `prompt_iterator_state.json` migrate smoothly from `user/default/LeafFlow/` to `user/default/SaturnNodes/`.
|
||||
- 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.
|
||||
|
||||
---
|
||||
@@ -86,10 +115,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### 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.*`).
|
||||
@@ -122,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`).
|
||||
@@ -160,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.
|
||||
@@ -171,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**:
|
||||
@@ -181,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`).
|
||||
@@ -196,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`).
|
||||
@@ -207,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`.
|
||||
|
||||
@@ -222,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.
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
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).
|
||||
|
||||
> 📖 **New here?** Follow the step-by-step [Complete Walkthrough](WALKTHROUGH.md) for setup, node recipes, and batch-prompting patterns.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Key Capabilities & Overview
|
||||
@@ -63,11 +65,17 @@ Loads a LoRA using formatted pretty names (e.g. `Cyberpunk Character V1` instead
|
||||
</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.
|
||||
@@ -88,7 +96,7 @@ Visual thumbnail browser for LoRAs with Civitai SHA256 search & TMDB auto-scrapi
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>🪐 📷 Visual Image Loader</b> (<code>VisualImageLoader</code> / alias <code>ImageLoaderVisualPrettyV2</code>)</summary>
|
||||
<summary><b>🪐 📷 Visual Image Loader</b> (<code>VisualImageLoader</code>)</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.
|
||||
@@ -152,10 +160,10 @@ Canvas rendering node that listens to sampler WebSocket latent binary streams an
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>🪐 ⏸️ Saturn Decision</b> (<code>SaturnDecision</code> / alias <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. Fully backward compatible with workflows containing `LeafFlowDecision`.
|
||||
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.
|
||||
|
||||
#### Inputs & Widgets
|
||||
- **`disable`** (`BOOLEAN`): Bypass decision gate.
|
||||
@@ -167,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.
|
||||
@@ -205,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.
|
||||
@@ -279,7 +287,7 @@ Performs multiple text replacements in a single step using comma-separated or ne
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>🪐 ✂️ Text Split</b> (<code>SaturnTextSplit</code> / alias <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.
|
||||
@@ -371,6 +379,16 @@ Configure options directly under ComfyUI Settings (⚙ gear icon):
|
||||
- **`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>
|
||||
<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>
|
||||
@@ -398,12 +416,13 @@ Configure options directly under ComfyUI Settings (⚙ gear icon):
|
||||
- **`Toolbar Button Paused Color`** (`text`, *Default: `#ea580c`*): Hex color for the toolbar paused state.
|
||||
- **`Enable System Tray Icon`** (`boolean`, *Default: false*): Displays an OS system tray icon with real-time queue status colors and outside-browser controls.
|
||||
- **`Allow Process Management (Restart / Shutdown)`** (`boolean`, *Default: false*): Opt-in authorization allowing server restart and shutdown actions from the SaturnNodes power controls. Disabled by default for maximum security.
|
||||
- **`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>
|
||||
<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>
|
||||
|
||||
@@ -438,6 +457,12 @@ Configure options directly under ComfyUI Settings (⚙ gear icon):
|
||||
- **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
|
||||
@@ -445,27 +470,38 @@ Configure options directly under ComfyUI Settings (⚙ gear icon):
|
||||
For the comprehensive engineering standard, threat model, and review precedents enforced by the ComfyUI-Manager maintainers, see the full **[ComfyUI Custom Node Security & Compliance Rulebook](SECURITY_AUDIT_PLAYBOOK.md)**.
|
||||
|
||||
- **Local Runner Sandbox & Operator Authorization Gate:** The `Run Local File` node enforces single-use interactive operator authorization (`⚡ Authorize Run (5 min)`), strictly confines execution to `ComfyUI/scripts/`, never uses `shell=True`, terminates whole process trees on timeout or cancellation, and **never persists authorization inside workflow JSON or image metadata**.
|
||||
- **CSRF Defense & Fetch Metadata Validation:** All administrative endpoints (`/saturnnodes/settings`, `/saturnnodes/power/*`, `/saturnnodes/local_runner/*`, `/saturnnodes/scrapes/clear` and backward-compatible `/leafflow/*` aliases) enforce loopback checks, `Sec-Fetch-Site` header checks (blocking cross-site requests from visited web pages), and cryptographic per-session `X-SaturnNodes-CSRF-Token` headers.
|
||||
- **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` inside `user/default/SaturnNodes/` (auto-migrated from legacy `LeafFlow/` folder if present).
|
||||
- **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-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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+2
-2
@@ -15,11 +15,11 @@ Instead, please report security vulnerabilities directly to the maintainer via G
|
||||
|
||||
## Security Architecture & Best Practices
|
||||
|
||||
- **CSRF Defense & Fetch Metadata Validation**: All administrative endpoints (`/saturnnodes/*`, `/leafflow/*`) 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-SaturnNodes-CSRF-Token` / `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 /saturnnodes/power/request_token`) valid for 30 seconds.
|
||||
|
||||
+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!
|
||||
|
||||
+27
-27
@@ -4,7 +4,7 @@ import subprocess
|
||||
from aiohttp import web
|
||||
from server import PromptServer
|
||||
|
||||
__version__ = "2.5.0"
|
||||
__version__ = "2.6.1"
|
||||
|
||||
from .nodes.queue_control import (
|
||||
setup_queue_control_routes,
|
||||
@@ -20,16 +20,15 @@ from .nodes.assets_restore import (
|
||||
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 SaturnDecision
|
||||
from .nodes.aspect_ratio import TextAspectRatioFinder, AspectRatioFinder, PreviewImageSizeAspectRatio
|
||||
from .nodes.lora_finder import TextLoraFinder, LoraTextFinder
|
||||
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
|
||||
@@ -38,7 +37,6 @@ from .nodes.local_runner import RunLocalFileNode, setup_local_runner_routes
|
||||
from .nodes.stereogram import SaturnStereo3D
|
||||
from .nodes.utils import (
|
||||
get_saturnnodes_user_dir,
|
||||
get_leafflow_user_dir,
|
||||
get_env_setting,
|
||||
is_local_request,
|
||||
is_safe_path,
|
||||
@@ -88,7 +86,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
|
||||
ALL_SATURN_NODE_CLASSES = dict(NODE_CLASS_MAPPINGS)
|
||||
ALL_SATURN_NODE_DISPLAY_NAMES = dict(NODE_DISPLAY_NAME_MAPPINGS)
|
||||
ALL_SATURN_NODE_KEYS = list(NODE_DISPLAY_NAME_MAPPINGS.keys())
|
||||
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."""
|
||||
@@ -150,32 +148,25 @@ def save_disabled_nodes(disabled_set):
|
||||
# Filter disabled nodes (all enabled by default when disabled_nodes.json is empty)
|
||||
_active_disabled_nodes = load_disabled_nodes()
|
||||
if _active_disabled_nodes:
|
||||
_alias_map = {
|
||||
"FolderLoraLoaderPretty": "FolderLoraLoaderVisualPrettyV2",
|
||||
"VisualImageLoader": "ImageLoaderVisualPrettyV2",
|
||||
"TextAspectRatioFinder": "AspectRatioFinder",
|
||||
"TextLoraFinder": "LoraTextFinder"
|
||||
}
|
||||
for _node_name in _active_disabled_nodes:
|
||||
NODE_CLASS_MAPPINGS.pop(_node_name, None)
|
||||
NODE_DISPLAY_NAME_MAPPINGS.pop(_node_name, None)
|
||||
_alias = _alias_map.get(_node_name)
|
||||
if _alias:
|
||||
NODE_CLASS_MAPPINGS.pop(_alias, None)
|
||||
|
||||
WEB_DIRECTORY = "./web"
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
|
||||
|
||||
server = PromptServer.instance
|
||||
setup_queue_control_routes(server)
|
||||
setup_local_runner_routes(server)
|
||||
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_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":
|
||||
@@ -479,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
|
||||
@@ -504,7 +499,8 @@ async def save_settings(request):
|
||||
os.environ["CLEAR_PROMPT_ITERATOR_ON_LAUNCH"] = str(clear_prompt_iterator_on_launch)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[SaturnNodes] 🪐 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"})
|
||||
|
||||
@@ -540,15 +536,19 @@ async def clear_scrapes_endpoint(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"[SaturnNodes] 🪐 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("/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
|
||||
@@ -568,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
|
||||
|
||||
@@ -251,5 +251,3 @@ class PreviewImageSizeAspectRatio:
|
||||
|
||||
return {}
|
||||
|
||||
# Alias for backward compatibility
|
||||
AspectRatioFinder = TextAspectRatioFinder
|
||||
|
||||
@@ -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]
|
||||
],
|
||||
|
||||
+13
-5
@@ -10,11 +10,19 @@ from .utils import sanitize_folder_path, is_safe_path, get_allowed_folder_watch_
|
||||
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
|
||||
|
||||
+21
-9
@@ -149,20 +149,35 @@ $toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
|
||||
return (True,)
|
||||
elif action == "stop":
|
||||
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 SaturnNodes Decision Node.")
|
||||
except AttributeError:
|
||||
raise Exception("Workflow stopped by SaturnNodes Decision Node.")
|
||||
raise RuntimeError("Workflow stopped by SaturnNodes Decision Node.")
|
||||
|
||||
print(f"[SaturnNodes] Node {unique_id} continuing.")
|
||||
return (False,)
|
||||
@@ -184,6 +199,3 @@ async def handle_decision(request):
|
||||
return web.json_response({"status": "error", "message": "Node is not waiting."}, status=400)
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
LeafFlowDecision = SaturnDecision
|
||||
SaturnDecision = SaturnDecision
|
||||
|
||||
+21
-17
@@ -11,7 +11,6 @@ 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,
|
||||
@@ -163,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"[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)
|
||||
@@ -223,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] = {
|
||||
@@ -251,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)
|
||||
@@ -282,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)
|
||||
@@ -343,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
|
||||
|
||||
@@ -57,7 +57,7 @@ class LoadRecentOutputs:
|
||||
except OSError:
|
||||
continue
|
||||
except Exception as e:
|
||||
print(f"[SaturnNodes] 🍃 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,)
|
||||
|
||||
|
||||
+11
-4
@@ -390,6 +390,8 @@ class RunLocalFileNode:
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
creationflags=creationflags
|
||||
)
|
||||
|
||||
@@ -411,9 +413,11 @@ 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:
|
||||
@@ -427,9 +431,12 @@ class RunLocalFileNode:
|
||||
_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)
|
||||
|
||||
|
||||
@@ -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"[SaturnNodes] 🍃 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"[SaturnNodes] 🍃 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
|
||||
|
||||
+89
-9
@@ -20,8 +20,8 @@ from .utils import (
|
||||
format_lora_output_name,
|
||||
sanitize_folder_path,
|
||||
get_saturnnodes_user_dir,
|
||||
get_leafflow_user_dir,
|
||||
is_local_request,
|
||||
is_authenticated_local_request,
|
||||
is_safe_path
|
||||
)
|
||||
|
||||
@@ -61,8 +61,10 @@ 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"[SaturnNodes] Error saving lora loader cycle state: {e}")
|
||||
|
||||
@@ -113,8 +115,10 @@ 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"[SaturnNodes] Error updating lora usage: {e}")
|
||||
|
||||
@@ -238,6 +242,9 @@ def scrape_missing_images_sync():
|
||||
success = True
|
||||
except (PermissionError, OSError) as 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
|
||||
|
||||
@@ -284,7 +291,10 @@ def scrape_missing_images_sync():
|
||||
break
|
||||
except (PermissionError, OSError) as write_err:
|
||||
print(f"[SaturnNodes] Warning: Could not save preview image to '{dest_path}': {write_err}")
|
||||
except Exception as tmdb_err:
|
||||
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:
|
||||
@@ -293,8 +303,10 @@ 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:
|
||||
@@ -353,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)
|
||||
@@ -430,7 +442,7 @@ def get_or_create_thumbnail(img_path, max_size=256):
|
||||
except Exception:
|
||||
return img_path
|
||||
|
||||
@routes.get("/folder_lora_loader/get_preview")
|
||||
@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)
|
||||
@@ -459,6 +471,76 @@ async def get_preview_endpoint(request):
|
||||
|
||||
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):
|
||||
@@ -796,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
|
||||
|
||||
@@ -2,7 +2,7 @@ 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).
|
||||
|
||||
+25
-18
@@ -9,7 +9,7 @@ import subprocess
|
||||
from aiohttp import web
|
||||
from server import PromptServer
|
||||
import folder_paths
|
||||
from .utils import get_saturnnodes_user_dir, 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_saturnnodes_user_dir()
|
||||
@@ -43,7 +43,7 @@ def load_state():
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
except Exception as e:
|
||||
print(f"[SaturnNodes] 🍃 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"[SaturnNodes] 🍃 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("[SaturnNodes] 🍃 Prompt iterator state cleared.")
|
||||
os.replace(tmp_file, STATE_FILE)
|
||||
print("[SaturnNodes] Prompt iterator state cleared.")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[SaturnNodes] 🍃 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
|
||||
@@ -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"[SaturnNodes] 🍃 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():
|
||||
@@ -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
|
||||
|
||||
+102
-38
@@ -9,7 +9,7 @@ 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_saturnnodes_user_dir, 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 = "🪐 SaturnNodes/Queue"
|
||||
|
||||
@@ -75,9 +75,15 @@ class PauseQueueManager:
|
||||
if self.paused:
|
||||
if not self.is_waiting:
|
||||
self.is_waiting = True
|
||||
print("[PauseQueue] Workflow paused: Queue is paused, waiting before next workflow.")
|
||||
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()
|
||||
@@ -99,17 +105,38 @@ 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:
|
||||
@@ -135,34 +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") and hasattr(server.prompt_queue, "not_empty"):
|
||||
with server.prompt_queue.mutex:
|
||||
server.prompt_queue.not_empty.notify_all()
|
||||
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):
|
||||
@@ -208,7 +250,7 @@ class PersistentQueueManager:
|
||||
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 = []
|
||||
@@ -236,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():
|
||||
@@ -258,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:
|
||||
@@ -273,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:
|
||||
@@ -281,7 +323,7 @@ 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:
|
||||
@@ -373,7 +415,7 @@ class PersistentQueueManager:
|
||||
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
|
||||
|
||||
@@ -388,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
|
||||
@@ -421,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
|
||||
@@ -473,7 +515,7 @@ class PowerControlManager:
|
||||
with self.lock:
|
||||
if action in ["restart", "shutdown"]:
|
||||
if not is_process_management_enabled():
|
||||
print(f"[SaturnNodes Power] Arm rejected: Process Management is disabled in LeafFlow settings.")
|
||||
print(f"[SaturnNodes Power] Arm rejected: Process Management is disabled in SaturnNodes settings.")
|
||||
return False
|
||||
self.pending_action = action
|
||||
import time
|
||||
@@ -538,7 +580,7 @@ class PowerControlManager:
|
||||
|
||||
def execute_restart(self):
|
||||
if not is_process_management_enabled():
|
||||
print("[SaturnNodes 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():
|
||||
@@ -551,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("[SaturnNodes 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
|
||||
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
|
||||
@@ -772,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)
|
||||
@@ -783,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)
|
||||
@@ -800,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)
|
||||
@@ -816,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)
|
||||
@@ -827,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)
|
||||
@@ -837,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"})
|
||||
|
||||
+9
-2
@@ -274,6 +274,7 @@ class SaturnStereo3D:
|
||||
"custom depth gamma/inversion, zoom & pan cropping, adjustable gap/border spacing, and focus alignment dots."
|
||||
)
|
||||
|
||||
@torch.no_grad()
|
||||
def generate(
|
||||
self,
|
||||
image,
|
||||
@@ -335,8 +336,8 @@ class SaturnStereo3D:
|
||||
d_np = _PREDICTED_DEPTH_CACHE[cache_key]
|
||||
else:
|
||||
d_np = predict_depth_map(img_slice)
|
||||
if len(_PREDICTED_DEPTH_CACHE) > 16:
|
||||
_PREDICTED_DEPTH_CACHE.clear()
|
||||
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))
|
||||
|
||||
@@ -465,5 +466,11 @@ class SaturnStereo3D:
|
||||
# 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)
|
||||
|
||||
|
||||
@@ -88,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"[SaturnNodes] 🍃 Invalid regex pattern '{pattern_str}': {e}")
|
||||
print(f"[SaturnNodes] Invalid regex pattern '{pattern_str}': {e}")
|
||||
|
||||
return (result_text, replaced_count)
|
||||
|
||||
@@ -85,6 +85,3 @@ class SaturnTextSplit:
|
||||
t2 = t2.strip()
|
||||
|
||||
return (t1, t2)
|
||||
|
||||
LeafFlowTextSplit = SaturnTextSplit
|
||||
SaturnTextSplit = SaturnTextSplit
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import sys
|
||||
import threading
|
||||
import webbrowser
|
||||
from PIL import Image, ImageDraw
|
||||
from .utils import get_saturnnodes_user_dir, get_leafflow_user_dir
|
||||
from .utils import get_saturnnodes_user_dir
|
||||
|
||||
USER_DIR = get_saturnnodes_user_dir()
|
||||
ENV_FILE = os.path.join(USER_DIR, ".env")
|
||||
|
||||
+11
-25
@@ -7,7 +7,6 @@ import folder_paths
|
||||
def get_saturnnodes_user_dir():
|
||||
"""
|
||||
Returns the centralized user data directory: ComfyUI/user/default/SaturnNodes/
|
||||
Automatically migrates any legacy LeafFlow directory contents if present.
|
||||
"""
|
||||
base_user = None
|
||||
try:
|
||||
@@ -28,24 +27,8 @@ def get_saturnnodes_user_dir():
|
||||
|
||||
saturn_dir = os.path.join(base_user, "SaturnNodes")
|
||||
os.makedirs(saturn_dir, exist_ok=True)
|
||||
|
||||
# Seamless automatic migration from legacy LeafFlow folder if needed
|
||||
legacy_dir = os.path.join(base_user, "LeafFlow")
|
||||
if os.path.isdir(legacy_dir):
|
||||
for f in [".env", "prompt_iterator_state.json"]:
|
||||
src = os.path.join(legacy_dir, f)
|
||||
dst = os.path.join(saturn_dir, f)
|
||||
if os.path.isfile(src) and not os.path.exists(dst):
|
||||
try:
|
||||
import shutil
|
||||
shutil.copy2(src, dst)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return saturn_dir
|
||||
|
||||
get_leafflow_user_dir = get_saturnnodes_user_dir
|
||||
|
||||
_SESSION_CSRF_TOKEN = secrets.token_hex(32)
|
||||
|
||||
def get_csrf_token():
|
||||
@@ -81,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
|
||||
@@ -93,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
|
||||
@@ -101,7 +84,7 @@ def is_authenticated_local_request(request):
|
||||
return False
|
||||
|
||||
headers = getattr(request, "headers", {})
|
||||
token = headers.get("X-SaturnNodes-CSRF-Token") or 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
|
||||
|
||||
@@ -109,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:
|
||||
@@ -123,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
|
||||
@@ -232,7 +218,7 @@ def sanitize_image_loader_folder(folder_input, default_to_output=True):
|
||||
|
||||
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_saturnnodes_user_dir()
|
||||
env_file = os.path.join(user_dir, ".env")
|
||||
|
||||
+12
-3
@@ -1,18 +1,27 @@
|
||||
[project]
|
||||
name = "comfyui-saturnnodes"
|
||||
name = "saturnnodes"
|
||||
description = "Essential workflow controls, mid-generation pausing, real-time queue persistence, folder watcher, and visual model loaders for ComfyUI."
|
||||
version = "2.5.0"
|
||||
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-SaturnNodes"
|
||||
"Bug Tracker" = "https://github.com/KOFiblto/ComfyUI-SaturnNodes/issues"
|
||||
Documentation = "https://github.com/KOFiblto/ComfyUI-SaturnNodes#readme"
|
||||
|
||||
[tool.comfy]
|
||||
PublisherId = "KOFiblto"
|
||||
DisplayName = "ComfyUI-SaturnNodes"
|
||||
Icon = "https://raw.githubusercontent.com/KOFiblto/ComfyUI-SaturnNodes/main/web/logo.png"
|
||||
requires-comfyui = ">=0.3.0"
|
||||
|
||||
+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)
|
||||
|
||||
+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:
|
||||
|
||||
+6
-4
@@ -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))
|
||||
|
||||
|
||||
+38
-40
@@ -43,17 +43,9 @@ const BATCH_COLORS = [
|
||||
];
|
||||
|
||||
const STORAGE_KEY = "saturnnodes_batch_queue_meta";
|
||||
const LEGACY_STORAGE_KEY = "leafflow_batch_queue_meta";
|
||||
const BATCH_COUNTER_KEY = "saturnnodes_batch_counter";
|
||||
const LEGACY_BATCH_COUNTER_KEY = "leafflow_batch_counter";
|
||||
const SETTING_ID = "SaturnNodes.🎨 Batch Queue.01_ShowBatchLines";
|
||||
const LEGACY_SETTING_ID_0 = "SaturnNodes.8 - 🎨 Batch Queue.01_ShowBatchLines";
|
||||
const LEGACY_SETTING_ID_1 = "SaturnNodes.BatchQueue.Enabled";
|
||||
const LEGACY_SETTING_ID_2 = "LeafFlow.BatchQueue.Enabled";
|
||||
const SETTING_SNAPSHOT_GUARD = "SaturnNodes.🎨 Batch Queue.02_SnapshotGuard";
|
||||
const LEGACY_SETTING_SNAPSHOT_GUARD_0 = "SaturnNodes.8 - 🎨 Batch Queue.02_SnapshotGuard";
|
||||
const LEGACY_SETTING_SNAPSHOT_GUARD_1 = "SaturnNodes.BatchQueue.SnapshotGuard";
|
||||
const LEGACY_SETTING_SNAPSHOT_GUARD_2 = "LeafFlow.BatchQueue.SnapshotGuard";
|
||||
|
||||
// In-memory registry mapping prompt_id -> batch metadata
|
||||
let batchRegistry = new Map();
|
||||
@@ -62,7 +54,7 @@ let batchCounter = 0;
|
||||
|
||||
function loadStorage() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY) || localStorage.getItem(LEGACY_STORAGE_KEY);
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) {
|
||||
@@ -73,7 +65,7 @@ function loadStorage() {
|
||||
}
|
||||
}
|
||||
}
|
||||
const savedCounter = localStorage.getItem(BATCH_COUNTER_KEY) || localStorage.getItem(LEGACY_BATCH_COUNTER_KEY);
|
||||
const savedCounter = localStorage.getItem(BATCH_COUNTER_KEY);
|
||||
if (savedCounter) {
|
||||
batchCounter = parseInt(savedCounter, 10) || 0;
|
||||
}
|
||||
@@ -115,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;
|
||||
@@ -136,7 +128,7 @@ function injectBatchStyles() {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.leafflow-batch-line-svg {
|
||||
.saturnnodes-batch-line-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
@@ -183,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>
|
||||
`;
|
||||
@@ -200,7 +192,7 @@ 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();
|
||||
}
|
||||
@@ -209,18 +201,13 @@ function removeBatchLineFromElement(element) {
|
||||
// Check if SaturnNodes Batch Queue is enabled in settings
|
||||
function isBatchQueueEnabled() {
|
||||
try {
|
||||
const keys = [SETTING_ID, LEGACY_SETTING_ID_0, LEGACY_SETTING_ID_1, LEGACY_SETTING_ID_2];
|
||||
if (app.extensionManager?.setting?.get) {
|
||||
for (const k of keys) {
|
||||
const val = app.extensionManager.setting.get(k);
|
||||
if (val !== undefined) return val !== false;
|
||||
}
|
||||
const val = app.extensionManager.setting.get(SETTING_ID);
|
||||
if (val !== undefined) return val !== false;
|
||||
}
|
||||
if (app.ui?.settings?.get) {
|
||||
for (const k of keys) {
|
||||
const val = app.ui.settings.get(k);
|
||||
if (val !== undefined) return val !== false;
|
||||
}
|
||||
const val = app.ui.settings.get(SETTING_ID);
|
||||
if (val !== undefined) return val !== false;
|
||||
}
|
||||
} catch (e) {}
|
||||
return true;
|
||||
@@ -228,18 +215,13 @@ function isBatchQueueEnabled() {
|
||||
|
||||
function isSnapshotGuardEnabled() {
|
||||
try {
|
||||
const keys = [SETTING_SNAPSHOT_GUARD, LEGACY_SETTING_SNAPSHOT_GUARD_0, LEGACY_SETTING_SNAPSHOT_GUARD_1, LEGACY_SETTING_SNAPSHOT_GUARD_2];
|
||||
if (app.extensionManager?.setting?.get) {
|
||||
for (const k of keys) {
|
||||
const val = app.extensionManager.setting.get(k);
|
||||
if (val !== undefined) return val !== false;
|
||||
}
|
||||
const val = app.extensionManager.setting.get(SETTING_SNAPSHOT_GUARD);
|
||||
if (val !== undefined) return val !== false;
|
||||
}
|
||||
if (app.ui?.settings?.get) {
|
||||
for (const k of keys) {
|
||||
const val = app.ui.settings.get(k);
|
||||
if (val !== undefined) return val !== false;
|
||||
}
|
||||
const val = app.ui.settings.get(SETTING_SNAPSHOT_GUARD);
|
||||
if (val !== undefined) return val !== false;
|
||||
}
|
||||
} catch (e) {}
|
||||
return true;
|
||||
@@ -288,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -300,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;
|
||||
}
|
||||
}
|
||||
@@ -342,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;
|
||||
}
|
||||
|
||||
@@ -460,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);
|
||||
|
||||
+243
-3
@@ -18,6 +18,16 @@ import { PreviewManager } from "./js/preview_manager.js";
|
||||
|
||||
const STORAGE_KEY_VISIBLE = "SaturnNodes.LivePreview.FloatingDockVisible";
|
||||
|
||||
// Absolute dock size bounds ("extreme but not absurd"): 100px .. 2000px
|
||||
const DOCK_MIN_SIZE = 100;
|
||||
const DOCK_MAX_SIZE = 2000;
|
||||
|
||||
const clampDockSize = (val) => {
|
||||
const num = Math.round(Number(val));
|
||||
if (!Number.isFinite(num)) return DOCK_MIN_SIZE;
|
||||
return Math.max(DOCK_MIN_SIZE, Math.min(DOCK_MAX_SIZE, num));
|
||||
};
|
||||
|
||||
class FloatingLatentDock {
|
||||
constructor() {
|
||||
this.dockEl = null;
|
||||
@@ -68,16 +78,37 @@ class FloatingLatentDock {
|
||||
return defaultVal;
|
||||
}
|
||||
|
||||
getScreenProfileKey() {
|
||||
if (typeof window === "undefined" || !window.screen) return "default";
|
||||
return `${window.screen.width}x${window.screen.height}`;
|
||||
}
|
||||
|
||||
getMaxWidth() {
|
||||
if (this._dragOverrideMaxW) return this._dragOverrideMaxW;
|
||||
try {
|
||||
const saved = localStorage.getItem(`Comfy.SaturnNodes.DockSize.${this.getScreenProfileKey()}`);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (parsed && typeof parsed.maxW === "number") return clampDockSize(parsed.maxW);
|
||||
}
|
||||
} catch (_) {}
|
||||
const val = this.getSetting("SaturnNodes.👁️ Live Preview.02a_FloatingDockMaxWidth", "280px");
|
||||
const parsed = parseInt(String(val), 10);
|
||||
return isNaN(parsed) || parsed < 50 ? 280 : parsed;
|
||||
return isNaN(parsed) ? 280 : clampDockSize(parsed);
|
||||
}
|
||||
|
||||
getMaxHeight() {
|
||||
if (this._dragOverrideMaxH) return this._dragOverrideMaxH;
|
||||
try {
|
||||
const saved = localStorage.getItem(`Comfy.SaturnNodes.DockSize.${this.getScreenProfileKey()}`);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (parsed && typeof parsed.maxH === "number") return clampDockSize(parsed.maxH);
|
||||
}
|
||||
} catch (_) {}
|
||||
const val = this.getSetting("SaturnNodes.👁️ Live Preview.02b_FloatingDockMaxHeight", "280px");
|
||||
const parsed = parseInt(String(val), 10);
|
||||
return isNaN(parsed) || parsed < 50 ? 280 : parsed;
|
||||
return isNaN(parsed) ? 280 : clampDockSize(parsed);
|
||||
}
|
||||
|
||||
init() {
|
||||
@@ -87,6 +118,28 @@ class FloatingLatentDock {
|
||||
this.setupExecutionListeners();
|
||||
this.setupPositionObserver();
|
||||
this.injectToolbarButtonLoop();
|
||||
this.setupScreenProfileWatcher();
|
||||
}
|
||||
|
||||
setupScreenProfileWatcher() {
|
||||
let lastScreenKey = this.getScreenProfileKey();
|
||||
window.addEventListener("resize", () => {
|
||||
const currentKey = this.getScreenProfileKey();
|
||||
if (currentKey !== lastScreenKey) {
|
||||
lastScreenKey = currentKey;
|
||||
if (this.currentImg) {
|
||||
this.onNewPreview(this.currentImg, this.latestSamplerId);
|
||||
} else if (this.placeholderEl && this.dockEl) {
|
||||
const mw = this.getMaxWidth();
|
||||
const mh = this.getMaxHeight();
|
||||
const pw = Math.max(140, Math.min(mw, 320));
|
||||
const ph = Math.max(100, Math.min(mh, 220));
|
||||
this.dockEl.style.width = `${pw}px`;
|
||||
this.dockEl.style.height = `${ph}px`;
|
||||
}
|
||||
this.requestPositionUpdate();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
injectStyles() {
|
||||
@@ -101,6 +154,8 @@ class FloatingLatentDock {
|
||||
z-index: 1050;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
background: #09090b;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
@@ -120,6 +175,34 @@ class FloatingLatentDock {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.saturn-dock-resize-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: nwse-resize;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
border-bottom-right-radius: 6px;
|
||||
border-top-left-radius: 7px;
|
||||
transition: color 0.15s ease, background 0.15s ease, transform 0.15s ease;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.saturn-dock-resize-handle:hover,
|
||||
.saturn-dock-resize-handle.active {
|
||||
color: #f59e0b;
|
||||
background: rgba(245, 158, 11, 0.3);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.saturn-dock-header {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -129,7 +212,7 @@ class FloatingLatentDock {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 6px;
|
||||
padding: 4px 6px 4px 22px;
|
||||
background: linear-gradient(180deg, rgba(0, 0, 0, 0.75) 0%, rgba(0, 0, 0, 0) 100%);
|
||||
gap: 4px;
|
||||
font-family: Inter, system-ui, -apple-system, sans-serif;
|
||||
@@ -381,11 +464,168 @@ class FloatingLatentDock {
|
||||
this.dockEl.appendChild(header);
|
||||
this.dockEl.appendChild(body);
|
||||
|
||||
// Top-left resize handle
|
||||
const resizeHandle = document.createElement("div");
|
||||
resizeHandle.className = "saturn-dock-resize-handle";
|
||||
resizeHandle.title = "Drag to resize Live Preview dock (remembers size per monitor)";
|
||||
resizeHandle.innerHTML = `
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round">
|
||||
<path d="M1 9L9 1M1 5L5 1M5 9L9 5"/>
|
||||
</svg>
|
||||
`;
|
||||
this.setupDockResizeHandle(resizeHandle);
|
||||
this.dockEl.appendChild(resizeHandle);
|
||||
|
||||
document.body.appendChild(this.dockEl);
|
||||
this.updateVisibility();
|
||||
this.updatePosition();
|
||||
}
|
||||
|
||||
persistMaxSize(maxW, maxH) {
|
||||
const widthSettingId = "SaturnNodes.👁️ Live Preview.02a_FloatingDockMaxWidth";
|
||||
const heightSettingId = "SaturnNodes.👁️ Live Preview.02b_FloatingDockMaxHeight";
|
||||
try {
|
||||
localStorage.setItem(`Comfy.SaturnNodes.DockSize.${this.getScreenProfileKey()}`, JSON.stringify({
|
||||
maxW: maxW,
|
||||
maxH: maxH
|
||||
}));
|
||||
} catch (_) {}
|
||||
try {
|
||||
if (app?.ui?.settings?.setSettingValue) {
|
||||
app.ui.settings.setSettingValue(widthSettingId, maxW);
|
||||
app.ui.settings.setSettingValue(heightSettingId, maxH);
|
||||
} else {
|
||||
localStorage.setItem(`Comfy.Settings.${widthSettingId}`, JSON.stringify(maxW));
|
||||
localStorage.setItem(`Comfy.Settings.${heightSettingId}`, JSON.stringify(maxH));
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
applyDragPreview(maxW, maxH) {
|
||||
if (!this.dockEl) return;
|
||||
// Show the free-form drag box (any size / aspect ratio); the latent is fitted & centered inside
|
||||
this.dockEl.style.width = `${maxW}px`;
|
||||
this.dockEl.style.height = `${maxH}px`;
|
||||
this.dockEl.style.minWidth = `${maxW}px`;
|
||||
this.dockEl.style.maxWidth = `${maxW}px`;
|
||||
this.dockEl.style.minHeight = `${maxH}px`;
|
||||
this.dockEl.style.maxHeight = `${maxH}px`;
|
||||
|
||||
if (!this.canvasEl || !this.currentImg) return;
|
||||
const nw = this.currentImg.naturalWidth || this.currentImg.width;
|
||||
const nh = this.currentImg.naturalHeight || this.currentImg.height;
|
||||
if (!nw || !nh) return;
|
||||
|
||||
const scale = Math.min(maxW / nw, maxH / nh);
|
||||
const tw = Math.max(1, Math.round(nw * scale));
|
||||
const th = Math.max(1, Math.round(nh * scale));
|
||||
|
||||
// Cheap path: only CSS display sizes change. No canvas backing-store resize,
|
||||
// no drawImage, no layout reads - keeps dragging at 60fps even on 4K latents.
|
||||
this.canvasEl.style.width = `${tw}px`;
|
||||
this.canvasEl.style.height = `${th}px`;
|
||||
if (this.titleTextEl) this.titleTextEl.style.display = tw < 140 ? "none" : "inline";
|
||||
if (this.statusBadge) this.statusBadge.style.display = tw < 170 ? "none" : "inline";
|
||||
}
|
||||
|
||||
setupDockResizeHandle(handle) {
|
||||
handle.addEventListener("pointerdown", (e) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
|
||||
// Baseline = the dock's CURRENT on-screen size (the latent-fitted box),
|
||||
// not the possibly larger saved settings box - so grabbing the corner
|
||||
// never makes the dock jump. The final dragged box is written to the
|
||||
// settings on release.
|
||||
const visualW = this.dockEl ? this.dockEl.offsetWidth : 0;
|
||||
const visualH = this.dockEl ? this.dockEl.offsetHeight : 0;
|
||||
const startMaxW = visualW > 0 ? visualW : this.getMaxWidth();
|
||||
const startMaxH = visualH > 0 ? visualH : this.getMaxHeight();
|
||||
const minDragW = Math.min(DOCK_MIN_SIZE, startMaxW);
|
||||
const minDragH = Math.min(DOCK_MIN_SIZE, startMaxH);
|
||||
|
||||
handle.classList.add("active");
|
||||
if (this.dockEl) this.dockEl.style.transition = "none";
|
||||
document.body.style.cursor = "nwse-resize";
|
||||
|
||||
let curMaxW = startMaxW;
|
||||
let curMaxH = startMaxH;
|
||||
let rafPending = false;
|
||||
let rafId = 0;
|
||||
let finished = false;
|
||||
|
||||
// Pointer capture guarantees pointerup delivery even when the cursor
|
||||
// leaves the window mid-drag (fixes "drag sometimes never stops").
|
||||
try {
|
||||
if (handle.setPointerCapture) handle.setPointerCapture(e.pointerId);
|
||||
} catch (_) {}
|
||||
|
||||
const renderDragFrame = () => {
|
||||
rafPending = false;
|
||||
rafId = 0;
|
||||
this._dragOverrideMaxW = curMaxW;
|
||||
this._dragOverrideMaxH = curMaxH;
|
||||
this.applyDragPreview(curMaxW, curMaxH);
|
||||
if (this.aspectBadge) {
|
||||
this.aspectBadge.textContent = `${curMaxW}×${curMaxH}`;
|
||||
this.aspectBadge.style.display = "inline";
|
||||
}
|
||||
};
|
||||
|
||||
const onPointerMove = (me) => {
|
||||
const dx = startX - me.clientX; // drag left -> expand width
|
||||
const dy = startY - me.clientY; // drag up -> expand height
|
||||
curMaxW = Math.max(minDragW, Math.min(DOCK_MAX_SIZE, Math.round(startMaxW + dx)));
|
||||
curMaxH = Math.max(minDragH, Math.min(DOCK_MAX_SIZE, Math.round(startMaxH + dy)));
|
||||
if (!rafPending) {
|
||||
rafPending = true;
|
||||
rafId = requestAnimationFrame(renderDragFrame);
|
||||
}
|
||||
};
|
||||
|
||||
const finishDrag = () => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
|
||||
if (rafId) {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = 0;
|
||||
}
|
||||
|
||||
window.removeEventListener("pointermove", onPointerMove);
|
||||
window.removeEventListener("pointerup", finishDrag);
|
||||
window.removeEventListener("pointercancel", finishDrag);
|
||||
window.removeEventListener("blur", finishDrag);
|
||||
try {
|
||||
if (handle.releasePointerCapture) handle.releasePointerCapture(e.pointerId);
|
||||
} catch (_) {}
|
||||
|
||||
handle.classList.remove("active");
|
||||
if (this.dockEl) this.dockEl.style.transition = "";
|
||||
document.body.style.cursor = "";
|
||||
|
||||
delete this._dragOverrideMaxW;
|
||||
delete this._dragOverrideMaxH;
|
||||
|
||||
this.persistMaxSize(clampDockSize(curMaxW), clampDockSize(curMaxH));
|
||||
|
||||
// Snap back to the exact latent fit within the new limits
|
||||
if (this.currentImg) {
|
||||
this.onNewPreview(this.currentImg, this.latestSamplerId);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", onPointerMove);
|
||||
window.addEventListener("pointerup", finishDrag);
|
||||
window.addEventListener("pointercancel", finishDrag);
|
||||
window.addEventListener("blur", finishDrag);
|
||||
});
|
||||
}
|
||||
|
||||
openLightbox() {
|
||||
if (this.lightboxEl) {
|
||||
this.updateLightbox();
|
||||
|
||||
@@ -38,13 +38,12 @@ export async function authenticatedFetch(endpoint, options = {}) {
|
||||
return api.fetchApi(endpoint, opts);
|
||||
}
|
||||
|
||||
// Expose on window for all SaturnNodes extensions (with LeafFlow alias for backward compatibility)
|
||||
// Expose on window for all SaturnNodes extensions
|
||||
if (typeof window !== "undefined") {
|
||||
const authObj = {
|
||||
getToken: getCsrfToken,
|
||||
fetch: authenticatedFetch
|
||||
};
|
||||
window._SaturnNodesAuth = authObj;
|
||||
window._LeafFlowAuth = authObj;
|
||||
}
|
||||
|
||||
|
||||
+21
-34
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -36,27 +36,14 @@ async function copyToClipboard(text) {
|
||||
function isCopyEnabled(settingKey, defaultVal = true) {
|
||||
try {
|
||||
let val;
|
||||
const saturnNumberedKey = settingKey.replace(/^LeafFlow\./, "SaturnNodes.");
|
||||
const saturnPlainKey = saturnNumberedKey.replace(/\d+\s*-\s*/, "");
|
||||
const keysToCheck = [saturnPlainKey, saturnNumberedKey, settingKey];
|
||||
|
||||
if (app.extensionManager?.setting?.get) {
|
||||
for (const k of keysToCheck) {
|
||||
val = app.extensionManager.setting.get(k);
|
||||
if (val !== undefined && val !== null && val !== "") break;
|
||||
}
|
||||
val = app.extensionManager.setting.get(settingKey);
|
||||
}
|
||||
if ((val === undefined || val === null || val === "") && app.ui?.settings?.getSettingValue) {
|
||||
for (const k of keysToCheck) {
|
||||
val = app.ui.settings.getSettingValue(k);
|
||||
if (val !== undefined && val !== null && val !== "") break;
|
||||
}
|
||||
val = app.ui.settings.getSettingValue(settingKey);
|
||||
}
|
||||
if ((val === undefined || val === null || val === "") && app.ui?.settings?.get) {
|
||||
for (const k of keysToCheck) {
|
||||
val = app.ui.settings.get(k);
|
||||
if (val !== undefined && val !== null && val !== "") break;
|
||||
}
|
||||
val = app.ui.settings.get(settingKey);
|
||||
}
|
||||
if (val === undefined || val === null || val === "") {
|
||||
return defaultVal;
|
||||
@@ -233,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;
|
||||
}
|
||||
@@ -339,7 +326,7 @@ app.registerExtension({
|
||||
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) {
|
||||
@@ -354,12 +341,12 @@ 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.");
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -435,8 +422,8 @@ 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 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 && !enableInspect) return;
|
||||
|
||||
@@ -459,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>`;
|
||||
@@ -503,14 +490,14 @@ function injectCopyPromptNextToDownload(downloadBtn) {
|
||||
}
|
||||
|
||||
// 2. Inspect Asset (Zoom) Button
|
||||
let inspectBtn = parent.querySelector(".leafflow-hover-inspect");
|
||||
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) => {
|
||||
@@ -534,8 +521,8 @@ 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;
|
||||
|
||||
// Guard: strictly ignore top menu, menubar, command menus, and main application menus!
|
||||
if (
|
||||
@@ -580,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");
|
||||
@@ -622,8 +609,8 @@ function injectContextMenuCopy(contextMenu) {
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -651,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");
|
||||
|
||||
+52
-17
@@ -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,13 +38,48 @@ 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.SaturnNodesDecision",
|
||||
name: "ComfyUI.SaturnNodes.Decision",
|
||||
async setup() {
|
||||
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();
|
||||
}
|
||||
@@ -54,7 +89,7 @@ app.registerExtension({
|
||||
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();
|
||||
}
|
||||
@@ -65,22 +100,22 @@ app.registerExtension({
|
||||
api.addEventListener("saturnnodes_decision_resolved", handleResolved);
|
||||
},
|
||||
async nodeCreated(node) {
|
||||
if (node.comfyClass === "SaturnDecision" || 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;
|
||||
|
||||
|
||||
+154
-40
@@ -1,6 +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) {
|
||||
@@ -96,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;
|
||||
@@ -397,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") {
|
||||
@@ -423,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();
|
||||
|
||||
@@ -530,31 +533,28 @@ 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");
|
||||
|
||||
// In ComfyUI LiteGraph (V1), computeLayoutSize handles dynamic widget space distribution.
|
||||
// Removing computeSize avoids an infinite expansion loop in _arrangeWidgets.
|
||||
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
|
||||
const zoomWidget = node.addWidget(
|
||||
"slider",
|
||||
@@ -579,7 +579,7 @@ 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();
|
||||
|
||||
@@ -707,7 +707,7 @@ app.registerExtension({
|
||||
const img = document.createElement("img");
|
||||
img.loading = "lazy";
|
||||
const bypassThumbs = app.ui.settings?.getSettingValue?.("SaturnNodes.🖼️ Visual Loaders.07_BypassThumbnailDownscaling", false);
|
||||
img.dataset.src = `/folder_lora_loader/get_preview?lora=${encodeURIComponent(loraName)}&folder=${encodeURIComponent(folder)}&pretty=true` + (bypassThumbs ? "&full=true" : "&size=200");
|
||||
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 = () => {
|
||||
@@ -795,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);
|
||||
@@ -810,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;
|
||||
|
||||
@@ -824,7 +828,7 @@ 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];
|
||||
|
||||
const folderWidget = node.widgets.find(w => w.name === "folder");
|
||||
@@ -929,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(180, restoredH - 150);
|
||||
|
||||
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");
|
||||
@@ -941,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);
|
||||
}
|
||||
};
|
||||
@@ -951,7 +956,7 @@ app.registerExtension({
|
||||
if (displayModeWidget && displayModeWidget.value === "Show All" && gridContainer) {
|
||||
return [Math.max(380, node.size[0]), Math.max(360, (gridContainer.scrollHeight || 0) + 160)];
|
||||
}
|
||||
return [Math.max(380, node.size[0]), 360];
|
||||
return [Math.max(380, node.size[0]), Math.max(360, node.size ? node.size[1] : 360)];
|
||||
};
|
||||
|
||||
const updateNodeSize = () => {
|
||||
@@ -1059,6 +1064,113 @@ 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);
|
||||
|
||||
@@ -1070,8 +1182,10 @@ app.registerExtension({
|
||||
});
|
||||
|
||||
// In ComfyUI LiteGraph (V1), computeLayoutSize handles dynamic widget space distribution.
|
||||
// A computeSize returning (node.size[1] - offset) causes an infinite loop in _arrangeWidgets
|
||||
// because LiteGraph sees (l > t) and expands node.size on every animation frame.
|
||||
// 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;
|
||||
@@ -1082,11 +1196,6 @@ app.registerExtension({
|
||||
if (!isShowAll && size) {
|
||||
node.userCustomHeight = size[1];
|
||||
userSavedHeight = size[1];
|
||||
if (viewContainer && viewContainer.style) {
|
||||
const h = Math.max(180, size[1] - 150);
|
||||
viewContainer.style.setProperty("height", `${h}px`, "important");
|
||||
viewContainer.style.setProperty("max-height", `${h}px`, "important");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1277,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();
|
||||
|
||||
@@ -1516,7 +1625,7 @@ app.registerExtension({
|
||||
const img = document.createElement("img");
|
||||
img.loading = "lazy";
|
||||
const bypassThumbs = app.ui.settings?.getSettingValue?.("SaturnNodes.🖼️ Visual Loaders.07_BypassThumbnailDownscaling", false);
|
||||
img.dataset.src = `/folder_lora_loader/get_preview?system_path=${encodeURIComponent(item.systemPath)}` + (bypassThumbs ? "&full=true" : "&size=200");
|
||||
img.dataset.src = `/saturnnodes/folder_lora_loader/get_preview?system_path=${encodeURIComponent(item.systemPath)}` + (bypassThumbs ? "&full=true" : "&size=200");
|
||||
imageObserver.observe(img);
|
||||
|
||||
img.onerror = () => {
|
||||
@@ -1616,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"]
|
||||
|
||||
+18
-16
@@ -201,15 +201,13 @@ 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);
|
||||
|
||||
@@ -303,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(180, restoredH - 150);
|
||||
|
||||
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");
|
||||
@@ -315,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);
|
||||
}
|
||||
};
|
||||
@@ -325,7 +324,7 @@ app.registerExtension({
|
||||
if (displayModeWidget && displayModeWidget.value === "Show All" && gridContainer) {
|
||||
return [Math.max(380, node.size[0]), Math.max(420, (gridContainer.scrollHeight || 0) + 160)];
|
||||
}
|
||||
return [Math.max(380, node.size[0]), 420];
|
||||
return [Math.max(380, node.size[0]), Math.max(420, node.size ? node.size[1] : 420)];
|
||||
};
|
||||
|
||||
const updateNodeSize = () => {
|
||||
@@ -351,6 +350,9 @@ app.registerExtension({
|
||||
|
||||
// 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;
|
||||
@@ -361,11 +363,6 @@ app.registerExtension({
|
||||
if (!isShowAll && size) {
|
||||
node.userCustomHeight = size[1];
|
||||
userSavedHeight = size[1];
|
||||
if (viewContainer && viewContainer.style) {
|
||||
const h = Math.max(180, size[1] - 150);
|
||||
viewContainer.style.setProperty("height", `${h}px`, "important");
|
||||
viewContainer.style.setProperty("max-height", `${h}px`, "important");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -511,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 = () => {
|
||||
@@ -562,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();
|
||||
@@ -599,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);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { authenticatedFetch } from "./auth_helper.js";
|
||||
|
||||
// SaturnNodes Local File Runner In-Memory Consent Gate Extension
|
||||
app.registerExtension({
|
||||
name: "SaturnNodes.LocalRunner.Security",
|
||||
name: "ComfyUI.SaturnNodes.LocalRunner",
|
||||
async nodeCreated(node) {
|
||||
if (node.comfyClass !== "RunLocalFileNode") return;
|
||||
|
||||
|
||||
+40
-39
@@ -3,7 +3,7 @@ import { api } from "/scripts/api.js";
|
||||
import { authenticatedFetch } from "./auth_helper.js";
|
||||
|
||||
const EXTENSION_NAME = "SaturnNodes.PowerControlSidebar";
|
||||
const STYLE_ID = "leafflow-power-sidebar-styles";
|
||||
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)");
|
||||
}
|
||||
});
|
||||
@@ -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)";
|
||||
|
||||
@@ -313,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();
|
||||
@@ -625,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");
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -40,7 +40,7 @@ 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");
|
||||
|
||||
@@ -2,6 +2,38 @@ 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.SaturnNodes.PromptQueueIterator",
|
||||
async setup() {
|
||||
@@ -9,7 +41,7 @@ app.registerExtension({
|
||||
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");
|
||||
if (statusWidget) {
|
||||
@@ -29,6 +61,13 @@ app.registerExtension({
|
||||
|
||||
// 2. Reset Counter (0) Button
|
||||
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);
|
||||
|
||||
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;
|
||||
|
||||
+8
-8
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,18 +9,14 @@ export const SATURN_NODE_TYPES = new Set([
|
||||
"VisualLoraLoader",
|
||||
"FolderLoraLoader",
|
||||
"FolderLoraLoaderPretty",
|
||||
"FolderLoraLoaderVisualPrettyV2",
|
||||
"VisualImageLoader",
|
||||
"ImageLoaderVisualPrettyV2",
|
||||
"LoadRecentOutputs",
|
||||
|
||||
// Automation, Flow & Utilities
|
||||
"LoadImageFromFolder",
|
||||
"TextAspectRatioFinder",
|
||||
"AspectRatioFinder",
|
||||
"PreviewImageSizeAspectRatio",
|
||||
"TextLoraFinder",
|
||||
"LoraTextFinder",
|
||||
"PromptQueueIterator",
|
||||
"PromptCounter",
|
||||
"MultiTextReplacer",
|
||||
|
||||
+240
-113
@@ -137,12 +137,8 @@ if (typeof document !== "undefined") {
|
||||
/* ComfyUI V2 Vue Toggle Switches Protection: NEVER apply button background or borders to switches */
|
||||
[data-setting-id*="SaturnNodes"] button[role="switch"],
|
||||
[data-setting-id*="saturnnodes"] button[role="switch"],
|
||||
[data-setting-id*="LeafFlow"] button[role="switch"],
|
||||
[data-setting-id*="leafflow"] button[role="switch"],
|
||||
[data-setting-id*="SaturnNodes"] [role="switch"],
|
||||
[data-setting-id*="saturnnodes"] [role="switch"],
|
||||
[data-setting-id*="LeafFlow"] [role="switch"],
|
||||
[data-setting-id*="leafflow"] [role="switch"],
|
||||
button[role="switch"].saturnnodes-settings-btn {
|
||||
background: transparent !important;
|
||||
border: 0 !important;
|
||||
@@ -183,11 +179,8 @@ if (typeof document !== "undefined") {
|
||||
|
||||
/* ComfyUI Settings Action Buttons (Buttons only: 1.5x height, font-size 13px) */
|
||||
button.saturnnodes-settings-btn,
|
||||
button.leafflow-settings-btn,
|
||||
[data-setting-id*="SaturnNodes"] button:not([role="switch"]):not([data-pc-name="toggleswitch"]),
|
||||
[data-setting-id*="saturnnodes"] button:not([role="switch"]):not([data-pc-name="toggleswitch"]),
|
||||
[data-setting-id*="LeafFlow"] button:not([role="switch"]):not([data-pc-name="toggleswitch"]),
|
||||
[data-setting-id*="leafflow"] button:not([role="switch"]):not([data-pc-name="toggleswitch"]) {
|
||||
[data-setting-id*="saturnnodes"] button:not([role="switch"]):not([data-pc-name="toggleswitch"]) {
|
||||
background: #8b7355 !important;
|
||||
border: 1px solid #a68a67 !important;
|
||||
color: #ffffff !important;
|
||||
@@ -215,11 +208,8 @@ if (typeof document !== "undefined") {
|
||||
}
|
||||
|
||||
button.saturnnodes-settings-btn:hover,
|
||||
button.leafflow-settings-btn:hover,
|
||||
[data-setting-id*="SaturnNodes"] button:not([role="switch"]):not([data-pc-name="toggleswitch"]):hover,
|
||||
[data-setting-id*="saturnnodes"] button:not([role="switch"]):not([data-pc-name="toggleswitch"]):hover,
|
||||
[data-setting-id*="LeafFlow"] button:not([role="switch"]):not([data-pc-name="toggleswitch"]):hover,
|
||||
[data-setting-id*="leafflow"] button:not([role="switch"]):not([data-pc-name="toggleswitch"]):hover {
|
||||
[data-setting-id*="saturnnodes"] button:not([role="switch"]):not([data-pc-name="toggleswitch"]):hover {
|
||||
background: #9e8565 !important;
|
||||
border-color: #c4a780 !important;
|
||||
color: #ffffff !important;
|
||||
@@ -228,7 +218,6 @@ if (typeof document !== "undefined") {
|
||||
}
|
||||
|
||||
button.saturnnodes-settings-btn:active,
|
||||
button.leafflow-settings-btn:active,
|
||||
[data-setting-id*="SaturnNodes"] button:not([role="switch"]):not([data-pc-name="toggleswitch"]):active {
|
||||
background: #7d6549 !important;
|
||||
transform: translateY(0) !important;
|
||||
@@ -612,6 +601,12 @@ function renderSketchColorSetting(settingId, defaultColor) {
|
||||
if (typeof setter === "function") {
|
||||
setter(newHex);
|
||||
}
|
||||
if (typeof app.ui?.settings?.setSettingValue === "function") {
|
||||
try { app.ui.settings.setSettingValue(settingId, newHex); } catch (_) {}
|
||||
}
|
||||
if (typeof app.ui?.settings?.set === "function") {
|
||||
try { app.ui.settings.set(settingId, newHex); } catch (_) {}
|
||||
}
|
||||
if (typeof localStorage !== "undefined") {
|
||||
try {
|
||||
localStorage.setItem(`Comfy.Settings.${settingId}`, JSON.stringify(newHex));
|
||||
@@ -720,71 +715,26 @@ function renderSettingButton(label, workingText, successText, onClickHandler) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Migration helper ensuring user preferences from legacy LeafFlow.* are seamlessly inherited.
|
||||
*/
|
||||
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;
|
||||
const newKey = oldKey.replace(/LeafFlow/g, "SaturnNodes").replace(/leafflow/g, "saturnnodes");
|
||||
if (newKey !== oldKey && localStorage.getItem(newKey) === null) {
|
||||
localStorage.setItem(newKey, val);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[SaturnNodes] Settings migration skipped:", e);
|
||||
}
|
||||
}
|
||||
migrateSavedSettings();
|
||||
|
||||
function getInitialSetting(saturnId, defaultVal) {
|
||||
if (typeof localStorage === "undefined") return defaultVal;
|
||||
|
||||
// Build lookup keys: unnumbered, numbered, and legacy variants
|
||||
const variations = [saturnId];
|
||||
|
||||
const categoryMap = {
|
||||
"🖼️ Visual Loaders": "1 - 🖼️ Visual Loaders",
|
||||
"🔄 Prompt Iterator": "2 - 🔄 Prompt Iterator",
|
||||
"📋 Prompt Actions": "3 - 📋 Prompt Actions",
|
||||
"⏸️ Pause Controls": "4 - ⏸️ Pause Controls",
|
||||
"💾 Persistent Queue": "5 - 💾 Persistent Queue",
|
||||
"🖼️ Assets Restore": "6 - 🖼️ Assets Restore",
|
||||
"🩺 Diagnostics": "7 - 🩺 Diagnostics",
|
||||
"🎨 Batch Queue": "8 - 🎨 Batch Queue",
|
||||
"🛡️ Security": "9 - 🛡️ Security",
|
||||
"👁️ Live Preview": "10 - 👁️ Live Preview",
|
||||
"🧩 Node Management": "11 - 🧩 Node Management"
|
||||
};
|
||||
|
||||
for (const [plain, num] of Object.entries(categoryMap)) {
|
||||
if (saturnId.includes(plain)) {
|
||||
variations.push(saturnId.replace(plain, num));
|
||||
break;
|
||||
}
|
||||
// 1. Check ComfyUI settings API (getSettingValue / get)
|
||||
if (typeof app.ui?.settings?.getSettingValue === "function") {
|
||||
try {
|
||||
const val = app.ui.settings.getSettingValue(saturnId);
|
||||
if (val !== undefined && val !== null) return val;
|
||||
} catch (_) {}
|
||||
}
|
||||
if (typeof app.ui?.settings?.get === "function") {
|
||||
try {
|
||||
const val = app.ui.settings.get(saturnId);
|
||||
if (val !== undefined && val !== null) return val;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
const len = variations.length;
|
||||
for (let i = 0; i < len; i++) {
|
||||
variations.push(variations[i].replace(/^SaturnNodes\./, "LeafFlow."));
|
||||
}
|
||||
|
||||
for (const prefix of ["Comfy.Settings.", ""]) {
|
||||
for (const vId of variations) {
|
||||
const val = localStorage.getItem(`${prefix}${vId}`);
|
||||
if (val !== null && val !== undefined) {
|
||||
try { return JSON.parse(val); } catch (_) { return val; }
|
||||
}
|
||||
// 2. Check localStorage
|
||||
if (typeof localStorage !== "undefined") {
|
||||
const val = localStorage.getItem(`Comfy.Settings.${saturnId}`);
|
||||
if (val !== null && val !== undefined) {
|
||||
try { return JSON.parse(val); } catch (_) { return val; }
|
||||
}
|
||||
}
|
||||
return defaultVal;
|
||||
@@ -793,7 +743,6 @@ function getInitialSetting(saturnId, defaultVal) {
|
||||
app.registerExtension({
|
||||
name: "ComfyUI.SaturnNodes.Settings",
|
||||
async setup() {
|
||||
migrateSavedSettings();
|
||||
|
||||
// =========================================================================
|
||||
// GROUP: 🖼️ Visual Loaders
|
||||
@@ -1001,15 +950,15 @@ app.registerExtension({
|
||||
|
||||
// 10.2a Floating Dock Max Width (default: 280)
|
||||
const rawMaxWidth = getInitialSetting("SaturnNodes.👁️ Live Preview.02a_FloatingDockMaxWidth", 280);
|
||||
const initialMaxWidth = parseInt(String(rawMaxWidth).match(/\d+/)?.[0] || rawMaxWidth, 10) || 280;
|
||||
const initialMaxWidth = Math.max(100, Math.min(2000, parseInt(String(rawMaxWidth).match(/\d+/)?.[0] || rawMaxWidth, 10) || 280));
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: "SaturnNodes.👁️ Live Preview.02a_FloatingDockMaxWidth",
|
||||
name: "Floating Dock Max Width",
|
||||
type: "slider",
|
||||
attrs: { min: 80, max: 1200, step: 10 },
|
||||
attrs: { min: 100, max: 2000, step: 1 },
|
||||
defaultValue: initialMaxWidth,
|
||||
tooltip: "Maximum width limit (in pixels) for the floating live preview dock (80px to 1200px). The dock automatically matches the exact latent aspect ratio without distortion.",
|
||||
tooltip: "Maximum width limit (in pixels) for the floating live preview dock (100px to 2000px, 1px steps). Drag the dock's top-left corner to set any box size; the dock itself always matches the exact latent aspect ratio without distortion.",
|
||||
onChange(value) {
|
||||
const num = parseInt(value, 10) || 280;
|
||||
if (typeof localStorage !== "undefined") {
|
||||
@@ -1022,15 +971,15 @@ app.registerExtension({
|
||||
|
||||
// 10.2b Floating Dock Max Height (default: 280)
|
||||
const rawMaxHeight = getInitialSetting("SaturnNodes.👁️ Live Preview.02b_FloatingDockMaxHeight", 280);
|
||||
const initialMaxHeight = parseInt(String(rawMaxHeight).match(/\d+/)?.[0] || rawMaxHeight, 10) || 280;
|
||||
const initialMaxHeight = Math.max(100, Math.min(2000, parseInt(String(rawMaxHeight).match(/\d+/)?.[0] || rawMaxHeight, 10) || 280));
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: "SaturnNodes.👁️ Live Preview.02b_FloatingDockMaxHeight",
|
||||
name: "Floating Dock Max Height",
|
||||
type: "slider",
|
||||
attrs: { min: 80, max: 1200, step: 10 },
|
||||
attrs: { min: 100, max: 2000, step: 1 },
|
||||
defaultValue: initialMaxHeight,
|
||||
tooltip: "Maximum height limit (in pixels) for the floating live preview dock (80px to 1200px). The dock automatically matches the exact latent aspect ratio without distortion.",
|
||||
tooltip: "Maximum height limit (in pixels) for the floating live preview dock (100px to 2000px, 1px steps). Drag the dock's top-left corner to set any box size; the dock itself always matches the exact latent aspect ratio without distortion.",
|
||||
onChange(value) {
|
||||
const num = parseInt(value, 10) || 280;
|
||||
if (typeof localStorage !== "undefined") {
|
||||
@@ -1235,9 +1184,75 @@ app.registerExtension({
|
||||
}
|
||||
});
|
||||
|
||||
// 4.8 Visible Queue Items (Height Limit)
|
||||
// 4.8 Visible Queue Items (Height Limit) with Per-Resolution Memory & Direct Resize Handle
|
||||
const getScreenProfileKey = () => {
|
||||
if (typeof window === "undefined" || !window.screen) return "default";
|
||||
return `${window.screen.width}x${window.screen.height}`;
|
||||
};
|
||||
|
||||
const getEffectiveQueueHeight = () => {
|
||||
try {
|
||||
const saved = localStorage.getItem(`Comfy.SaturnNodes.QueueHeight.${getScreenProfileKey()}`);
|
||||
if (saved) {
|
||||
const parsed = parseInt(saved, 10);
|
||||
if (!isNaN(parsed) && parsed > 0) return parsed;
|
||||
}
|
||||
} catch (_) {}
|
||||
const rawQueueHeight = getInitialSetting("SaturnNodes.⏸️ Pause Controls.08_QueueOverlayMaxHeight", 0);
|
||||
if (typeof rawQueueHeight === "number") return rawQueueHeight;
|
||||
if (rawQueueHeight && rawQueueHeight !== "Native") {
|
||||
const match = String(rawQueueHeight).match(/\d+/);
|
||||
if (match) return parseInt(match[0], 10);
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
let saturnQueueHeightPx = 0;
|
||||
|
||||
const ensureQueueHandleStyles = () => {
|
||||
if (document.getElementById("saturn-queue-overlay-height")) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = "saturn-queue-overlay-height";
|
||||
style.textContent = `
|
||||
.saturn-queue-resize-handle {
|
||||
width: 100%;
|
||||
height: 12px;
|
||||
cursor: ns-resize;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
margin-top: auto;
|
||||
background: transparent;
|
||||
transition: background 0.15s ease;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
z-index: 25;
|
||||
}
|
||||
.saturn-queue-resize-handle:hover,
|
||||
.saturn-queue-resize-handle.active {
|
||||
background: rgba(245, 158, 11, 0.2);
|
||||
}
|
||||
.saturn-queue-resize-bar {
|
||||
width: 36px;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transition: background 0.15s ease, width 0.15s ease;
|
||||
}
|
||||
.saturn-queue-resize-handle:hover .saturn-queue-resize-bar,
|
||||
.saturn-queue-resize-handle.active .saturn-queue-resize-bar {
|
||||
background: #f59e0b;
|
||||
width: 52px;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
};
|
||||
|
||||
// Applies the queue height via direct inline styles on the overlay elements.
|
||||
// Rewriting a <style> element's text on every pointermove forced a full-document
|
||||
// style recalculation and made resizing extremely laggy.
|
||||
const applyQueueOverlayHeight = (val) => {
|
||||
let style = document.getElementById("saturn-queue-overlay-height");
|
||||
let px = 0;
|
||||
if (typeof val === "number") {
|
||||
px = val;
|
||||
@@ -1245,51 +1260,160 @@ app.registerExtension({
|
||||
const match = String(val).match(/\d+/);
|
||||
if (match) px = parseInt(match[0], 10);
|
||||
}
|
||||
saturnQueueHeightPx = (px && px > 0) ? Math.min(1200, Math.round(px)) : 0;
|
||||
|
||||
if (!px || px <= 0) {
|
||||
if (style) style.remove();
|
||||
return;
|
||||
}
|
||||
const overlay = document.querySelector('[data-testid="queue-progress-overlay"]');
|
||||
const lists = overlay
|
||||
? Array.from(overlay.querySelectorAll(".comfy-list"))
|
||||
: Array.from(document.querySelectorAll(".comfy-list"));
|
||||
|
||||
if (!style) {
|
||||
style = document.createElement("style");
|
||||
style.id = "saturn-queue-overlay-height";
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
style.textContent = `
|
||||
[data-testid="queue-progress-overlay"] {
|
||||
max-height: ${px}px !important;
|
||||
if (overlay) {
|
||||
if (saturnQueueHeightPx > 0) {
|
||||
overlay.style.setProperty("max-height", `${saturnQueueHeightPx}px`, "important");
|
||||
} else {
|
||||
overlay.style.removeProperty("max-height");
|
||||
}
|
||||
.comfy-list {
|
||||
max-height: ${px}px !important;
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
for (const listEl of lists) {
|
||||
listEl.style.setProperty("overflow-y", "auto", "important");
|
||||
if (saturnQueueHeightPx > 0) {
|
||||
listEl.style.setProperty("max-height", `${saturnQueueHeightPx}px`, "important");
|
||||
} else {
|
||||
listEl.style.removeProperty("max-height");
|
||||
}
|
||||
`;
|
||||
}
|
||||
};
|
||||
|
||||
const rawQueueHeight = getInitialSetting("SaturnNodes.⏸️ Pause Controls.08_QueueOverlayMaxHeight", 0);
|
||||
let initialQueueHeight = 0;
|
||||
if (typeof rawQueueHeight === "number") {
|
||||
initialQueueHeight = rawQueueHeight;
|
||||
} else if (rawQueueHeight && rawQueueHeight !== "Native") {
|
||||
const match = String(rawQueueHeight).match(/\d+/);
|
||||
if (match) initialQueueHeight = parseInt(match[0], 10);
|
||||
}
|
||||
const attachQueueResizeHandle = (overlay) => {
|
||||
if (!overlay || overlay.querySelector(".saturn-queue-resize-handle")) return;
|
||||
|
||||
ensureQueueHandleStyles();
|
||||
|
||||
const handle = document.createElement("div");
|
||||
handle.className = "saturn-queue-resize-handle";
|
||||
handle.title = "Drag to resize queue overlay (remembers size per monitor)";
|
||||
handle.innerHTML = `<div class="saturn-queue-resize-bar"></div>`;
|
||||
|
||||
handle.addEventListener("pointerdown", (e) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const startY = e.clientY;
|
||||
const startH = overlay.offsetHeight || saturnQueueHeightPx || getEffectiveQueueHeight() || 360;
|
||||
handle.classList.add("active");
|
||||
document.body.style.cursor = "ns-resize";
|
||||
|
||||
let currentH = startH;
|
||||
let rafPending = false;
|
||||
let rafId = 0;
|
||||
let finished = false;
|
||||
|
||||
// Pointer capture guarantees pointerup delivery even outside the window
|
||||
try {
|
||||
if (handle.setPointerCapture) handle.setPointerCapture(e.pointerId);
|
||||
} catch (_) {}
|
||||
|
||||
const applyFrame = () => {
|
||||
rafPending = false;
|
||||
rafId = 0;
|
||||
applyQueueOverlayHeight(currentH);
|
||||
};
|
||||
|
||||
const onPointerMove = (me) => {
|
||||
const dy = me.clientY - startY;
|
||||
currentH = Math.max(120, Math.min(1200, Math.round(startH + dy)));
|
||||
if (!rafPending) {
|
||||
rafPending = true;
|
||||
rafId = requestAnimationFrame(applyFrame);
|
||||
}
|
||||
};
|
||||
|
||||
const finishDrag = () => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
|
||||
if (rafId) {
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = 0;
|
||||
}
|
||||
|
||||
window.removeEventListener("pointermove", onPointerMove);
|
||||
window.removeEventListener("pointerup", finishDrag);
|
||||
window.removeEventListener("pointercancel", finishDrag);
|
||||
window.removeEventListener("blur", finishDrag);
|
||||
try {
|
||||
if (handle.releasePointerCapture) handle.releasePointerCapture(e.pointerId);
|
||||
} catch (_) {}
|
||||
|
||||
handle.classList.remove("active");
|
||||
document.body.style.cursor = "";
|
||||
|
||||
const screenKey = getScreenProfileKey();
|
||||
try {
|
||||
localStorage.setItem(`Comfy.SaturnNodes.QueueHeight.${screenKey}`, String(currentH));
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
localStorage.setItem("Comfy.Settings.SaturnNodes.⏸️ Pause Controls.08_QueueOverlayMaxHeight", JSON.stringify(currentH));
|
||||
if (app?.ui?.settings?.setSettingValue) {
|
||||
app.ui.settings.setSettingValue("SaturnNodes.⏸️ Pause Controls.08_QueueOverlayMaxHeight", currentH);
|
||||
}
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", onPointerMove);
|
||||
window.addEventListener("pointerup", finishDrag);
|
||||
window.addEventListener("pointercancel", finishDrag);
|
||||
window.addEventListener("blur", finishDrag);
|
||||
});
|
||||
|
||||
overlay.appendChild(handle);
|
||||
|
||||
// Re-apply the stored height budget to freshly mounted overlays
|
||||
applyQueueOverlayHeight(saturnQueueHeightPx);
|
||||
};
|
||||
|
||||
const existingQueueOverlay = document.querySelector('[data-testid="queue-progress-overlay"]');
|
||||
if (existingQueueOverlay) attachQueueResizeHandle(existingQueueOverlay);
|
||||
|
||||
const queueOverlayObserver = new MutationObserver(() => {
|
||||
const overlay = document.querySelector('[data-testid="queue-progress-overlay"]');
|
||||
if (overlay) {
|
||||
attachQueueResizeHandle(overlay);
|
||||
}
|
||||
});
|
||||
queueOverlayObserver.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
// Listen for screen resolution changes when moving between laptop and 4K display
|
||||
let lastScreenKey = getScreenProfileKey();
|
||||
window.addEventListener("resize", () => {
|
||||
const currentKey = getScreenProfileKey();
|
||||
if (currentKey !== lastScreenKey) {
|
||||
lastScreenKey = currentKey;
|
||||
applyQueueOverlayHeight(getEffectiveQueueHeight());
|
||||
}
|
||||
});
|
||||
|
||||
const initialQueueHeight = getEffectiveQueueHeight();
|
||||
applyQueueOverlayHeight(initialQueueHeight);
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: "SaturnNodes.⏸️ Pause Controls.08_QueueOverlayMaxHeight",
|
||||
name: "Visible Queue Items (Height)",
|
||||
type: "slider",
|
||||
attrs: { min: 0, max: 1200, step: 10 },
|
||||
attrs: { min: 0, max: 1200, step: 1 },
|
||||
defaultValue: initialQueueHeight,
|
||||
tooltip: "Limits the visible height of the queue overlay panel in pixels (e.g. 360 ≈ 10 items, 500 ≈ 15 items) with a scrollbar. Set to 0 for Native (unconstrained ComfyUI default height).",
|
||||
tooltip: "Limits the visible height of the queue overlay panel in pixels (e.g. 360 ≈ 10 items, 500 ≈ 15 items) with a scrollbar. You can also drag the bottom edge of the queue overlay directly on screen (remembers size per monitor). Set to 0 for Native.",
|
||||
onChange(value) {
|
||||
const num = parseInt(value, 10) || 0;
|
||||
const screenKey = getScreenProfileKey();
|
||||
if (typeof localStorage !== "undefined") {
|
||||
try {
|
||||
localStorage.setItem("Comfy.Settings.SaturnNodes.⏸️ Pause Controls.08_QueueOverlayMaxHeight", JSON.stringify(num));
|
||||
if (num > 0) {
|
||||
localStorage.setItem(`Comfy.SaturnNodes.QueueHeight.${screenKey}`, String(num));
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
applyQueueOverlayHeight(num);
|
||||
@@ -1558,7 +1682,7 @@ function setupSettingsFooterObserver() {
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<span style="font-size: 16px;">🪐</span>
|
||||
<span style="font-weight: 600; color: #e4e4e7;">ComfyUI-SaturnNodes</span>
|
||||
<span style="font-size: 11px; padding: 2px 7px; border-radius: 4px; background: rgba(180, 140, 95, 0.18); color: #decbb2; border: 1px solid rgba(180, 140, 95, 0.35);">v2.5.0</span>
|
||||
<span style="font-size: 11px; padding: 2px 7px; border-radius: 4px; background: rgba(180, 140, 95, 0.18); color: #decbb2; border: 1px solid rgba(180, 140, 95, 0.35);">v2.6.1 (dev)</span>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap;">
|
||||
<a href="https://github.com/KOFiblto/ComfyUI-SaturnNodes" target="_blank" rel="noopener noreferrer" style="color: #decbb2; text-decoration: underline; text-decoration-color: rgba(222, 203, 178, 0.4); text-underline-offset: 3px; font-weight: 500; font-size: 12px; display: inline-flex; align-items: center; gap: 4px; padding: 4px 10px; border-radius: 6px; background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.12); transition: all 0.2s ease;" onmouseover="this.style.color='#ffffff'; this.style.background='rgba(255,255,255,0.12)'; this.style.borderColor='rgba(255,255,255,0.25)';" onmouseout="this.style.color='#decbb2'; this.style.background='rgba(255,255,255,0.05)'; this.style.borderColor='rgba(255,255,255,0.12)';">
|
||||
@@ -1626,10 +1750,13 @@ function setupSettingsFooterObserver() {
|
||||
}
|
||||
|
||||
try {
|
||||
// Shallow body observer (settings dialog mounts/unmounts as a direct child);
|
||||
// deep subtree observation fired this callback for every DOM mutation in the
|
||||
// whole app. The 500ms interval below remains as the catch-all.
|
||||
const observer = new MutationObserver(() => {
|
||||
updateFooter();
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
observer.observe(document.body, { childList: true, subtree: false });
|
||||
} catch (_) {}
|
||||
|
||||
setInterval(updateFooter, 500);
|
||||
|
||||
Reference in New Issue
Block a user