Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9594ec428 | ||
|
|
b6b2356389 | ||
|
|
c3375c61af | ||
|
|
d39c0c89fe | ||
|
|
e1a0187f8f | ||
|
|
ac181210d1 | ||
|
|
e47ce1dd43 | ||
|
|
1c9f9568b5 | ||
|
|
29d9790c8a | ||
|
|
15c6f13ac3 | ||
|
|
b62511a5b3 | ||
|
|
3c7c1ca267 | ||
|
|
0e456783e3 | ||
|
|
b93d7ffe18 | ||
|
|
026107923b | ||
|
|
3bb42c3d2f | ||
|
|
5577efa485 | ||
|
|
121802da9e | ||
|
|
a40f09c7d9 | ||
|
|
95421b410f | ||
|
|
a3a1bb2446 | ||
|
|
3e9b4e1d6e | ||
|
|
dcadd6d605 | ||
|
|
720021b940 | ||
|
|
65635e3e95 | ||
|
|
024cb92f1f | ||
|
|
b2cfa6b8d9 | ||
|
|
babfecfaf1 | ||
|
|
66ab2f9606 | ||
|
|
3af42597d0 | ||
|
|
f0942b25b3 | ||
|
|
b82da68c8a | ||
|
|
ac2c097bfa | ||
|
|
c61537509f | ||
|
|
402c26db7f | ||
|
|
995d508979 | ||
|
|
a9ee9239f3 | ||
|
|
06ddb6540b | ||
|
|
e6ab7910a7 | ||
|
|
3473644b75 | ||
|
|
a98b8ebb18 | ||
|
|
ff969b8455 | ||
|
|
bebb9f6e7f | ||
|
|
5e3d357304 | ||
|
|
49a2e9d8fd | ||
|
|
cc44174547 | ||
|
|
7e950bae86 | ||
|
|
9e011874ea | ||
|
|
5225d8ea17 | ||
|
|
d92d115e96 | ||
|
|
59d7d1efa8 | ||
|
|
f8411cad14 | ||
|
|
2e7674a387 | ||
|
|
88c14feea7 | ||
|
|
f4fddec96d | ||
|
|
92b06c3f41 | ||
|
|
3e910d8fd9 | ||
|
|
e149843077 | ||
|
|
ccff1bd256 | ||
|
|
b6f63ab7f8 | ||
|
|
1ce384d2d8 | ||
|
|
a603b8175d | ||
|
|
7782c50b5c | ||
|
|
c9f55c28c1 | ||
|
|
fac1de762a | ||
|
|
6bbe02eecd | ||
|
|
18d1d80b15 | ||
|
|
c5aeb01db0 | ||
|
|
919d7a1f42 | ||
|
|
a185d5091f | ||
|
|
b96dab6eb5 | ||
|
|
2650192596 | ||
|
|
1fade6be5d | ||
|
|
d319916fb9 | ||
|
|
628b074d84 | ||
|
|
8757cbadb6 | ||
|
|
fa8cd0c77b | ||
|
|
a3f5ae33f2 | ||
|
|
ebf5b3e61a | ||
|
|
4480a17488 | ||
|
|
0b1af84172 | ||
|
|
ee63ca5ab5 | ||
|
|
8a3d4d2e48 | ||
|
|
2d91535777 | ||
|
|
bd9faa4ee6 | ||
|
|
08a6563378 | ||
|
|
128f37ac48 | ||
|
|
abc2fd6ee5 | ||
|
|
b99d14916e | ||
|
|
40e6079c34 | ||
|
|
a020c826ad | ||
|
|
bc62af2c35 | ||
|
|
7107740f71 | ||
|
|
64af37a73e | ||
|
|
9a096e73eb | ||
|
|
7bf9363777 | ||
|
|
445c4500b5 | ||
|
|
af3606677f | ||
|
|
5b52d88daa | ||
|
|
455b122e05 | ||
|
|
5936cda2e7 | ||
|
|
e9ca2935e6 | ||
|
|
de1d23911b | ||
|
|
4569fbeb98 | ||
|
|
7601b266ca | ||
|
|
71f5edb5bb | ||
|
|
102b0882e6 | ||
|
|
58d4925c28 | ||
|
|
22a5d248c9 | ||
|
|
70ffb2a661 | ||
|
|
9ac769fa22 | ||
|
|
bd6bbb7b6f | ||
|
|
f0ed280d6d | ||
|
|
47a9caa8bd | ||
|
|
38fb60ac78 | ||
|
|
44a07af659 | ||
|
|
7259978dc8 | ||
|
|
7260d2d04a | ||
|
|
748b3e1636 | ||
|
|
0b88844001 | ||
|
|
95ff2ed97a | ||
|
|
0eef846640 | ||
|
|
3476a41c24 | ||
|
|
2c057ca539 | ||
|
|
6754b429f3 | ||
|
|
86c3a9aba7 | ||
|
|
96bdc8bb38 | ||
|
|
083f20a36f | ||
|
|
09273849c5 | ||
|
|
f47619b37a | ||
|
|
46e5ffb69c | ||
|
|
b62a643920 | ||
|
|
6ee057e3d8 | ||
|
|
782e0709e2 | ||
|
|
4692503594 | ||
|
|
dcab859972 | ||
|
|
056604ebdb | ||
|
|
dd7c8e79eb | ||
|
|
9d2c335f2b | ||
|
|
a0a404e676 | ||
|
|
0592906b1c | ||
|
|
0a39bbbf80 | ||
|
|
de3c599991 | ||
|
|
5b3e2b5022 | ||
|
|
82cbd230b1 |
+17
-3
@@ -1,8 +1,22 @@
|
||||
# ComfyUI-FlowControl Environment Configuration Example
|
||||
# Copy or rename this file to .env to configure custom API keys.
|
||||
# ComfyUI-LeafFlow Environment Configuration Example
|
||||
# Copy or rename this file to .env to configure custom API keys and default behaviors.
|
||||
|
||||
# Optional Civitai API Key for auto-downloading LoRA preview thumbnails
|
||||
CIVITAI_API_KEY=
|
||||
|
||||
# Optional TMDB API Key for celebrity image preview search
|
||||
# Optional TMDB API Key / Read Access Token for celebrity image preview search
|
||||
TMDB_API_KEY=
|
||||
|
||||
# Feature toggles
|
||||
ENABLE_CIVITAI_SCRAPING=true
|
||||
ENABLE_TMDB_SCRAPING=true
|
||||
ENABLE_LORA_USAGE=true
|
||||
ENABLE_PERSISTENT_QUEUE=true
|
||||
ENABLE_TRAY_ICON=false
|
||||
ENABLE_ASSETS_RESTORE=true
|
||||
RESTORE_ASSETS_COUNT=64
|
||||
|
||||
# Launch defaults
|
||||
DEFAULT_PAUSE_STATE=Paused
|
||||
DEFAULT_PAUSE_MODE=after_finish
|
||||
PERSISTENT_QUEUE_RESTORED_STATE=Match Default
|
||||
|
||||
@@ -1,29 +1,30 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve ComfyUI-FlowControl
|
||||
name: 🐛 Bug Report
|
||||
about: Create a report to help us fix a bug or unexpected behavior
|
||||
title: '[BUG] '
|
||||
labels: 'bug'
|
||||
labels: bug
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
**Describe the Bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**ComfyUI & System Information**
|
||||
- ComfyUI Version:
|
||||
- Frontend Version (V1 / V2):
|
||||
- OS (Windows / Linux / macOS):
|
||||
- Browser:
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
1. Load node '...'
|
||||
2. Connect input '...'
|
||||
3. Queue prompt
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
**Expected Behavior**
|
||||
A clear description of what you expected to happen.
|
||||
|
||||
**Console Logs / Screenshots**
|
||||
If applicable, paste terminal console logs or screenshots to help explain your problem.
|
||||
**Environment (please complete the following information):**
|
||||
- ComfyUI Version / UI Version: [e.g. Nodes 2.0 Vue UI or LiteGraph V1]
|
||||
- OS: [e.g. Windows 11, Ubuntu 22.04, macOS Sonoma]
|
||||
- Browser: [e.g. Chrome, Firefox, Edge]
|
||||
|
||||
**Console Logs / Traceback**
|
||||
```text
|
||||
Paste full terminal error traceback here (if applicable)
|
||||
```
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for ComfyUI-FlowControl
|
||||
about: Suggest an idea for ComfyUI-LeafFlow
|
||||
title: '[FEAT] '
|
||||
labels: 'enhancement'
|
||||
assignees: ''
|
||||
|
||||
@@ -8,6 +8,8 @@ on:
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Create GitHub Release
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
name: Run Unit Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "**" ]
|
||||
pull_request:
|
||||
branches: [ "**" ]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test on Python ${{ matrix.python-version }} (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
python-version: ['3.10', '3.11', '3.12']
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install Python Dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install Pillow torch numpy aiohttp piexif
|
||||
|
||||
- name: Execute LeafFlow Unit Test Suite
|
||||
run: |
|
||||
python tests/run_tests.py
|
||||
@@ -13,6 +13,10 @@ persistent_queue.json.tmp
|
||||
lora_usage.json
|
||||
failed_scrapes.json
|
||||
image_prompts_cache.json
|
||||
assets_restore_debug.json
|
||||
assets_restore_debug.log
|
||||
user/
|
||||
*.json.tmp
|
||||
|
||||
# IDE and OS metadata
|
||||
.idea/
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
# 🤖 Agent & Developer Standard Operating Procedure (SOP)
|
||||
## `ComfyUI-LeafFlow` 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`**.
|
||||
|
||||
For Git workflow, branch protection, and Pull Request guidelines, see **[`CONTRIBUTING.md`](./CONTRIBUTING.md)**.
|
||||
|
||||
---
|
||||
|
||||
## 📚 Repository Markdown Index
|
||||
|
||||
| File | Purpose & Contents | Link |
|
||||
| :--- | :--- | :--- |
|
||||
| **`AGENTS.md`** | Authoritative developer & agent standard operating procedure, engineering checklists, architecture rules. | [AGENTS.md](./AGENTS.md) |
|
||||
| **`CONTRIBUTING.md`** | Community contribution guidelines, strict branch rules, PR submission workflow. | [CONTRIBUTING.md](./CONTRIBUTING.md) |
|
||||
| **`README.md`** | Main project homepage, installation guide, complete node catalog with parameter tables and UI settings guide. | [README.md](./README.md) |
|
||||
| **`WALKTHROUGH.md`** | Step-by-step user tutorials, example workflows, visual load guides, and batch prompting patterns. | [WALKTHROUGH.md](./WALKTHROUGH.md) |
|
||||
| **`CHANGELOG.md`** | Strict Keep-A-Changelog semantic version release history across all updates. | [CHANGELOG.md](./CHANGELOG.md) |
|
||||
| **`SECURITY.md`** | Security policies, supported versions, and private vulnerability disclosure instructions. | [SECURITY.md](./SECURITY.md) |
|
||||
| **`CLAUDE.md`** | AI agent instruction pointer. | [CLAUDE.md](./CLAUDE.md) |
|
||||
| **`GEMINI.md`** | AI agent instruction pointer. | [GEMINI.md](./GEMINI.md) |
|
||||
| **`AGENTS.md`** | AI agent instruction pointer. | [AGENTS.md](./AGENTS.md) |
|
||||
| **`bug_report.md`** | GitHub issue template for reporting reproducible defects. | [bug_report.md](./.github/ISSUE_TEMPLATE/bug_report.md) |
|
||||
| **`feature_request.md`** | GitHub issue template for proposing new nodes or UI features. | [feature_request.md](./.github/ISSUE_TEMPLATE/feature_request.md) |
|
||||
|
||||
---
|
||||
|
||||
## 📑 Table of Contents
|
||||
1. [Architecture & Directory Layout](#1-architecture--directory-layout)
|
||||
2. [Checklist: Adding a New Node](#2-checklist-adding-a-new-node)
|
||||
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)
|
||||
7. [Git, Branching & Commit Conventions](#6-git-branching--commit-conventions)
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
├── .env.example # Template of all supported environment variables
|
||||
├── .gitignore # Exclusion rules for caches, states, and credentials
|
||||
├── nodes/ # Python backend node implementations
|
||||
│ ├── aspect_ratio.py # Text & preview aspect ratio calculation nodes
|
||||
│ ├── 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
|
||||
│ ├── 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_iterator.py # Multiline prompt queue batch popper
|
||||
│ ├── queue_control.py # Pause Queue, Persistent Queue hooks, and server routes
|
||||
│ ├── text_replacer.py # Multiline regex & list text replacer 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
|
||||
│ ├── pause_queue.js # Top toolbar Pause/Continue button group & dropdown
|
||||
│ ├── pause_queue.css # Toolbar styling
|
||||
│ ├── persistent_queue.js # Queue crash-recovery client ownership claimer
|
||||
│ ├── preview_node.js # Live latent canvas preview widget
|
||||
│ └── js/ # Node-specific UI widgets (visual pickers, buttons, aspect preview)
|
||||
│ ├── decision_node.js
|
||||
│ ├── copy_prompt.js
|
||||
│ ├── folder_lora_loader.js
|
||||
│ ├── image_loader.js
|
||||
│ ├── preview_aspect_ratio.js
|
||||
│ ├── preview_manager.js
|
||||
│ └── prompt_iterator.js
|
||||
├── README.md # Main documentation & complete node reference
|
||||
├── WALKTHROUGH.md # End-user guide for workflows and features
|
||||
└── CHANGELOG.md # Version release history
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Checklist: Adding a New Node
|
||||
|
||||
When implementing a new custom node, you **must complete all 5 steps**:
|
||||
|
||||
### Step 2.1: Python Backend (`nodes/<module>.py`)
|
||||
- [ ] Define standard class attributes:
|
||||
- `INPUT_TYPES(cls)`: Return dict with `required`, `optional`, and `hidden` widgets. Always specify defaults, min/max/step for numbers.
|
||||
- `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"`.
|
||||
- `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.
|
||||
- [ ] Use `comfy.model_management.throw_exception_if_processing_interrupted()` inside long loops.
|
||||
|
||||
### Step 2.2: Export & Map in `__init__.py`
|
||||
- [ ] Import the node class into [`__init__.py`](./__init__.py).
|
||||
- [ ] Register in `NODE_CLASS_MAPPINGS`:
|
||||
```python
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"YourNewNodeName": YourNewNodeClass,
|
||||
# ...
|
||||
}
|
||||
```
|
||||
- [ ] Register in `NODE_DISPLAY_NAME_MAPPINGS` with leaf prefix and emoji:
|
||||
```python
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"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!
|
||||
|
||||
### 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:
|
||||
```javascript
|
||||
const isTargetNode = ["YourNodeName", "YourOldAlias"].includes(node.comfyClass) ||
|
||||
["YourNodeName", "YourOldAlias"].includes(node.type);
|
||||
```
|
||||
- [ ] 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"`)
|
||||
|
||||
### Step 2.4: Documentation (`README.md` & `WALKTHROUGH.md`)
|
||||
- [ ] Add a collapsible `<details>` section in [`README.md`](./README.md) under `## 📦 Individual Nodes Reference`:
|
||||
- Node summary tag: `<summary><b>🍃 🏷️ Display Name</b> (<code>ClassName</code>)</summary>`
|
||||
- `#### Overview`
|
||||
- `#### Inputs & Widgets`
|
||||
- `#### Outputs`
|
||||
- [ ] If the node introduces a new workflow pattern, add a section in [`WALKTHROUGH.md`](./WALKTHROUGH.md).
|
||||
|
||||
### Step 2.5: Changelog
|
||||
- [ ] Add entry under `### Added` in [`CHANGELOG.md`](./CHANGELOG.md).
|
||||
|
||||
---
|
||||
|
||||
## 3. Checklist: Adding a New Setting / Feature Flag
|
||||
|
||||
When introducing a configurable option or feature toggle:
|
||||
|
||||
### Step 3.1: `.env.example`
|
||||
- [ ] Add the variable with default value and explanation in [`.env.example`](./.env.example):
|
||||
```ini
|
||||
# Brief description of what this setting controls
|
||||
YOUR_NEW_SETTING=true
|
||||
```
|
||||
|
||||
### Step 3.2: Backend Settings Synchronization (`__init__.py`)
|
||||
- [ ] 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`)
|
||||
- [ ] Register via `app.ui.settings.addSetting({...})`:
|
||||
```javascript
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.YourSettingName",
|
||||
name: "🍃 LeafFlow: Your Setting Display Name",
|
||||
type: "boolean" /* or "text", "combo" */,
|
||||
defaultValue: false,
|
||||
tooltip: "Descriptive tooltip explaining user impact.",
|
||||
onChange(value) {
|
||||
api.fetchApi("/leafflow/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ your_setting_key: value })
|
||||
}).catch(() => {});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Step 3.4: Documentation
|
||||
- [ ] Update `## ⚙️ ComfyUI Settings Menu Reference` in [`README.md`](./README.md).
|
||||
- [ ] Update `## 4. Settings & Configuration` in [`WALKTHROUGH.md`](./WALKTHROUGH.md).
|
||||
|
||||
---
|
||||
|
||||
## 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).
|
||||
- [ ] Check imports gracefully handle missing optional libraries (e.g. `try... except ImportError`).
|
||||
|
||||
---
|
||||
|
||||
## 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] 🍃 ...")`).
|
||||
- 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()`.
|
||||
3. **OS Platform Detection**:
|
||||
- Use `sys.platform` (`'win32'`, `'darwin'`, `'linux'`) rather than POSIX-only calls like `os.uname()`.
|
||||
4. **Non-Blocking & Daemon Threads**:
|
||||
- All background threads (scraping, tray icons, persistent queue sync) must set `daemon=True` so they do not block ComfyUI shutdown.
|
||||
5. **Bytecode Validation**:
|
||||
- Before committing, always run:
|
||||
```bash
|
||||
python -m compileall .
|
||||
```
|
||||
Ensure 0 syntax errors across all modules.
|
||||
|
||||
---
|
||||
|
||||
## 7. User Data & Storage Architecture (`ComfyUI/user/default/LeafFlow/`)
|
||||
|
||||
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`:
|
||||
|
||||
```python
|
||||
from .utils import get_leafflow_user_dir
|
||||
|
||||
USER_DIR = get_leafflow_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
|
||||
|
||||
### 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/`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Git, Branching & Commit Conventions
|
||||
|
||||
### 6.1 Strict Branch Naming Classification
|
||||
Never create misleading or overly narrow branch names when multiple components, fixes, and features are bundled together!
|
||||
- **Atomic Feature Branches** (`feature/<feature-name>`):
|
||||
- Used **ONLY** when implementing a single, isolated new feature or node.
|
||||
- Examples: `feature/prompt-iterator-privacy`, `feature/visual-loaders-pathing`
|
||||
- **Atomic Bug Fix Branches** (`fix/<bug-name>`):
|
||||
- Used **ONLY** for targeted bug fixes.
|
||||
- Examples: `fix/preview-latent-insets`, `fix/tray-icon-persistence`
|
||||
- **Comprehensive Integration & Release Branches** (`release/vX.Y.Z-<summary>` or `dev/<summary>`):
|
||||
- **MANDATORY**: Whenever bundling multiple features, fixes, rebrands, or architectural updates into a combined testing or release candidate, use a `release/...` or `dev/...` branch name.
|
||||
- **STRICTLY PROHIBITED**: Never disguise a multi-feature/fix release bundle under a single isolated `feature/<narrow-name>`.
|
||||
- Examples: `release/v2.2.0-leafflow-update`, `dev/v2.2.0-full-integration`
|
||||
|
||||
### 6.2 Excluded Files & Privacy
|
||||
Never commit sensitive keys, runtime states, or caches:
|
||||
- `.env`, `.env.local`
|
||||
- `persistent_queue.json`, `lora_usage.json`, `failed_scrapes.json`, `image_prompts_cache.json`
|
||||
- `user/` directory
|
||||
|
||||
### 6.3 Commit Messages
|
||||
Use atomic, descriptive English commit headers:
|
||||
- `Feat: ...` (New features or nodes)
|
||||
- `Fix: ...` (Bug fixes, UI sync repairs)
|
||||
- `Docs: ...` (Documentation, README, Walkthrough updates)
|
||||
- `Refactor: ...` (Code restructuring or rebrands)
|
||||
- `Release: ...` (Consolidated version releases)
|
||||
+147
-1
@@ -1,10 +1,156 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to `ComfyUI-FlowControl` will be documented in this file.
|
||||
All notable changes to `ComfyUI-LeafFlow` will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [2.3.1] - 2026-09-22
|
||||
|
||||
### Security & Hardening
|
||||
- **Universal CSRF Protection Across All Mutation Routes**:
|
||||
- Protected `POST /leafflow/prompt_iterator/reset_node`, `POST /leafflow/prompt_iterator/clear`, and `POST /leafflow/prompt_iterator/open_file` with `is_authenticated_local_request`.
|
||||
- Protected `POST /leafflow/decision` with `is_authenticated_local_request`.
|
||||
- Protected queue-mutating endpoints (`POST /pause_queue/toggle`, `POST /pause_queue/mode`, `POST /pause_queue/continue`, `POST /persistent_queue/claim`, `POST /leafflow/batch_queue/sync`, `POST /leafflow/assets/restore`) with `is_authenticated_local_request`.
|
||||
- Updated all frontend callers in `web/` to send the `X-LeafFlow-CSRF-Token` session header via `authenticatedFetch`.
|
||||
- **SSRF Protection & Scheme Filtering for Preview Scrapers**:
|
||||
- Added `is_safe_external_image_url` validating that downloaded image URLs use standard `https` or `http` schemes.
|
||||
- Strictly blocked internal loopback (`127.0.0.1`, `localhost`), private networks (`10.*`, `172.16.*`, `192.168.*`), and link-local cloud metadata endpoints (`169.254.*`).
|
||||
- **Safe Write & Permission Error Handling**:
|
||||
- Wrapped downloaded preview image disk writes in `try / except (PermissionError, OSError)` with clear logging to prevent crashes on read-only directories or shared network mounts.
|
||||
- **Canonical Confinement for OS Application Launches**:
|
||||
- Verified that `open_prompt_iterator_file` can only open files strictly confined within the authorized ComfyUI user directory (`USER_DIR`) via `os.path.commonpath`.
|
||||
- **Conditional Core Queue Monkey-Patching**:
|
||||
- Guarded `PersistentQueueManager.patch_server()` so core ComfyUI `prompt_queue` methods are not touched when `ENABLE_PERSISTENT_QUEUE=false`.
|
||||
- **Explicit Scripts Confinement Documentation & UI Canvas Indicators**:
|
||||
- Added explicit italic indicator `📂 Script Location: ComfyUI/scripts/` to `RunLocalFileNode` canvas foreground.
|
||||
- Added widget tooltips and settings descriptions clarifying that scripts and working directories must reside exclusively in `ComfyUI/scripts/`.
|
||||
|
||||
---
|
||||
|
||||
## [2.3.0] - 2026-09-20
|
||||
|
||||
### Added
|
||||
- **🍃 ⚡ "Run Local File" Node (`RunLocalFileNode`)**:
|
||||
- Executes local scripts and binaries (`.bat`, `.cmd`, `.ps1`, `.exe` on Windows; `.sh` and executables on Linux/macOS) directly within ComfyUI workflows.
|
||||
- Parameter arguments supporting multi-line or standard quotes (`shlex` tokenized, safe non-shell execution).
|
||||
- Configurable working directory (`cwd`), defaulting to the script's directory if unspecified.
|
||||
- **Synchronous Mode**: Captures `stdout`, `stderr`, `exit_code`, and `success` boolean outputs, with real-time ComfyUI cancel button interrupt handling and configurable execution timeout.
|
||||
- **Asynchronous Mode**: Detaches process to run in the background without blocking the ComfyUI generation queue.
|
||||
- Optional `trigger` wildcard input and `passthrough` wildcard output for sequencing file execution in any pipeline.
|
||||
- **Multi-Layered Security Sandbox**:
|
||||
- Confined strictly to `ComfyUI/scripts/` via canonical `realpath` and `commonpath` validation.
|
||||
- Strict rejection of absolute paths (`C:\`, `/etc/`, `\\server`) and directory traversal sequences (`..`).
|
||||
- Never invokes `shell=True` (eliminating command chaining and command injection vulnerabilities).
|
||||
- Recursive process tree termination on timeout or cancellation (uses `taskkill /F /T` on Windows to prevent orphaned background processes).
|
||||
- **In-Memory Operator Authorization Gate**: Removed `security_consent` from `INPUT_TYPES` so consent never serializes into workflow JSON or dropped image metadata. Requires active canvas button click `[⚡ Authorize Run]` with 5-minute single-use memory token.
|
||||
- Visual node status indicator (`[ARMED]` in emerald green vs `[DISARMED]` in red).
|
||||
- Global toggle setting (`LeafFlow.9 - 🛡️ Security.01_AllowLocalFileExecution`, default `false`).
|
||||
- **🛡️ CSRF Defense & Request Authentication**:
|
||||
- Added `is_authenticated_local_request` validating loopback origin, `Sec-Fetch-Site` header (blocking `cross-site` calls from visited web pages), and `Origin`/`Referer` headers.
|
||||
- Cryptographically secure per-session CSRF token (`X-LeafFlow-CSRF-Token`) generated via `secrets.token_hex(32)` and validated with `secrets.compare_digest`.
|
||||
- Added frontend auth helper (`web/js/auth_helper.js`) automatically fetching and attaching CSRF tokens to administrative fetch requests.
|
||||
- **⚡ Two-Step Power Action Handshake**:
|
||||
- Server restart and shutdown endpoints require `ALLOW_PROCESS_MANAGEMENT=true` plus a two-step confirmation handshake (`POST /leafflow/power/request_token` to issue a 30s single-use ticket, followed by `POST /leafflow/power/confirm_action` to consume and execute).
|
||||
|
||||
### Fixed & Compliance
|
||||
- **Complete Removal of `ALLOW_ANY_SCRIPT_PATH`**: Eliminated all path escape settings to guarantee execution confinement to `ComfyUI/scripts/`.
|
||||
- **Dynamic Node Count**: Replaced hardcoded node count in startup banner with dynamic `len(NODE_CLASS_MAPPINGS)` (now reporting all 16 registered nodes).
|
||||
- **Universal Synchronized Versioning**: Version `2.3.0` synchronized across `__init__.py`, `pyproject.toml`, `/leafflow/debug/export`, `README.md`, and `CHANGELOG.md`.
|
||||
- **Process Management Security Toggle**: Server restart and shutdown controls are disabled by default (`ALLOW_PROCESS_MANAGEMENT=false`). If disabled, attempts to trigger restart/shutdown via the UI or API return HTTP 403 with clear instructions.
|
||||
- **Optional `pystray` Dependency**: Made `pystray` an optional extra (`[project.optional-dependencies] tray = ["pystray"]`) and removed all runtime `pip install` subprocess calls, ensuring zero crashes on headless cloud servers, Docker, or Google Colab.
|
||||
- **Universal Loopback Protection**: Added `is_local_request(request)` validation to all legacy queue endpoints (`/pause_queue/*` and `/persistent_queue/claim`).
|
||||
- **Windows Console Unicode Safety**: Added `UnicodeEncodeError` guard around startup emoji printing for legacy Windows terminals.
|
||||
- **Clean Dynamic Imports**: Replaced static imports of desktop private `app.*` in `assets_restore.py` with dynamic `importlib.import_module` calls.
|
||||
- **Cleaned Dead Code**: Removed orphaned canvas classes from `queue_control.py`.
|
||||
|
||||
### Testing
|
||||
- Comprehensive automated test coverage expanded to **105 unit tests** covering all nodes, CSRF authentication, power action tickets, in-memory runner authorization, and path confinement.
|
||||
|
||||
---
|
||||
|
||||
## [2.2.0] - 2026-09-08
|
||||
|
||||
> ⚠️ **Important Frontend Compatibility Notice**:
|
||||
> The previous release (**v2.1.0**) does **not** support the new ComfyUI frontend package (`@comfyorg/comfyui-frontend` / modern Vue virtual grid).
|
||||
> **v2.2.0 introduces complete support for the new frontend package**, restoring action buttons (Copy Prompt, Save to Prompt Bookmarks, Inspect Asset zoom) directly on modern virtual grid cards, alongside the 1D Git-Graph batch queue visualizer and per-node zoom settings!
|
||||
|
||||
### Added
|
||||
- **🌐 Full Modern ComfyUI Frontend Package Support**:
|
||||
- Full compatibility with `@comfyorg/comfyui-frontend` modern virtual grid and card DOM structures.
|
||||
- Seamless overlay action bar button groups for modern asset cards with dynamic rounded pill styling (`rounded-l-lg`, `rounded-r-lg`, `rounded-none`, `border-r`).
|
||||
- **🔍 Toggleable "Inspect Asset" Button (`04_EnableInspectAssetButton`)**:
|
||||
- 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`)**:
|
||||
- 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.
|
||||
- **Start / Middle / End / Single Segments**:
|
||||
- First item in batch segment curves inward at the top (`┌`) and continues straight down.
|
||||
- Middle items connect with a continuous straight line (`│`).
|
||||
- Last item in batch segment comes straight from the top and curves inward at the bottom (`└`).
|
||||
- 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.
|
||||
|
||||
### Fixed
|
||||
- **📐 Independent Per-Node `tile_size` in Visual Loaders**:
|
||||
- Decoupled `tile_size` zoom levels from global browser `localStorage` in `VisualLoraLoader` (V1 & V2) and `VisualImageLoader`.
|
||||
- Tile zoom values are now persisted directly in `node.properties["tile_size"]` within the workflow JSON, allowing multiple loader nodes on the canvas to maintain independent, custom tile sizes across browser reloads.
|
||||
- **🛡️ Queue Container Conflict Prevention**:
|
||||
- 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`.
|
||||
- **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`).
|
||||
|
||||
### Changed
|
||||
- **Text Aspect Ratio Finder Whitelist / Open Mode**:
|
||||
- When `aspect_ratios` is populated (e.g. `16:9, 9:16, 1:1`), strictly matches only those configured ratios in prompt text, falling back to default if prompt contains non-whitelisted ratios.
|
||||
- When `aspect_ratios` is left empty (`""`), enters open mode and accepts any valid aspect ratio found in text.
|
||||
|
||||
---
|
||||
|
||||
## [2.1.0] - 2026-08-24
|
||||
|
||||
### Added
|
||||
- **Centralized ComfyUI User Directory (`ComfyUI/user/default/LeafFlow/`)**:
|
||||
- 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`).
|
||||
- **Prompt Queue Iterator Live Sync**:
|
||||
- Added interactive Reset widget button directly onto the canvas node and WebSocket live synchronization for multiline prompt text as items are popped.
|
||||
- **Cross-Platform Matrix CI**:
|
||||
- Automated GitHub Actions unit testing across Python 3.10, 3.11, 3.12 on both Ubuntu and Windows with 56 automated unit tests.
|
||||
|
||||
### Fixed
|
||||
- Fixed empty folder matching edge case in `BackToPlaceholder` (`undo_placeholder.py`).
|
||||
- Fixed XML entity escaping for native Windows Toast Notifications in `LeafFlowDecision`.
|
||||
- Removed accidental scrape cache reset during minor settings updates in `__init__.py`.
|
||||
- Removed obsolete `playwright` dependency from `pyproject.toml` and `install.py`.
|
||||
|
||||
---
|
||||
|
||||
## [2.0.0] - 2026-08-20
|
||||
|
||||
### Added
|
||||
- **🍃 ⭐ Favorite Prompts**: Preview and loader system for saved favorite prompts and generation presets with subfolder categorizing.
|
||||
- **🍃 📐 Text Aspect Ratio Finder & Preview**: Dynamic aspect ratio parser (e.g. `16:9`, `2:3`) with resolution calculation and visual aspect box preview.
|
||||
- **🍃 🔎 Text LoRA Finder & Loader**: Automatic text prompt scanning for LoRA names with model/clip loading and pretty name formatting.
|
||||
- **🍃 🔄 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.
|
||||
- **🍃 🔔 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.
|
||||
|
||||
---
|
||||
|
||||
## [1.0.0] - 2026-07-24
|
||||
|
||||
### Added
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# AI Agent Guidelines
|
||||
|
||||
All AI agents (Claude, Gemini, Antigravity, Cursor, Copilot) contributing to this repository must follow the instructions in:
|
||||
- [`AGENTS.md`](./AGENTS.md) — Standard Operating Procedure & Architecture
|
||||
- [`CONTRIBUTING.md`](./CONTRIBUTING.md) — Git Workflow & Contribution Rules
|
||||
+77
-14
@@ -1,19 +1,82 @@
|
||||
# Contributing to ComfyUI-FlowControl
|
||||
# 🤝 Contributing to ComfyUI-LeafFlow
|
||||
|
||||
Thank you for your interest in contributing to ComfyUI-FlowControl!
|
||||
Thank you for your interest in contributing to **`ComfyUI-LeafFlow`**! We welcome bug reports, feature suggestions, documentation updates, and pull requests from the community.
|
||||
|
||||
## Development Workflow
|
||||
Please take a moment to review these guidelines before submitting an issue or pull request.
|
||||
|
||||
1. Fork the repository on GitHub.
|
||||
2. Clone your fork locally into `ComfyUI/custom_nodes/ComfyUI-FlowControl`.
|
||||
3. Create a feature branch: `git checkout -b feature/my-feature`.
|
||||
4. Make your changes and test locally in ComfyUI.
|
||||
5. Ensure code is formatted cleanly and free of hardcoded personal paths or credentials.
|
||||
6. Commit your changes: `git commit -m "Add new feature"`.
|
||||
7. Push to your branch and submit a Pull Request.
|
||||
---
|
||||
|
||||
## Code Guidelines
|
||||
## 🐛 Reporting Issues & Bugs
|
||||
|
||||
- **Python**: Follow PEP 8 style guidelines. Ensure thread operations use atomic writes (`.tmp` + `os.replace`).
|
||||
- **JavaScript**: Follow ES Module conventions. Use `import { app } from "../../scripts/app.js"`.
|
||||
- **Security**: Never commit personal paths, hardcoded keys, or sensitive `.env` files.
|
||||
Before opening a new issue:
|
||||
1. **Check Existing Issues**: Search the [Issue Tracker](https://github.com/KOFiblto/ComfyUI-LeafFlow/issues) to see if your bug or feature request has already been reported.
|
||||
2. **Include Reproduction Steps**: Provide a clear step-by-step description of how to reproduce the bug.
|
||||
3. **Include Logs & Environment Details**:
|
||||
- **ComfyUI Version** (V2 Vue UI or Classic V1 LiteGraph)
|
||||
- **OS** (Windows, Linux, macOS)
|
||||
- **Browser** (Chrome, Firefox, Edge)
|
||||
- Full Python console log traceback (if execution errored).
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Contribution Workflow (Feature Branches & PRs)
|
||||
|
||||
### Branch and Pull Request Policy
|
||||
Direct pushes to the `main` branch are restricted. All changes, bug fixes, and new features must be developed on a dedicated feature or fix branch and merged via Pull Request.
|
||||
|
||||
All development follows a **Feature Branch $\rightarrow$ Pull Request** workflow:
|
||||
|
||||
```
|
||||
1. Fork/Branch Repository --> 2. Create Feature Branch --> 3. Commit Changes --> 4. Open Pull Request
|
||||
```
|
||||
|
||||
### Step 1: Fork and Clone
|
||||
Fork the repository on GitHub, then clone your fork locally:
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/ComfyUI-LeafFlow.git
|
||||
cd ComfyUI-LeafFlow
|
||||
```
|
||||
|
||||
### Step 2: Create a Feature Branch
|
||||
Create a descriptive branch for your change:
|
||||
```bash
|
||||
git checkout -b feature/short-description
|
||||
# Example: git checkout -b feature/live-preview-aspect-ratio
|
||||
```
|
||||
|
||||
### Step 3: Coding & Style Guidelines
|
||||
- **Python Style:** Match existing repository conventions. Keep imports at module scope. Do not add unnecessary `try/except` swallow blocks.
|
||||
- **No Internet Requests in Core Execution:** All node execution must run locally. Outbound network requests (e.g. Civitai/TMDB thumbnail scraping) must strictly require user consent via settings.
|
||||
- **No Hardcoded Keys or Absolute System Paths:** Do not commit personal system paths (`C:\Users\...` or `D:\...`), API keys, or private tokens.
|
||||
|
||||
### Step 4: Commit Your Changes
|
||||
Use concise, imperative subject lines for commits:
|
||||
- `Feat: ...` (New node or feature)
|
||||
- `Fix: ...` (Bug fix)
|
||||
- `Docs: ...` (Documentation update)
|
||||
- `Refactor: ...` (Code cleanup or restructuring)
|
||||
|
||||
Example:
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "Fix: Resolve aspect ratio bounding box in Live Latent Preview"
|
||||
```
|
||||
|
||||
### Step 5: Push & Submit a Pull Request
|
||||
Push your branch to your fork on GitHub and open a Pull Request:
|
||||
```bash
|
||||
git push origin feature/short-description
|
||||
```
|
||||
1. Open [Pull Requests](https://github.com/KOFiblto/ComfyUI-LeafFlow/pulls) $\rightarrow$ Click **New pull request**.
|
||||
2. Select `base: main` $\leftarrow$ `compare: feature/short-description`.
|
||||
3. Provide a brief summary of what changed and how you verified your fix.
|
||||
4. Wait for code review approval and automated status check validation!
|
||||
|
||||
---
|
||||
|
||||
## 📜 Code of Conduct & Mascot
|
||||
|
||||
- Be respectful and constructive when commenting on issues and reviewing pull requests.
|
||||
- The official mascot of ComfyUI is a very cute anime girl with massive fennec ears, a big fluffy tail, long blonde wavy hair, and blue eyes. Treat her with respect!
|
||||
|
||||
Thank you for helping make `ComfyUI-LeafFlow` better for everyone!
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# AI Agent Guidelines
|
||||
|
||||
All AI agents (Claude, Gemini, Antigravity, Cursor, Copilot) contributing to this repository must follow the instructions in:
|
||||
- [`AGENTS.md`](./AGENTS.md) — Standard Operating Procedure & Architecture
|
||||
- [`CONTRIBUTING.md`](./CONTRIBUTING.md) — Git Workflow & Contribution Rules
|
||||
@@ -1,104 +1,412 @@
|
||||
# 🍃 ComfyUI-FlowControl
|
||||
# 🍃 ComfyUI-LeafFlow
|
||||
|
||||
A unified custom node suite for **ComfyUI** featuring real-time queue controls, mid-generation pausing, queue crash recovery, visual model pickers, live latent canvas previews, folder automation, and prompt utilities.
|
||||
A unified custom node suite for **ComfyUI** featuring real-time queue controls, mid-generation pausing, queue crash recovery, visual model pickers, live latent canvas previews, folder automation, prompt utilities, and resolution tools.
|
||||
|
||||
> ⚠️ **Important Notice**: This custom node pack (`ComfyUI-FlowControl`) is engineered exclusively for the **New ComfyUI Frontend (v2)**. The legacy/old ComfyUI frontend is not supported.
|
||||
> ⚠️ **Notice**: Fully compatible with both **ComfyUI Frontend V2 (Nodes 2.0 / Vue UI)** and **Classic ComfyUI Frontend (Nodes 1.0 / LiteGraph)**.
|
||||
|
||||
---
|
||||
|
||||
## 🌟 Features & Included Nodes
|
||||
## 📦 Individual Nodes Reference
|
||||
|
||||
Every node in this suite is prefixed with the signature **`🍃`** brand icon for quick identification on your canvas and search menu.
|
||||
Expand any node below to view its description, inputs, outputs, and usage documentation.
|
||||
|
||||
### ⏸️ Queue & Workflow Control (`FlowControl/Queue`)
|
||||
<details>
|
||||
<summary><b>🍃 📁 LoRA Loader (Folder)</b> (<code>FolderLoraLoader</code>)</summary>
|
||||
|
||||
1. **`🍃 Pause Queue`**
|
||||
- Adds a native-styled **Pause / Continue** button directly into the ComfyUI V2 top action bar.
|
||||
- **Pause (Finish)**: Completes the currently active generation, then holds the queue before starting the next prompt.
|
||||
- **Pause (Instant)**: Pauses immediately before the next node executes in the active workflow (resumable mid-workflow without losing progress).
|
||||
- **Visual Indicators**: Green when unpaused, Orange when pausing/paused with pulsing glow animation.
|
||||
- **Status Text**: Displays `Pausing (Finish)...` or `Pausing (Instant)...` while waiting for execution to reach the pause gate, then transforms into `Continue` with a play icon `▶`.
|
||||
#### Overview
|
||||
Loads a LoRA by folder path using raw filename matching or formatted names with customizable output formatting options.
|
||||
|
||||
2. **`🍃 Persistent Queue`**
|
||||
- Automatically saves all queued workflows to `persistent_queue.json` in real time on disk.
|
||||
- If ComfyUI crashes, suffers a power outage, or is closed, all unfinished prompts are automatically restored to the queue on next boot in a paused state.
|
||||
- **Session Ownership Claiming**: Automatically re-assigns restored queue items to your active browser tab's `client_id` when you open the page, removing the *"Running in another tab"* warning.
|
||||
#### Inputs & Widgets
|
||||
- **`model`** (`MODEL`): Input model.
|
||||
- **`clip`** (`CLIP`): Input CLIP.
|
||||
- **`folder`** (`STRING`): Subfolder filter (e.g. `Celebrities`, `PonyV6`, `*`).
|
||||
- **`lora_name`** (`COMBO`): Dropdown listing available LoRA files in the target folder.
|
||||
- **`strength_model`** (`FLOAT`): Model weight strength (default `1.0`).
|
||||
- **`strength_clip`** (`FLOAT`): CLIP weight strength (default `1.0`).
|
||||
- **`output_format`** (`COMBO`, *Advanced*): Choose output format (`Parsed Name`, `Filename`, `Filename without extension`, `Relative Path`, `Full Path`, `Custom Regex`).
|
||||
- **`custom_regex`** (`STRING`, *Advanced*): Pattern used when `output_format` is `Custom Regex`.
|
||||
|
||||
#### Outputs
|
||||
- **`MODEL`**: Patched model.
|
||||
- **`CLIP`**: Patched CLIP.
|
||||
- **`lora_name`** (`STRING`): Formatted name or filename of the loaded LoRA.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>🍃 ✨ LoRA Loader (Pretty)</b> (<code>FolderLoraLoaderPretty</code>)</summary>
|
||||
|
||||
#### Overview
|
||||
Loads a LoRA using formatted pretty names (e.g. `Ana De Armas V1` instead of `krea2_Ana-De-Armas_v1.safetensors`).
|
||||
|
||||
#### Inputs & Widgets
|
||||
- **`model`** (`MODEL`): Input model.
|
||||
- **`clip`** (`CLIP`): Input CLIP.
|
||||
- **`folder`** (`STRING`): Subfolder filter.
|
||||
- **`lora_name`** (`COMBO`): Dropdown listing formatted LoRA names.
|
||||
- **`strength_model`** (`FLOAT`): Model weight strength.
|
||||
- **`strength_clip`** (`FLOAT`): CLIP weight strength.
|
||||
|
||||
#### Outputs
|
||||
- **`MODEL`**: Patched model.
|
||||
- **`CLIP`**: Patched CLIP.
|
||||
- **`lora_name`** (`STRING`): Selected LoRA name.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>🍃 🖼️ Visual LoRA Loader</b> (<code>VisualLoraLoader</code> / alias <code>FolderLoraLoaderVisualPrettyV2</code>)</summary>
|
||||
|
||||
#### Overview
|
||||
Visual thumbnail browser for LoRAs with Civitai SHA256 search & TMDB auto-scraping, popularity rank badges (🔥), multi-selection support, and independent per-node thumbnail zoom sizing.
|
||||
|
||||
#### Inputs & Widgets
|
||||
- **`model`** (`MODEL`): Input model.
|
||||
- **`clip`** (`CLIP`): Input CLIP.
|
||||
- **`folder`** (`STRING`): Subfolder filter.
|
||||
- **`strength_model`** (`FLOAT`): Model strength.
|
||||
- **`strength_clip`** (`FLOAT`): CLIP strength.
|
||||
- **`tile_size`** (`SLIDER`, UI): Adjust thumbnail card size (persisted per node in workflow JSON).
|
||||
- **`display_mode`** (`COMBO`, *Advanced*): `Scrollable` vs `Show All`.
|
||||
- **`sort_loras_by`** (`COMBO`, *Advanced*): Sort by `Name (A-Z)`, `Name (Z-A)`, `Date Modified (Newest First)`, `Date Modified (Oldest First)`, or `Usage (Most Used First)`.
|
||||
|
||||
#### Outputs
|
||||
- **`MODEL`**: Patched model.
|
||||
- **`CLIP`**: Patched CLIP.
|
||||
- **`lora_name`** (`STRING`): Comma-separated list of active LoRA names.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>🍃 📷 Visual Image Loader</b> (<code>VisualImageLoader</code> / alias <code>ImageLoaderVisualPrettyV2</code>)</summary>
|
||||
|
||||
#### Overview
|
||||
Visual thumbnail browser for image folders with instant preview selection, independent per-node thumbnail zoom sizing, and EXIF positive prompt metadata extraction. Path access is strictly confined to ComfyUI `input`, `output`, and `temp` directories for safe operation.
|
||||
|
||||
#### Inputs & Widgets
|
||||
- **`folder`** (`STRING`): Folder path to load images from (confined to ComfyUI input/output/temp).
|
||||
- **`tile_size`** (`SLIDER`, UI): Adjust thumbnail card size (persisted per node in workflow JSON).
|
||||
- **`display_mode`** (`COMBO`, *Advanced*): `Scrollable` vs `Show All`.
|
||||
- **`sort_images_by`** (`COMBO`, *Advanced*): `Name (A-Z)`, `Name (Z-A)`, `Date Modified (Newest First)`, `Date Modified (Oldest First)`.
|
||||
|
||||
#### Outputs
|
||||
- **`IMAGE`**: Selected image tensor.
|
||||
- **`positive_prompt`** (`STRING`): Extracted prompt metadata.
|
||||
- **`width`** (`INT`): Image width.
|
||||
- **`height`** (`INT`): Image height.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>🍃 📂 Load Image From Folder</b> (<code>LoadImageFromFolder</code>)</summary>
|
||||
|
||||
#### Overview
|
||||
Monitors a folder for incoming images, loads the target image into a PyTorch tensor, with an optional toggle to delete the image after loading.
|
||||
|
||||
#### Inputs & Widgets
|
||||
- **`folder`** (`STRING`): Watch directory path (default `input/watch`).
|
||||
- **`wait_if_folder_is_empty`** (`BOOLEAN`): Poll until image arrives vs return dummy tensor immediately.
|
||||
- **`rescan_interval`** (`INT`): Polling frequency in seconds.
|
||||
- **`sort_by`** (`COMBO`): Sort by `date_modified`, `date_created`, or `name`.
|
||||
- **`regex_filter`** (`STRING`): Regex pattern to filter filenames.
|
||||
- **`delete_image`** (`BOOLEAN`, *Default: false*): If enabled, deletes the image file from disk after loading.
|
||||
|
||||
#### Outputs
|
||||
- **`image`** (`IMAGE`): Loaded image tensor.
|
||||
- **`has_image`** (`BOOLEAN`): True if image loaded, False if folder empty.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>🍃 ⏱️ Recent Outputs</b> (<code>LoadRecentOutputs</code>)</summary>
|
||||
|
||||
#### Overview
|
||||
Loads the N newest images from an output directory with step-through index selection.
|
||||
|
||||
#### Inputs & Widgets
|
||||
- **`output_folder`** (`STRING`): Target output directory.
|
||||
- **`amount`** (`INT`): Number of newest images to pool (1-100).
|
||||
- **`index`** (`INT`): Step index selection.
|
||||
|
||||
#### Outputs
|
||||
- **`IMAGE`**: Output image tensor.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>🍃 👁️ Live Latent Preview</b> (<code>PreviewLatentLiveNode</code>)</summary>
|
||||
|
||||
#### Overview
|
||||
Canvas rendering node that listens to sampler WebSocket latent binary streams and displays real-time live previews on the canvas during generation.
|
||||
|
||||
#### Inputs & Outputs
|
||||
- **Category**: `LeafFlow/Previews`
|
||||
- **Output Node**: True
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>🍃 ⏸️ LeafFlow Decision</b> (<code>LeafFlowDecision</code>)</summary>
|
||||
|
||||
#### Overview
|
||||
Pauses workflow execution at a specific step and displays an inline UI popup with Continue, Cancel, or Stop Workflow actions, plus optional native OS desktop notifications.
|
||||
|
||||
#### Inputs & Widgets
|
||||
- **`disable`** (`BOOLEAN`): Bypass decision gate.
|
||||
- **`send_os_notification`** (`BOOLEAN`): Trigger native OS desktop toast on pause.
|
||||
- **`timeout`** (`INT`): Auto-continue timeout in seconds (-1 = infinite).
|
||||
|
||||
#### Outputs
|
||||
- **`cancel`** (`BOOLEAN`): False on Continue, True on Cancel (for branch routing).
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>🍃 📐 Text Aspect Ratio Finder</b> (<code>TextAspectRatioFinder</code> / alias <code>AspectRatioFinder</code>)</summary>
|
||||
|
||||
#### Overview
|
||||
Parses input text for aspect ratios (e.g. `16:9`, `2.35:1`), syntax-checks them, and calculates pixel resolution for a target megapixel target.
|
||||
|
||||
#### Inputs & Widgets
|
||||
- **`aspect_ratios`** (`STRING`): Allowed ratio list (e.g. `16:9, 9:16, 1:1`). When populated, strictly enforces matching only the configured ratios and ignores any other numbers/ratios in text. When left completely empty (`""`), acts in open mode and accepts any valid ratio found in text.
|
||||
- **`search_mode`** (`COMBO`): `First match (Front)` vs `Last match (Back)`.
|
||||
- **`target_mp`** (`FLOAT`): Target megapixels (e.g. `1.0 MP`).
|
||||
- **`default_aspect_ratio`** (`COMBO`): Fallback ratio if none found.
|
||||
- **`multiple_of`** (`INT`, *Advanced*): Dimension step multiple (default `8`).
|
||||
- **`min_mp`** (`FLOAT`, *Advanced*): Minimum megapixel limit.
|
||||
- **`max_mp`** (`FLOAT`, *Advanced*): Maximum megapixel limit.
|
||||
- **`text`** (`STRING`, *Optional Input*): Input text to search.
|
||||
|
||||
#### Outputs
|
||||
- **`width`** (`INT`): Computed width in pixels.
|
||||
- **`height`** (`INT`): Computed height in pixels.
|
||||
- **`aspect_ratio`** (`STRING`): Detected or fallback aspect ratio string.
|
||||
- **`cleaned_text`** (`STRING`): Input text with the aspect ratio token stripped.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>🍃 📐 Preview Image Size & Aspect Ratio</b> (<code>PreviewImageSizeAspectRatio</code>)</summary>
|
||||
|
||||
#### Overview
|
||||
Computes image dimensions and aspect ratio from an input image tensor and formats the result for previewing.
|
||||
|
||||
#### Inputs & Widgets
|
||||
- **`image`** (`IMAGE`): Input image tensor.
|
||||
|
||||
#### Outputs
|
||||
- **`width`** (`INT`): Image width.
|
||||
- **`height`** (`INT`): Image height.
|
||||
- **`aspect_ratio`** (`STRING`): Closest matched aspect ratio string.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>🍃 🔎 Text LoRA Finder & Loader</b> (<code>TextLoraFinder</code> / alias <code>LoraTextFinder</code>)</summary>
|
||||
|
||||
#### Overview
|
||||
Scans input prompt text for `<lora:name:strength>` tags or names matching a folder on disk, dynamically applies them, and returns patched models along with sanitized prompt text.
|
||||
|
||||
#### Inputs & Widgets
|
||||
- **`model`** (`MODEL`): Input model.
|
||||
- **`clip`** (`CLIP`): Input CLIP.
|
||||
- **`folder`** (`STRING`): Subfolder to search for LoRA files.
|
||||
- **`fallback_strength_model`** (`FLOAT`): Default model strength if omitted in prompt tag.
|
||||
- **`fallback_strength_clip`** (`FLOAT`): Default CLIP strength if omitted in prompt tag.
|
||||
- **`clean_prompt`** (`BOOLEAN`): Strips matched `<lora:...>` tags from output text.
|
||||
- **`text`** (`STRING`, *Optional Input*): Input prompt text.
|
||||
|
||||
#### Outputs
|
||||
- **`MODEL`**: Patched model.
|
||||
- **`CLIP`**: Patched CLIP.
|
||||
- **`text`** (`STRING`): Cleaned prompt text.
|
||||
- **`lora_name`** (`STRING`): Comma-separated list of loaded LoRAs.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>🍃 🔄 Prompt Queue Iterator</b> (<code>PromptQueueIterator</code>)</summary>
|
||||
|
||||
#### Overview
|
||||
Deterministically iterates over multiline prompt text blocks per queue run with live progress display, counter reset controls, and index tracking. Supports splitting prompts by empty lines, newlines, or a custom regular expression delimiter.
|
||||
|
||||
#### Inputs & Widgets
|
||||
- **`pop_mode`** (`COMBO`): `Sequential (Loop on End)`, `Sequential (Stop on End)`, `Random (Keep)`, `Random (Cycle)`.
|
||||
- **`separator`** (`COMBO`): Split prompts by `>1 Empty Line`, `Newline`, `>2 Empty Lines`, or `Custom Regex`.
|
||||
- **`custom_regex`** (`STRING`): Custom regular expression delimiter (e.g. `\n---\n`). Automatically enabled when `separator` is set to `Custom Regex` and disabled otherwise.
|
||||
- **`text`** (`STRING`, Multiline): Multiline prompt text containing your queued blocks.
|
||||
- **`prompt`** (`STRING`, Optional Input): Connect an external multiline string node (such as *Prompt Counter*).
|
||||
|
||||
#### Outputs
|
||||
- **`prompt`** (`STRING`): The selected prompt block for the current queue run.
|
||||
- **`remaining_text`** (`STRING`): All remaining prompt blocks joined by the delimiter.
|
||||
- **`remaining_count`** (`INT`): Count of remaining items in queue.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>🍃 📝 Prompt Counter</b> (<code>PromptCounter</code>)</summary>
|
||||
|
||||
#### Overview
|
||||
Multiline text prompt input node that functions identically to a standard multiline string node, with real-time prompt counting according to your chosen delimiter (`>1 Empty Line`, `Newline`, `>2 Empty Lines`, or `Custom Regex`). Displays the live prompt count directly on the node (e.g. `🍃 24 Prompts`) on every keystroke, allowing you to easily determine and set the exact ComfyUI batch size before running your queue.
|
||||
|
||||
#### Inputs & Widgets
|
||||
- **`text`** (`STRING`, Multiline): Input prompt text block.
|
||||
- **`separator`** (`COMBO`, *Advanced*): Prompt delimiter (`>1 Empty Line`, `Newline`, `>2 Empty Lines`, `Custom Regex`). Hidden by default under Advanced Options.
|
||||
- **`custom_regex`** (`STRING`, *Advanced*): Custom regex delimiter pattern (e.g. `\n---\n`). Dynamically enabled/disabled based on delimiter selection.
|
||||
|
||||
#### Outputs
|
||||
- **`STRING`**: The unmodified prompt text (ready to connect into samplers or *Prompt Queue Iterator*).
|
||||
- **`count`** (`INT`): The registered prompt count integer.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>🍃 🔤 Multi Text Replacer</b> (<code>MultiTextReplacer</code>)</summary>
|
||||
|
||||
#### Overview
|
||||
Performs multiple text replacements in a single step using comma-separated or newline-separated find/replace lists, with support for exact phrases and regex matching.
|
||||
|
||||
#### Inputs & Widgets
|
||||
- **`text`** (`STRING`, Multiline): Input text to modify.
|
||||
- **`find_text`** (`STRING`, Multiline): Search terms (comma-separated or lines).
|
||||
- **`replace_text`** (`STRING`, Multiline): Replacement terms.
|
||||
- **`case_sensitive`** (`BOOLEAN`): Toggle case-sensitive matching.
|
||||
- **`use_regex`** (`BOOLEAN`): Treat find patterns as regular expressions.
|
||||
|
||||
#### Outputs
|
||||
- **`text`** (`STRING`): Modified text.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>🍃 ✂️ Text Split</b> (<code>LeafFlowTextSplit</code>)</summary>
|
||||
|
||||
#### Overview
|
||||
Splits text into two parts at a specified delimiter. Supports forward (from start) and backward (from end) search, as well as regular expressions.
|
||||
|
||||
#### Inputs & Widgets
|
||||
- **`text`** (`STRING`, Multiline): Input text to split.
|
||||
- **`delimiter`** (`STRING`): Character or string to split on (e.g. `,`, `---`, `\n`).
|
||||
- **`search_from`** (`COMBO`): `Forward (First Match)` vs `Backward (Last Match)`.
|
||||
- **`use_regex`** (`BOOLEAN`): Treat delimiter as a regular expression pattern.
|
||||
- **`strip_whitespace`** (`BOOLEAN`): Trims surrounding whitespace from output strings.
|
||||
|
||||
#### Outputs
|
||||
- **`left_text`** (`STRING`): Text before the split delimiter.
|
||||
- **`right_text`** (`STRING`): Text after the split delimiter.
|
||||
- **`delimiter_found`** (`BOOLEAN`): True if delimiter was found, False otherwise.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>🍃 ⚡ Run Local File</b> (<code>RunLocalFileNode</code>)</summary>
|
||||
|
||||
#### Overview
|
||||
Safely executes a local script or executable (`.bat`, `.cmd`, `.ps1`, `.exe` on Windows; `.sh` or binaries on Linux/macOS) with command-line parameters and working directory control. Built with multi-layered security guards to ensure malicious workflows or dropped images cannot execute code without explicit authorization.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Scripts Directory Restriction**: For operator security, this node can **ONLY execute scripts and binaries located inside the `ComfyUI/scripts/` directory** (e.g. `ComfyUI/scripts/my_script.bat`). Relative subfolders (such as `subfolder/process.sh`) are supported as long as they resolve inside `ComfyUI/scripts/`. Absolute paths (e.g. `C:\...` or `/bin/...`) and directory traversal sequences (`..`) are permanently blocked.
|
||||
|
||||
#### Inputs & Widgets
|
||||
- **`file_path`** (`STRING`): Relative path to the executable or script (e.g. `process.bat`, `subfolder/script.sh`). Strictly confined to `ComfyUI/scripts/` with absolute path and traversal (`..`) rejection.
|
||||
- **`parameters`** (`STRING`, Multiline, Optional): Command-line arguments. Supports spaces and standard quoting (parsed safely via `shlex` without shell expansion).
|
||||
- **`working_directory`** (`STRING`, Optional): Execution working directory (`cwd`). Defaults to the folder containing the script if left blank.
|
||||
- **`run_mode`** (`COMBO`): `Synchronous (Wait for Output)` vs `Asynchronous (Background)`.
|
||||
- **`timeout`** (`INT`): Maximum execution time in seconds for synchronous mode (`0` = no timeout). If exceeded, the process tree is cleanly terminated via OS process management.
|
||||
- **`trigger`** (`*`, Optional Wildcard): Input wire allowing you to connect any workflow data to order execution.
|
||||
- **⚡ In-Memory Authorization**: Does NOT use an input widget (preventing malicious authorization smuggling inside shared workflow JSON or image metadata). Instead, the operator must click the interactive `[⚡ Authorize Run]` button directly on the canvas node to grant a 5-minute single-use execution token.
|
||||
|
||||
#### Outputs
|
||||
- **`stdout`** (`STRING`): Captured standard output stream.
|
||||
- **`stderr`** (`STRING`): Captured standard error stream.
|
||||
- **`exit_code`** (`INT`): Process exit return code (`0` = success, `-1` = blocked / timed out).
|
||||
- **`success`** (`BOOLEAN`): `True` if `exit_code == 0`, `False` otherwise.
|
||||
- **`passthrough`** (`*`): Passes input `trigger` data through untouched to downstream nodes.
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### 🎨 Visual Loaders & Pickers (`FlowControl/Loaders`)
|
||||
|
||||
3. **`🍃 Visual LoRA Loader`**
|
||||
- Visual LoRA selector supporting folder filtering (e.g. `Celebrity/*`) and formatted pretty names (e.g. `Ana De Armas V1`).
|
||||
- Automatically tracks usage frequency (`lora_usage.json`).
|
||||
- **Custom Preview Images**: Simply save a preview image file with the exact same name as your LoRA in the same folder (e.g. `my_lora.png`, `my_lora.jpg`, or `my_lora.webp` next to `my_lora.safetensors`).
|
||||
- **Automated Civitai SHA256 Search**: Computes SHA256 file hashes and queries Civitai's API (`/api/v1/model-versions/by-hash/{hash}`) with optional `CIVITAI_API_KEY` authorization to download model preview thumbnails.
|
||||
- **TMDB Fallback**: Optional celebrity preview search via TMDB API key.
|
||||
|
||||
4. **`🍃 Visual Image Loader`**
|
||||
- Visual image folder browser with instant visual preview pickers.
|
||||
- Extracts positive prompt metadata from PNG parameters and EXIF user comments.
|
||||
- Outputs `IMAGE`, `positive_prompt`, `width`, and `height`.
|
||||
|
||||
5. **`🍃 Recent Outputs`**
|
||||
- Recursively scans ComfyUI's `output/` directory and cycles through recently generated images by step index for seamless integration with ComfyUI's sidebar/assets panel.
|
||||
|
||||
---
|
||||
|
||||
### ⚙️ Automation, Previews & Utilities (`FlowControl/Automation`, `FlowControl/Previews`, `FlowControl/Utils`)
|
||||
|
||||
6. **`🍃 Auto Watcher`**
|
||||
- Monitors a directory for incoming images (`.png`, `.jpg`, `.jpeg`, `.webp`), converts them into PyTorch image tensors, and removes processed files.
|
||||
- **`wait_for_image` Toggle**:
|
||||
- `True` (Default): Safely polls until an image arrives in the directory. Does not create missing folders automatically or crash if folder is deleted.
|
||||
- `False`: Immediately checks the directory. If an image is present, loads it and returns `(image, has_image=True)`. If no image is present (or folder is missing), returns a dummy tensor and `has_image=False` immediately without blocking.
|
||||
|
||||
7. **`🍃 Undo Placeholder`**
|
||||
- Scans text prompt strings for names matching LoRA files in a specified folder filter and restores placeholder tokens like `%celeb%`.
|
||||
|
||||
8. **`🍃 Live Latent Preview`**
|
||||
- Custom canvas node that listens to WebSocket latent preview binary streams (`b_preview`, `b_preview_with_metadata`) during sampler execution and renders real-time sampler previews directly inside the node canvas.
|
||||
|
||||
---
|
||||
|
||||
## 📋 Complete Node Overview Table
|
||||
|
||||
| Node Class | Display Name | Category | Primary Function |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `PauseQueueNode` | **`🍃 Pause Queue`** | `FlowControl/Queue` | V2 action bar Pause (Finish) & Pause (Instant) controls |
|
||||
| `PersistentQueueNode` | **`🍃 Persistent Queue`** | `FlowControl/Queue` | Real-time queue persistence & crash recovery |
|
||||
| `FolderLoraLoaderVisualPrettyV2` | **`🍃 Visual LoRA Loader`** | `FlowControl/Loaders` | Visual LoRA picker with SHA256 Civitai/TMDB previews |
|
||||
| `ImageLoaderVisualPrettyV2` | **`🍃 Visual Image Loader`** | `FlowControl/Loaders` | Visual image browser with EXIF prompt extraction |
|
||||
| `LoadRecentOutputs` | **`🍃 Recent Outputs`** | `FlowControl/Loaders` | Cycles recent output images for assets/sidebar |
|
||||
| `AutoWatcherNode` | **`🍃 Auto Watcher`** | `FlowControl/Automation` | Folder image watcher with `wait_for_image` toggle |
|
||||
| `UndoPlaceholder` | **`🍃 Undo Placeholder`** | `FlowControl/Utils` | Replaces pretty LoRA names with `%placeholder%` tokens |
|
||||
| `PreviewLatentLive` | **`🍃 Live Latent Preview`** | `FlowControl/Previews` | Canvas real-time sampler latent preview display |
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ ComfyUI Settings Menu Integration
|
||||
## ⚙️ ComfyUI Settings Menu Reference
|
||||
|
||||
Configure options directly under ComfyUI Settings (⚙ gear icon):
|
||||
|
||||
- **🍃 FlowControl: Enable Civitai Auto-Scraping**: Automatically download model preview images from Civitai via SHA256 file hashes.
|
||||
- **🍃 FlowControl: Civitai API Key**: Optional key for Civitai SHA256 model preview search (`Authorization: Bearer <key>`).
|
||||
- **🍃 FlowControl: Enable TMDB Auto-Scraping**: Automatically download celebrity preview images from TMDB.
|
||||
- **🍃 FlowControl: TMDB API Key**: Optional key for celebrity preview search.
|
||||
- **🍃 FlowControl: Default Pause Queue State on Launch**: Sets whether the queue should start `Unpaused` or `Paused` on boot.
|
||||
- **🍃 FlowControl: Default Pause Queue Mode on Launch**: Sets the default pause behavior (`Finish Active Prompt` vs `Instant Resume Node`).
|
||||
- **🍃 FlowControl: Persistent Queue Restored Launch State**: Controls what happens to crash-recovered queue items (`Start Paused` or `Start Unpaused`).
|
||||
- **🍃 FlowControl: Pause Button Unpaused Color**: Customizable hex color for the toolbar unpaused state.
|
||||
- **🍃 FlowControl: Pause Button Paused Color**: Customizable hex color for the toolbar paused/pausing state.
|
||||
- **🍃 FlowControl: Enable Pause Queue Toolbar Button**: Toggle top action bar Pause & Continue button ON/OFF.
|
||||
- **🍃 FlowControl: Enable Persistent Queue (Auto-Recovery)**: Toggle the real-time crash recovery queue saving on or off.
|
||||
<details open>
|
||||
<summary><b>1. 🖼️ Visual Loaders (Civitai & TMDB Duo)</b></summary>
|
||||
|
||||
- **`Enable Custom LeafFlow Node Colors`** (`boolean`, *Default: true*): Applies a vibrant Leaf Green color theme to LeafFlow nodes on the canvas. When disabled, nodes use default ComfyUI colors.
|
||||
- **`Civitai API Key`** (`text`): Optional key for Civitai SHA256 model preview search (`Authorization: Bearer <key>`).
|
||||
- **`Enable Civitai Auto-Scraping`** (`boolean`, *Default: true*): Automatically download model preview images from Civitai via SHA256 file hashes. (Note: Local SHA256 hash searching always works).
|
||||
- **`TMDB Access Token`** (`text`): Optional key or v4 Read Access Token (`eyJ...`) for celebrity preview search.
|
||||
- **`Enable TMDB Auto-Scraping`** (`boolean`, *Default: false*): Automatically download celebrity preview images from TMDB.
|
||||
- **`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.
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<summary><b>2. 🔄 Prompt Iterator</b></summary>
|
||||
|
||||
- **`Clear State on Launch`** (`boolean`, *Default: false*): Privacy toggle to empty `prompt_iterator_state.json` on ComfyUI startup.
|
||||
- **`Reset All Queues`** (*Button: `🔄 Reset All Queues`*): Immediately empties all active prompt queues and resets iterator state.
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<summary><b>3. 📋 Prompt Actions</b></summary>
|
||||
|
||||
- **`Show "Copy Prompt" Button on Images`** (`boolean`, *Default: true*): Shows the 📋 "Copy Prompt" overlay action button when hovering over generated images in the Assets / History pane and preview nodes.
|
||||
- **`Show Right-Click "Copy Prompt" Menu Action`** (`boolean`, *Default: true*): Adds "📋 Copy Prompt" to node right-click context menus.
|
||||
- **`Show "Save to Prompt Saver" Action`** (`boolean`, *Default: true*): Adds 🔖 "Save to Prompt Bookmarks" to image hover overlay bars and node context menus.
|
||||
- **`Show "Inspect Asset" (Zoom) Button on Images`** (`boolean`, *Default: false*): Restores the 🔍 "Inspect asset" (zoom in) button directly onto image cards in the Assets pane next to Download, Copy Prompt, and Bookmarks (restoring one-click access moved behind the 3-dots menu in newer ComfyUI versions).
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<summary><b>4. ⏸️ Pause Controls</b></summary>
|
||||
|
||||
- **`Default State on Launch`** (`combo`, *Default: `Paused`*): Sets whether the queue starts `Paused` or `Running` on boot.
|
||||
- **`Default Pause Action`** (`combo`, *Default: `Finish Active Prompt`*): Sets default pause behavior (`Finish Active Prompt` vs `Instant Resume Node`).
|
||||
- **`Enable Top Toolbar Button`** (`boolean`, *Default: true*): Toggle top action bar Pause & Continue button group ON/OFF.
|
||||
- **`Toolbar Button Unpaused Color`** (`text`, *Default: `#16a34a`*): Hex color for the toolbar unpaused/running state.
|
||||
- **`Toolbar Button Paused Color`** (`text`, *Default: `#ea580c`*): Hex color for the toolbar paused state.
|
||||
- **`Enable System Tray Icon`** (`boolean`, *Default: false*): Displays an OS system tray icon with real-time queue status colors and outside-browser controls.
|
||||
- **`Allow Process Management (Restart / Shutdown)`** (`boolean`, *Default: false*): Opt-in authorization allowing server restart and shutdown actions from the LeafFlow sidebar and API. Disabled by default for maximum security.
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<summary><b>5. 💾 Persistent Queue</b></summary>
|
||||
|
||||
- **`Persistent Queue (Auto-Recovery)`** (`boolean`, *Default: true*): Automatically saves unfinished queue items and restores them after restart/crash.
|
||||
- **`Recovery Launch State`** (`combo`, *Default: `Match Default`*): Override launch state when restored queue items are recovered on startup (`Match Default`, `Force Paused`, `Force Running`).
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<summary><b>6. 🖼️ Assets & History Restore</b></summary>
|
||||
|
||||
- **`Restore Assets on Launch`** (`boolean`, *Default: true*): Automatically restores your latest generated images into the Assets / History pane on startup.
|
||||
- **`Restored Assets Count`** (`number`, *Default: 64*): The number of newest images from the output folder to populate into the Assets pane.
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<summary><b>7. 🩺 Diagnostics & Debug</b></summary>
|
||||
|
||||
- **`Export Debug Profile`** (*Button: `📥 Export Debug Profile`*): Exports non-sensitive system environment details (OS, Python, PyTorch, LeafFlow settings, local cache counts) to a JSON file to share when reporting bugs or requesting assistance. Sensitive API keys and tokens are never exported.
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<summary><b>8. 🎨 Batch Queue Visuals</b></summary>
|
||||
|
||||
- **`Show Batch Queue 1D Lines`** (`boolean`, *Default: true*): Renders a 1D git-graph style colored line on the left side of queued items in the queue list.
|
||||
- **36 Cycling Colors:** Every queued batch is assigned a vibrant, high-contrast color from a 36-color sequence.
|
||||
- **Clean 1D Line:** The first item starts with a top curve (`┌`), middle items are straight lines (`│`), and the last item ends with a bottom curve (`└`). Standalone 1-item batches are rounded at both ends (`(`).
|
||||
- **In-between Infiltration Detection:** If a different prompt is queued in-between items of a batch, the interrupted batch maintains its open line ends without false curves, while the inserted item is highlighted with its own bracket.
|
||||
- **Works Standalone or with PersistentQueue:** Operates client-side via `localStorage` when standalone, and syncs with `PersistentQueue` to retain batch relationships after server restarts.
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<summary><b>9. 🛡️ Security & Script Execution</b></summary>
|
||||
|
||||
- **`Allow Local File Execution`** (`boolean`, *Default: false*): Master toggle controlling whether the *Run Local File* node is permitted to execute local scripts or executables. Disabled by default for operator safety.
|
||||
- **Strict Confinement**: Execution is strictly limited to the `ComfyUI/scripts/` directory. Absolute paths and directory traversal (`..`) are permanently blocked.
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security & Privacy
|
||||
|
||||
- All API keys and settings are stored locally in `.env` (excluded from git commits via `.gitignore`).
|
||||
- No hardcoded personal paths, keys, or credentials.
|
||||
- Atomic file writes (`.tmp` + `os.replace`) prevent JSON corruption during sudden crashes.
|
||||
- **Local Runner Sandbox & Operator Authorization Gate:** The `Run Local File` node enforces single-use interactive operator authorization (`[⚡ Authorize Run]`), strictly confines execution to `ComfyUI/scripts/`, never uses `shell=True`, terminates whole process trees on timeout or cancellation, and **never persists authorization inside workflow JSON or image metadata**.
|
||||
- **CSRF Defense & Fetch Metadata Validation:** All administrative endpoints (`/leafflow/settings`, `/leafflow/power/*`, `/leafflow/local_runner/*`, `/leafflow/scrapes/clear`) enforce loopback checks, `Sec-Fetch-Site` header checks (blocking cross-site requests from visited web pages), and cryptographic per-session `X-LeafFlow-CSRF-Token` headers.
|
||||
- **Two-Step Ephemeral Ticket Handshake for Power Actions:** Process restart and shutdown controls are disabled by default (`ALLOW_PROCESS_MANAGEMENT=false`), require CSRF authentication, and execute only after a single-use 30-second ephemeral ticket confirmation handshake.
|
||||
- **Universal Loopback Protection:** All administrative and queue endpoints strictly verify loopback origin (`127.0.0.1` / `::1`), rejecting external network requests with `403 Forbidden`.
|
||||
- **Directory Traversal Protection:** Image loading and thumbnail routes are strictly confined to ComfyUI `input`, `output`, and `temp` directories with traversal guards.
|
||||
- **Optional Headless Dependencies:** System tray desktop integration (`pystray`) is an optional dependency, ensuring cloud containers (Docker, Colab, RunPod) run with zero dependency errors.
|
||||
- **Opt-In Scraping:** Civitai and TMDB network scraping are opt-in and disabled by default.
|
||||
- **Zero Runtime Package Installs:** No `install.py` or dynamic `pip` execution; all standard dependencies are cleanly declared in `requirements.txt`.
|
||||
- **Local Environment:** All API keys and settings are stored locally in `.env` (excluded from git commits via `.gitignore`).
|
||||
- **Atomic File Writes:** Safe atomic writes (`.tmp` + `os.replace`) prevent JSON corruption during sudden crashes.
|
||||
|
||||
---
|
||||
|
||||
@@ -107,17 +415,13 @@ Configure options directly under ComfyUI Settings (⚙ gear icon):
|
||||
1. Open your terminal and navigate to your ComfyUI `custom_nodes` directory:
|
||||
```bash
|
||||
cd ComfyUI/custom_nodes
|
||||
git clone https://github.com/KOFiblto/ComfyUI-FlowControl.git
|
||||
git clone https://github.com/KOFiblto/ComfyUI-LeafFlow.git
|
||||
```
|
||||
2. Restart ComfyUI.
|
||||
3. Open ComfyUI Settings (⚙ gear menu) to configure API keys or feature toggles if desired.
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions are welcome! Please check out [CONTRIBUTING.md](CONTRIBUTING.md) for local development guidelines, security policy, and coding standards.
|
||||
|
||||
## 📜 License
|
||||
|
||||
Distributed under the [MIT License](LICENSE).
|
||||
|
||||
+27
-4
@@ -2,7 +2,7 @@
|
||||
|
||||
## Reporting Security Issues
|
||||
|
||||
We take the security of ComfyUI-FlowControl seriously. If you discover a security vulnerability or sensitive key exposure, please **DO NOT** open a public issue.
|
||||
We take the security of ComfyUI-LeafFlow seriously. If you discover a security vulnerability or sensitive key exposure, please **DO NOT** open a public issue.
|
||||
|
||||
Instead, please report security vulnerabilities directly to the maintainer via GitHub private vulnerability reporting.
|
||||
|
||||
@@ -10,9 +10,32 @@ Instead, please report security vulnerabilities directly to the maintainer via G
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 2.x | :white_check_mark: |
|
||||
| 1.x | :white_check_mark: |
|
||||
|
||||
## Security Best Practices
|
||||
## Security Architecture & Best Practices
|
||||
|
||||
- **CSRF Defense & Fetch Metadata Validation**: All administrative endpoints (`/leafflow/settings`, `/leafflow/power/*`, `/leafflow/local_runner/*`, `/leafflow/scrapes/clear`) enforce multi-layer CSRF protection via `is_authenticated_local_request(request)`.
|
||||
- Enforces loopback origin checks (`127.0.0.1` / `::1`), rejecting remote calls with `403 Forbidden`.
|
||||
- Rejects `cross-site` requests via `Sec-Fetch-Site` header checks, preventing external malicious web pages visited by the operator from triggering actions.
|
||||
- Verifies `Origin` and `Referer` headers against loopback hostnames.
|
||||
- Requires cryptographically random session tokens in the `X-LeafFlow-CSRF-Token` header (`secrets.compare_digest`), prompting CORS preflight rejection if an external origin attempts unauthorized requests.
|
||||
- **Two-Step Ephemeral Ticket Handshake for Power Actions**:
|
||||
- Server restart and shutdown endpoints require `ALLOW_PROCESS_MANAGEMENT=true` in settings.
|
||||
- Execution requires a two-step handshake: the client requests a single-use ephemeral ticket (`POST /leafflow/power/request_token`) valid for 30 seconds.
|
||||
- The confirmation endpoint (`POST /leafflow/power/confirm_action`) consumes the single-use ticket in server memory before executing restart or shutdown, preventing arbitrary or cross-site triggers.
|
||||
- **Strict Local Script Confinement**:
|
||||
- `RunLocalFileNode` strictly confines executable scripts and binaries to `ComfyUI/scripts/`.
|
||||
- Absolute paths (e.g. `C:\`, `/etc/`, `\\server`) and directory traversal sequences (`..`) are strictly rejected.
|
||||
- Paths are verified via canonical `os.path.realpath` and `os.path.commonpath` checks against the scripts directory. Unsafe path bypasses have been completely removed.
|
||||
- **In-Memory Operator Authorization Gate**:
|
||||
- Local file execution defaults to disabled (`ENABLE_LOCAL_FILE_EXECUTION = false`).
|
||||
- `security_consent` input widget was removed from `INPUT_TYPES` to ensure authorization parameters NEVER travel within workflow JSON or embedded PNG metadata.
|
||||
- Workflows loaded from the internet or images dropped onto the canvas cannot execute local scripts without explicit manual authorization.
|
||||
- Execution requires an interactive user action on the canvas node via `[⚡ Authorize Run]`, which registers single-use authorization exclusively in server RAM with a 5-minute TTL.
|
||||
- **Filesystem Path Confinement**: Image loading (`VisualImageLoader`) and thumbnail file streaming endpoints are strictly confined to standard ComfyUI `input`, `output`, and `temp` directories with path canonicalization and directory traversal guards.
|
||||
- **Opt-In Network Requests**: External metadata and preview scraping (Civitai / TMDB) are completely opt-in and disabled by default.
|
||||
- **No Dynamic Package Installation**: Runtime `pip` execution (`install.py` / `subprocess`) is eliminated; all dependencies are transparently listed in `requirements.txt`.
|
||||
- **Local Environment**: All API keys (Civitai / TMDB) are stored locally in `.env` and are strictly excluded from git tracking via `.gitignore`. Sensitive keys are masked in server log outputs.
|
||||
- **Atomic File Writes**: Atomic file operations (`.tmp` + `os.replace`) prevent JSON file corruption on sudden system shutdown or crash.
|
||||
|
||||
- All API keys (Civitai / TMDB) are stored locally in `.env` and are strictly excluded from git tracking via `.gitignore`.
|
||||
- API keys are masked in log outputs (e.g. `Bearer civ_*****`).
|
||||
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
# 🍃 ComfyUI-LeafFlow 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.
|
||||
|
||||
This walkthrough covers everything from basic setup to advanced workflows across both the new ComfyUI V2 (Vue) and the classic ComfyUI (LiteGraph).
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
- **Green (Running)**: ComfyUI operates normally.
|
||||
- **Orange (Pausing/Paused)**: ComfyUI is holding the queue.
|
||||
|
||||
You have two pause modes, accessible by clicking the small dropdown arrow next to the Pause button:
|
||||
1. **Pause (Finish)**: This will let your currently generating workflow finish completely, but it will intercept and hold any subsequent workflows in the queue until you click Continue.
|
||||
2. **Pause (Instant)**: This tells ComfyUI to freeze *immediately* before executing the next node in your active workflow. You can resume precisely from where you left off later without losing progress!
|
||||
|
||||
### 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:
|
||||
- **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.
|
||||
3. Left-click the tray icon to quickly toggle between **Pause** and **Continue**.
|
||||
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:
|
||||
- **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.
|
||||
- **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.
|
||||
- **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).
|
||||
|
||||
### 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).
|
||||
- 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`**.
|
||||
|
||||
### 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).
|
||||
- **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
|
||||
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.
|
||||
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
|
||||
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
|
||||
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.
|
||||
|
||||
### 🍃 ⏱️ Recent Outputs
|
||||
Want to quickly reference your recent generations without digging through Windows Explorer? The **`🍃 ⏱️ Recent Outputs`** node cycles chronologically through your `output/` directory, pulling your freshest creations right back into the workflow canvas.
|
||||
|
||||
### 🍃 📝 Prompt Counter & 🔄 Prompt Queue Iterator
|
||||
Managing multi-prompt queues and setting the exact ComfyUI batch size has never been easier:
|
||||
1. **`🍃 📝 Prompt Counter`**: Place this multiline text node in your workflow and paste all your prompts.
|
||||
- As you type or paste, the live badge instantly updates (e.g. `🍃 24 Prompts`), so you know exactly what batch size to set before clicking Queue!
|
||||
- Under Advanced Options, select your delimiter (`>1 Empty Line`, `Newline`, `>2 Empty Lines`, or `Custom Regex`).
|
||||
- If `Custom Regex` is selected, enter any custom delimiter pattern (such as `\n---\n`). The regex field is dynamically enabled only when Custom Regex is active.
|
||||
2. **`🍃 🔄 Prompt Queue Iterator`**: Connect the `STRING` output of the Prompt Counter into the Prompt Queue Iterator to sequentially pop and execute each prompt run-by-run.
|
||||
- Now supports `Custom Regex` in addition to empty line and newline separators.
|
||||
- Includes live progress counter widget (`🍃 Run X / Y`), instant index reset button (`🔄 Reset Counter (0)`), and direct state file access (`📂 Open Queue File`).
|
||||
|
||||
---
|
||||
|
||||
## 3. Formatting Folder Paths
|
||||
Several nodes across this suite (e.g. *Load Image From Folder*, *Recent Outputs*, *Visual Image Loader*, *Visual LoRA Loader*) take a `folder` or `folder_path` string as input. Here is how they work:
|
||||
|
||||
- **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 (`*`)**:
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
## 4. Settings & Configuration
|
||||
|
||||
Click the standard ComfyUI Settings gear ⚙️ to access LeafFlow'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).
|
||||
|
||||
> **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!
|
||||
+325
-36
@@ -1,46 +1,75 @@
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
from aiohttp import web
|
||||
from server import PromptServer
|
||||
|
||||
from .nodes.queue_control import PauseQueueNode, PersistentQueueNode, setup_queue_control_routes
|
||||
__version__ = "2.3.1"
|
||||
|
||||
from .nodes.queue_control import setup_queue_control_routes, tray_manager
|
||||
from .nodes.lora_loader import (
|
||||
FolderLoraLoader,
|
||||
FolderLoraLoaderPretty,
|
||||
FolderLoraLoaderVisualPretty,
|
||||
VisualLoraLoader,
|
||||
FolderLoraLoaderVisualPrettyV2
|
||||
)
|
||||
from .nodes.image_loader import ImageLoaderVisualPrettyV2
|
||||
from .nodes.undo_placeholder import UndoPlaceholder
|
||||
from .nodes.auto_watcher import AutoWatcherNode
|
||||
from .nodes.image_loader import VisualImageLoader, ImageLoaderVisualPrettyV2
|
||||
from .nodes.auto_watcher import LoadImageFromFolder
|
||||
from .nodes.load_recent import LoadRecentOutputs
|
||||
from .nodes.preview_latent import PreviewLatentLiveNode
|
||||
from .nodes.decision_node import LeafFlowDecision
|
||||
from .nodes.aspect_ratio import TextAspectRatioFinder, AspectRatioFinder, PreviewImageSizeAspectRatio
|
||||
from .nodes.lora_finder import TextLoraFinder, LoraTextFinder
|
||||
from .nodes.prompt_iterator import PromptQueueIterator
|
||||
from .nodes.prompt_counter import PromptCounter
|
||||
from .nodes.text_replacer import MultiTextReplacer
|
||||
from .nodes.text_split import LeafFlowTextSplit
|
||||
from .nodes.local_runner import RunLocalFileNode, setup_local_runner_routes
|
||||
from .nodes.utils import (
|
||||
get_leafflow_user_dir,
|
||||
get_env_setting,
|
||||
is_local_request,
|
||||
is_safe_path,
|
||||
get_csrf_token,
|
||||
is_authenticated_local_request
|
||||
)
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"PauseQueueNode": PauseQueueNode,
|
||||
"PersistentQueueNode": PersistentQueueNode,
|
||||
"FolderLoraLoader": FolderLoraLoader,
|
||||
"FolderLoraLoaderPretty": FolderLoraLoaderPretty,
|
||||
"FolderLoraLoaderVisualPretty": FolderLoraLoaderVisualPretty,
|
||||
"FolderLoraLoaderVisualPrettyV2": FolderLoraLoaderVisualPrettyV2,
|
||||
"ImageLoaderVisualPrettyV2": ImageLoaderVisualPrettyV2,
|
||||
"UndoPlaceholder": UndoPlaceholder,
|
||||
"AutoWatcherNode": AutoWatcherNode,
|
||||
"VisualLoraLoader": VisualLoraLoader,
|
||||
"VisualImageLoader": VisualImageLoader,
|
||||
"LoadImageFromFolder": LoadImageFromFolder,
|
||||
"LoadRecentOutputs": LoadRecentOutputs,
|
||||
"PreviewLatentLive": PreviewLatentLiveNode
|
||||
"PreviewLatentLive": PreviewLatentLiveNode,
|
||||
"LeafFlowDecision": LeafFlowDecision,
|
||||
"TextAspectRatioFinder": TextAspectRatioFinder,
|
||||
"PreviewImageSizeAspectRatio": PreviewImageSizeAspectRatio,
|
||||
"TextLoraFinder": TextLoraFinder,
|
||||
"PromptQueueIterator": PromptQueueIterator,
|
||||
"PromptCounter": PromptCounter,
|
||||
"MultiTextReplacer": MultiTextReplacer,
|
||||
"LeafFlowTextSplit": LeafFlowTextSplit,
|
||||
"RunLocalFileNode": RunLocalFileNode,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"PauseQueueNode": "🍃 Pause Queue",
|
||||
"PersistentQueueNode": "🍃 Persistent Queue",
|
||||
"FolderLoraLoader": "🍃 LoRA Loader (by Folder)",
|
||||
"FolderLoraLoaderPretty": "🍃 LoRA Loader (Pretty)",
|
||||
"FolderLoraLoaderVisualPretty": "🍃 Visual LoRA Picker",
|
||||
"FolderLoraLoaderVisualPrettyV2": "🍃 Visual LoRA Loader",
|
||||
"ImageLoaderVisualPrettyV2": "🍃 Visual Image Loader",
|
||||
"UndoPlaceholder": "🍃 Undo Placeholder",
|
||||
"AutoWatcherNode": "🍃 Auto Watcher",
|
||||
"LoadRecentOutputs": "🍃 Recent Outputs",
|
||||
"PreviewLatentLive": "🍃 Live Latent Preview"
|
||||
"FolderLoraLoader": "🍃 📁 LoRA Loader (Folder)",
|
||||
"FolderLoraLoaderPretty": "🍃 ✨ LoRA Loader (Pretty)",
|
||||
"VisualLoraLoader": "🍃 🖼️ Visual LoRA Loader",
|
||||
"VisualImageLoader": "🍃 📷 Visual Image Loader",
|
||||
"LoadImageFromFolder": "🍃 📂 Load Image From Folder",
|
||||
"LoadRecentOutputs": "🍃 ⏱️ Recent Outputs",
|
||||
"PreviewLatentLive": "🍃 👁️ Live Latent Preview",
|
||||
"LeafFlowDecision": "🍃 ⏸️ LeafFlow Decision",
|
||||
"TextAspectRatioFinder": "🍃 📐 Text Aspect Ratio Finder",
|
||||
"PreviewImageSizeAspectRatio": "🍃 📐 Preview Image Size & Aspect Ratio",
|
||||
"TextLoraFinder": "🍃 🔎 Text LoRA Finder & Loader",
|
||||
"PromptQueueIterator": "🍃 🔄 Prompt Queue Iterator",
|
||||
"PromptCounter": "🍃 📝 Prompt Counter",
|
||||
"MultiTextReplacer": "🍃 🔤 Multi Text Replacer",
|
||||
"LeafFlowTextSplit": "🍃 ✂️ Text Split",
|
||||
"RunLocalFileNode": "🍃 ⚡ Run Local File"
|
||||
}
|
||||
|
||||
WEB_DIRECTORY = "./web"
|
||||
@@ -48,18 +77,86 @@ __all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
|
||||
|
||||
server = PromptServer.instance
|
||||
setup_queue_control_routes(server)
|
||||
setup_local_runner_routes(server)
|
||||
|
||||
try:
|
||||
from .prompt_bookmarks.api import register_routes as register_prompt_bookmarks_routes
|
||||
register_prompt_bookmarks_routes()
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
ENV_FILE = os.path.join(CURRENT_DIR, ".env")
|
||||
USER_DIR = get_leafflow_user_dir()
|
||||
ENV_FILE = os.path.join(USER_DIR, ".env")
|
||||
|
||||
routes = server.routes
|
||||
|
||||
print("[ComfyUI-FlowControl] 🍃 Loaded 11 nodes & visual endpoints successfully.")
|
||||
try:
|
||||
print(f"[ComfyUI-LeafFlow] 🍃 Loaded {len(NODE_CLASS_MAPPINGS)} nodes & visual endpoints successfully (v{__version__}).")
|
||||
except UnicodeEncodeError:
|
||||
print(f"[ComfyUI-LeafFlow] Loaded {len(NODE_CLASS_MAPPINGS)} nodes & visual endpoints successfully (v{__version__}).")
|
||||
|
||||
@routes.get("/leafflow/auth/token")
|
||||
async def get_csrf_token_endpoint(request):
|
||||
if not is_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local access only"}, status=403)
|
||||
return web.json_response({"csrf_token": get_csrf_token()})
|
||||
|
||||
@routes.get("/leafflow/get_image_prompt")
|
||||
@routes.get("/leafflow/view_image_prompt")
|
||||
async def get_image_prompt_endpoint(request):
|
||||
if not is_local_request(request):
|
||||
return web.json_response({"success": False, "error": "Forbidden: Local access only"}, status=403)
|
||||
try:
|
||||
import folder_paths
|
||||
filename = request.query.get("filename")
|
||||
subfolder = request.query.get("subfolder", "")
|
||||
folder_type = request.query.get("type", "output")
|
||||
|
||||
if not filename:
|
||||
return web.json_response({"success": False, "error": "Missing filename"}, status=400)
|
||||
|
||||
if folder_type == "input":
|
||||
base_dir = folder_paths.get_input_directory()
|
||||
elif folder_type == "temp":
|
||||
base_dir = folder_paths.get_temp_directory()
|
||||
else:
|
||||
base_dir = folder_paths.get_output_directory()
|
||||
|
||||
if subfolder:
|
||||
subfolder_clean = str(subfolder).strip().lstrip("/\\")
|
||||
filepath = os.path.join(base_dir, subfolder_clean, filename)
|
||||
else:
|
||||
filepath = os.path.join(base_dir, filename)
|
||||
|
||||
if not is_safe_path(filepath) or not os.path.exists(filepath) or not os.path.isfile(filepath):
|
||||
return web.json_response({"success": False, "error": "File not found or forbidden"}, status=404)
|
||||
|
||||
from .nodes.image_loader import extract_metadata_from_image
|
||||
prompt, width, height = extract_metadata_from_image(filepath)
|
||||
|
||||
return web.json_response({
|
||||
"success": True,
|
||||
"prompt": prompt,
|
||||
"filename": filename,
|
||||
"width": width,
|
||||
"height": height
|
||||
})
|
||||
except Exception as e:
|
||||
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||
|
||||
@routes.get("/leafflow/settings")
|
||||
@routes.get("/flow_control/settings")
|
||||
async def get_settings(request):
|
||||
if not is_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local access only"}, status=403)
|
||||
civitai_key = os.getenv("CIVITAI_API_KEY", "")
|
||||
tmdb_key = os.getenv("TMDB_API_KEY", "")
|
||||
enable_tray = os.getenv("ENABLE_TRAY_ICON", "false").lower() in ["true", "1", "yes"]
|
||||
enable_assets_restore = os.getenv("ENABLE_ASSETS_RESTORE", "true").lower() in ["true", "1", "yes"]
|
||||
restore_assets_count = int(os.getenv("RESTORE_ASSETS_COUNT", "64"))
|
||||
enable_process_management = os.getenv("ALLOW_PROCESS_MANAGEMENT", "false").lower() in ["true", "1", "yes"]
|
||||
enable_local_file_execution = os.getenv("ENABLE_LOCAL_FILE_EXECUTION", "false").lower() in ["true", "1", "yes"]
|
||||
if os.path.exists(ENV_FILE):
|
||||
try:
|
||||
with open(ENV_FILE, "r", encoding="utf-8") as f:
|
||||
@@ -69,25 +166,58 @@ async def get_settings(request):
|
||||
civitai_key = line.split("=", 1)[1].strip()
|
||||
elif line.startswith("TMDB_API_KEY="):
|
||||
tmdb_key = line.split("=", 1)[1].strip()
|
||||
elif line.startswith("ENABLE_TRAY_ICON="):
|
||||
enable_tray = line.split("=", 1)[1].strip().lower() in ["true", "1", "yes"]
|
||||
elif line.startswith("ENABLE_ASSETS_RESTORE="):
|
||||
enable_assets_restore = line.split("=", 1)[1].strip().lower() in ["true", "1", "yes"]
|
||||
elif line.startswith("RESTORE_ASSETS_COUNT="):
|
||||
try:
|
||||
restore_assets_count = int(line.split("=", 1)[1].strip())
|
||||
except Exception:
|
||||
pass
|
||||
elif line.startswith("ALLOW_PROCESS_MANAGEMENT="):
|
||||
enable_process_management = line.split("=", 1)[1].strip().lower() in ["true", "1", "yes"]
|
||||
elif line.startswith("ENABLE_LOCAL_FILE_EXECUTION="):
|
||||
enable_local_file_execution = line.split("=", 1)[1].strip().lower() in ["true", "1", "yes"]
|
||||
except Exception:
|
||||
pass
|
||||
return web.json_response({
|
||||
"civitai_api_key": civitai_key,
|
||||
"tmdb_api_key": tmdb_key
|
||||
"tmdb_api_key": tmdb_key,
|
||||
"enable_tray_icon": enable_tray,
|
||||
"enable_assets_restore": enable_assets_restore,
|
||||
"restore_assets_count": restore_assets_count,
|
||||
"enable_process_management": enable_process_management,
|
||||
"enable_local_file_execution": enable_local_file_execution
|
||||
})
|
||||
|
||||
def _clean_env_val(v):
|
||||
if v is None:
|
||||
return None
|
||||
return str(v).replace("\n", "").replace("\r", "").strip()
|
||||
|
||||
@routes.post("/leafflow/settings")
|
||||
@routes.post("/flow_control/settings")
|
||||
async def save_settings(request):
|
||||
if not is_authenticated_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
|
||||
try:
|
||||
data = await request.json()
|
||||
civitai_key = data.get("civitai_api_key")
|
||||
tmdb_key = data.get("tmdb_api_key")
|
||||
enable_persistent_queue = data.get("enable_persistent_queue")
|
||||
default_pause_state = data.get("default_pause_state")
|
||||
default_pause_mode = data.get("default_pause_mode")
|
||||
enable_civitai = data.get("enable_civitai_scraping")
|
||||
enable_tmdb = data.get("enable_tmdb_scraping")
|
||||
restored_state = data.get("persistent_queue_restored_state")
|
||||
civitai_key = _clean_env_val(data.get("civitai_api_key"))
|
||||
tmdb_key = _clean_env_val(data.get("tmdb_api_key"))
|
||||
enable_persistent_queue = _clean_env_val(data.get("enable_persistent_queue"))
|
||||
default_pause_state = _clean_env_val(data.get("default_pause_state"))
|
||||
default_pause_mode = _clean_env_val(data.get("default_pause_mode"))
|
||||
enable_civitai = _clean_env_val(data.get("enable_civitai_scraping"))
|
||||
enable_tmdb = _clean_env_val(data.get("enable_tmdb_scraping"))
|
||||
enable_lora_usage = _clean_env_val(data.get("enable_lora_usage"))
|
||||
enable_tray_icon = _clean_env_val(data.get("enable_tray_icon"))
|
||||
enable_assets_restore = _clean_env_val(data.get("enable_assets_restore"))
|
||||
restore_assets_count = _clean_env_val(data.get("restore_assets_count"))
|
||||
clear_prompt_iterator_on_launch = _clean_env_val(data.get("clear_prompt_iterator_on_launch"))
|
||||
restored_state = _clean_env_val(data.get("persistent_queue_restored_state"))
|
||||
enable_process_management = _clean_env_val(data.get("enable_process_management"))
|
||||
enable_local_file_execution = _clean_env_val(data.get("enable_local_file_execution"))
|
||||
|
||||
lines = []
|
||||
if os.path.exists(ENV_FILE):
|
||||
@@ -105,7 +235,14 @@ async def save_settings(request):
|
||||
has_pause_mode = False
|
||||
has_civ_enable = False
|
||||
has_tmdb_enable = False
|
||||
has_lora_usage = False
|
||||
has_tray_enable = False
|
||||
has_assets_restore = False
|
||||
has_restore_count = False
|
||||
has_clear_prompt_iterator = False
|
||||
has_restored_state = False
|
||||
has_process_mgmt = False
|
||||
has_local_file_exec = False
|
||||
|
||||
for line in lines:
|
||||
if line.strip().startswith("CIVITAI_API_KEY=") and civitai_key is not None:
|
||||
@@ -129,9 +266,33 @@ async def save_settings(request):
|
||||
elif line.strip().startswith("ENABLE_TMDB_SCRAPING=") and enable_tmdb is not None:
|
||||
new_lines.append(f"ENABLE_TMDB_SCRAPING={enable_tmdb}\n")
|
||||
has_tmdb_enable = True
|
||||
elif line.strip().startswith("ENABLE_LORA_USAGE=") and enable_lora_usage is not None:
|
||||
new_lines.append(f"ENABLE_LORA_USAGE={enable_lora_usage}\n")
|
||||
has_lora_usage = True
|
||||
elif line.strip().startswith("ENABLE_TRAY_ICON=") and enable_tray_icon is not None:
|
||||
new_lines.append(f"ENABLE_TRAY_ICON={enable_tray_icon}\n")
|
||||
has_tray_enable = True
|
||||
elif line.strip().startswith("ENABLE_ASSETS_RESTORE=") and enable_assets_restore is not None:
|
||||
new_lines.append(f"ENABLE_ASSETS_RESTORE={enable_assets_restore}\n")
|
||||
has_assets_restore = True
|
||||
elif line.strip().startswith("RESTORE_ASSETS_COUNT=") and restore_assets_count is not None:
|
||||
new_lines.append(f"RESTORE_ASSETS_COUNT={restore_assets_count}\n")
|
||||
has_restore_count = True
|
||||
elif line.strip().startswith("CLEAR_PROMPT_ITERATOR_ON_LAUNCH=") and clear_prompt_iterator_on_launch is not None:
|
||||
new_lines.append(f"CLEAR_PROMPT_ITERATOR_ON_LAUNCH={clear_prompt_iterator_on_launch}\n")
|
||||
has_clear_prompt_iterator = True
|
||||
elif line.strip().startswith("PERSISTENT_QUEUE_RESTORED_STATE=") and restored_state is not None:
|
||||
new_lines.append(f"PERSISTENT_QUEUE_RESTORED_STATE={restored_state}\n")
|
||||
has_restored_state = True
|
||||
elif line.strip().startswith("ALLOW_PROCESS_MANAGEMENT=") and enable_process_management is not None:
|
||||
new_lines.append(f"ALLOW_PROCESS_MANAGEMENT={enable_process_management}\n")
|
||||
has_process_mgmt = True
|
||||
elif line.strip().startswith("ENABLE_LOCAL_FILE_EXECUTION=") and enable_local_file_execution is not None:
|
||||
new_lines.append(f"ENABLE_LOCAL_FILE_EXECUTION={enable_local_file_execution}\n")
|
||||
has_local_file_exec = True
|
||||
elif line.strip().startswith("ALLOW_ANY_SCRIPT_PATH="):
|
||||
# Deprecated and removed for security; drop from .env
|
||||
continue
|
||||
else:
|
||||
new_lines.append(line)
|
||||
|
||||
@@ -149,8 +310,22 @@ async def save_settings(request):
|
||||
new_lines.append(f"ENABLE_CIVITAI_SCRAPING={enable_civitai}\n")
|
||||
if not has_tmdb_enable and enable_tmdb is not None:
|
||||
new_lines.append(f"ENABLE_TMDB_SCRAPING={enable_tmdb}\n")
|
||||
if not has_lora_usage and enable_lora_usage is not None:
|
||||
new_lines.append(f"ENABLE_LORA_USAGE={enable_lora_usage}\n")
|
||||
if not has_tray_enable and enable_tray_icon is not None:
|
||||
new_lines.append(f"ENABLE_TRAY_ICON={enable_tray_icon}\n")
|
||||
if not has_assets_restore and enable_assets_restore is not None:
|
||||
new_lines.append(f"ENABLE_ASSETS_RESTORE={enable_assets_restore}\n")
|
||||
if not has_restore_count and restore_assets_count is not None:
|
||||
new_lines.append(f"RESTORE_ASSETS_COUNT={restore_assets_count}\n")
|
||||
if not has_clear_prompt_iterator and clear_prompt_iterator_on_launch is not None:
|
||||
new_lines.append(f"CLEAR_PROMPT_ITERATOR_ON_LAUNCH={clear_prompt_iterator_on_launch}\n")
|
||||
if not has_restored_state and restored_state is not None:
|
||||
new_lines.append(f"PERSISTENT_QUEUE_RESTORED_STATE={restored_state}\n")
|
||||
if not has_process_mgmt and enable_process_management is not None:
|
||||
new_lines.append(f"ALLOW_PROCESS_MANAGEMENT={enable_process_management}\n")
|
||||
if not has_local_file_exec and enable_local_file_execution is not None:
|
||||
new_lines.append(f"ENABLE_LOCAL_FILE_EXECUTION={enable_local_file_execution}\n")
|
||||
|
||||
with open(ENV_FILE, "w", encoding="utf-8") as f:
|
||||
f.writelines(new_lines)
|
||||
@@ -159,8 +334,122 @@ async def save_settings(request):
|
||||
os.environ["CIVITAI_API_KEY"] = civitai_key
|
||||
if tmdb_key is not None:
|
||||
os.environ["TMDB_API_KEY"] = tmdb_key
|
||||
if enable_lora_usage is not None:
|
||||
os.environ["ENABLE_LORA_USAGE"] = enable_lora_usage
|
||||
if enable_tray_icon is not None:
|
||||
os.environ["ENABLE_TRAY_ICON"] = str(enable_tray_icon)
|
||||
tray_enabled_bool = str(enable_tray_icon).lower() in ["true", "1", "yes"]
|
||||
tray_manager.set_enabled(tray_enabled_bool)
|
||||
if enable_assets_restore is not None:
|
||||
os.environ["ENABLE_ASSETS_RESTORE"] = str(enable_assets_restore)
|
||||
if enable_process_management is not None:
|
||||
os.environ["ALLOW_PROCESS_MANAGEMENT"] = str(enable_process_management)
|
||||
if enable_local_file_execution is not None:
|
||||
os.environ["ENABLE_LOCAL_FILE_EXECUTION"] = str(enable_local_file_execution)
|
||||
if restore_assets_count is not None:
|
||||
os.environ["RESTORE_ASSETS_COUNT"] = str(restore_assets_count)
|
||||
if clear_prompt_iterator_on_launch is not None:
|
||||
os.environ["CLEAR_PROMPT_ITERATOR_ON_LAUNCH"] = str(clear_prompt_iterator_on_launch)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[FlowControl] 🍃 Error saving settings to .env: {e}")
|
||||
print(f"[LeafFlow] 🍃 Error saving settings to .env: {e}")
|
||||
|
||||
return web.json_response({"status": "ok"})
|
||||
|
||||
@routes.post("/leafflow/scrapes/clear")
|
||||
async def clear_scrapes_endpoint(request):
|
||||
if not is_authenticated_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
|
||||
try:
|
||||
failed_file = os.path.join(USER_DIR, "failed_scrapes.json")
|
||||
with open(failed_file, "w", encoding="utf-8") as f:
|
||||
f.write("{}")
|
||||
return web.json_response({"status": "ok"})
|
||||
except Exception as e:
|
||||
print(f"[LeafFlow] 🍃 Error clearing scrapes cache: {e}")
|
||||
return web.json_response({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
@routes.get("/leafflow/debug/export")
|
||||
async def export_debug_profile(request):
|
||||
try:
|
||||
import platform
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
import torch
|
||||
|
||||
# Safe GPU / hardware information
|
||||
cuda_avail = torch.cuda.is_available() if hasattr(torch, "cuda") else False
|
||||
device_name = torch.cuda.get_device_name(0) if cuda_avail else "CPU"
|
||||
|
||||
# Check API key configuration status without revealing sensitive strings
|
||||
civitai_key = os.getenv("CIVITAI_API_KEY", "")
|
||||
tmdb_key = os.getenv("TMDB_API_KEY", "")
|
||||
if os.path.exists(ENV_FILE):
|
||||
try:
|
||||
with open(ENV_FILE, "r", encoding="utf-8") as f:
|
||||
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:
|
||||
tmdb_key = line.split("=", 1)[1].strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Calculate counts of local storage files safely
|
||||
def safe_json_count(filename):
|
||||
p = os.path.join(USER_DIR, filename)
|
||||
if os.path.exists(p):
|
||||
try:
|
||||
with open(p, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return len(data) if isinstance(data, (list, dict)) else 0
|
||||
except Exception:
|
||||
pass
|
||||
return 0
|
||||
|
||||
debug_profile = {
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"package": "ComfyUI-LeafFlow",
|
||||
"version": __version__,
|
||||
"system": {
|
||||
"os": platform.system(),
|
||||
"os_release": platform.release(),
|
||||
"os_version": platform.version(),
|
||||
"architecture": platform.machine(),
|
||||
"python_version": sys.version.split()[0],
|
||||
"torch_version": getattr(torch, "__version__", "unknown"),
|
||||
"cuda_available": cuda_avail,
|
||||
"device_name": device_name,
|
||||
},
|
||||
"registered_nodes_count": len(NODE_CLASS_MAPPINGS),
|
||||
"settings": {
|
||||
"enable_persistent_queue": get_env_setting("ENABLE_PERSISTENT_QUEUE", "true"),
|
||||
"default_pause_state": get_env_setting("DEFAULT_PAUSE_STATE", "Paused"),
|
||||
"default_pause_mode": get_env_setting("DEFAULT_PAUSE_MODE", "after_finish"),
|
||||
"enable_civitai_scraping": get_env_setting("ENABLE_CIVITAI_SCRAPING", "true"),
|
||||
"enable_tmdb_scraping": get_env_setting("ENABLE_TMDB_SCRAPING", "true"),
|
||||
"enable_lora_usage": get_env_setting("ENABLE_LORA_USAGE", "true"),
|
||||
"enable_tray_icon": get_env_setting("ENABLE_TRAY_ICON", "false"),
|
||||
"enable_assets_restore": get_env_setting("ENABLE_ASSETS_RESTORE", "true"),
|
||||
"restore_assets_count": get_env_setting("RESTORE_ASSETS_COUNT", "64"),
|
||||
"clear_prompt_iterator_on_launch": get_env_setting("CLEAR_PROMPT_ITERATOR_ON_LAUNCH", "false"),
|
||||
"persistent_queue_restored_state": get_env_setting("PERSISTENT_QUEUE_RESTORED_STATE", "Match Default"),
|
||||
"allow_process_management": get_env_setting("ALLOW_PROCESS_MANAGEMENT", "false"),
|
||||
"enable_local_file_execution": get_env_setting("ENABLE_LOCAL_FILE_EXECUTION", "false"),
|
||||
"civitai_api_key_configured": bool(civitai_key and civitai_key.strip()),
|
||||
"tmdb_api_key_configured": bool(tmdb_key and tmdb_key.strip()),
|
||||
},
|
||||
"local_storage_stats": {
|
||||
"tracked_loras": safe_json_count("lora_usage.json"),
|
||||
"lora_cycle_states": safe_json_count("lora_loader_state.json"),
|
||||
"image_prompts_cached": safe_json_count("image_prompts_cache.json"),
|
||||
"failed_scrapes_cached": safe_json_count("failed_scrapes.json"),
|
||||
"prompt_iterator_queues": safe_json_count("prompt_iterator_state.json"),
|
||||
"persisted_queue_items": safe_json_count("persistent_queue.json"),
|
||||
}
|
||||
}
|
||||
return web.json_response(debug_profile)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
|
||||
def main():
|
||||
print("[ComfyUI-FlowControl] Checking dependencies...")
|
||||
dependencies = ["pillow", "piexif", "numpy"]
|
||||
for dep in dependencies:
|
||||
try:
|
||||
__import__(dep)
|
||||
except ImportError:
|
||||
print(f"[ComfyUI-FlowControl] Installing missing dependency: {dep}")
|
||||
subprocess.check_call([sys.executable, "-m", "pip", "install", dep])
|
||||
|
||||
print("[ComfyUI-FlowControl] Installation check complete! Restart ComfyUI to activate.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
# Package marker for LeafFlow nodes
|
||||
@@ -0,0 +1,255 @@
|
||||
import re
|
||||
import math
|
||||
|
||||
class TextAspectRatioFinder:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"aspect_ratios": ("STRING", {
|
||||
"default": "1:1, 2:3, 3:2, 3:4, 4:3, 9:16, 16:9, 21:9",
|
||||
"multiline": False
|
||||
}),
|
||||
"search_mode": (["First match (Front)", "Last match (Back)"], {
|
||||
"default": "First match (Front)"
|
||||
}),
|
||||
"target_mp": ("FLOAT", {
|
||||
"default": 1.0,
|
||||
"min": 0.1,
|
||||
"max": 16.0,
|
||||
"step": 0.1,
|
||||
"display": "number"
|
||||
}),
|
||||
"default_aspect_ratio": ("STRING", {
|
||||
"default": "1:1",
|
||||
"multiline": False
|
||||
}),
|
||||
"multiple_of": ("INT", {
|
||||
"default": 8,
|
||||
"min": 8,
|
||||
"max": 128,
|
||||
"step": 4,
|
||||
"advanced": True
|
||||
}),
|
||||
"min_mp": ("FLOAT", {
|
||||
"default": 0.1,
|
||||
"min": 0.01,
|
||||
"max": 64.0,
|
||||
"step": 0.1,
|
||||
"display": "number",
|
||||
"advanced": True
|
||||
}),
|
||||
"max_mp": ("FLOAT", {
|
||||
"default": 16.0,
|
||||
"min": 0.1,
|
||||
"max": 64.0,
|
||||
"step": 0.1,
|
||||
"display": "number",
|
||||
"advanced": True
|
||||
}),
|
||||
},
|
||||
"optional": {
|
||||
"text": ("STRING", {"forceInput": True}),
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("INT", "INT", "STRING")
|
||||
RETURN_NAMES = ("width", "height", "aspect_ratio")
|
||||
FUNCTION = "find_aspect_ratio"
|
||||
CATEGORY = "🍃 LeafFlow/Utils"
|
||||
DESCRIPTION = "Searches text for aspect ratios (e.g. 16:9), syntax checks them, and calculates width & height for target megapixels."
|
||||
|
||||
def find_aspect_ratio(
|
||||
self,
|
||||
aspect_ratios="1:1, 2:3, 3:2, 3:4, 4:3, 9:16, 16:9, 21:9",
|
||||
search_mode="First match (Front)",
|
||||
target_mp=1.0,
|
||||
default_aspect_ratio="1:1",
|
||||
multiple_of=8,
|
||||
min_mp=0.1,
|
||||
max_mp=16.0,
|
||||
text=None,
|
||||
unique_id=None,
|
||||
**kwargs
|
||||
):
|
||||
text_str = str(text) if text is not None else ""
|
||||
|
||||
min_v = float(min_mp)
|
||||
max_v = max(min_v, float(max_mp))
|
||||
effective_mp = max(min_v, min(max_v, float(target_mp)))
|
||||
|
||||
valid_ratios = []
|
||||
raw_items = [item.strip() for item in str(aspect_ratios or "").split(",") if item.strip()]
|
||||
|
||||
for item in raw_items:
|
||||
match = re.match(r'^(\d+(?:\.\d+)?)\s*[:xX/]\s*(\d+(?:\.\d+)?)$', item)
|
||||
if match:
|
||||
w_val = float(match.group(1))
|
||||
h_val = float(match.group(2))
|
||||
if w_val > 0 and h_val > 0:
|
||||
w_str = str(int(w_val)) if w_val.is_integer() else str(w_val)
|
||||
h_str = str(int(h_val)) if h_val.is_integer() else str(h_val)
|
||||
ratio_fmt = f"{w_str}:{h_str}"
|
||||
if ratio_fmt not in valid_ratios:
|
||||
valid_ratios.append(ratio_fmt)
|
||||
|
||||
found_ratio = None
|
||||
matches = []
|
||||
|
||||
if text_str:
|
||||
if valid_ratios:
|
||||
# 1. When allowed aspect_ratios are configured: ONLY match these exact ratios!
|
||||
for ratio in valid_ratios:
|
||||
w_p, h_p = ratio.split(":")
|
||||
pattern = r'(?<![\d.])' + re.escape(w_p) + r'\s*[:xX/]\s*' + re.escape(h_p) + r'(?!\d|\.\d)'
|
||||
for m in re.finditer(pattern, text_str):
|
||||
matches.append((m.start(), ratio))
|
||||
else:
|
||||
# 2. When aspect_ratios input is EMPTY: accept ANY valid aspect ratio found in the text
|
||||
gen_pattern = r'(?<![\d.])(\d+(?:\.\d+)?)\s*[:xX/]\s*(\d+(?:\.\d+)?)(?!\d|\.\d)'
|
||||
for m in re.finditer(gen_pattern, text_str):
|
||||
try:
|
||||
w_val = float(m.group(1))
|
||||
h_val = float(m.group(2))
|
||||
if w_val > 0 and h_val > 0:
|
||||
w_str = str(int(w_val)) if w_val.is_integer() else str(w_val)
|
||||
h_str = str(int(h_val)) if h_val.is_integer() else str(h_val)
|
||||
ratio_fmt = f"{w_str}:{h_str}"
|
||||
if not any(m_pos == m.start() for m_pos, _ in matches):
|
||||
matches.append((m.start(), ratio_fmt))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if matches:
|
||||
matches.sort(key=lambda x: x[0])
|
||||
if search_mode in ["Back", "Last match (Back)"]:
|
||||
found_ratio = matches[-1][1]
|
||||
else:
|
||||
found_ratio = matches[0][1]
|
||||
else:
|
||||
found_ratio = default_aspect_ratio
|
||||
|
||||
# Parse found_ratio and validate. If corrupted/invalid, fall back to "1:1"
|
||||
try:
|
||||
m_ratio = re.match(r'^(\d+(?:\.\d+)?)\s*[:xX/]\s*(\d+(?:\.\d+)?)$', str(found_ratio or "").strip())
|
||||
if m_ratio:
|
||||
w_part = float(m_ratio.group(1))
|
||||
h_part = float(m_ratio.group(2))
|
||||
if w_part <= 0 or h_part <= 0:
|
||||
raise ValueError("Dimensions must be positive")
|
||||
w_str = str(int(w_part)) if w_part.is_integer() else str(w_part)
|
||||
h_str = str(int(h_part)) if h_part.is_integer() else str(h_part)
|
||||
found_ratio = f"{w_str}:{h_str}"
|
||||
else:
|
||||
raise ValueError(f"Cannot parse aspect ratio: {found_ratio}")
|
||||
except Exception as e:
|
||||
err_msg = f"Aspect ratio '{found_ratio}' is invalid. Falling back to '1:1'."
|
||||
print(f"[LeafFlow] Warning: {err_msg} ({e})")
|
||||
try:
|
||||
from server import PromptServer
|
||||
if hasattr(PromptServer, "instance") and PromptServer.instance:
|
||||
PromptServer.instance.send_sync("leafflow_node_error_state", {
|
||||
"node_id": str(unique_id or ""),
|
||||
"title": "Aspect Ratio Warning",
|
||||
"message": err_msg,
|
||||
"fallback": "1:1"
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
w_part, h_part = 1.0, 1.0
|
||||
found_ratio = "1:1"
|
||||
|
||||
r = w_part / h_part
|
||||
|
||||
total_pixels = effective_mp * 1024.0 * 1024.0
|
||||
|
||||
raw_h = math.sqrt(total_pixels / r)
|
||||
raw_w = raw_h * r
|
||||
|
||||
m = max(4, int(multiple_of))
|
||||
width = max(m, int(round(raw_w / m)) * m)
|
||||
height = max(m, int(round(raw_h / m)) * m)
|
||||
|
||||
return (width, height, found_ratio)
|
||||
|
||||
from server import PromptServer
|
||||
|
||||
class PreviewImageSizeAspectRatio:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {},
|
||||
"optional": {
|
||||
"width": ("INT", {"default": 0, "min": 0, "max": 16384, "forceInput": True}),
|
||||
"height": ("INT", {"default": 0, "min": 0, "max": 16384, "forceInput": True}),
|
||||
"aspect_ratio": ("STRING", {"forceInput": True}),
|
||||
"ratio_float": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 100.0, "step": 0.01, "forceInput": True}),
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
RETURN_NAMES = ()
|
||||
FUNCTION = "process_preview"
|
||||
CATEGORY = "🍃 LeafFlow/Utils"
|
||||
OUTPUT_NODE = True
|
||||
DESCRIPTION = "Visual preview node that displays image aspect ratio and dimension summary."
|
||||
|
||||
def process_preview(
|
||||
self,
|
||||
width=None,
|
||||
height=None,
|
||||
aspect_ratio=None,
|
||||
ratio_float=None,
|
||||
unique_id="default"
|
||||
):
|
||||
w_val = int(width) if width is not None and int(width) > 0 else 0
|
||||
h_val = int(height) if height is not None and int(height) > 0 else 0
|
||||
ar_str = str(aspect_ratio).strip() if aspect_ratio is not None else ""
|
||||
r_flt = float(ratio_float) if ratio_float is not None and float(ratio_float) > 0 else 0.0
|
||||
|
||||
calc_ratio = 1.0
|
||||
display_text = "1 x 1"
|
||||
|
||||
if w_val > 0 and h_val > 0:
|
||||
calc_ratio = w_val / h_val
|
||||
display_text = f"{w_val} x {h_val}"
|
||||
elif ar_str:
|
||||
match = re.match(r'^(\d+(?:\.\d+)?)\s*[:xX/]\s*(\d+(?:\.\d+)?)$', ar_str)
|
||||
if match:
|
||||
w_p = float(match.group(1))
|
||||
h_p = float(match.group(2))
|
||||
if w_p > 0 and h_p > 0:
|
||||
calc_ratio = w_p / h_p
|
||||
w_disp = int(w_p) if w_p.is_integer() else w_p
|
||||
h_disp = int(h_p) if h_p.is_integer() else h_p
|
||||
display_text = f"{w_disp} x {h_disp}"
|
||||
else:
|
||||
display_text = ar_str
|
||||
elif r_flt > 0:
|
||||
calc_ratio = r_flt
|
||||
if r_flt >= 1.0:
|
||||
w_disp = round(r_flt, 2)
|
||||
display_text = f"{w_disp} x 1"
|
||||
else:
|
||||
h_disp = round(1.0 / r_flt, 2)
|
||||
display_text = f"1 x {h_disp}"
|
||||
|
||||
try:
|
||||
PromptServer.instance.send_sync("leafflow_update_preview_aspect_ratio", {
|
||||
"node_id": str(unique_id),
|
||||
"ratio": calc_ratio,
|
||||
"display_text": display_text
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {}
|
||||
|
||||
# Alias for backward compatibility
|
||||
AspectRatioFinder = TextAspectRatioFinder
|
||||
@@ -0,0 +1,243 @@
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
import hashlib
|
||||
import datetime
|
||||
import logging
|
||||
from PIL import Image
|
||||
|
||||
import folder_paths
|
||||
from .utils import get_leafflow_user_dir
|
||||
|
||||
USER_DIR = get_leafflow_user_dir()
|
||||
ENV_FILE = os.path.join(USER_DIR, ".env")
|
||||
|
||||
def get_env_setting(key, default_val):
|
||||
if os.path.exists(ENV_FILE):
|
||||
try:
|
||||
with open(ENV_FILE, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
if line.strip().startswith(f"{key}="):
|
||||
return line.strip().split("=", 1)[1].strip()
|
||||
except Exception:
|
||||
pass
|
||||
return default_val
|
||||
|
||||
def is_assets_restore_enabled():
|
||||
val = get_env_setting("ENABLE_ASSETS_RESTORE", "true").lower()
|
||||
return val in ["true", "1", "yes"]
|
||||
|
||||
def get_assets_restore_count():
|
||||
try:
|
||||
val = int(get_env_setting("RESTORE_ASSETS_COUNT", "64"))
|
||||
return max(1, min(1000, val))
|
||||
except Exception:
|
||||
return 64
|
||||
|
||||
class AssetsRestoreManager:
|
||||
def __init__(self):
|
||||
self._restored_on_launch = False
|
||||
self.last_debug_report = {}
|
||||
|
||||
def extract_image_metadata(self, filepath):
|
||||
"""Extracts embedded prompt and workflow graph metadata from image (read-only)."""
|
||||
prompt_dict = {}
|
||||
workflow_dict = {}
|
||||
positive_prompt = ""
|
||||
|
||||
try:
|
||||
with Image.open(filepath) as img:
|
||||
if img.format == "PNG":
|
||||
prompt_raw = img.info.get("prompt")
|
||||
workflow_raw = img.info.get("workflow")
|
||||
parameters_raw = img.info.get("parameters")
|
||||
|
||||
if prompt_raw:
|
||||
try:
|
||||
prompt_dict = json.loads(prompt_raw) if isinstance(prompt_raw, str) else prompt_raw
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if workflow_raw:
|
||||
try:
|
||||
workflow_dict = json.loads(workflow_raw) if isinstance(workflow_raw, str) else workflow_raw
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if parameters_raw:
|
||||
positive_prompt = str(parameters_raw)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return prompt_dict, workflow_dict, positive_prompt
|
||||
|
||||
def scan_output_images(self, base_output_dir, limit=64):
|
||||
"""Scans output directory recursively (strictly read-only) and returns top `limit` newest images."""
|
||||
if not os.path.exists(base_output_dir):
|
||||
return []
|
||||
|
||||
valid_extensions = {".png", ".jpg", ".jpeg", ".webp", ".PNG", ".JPG", ".JPEG", ".WEBP"}
|
||||
found_images = []
|
||||
|
||||
norm_base = os.path.normpath(base_output_dir)
|
||||
|
||||
for root, _, files in os.walk(norm_base):
|
||||
for file in files:
|
||||
_, ext = os.path.splitext(file)
|
||||
if ext in valid_extensions:
|
||||
full_path = os.path.join(root, file)
|
||||
try:
|
||||
mtime = os.path.getmtime(full_path)
|
||||
rel_dir = os.path.relpath(root, norm_base)
|
||||
subfolder = "" if rel_dir == "." else rel_dir.replace("\\", "/")
|
||||
found_images.append({
|
||||
"filepath": full_path,
|
||||
"filename": file,
|
||||
"subfolder": subfolder,
|
||||
"mtime": mtime
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Sort newest first, take limit, then reverse to chronological (oldest to newest)
|
||||
found_images.sort(key=lambda x: x["mtime"], reverse=True)
|
||||
top_images = found_images[:limit]
|
||||
top_images.reverse()
|
||||
|
||||
return top_images
|
||||
|
||||
def restore_on_launch(self, server, limit=None, force=False):
|
||||
"""Populates PromptQueue.history on ComfyUI startup with the latest generated images."""
|
||||
if self._restored_on_launch and not force:
|
||||
return 0
|
||||
|
||||
if limit is None:
|
||||
limit = get_assets_restore_count()
|
||||
|
||||
enabled = is_assets_restore_enabled()
|
||||
if not enabled:
|
||||
self._restored_on_launch = True
|
||||
return 0
|
||||
|
||||
base_output_dir = folder_paths.get_output_directory()
|
||||
output_exists = os.path.exists(base_output_dir)
|
||||
|
||||
images = self.scan_output_images(base_output_dir, limit=limit) if output_exists else []
|
||||
|
||||
restored_count = 0
|
||||
prompt_queue = getattr(server, "prompt_queue", None)
|
||||
|
||||
if prompt_queue is not None:
|
||||
with prompt_queue.mutex:
|
||||
for idx, item in enumerate(images, 1):
|
||||
filepath = item["filepath"]
|
||||
filename = item["filename"]
|
||||
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_dict, workflow_dict, positive_prompt = self.extract_image_metadata(filepath)
|
||||
output_node_id = "9"
|
||||
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
|
||||
|
||||
extra_pnginfo = {}
|
||||
if workflow_dict:
|
||||
extra_pnginfo["workflow"] = workflow_dict
|
||||
if positive_prompt:
|
||||
extra_pnginfo["positive_prompt"] = positive_prompt
|
||||
|
||||
mtime_ms = int(item["mtime"] * 1000)
|
||||
history_entry = {
|
||||
"prompt": [
|
||||
idx,
|
||||
prompt_id,
|
||||
prompt_dict,
|
||||
{
|
||||
"create_time": mtime_ms,
|
||||
"extra_pnginfo": extra_pnginfo,
|
||||
"client_id": "leafflow_restored"
|
||||
},
|
||||
[output_node_id]
|
||||
],
|
||||
"outputs": {
|
||||
output_node_id: {
|
||||
"images": [
|
||||
{
|
||||
"filename": filename,
|
||||
"subfolder": subfolder,
|
||||
"type": "output"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"status_str": "success",
|
||||
"completed": True,
|
||||
"messages": [
|
||||
["execution_start", {"timestamp": mtime_ms}],
|
||||
["execution_success", {"timestamp": mtime_ms}]
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
prompt_queue.history[prompt_id] = history_entry
|
||||
restored_count += 1
|
||||
|
||||
# Ingest into modern ComfyUI asset database if --enable-assets is active
|
||||
try:
|
||||
import importlib
|
||||
ingest_mod = importlib.import_module("app.assets.services.ingest")
|
||||
register_file_in_place = getattr(ingest_mod, "register_file_in_place", None)
|
||||
if register_file_in_place:
|
||||
res = register_file_in_place(abs_path=filepath, name=filename, tags=["output"])
|
||||
if res and hasattr(res, "ref") and res.ref and hasattr(res.ref, "id"):
|
||||
try:
|
||||
db_mod = importlib.import_module("app.database.db")
|
||||
queries_mod = importlib.import_module("app.assets.database.queries")
|
||||
init_db = getattr(db_mod, "init_db", None)
|
||||
create_session = getattr(db_mod, "create_session", None)
|
||||
set_reference_metadata = getattr(queries_mod, "set_reference_metadata", None)
|
||||
if init_db and create_session and set_reference_metadata:
|
||||
init_db()
|
||||
with create_session() as session:
|
||||
set_reference_metadata(session, reference_id=res.ref.id, metadata={
|
||||
"jobId": prompt_id,
|
||||
"nodeId": output_node_id,
|
||||
"filename": filename,
|
||||
"subfolder": subfolder
|
||||
})
|
||||
session.commit()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._restored_on_launch = True
|
||||
|
||||
self.last_debug_report = {
|
||||
"timestamp": datetime.datetime.now().isoformat(),
|
||||
"status": "success",
|
||||
"enabled": enabled,
|
||||
"configured_limit": limit,
|
||||
"total_scanned_images": len(images),
|
||||
"restored_count": restored_count
|
||||
}
|
||||
|
||||
if restored_count > 0:
|
||||
print(f"[LeafFlow] Restored {restored_count} recent image(s) into Assets / History pane.")
|
||||
if server:
|
||||
try:
|
||||
server.queue_updated()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return restored_count
|
||||
|
||||
assets_restore_manager = AssetsRestoreManager()
|
||||
+142
-45
@@ -2,25 +2,57 @@ import os
|
||||
import time
|
||||
import torch
|
||||
import numpy as np
|
||||
import re
|
||||
from PIL import Image, ImageOps
|
||||
import comfy.model_management
|
||||
from .utils import sanitize_folder_path
|
||||
|
||||
class AutoWatcherNode:
|
||||
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])
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def is_file_ready(filepath):
|
||||
try:
|
||||
if not os.path.exists(filepath) or os.path.getsize(filepath) == 0:
|
||||
return False
|
||||
with open(filepath, "rb") as f:
|
||||
f.seek(0, os.SEEK_END)
|
||||
return True
|
||||
except (OSError, IOError, PermissionError):
|
||||
return False
|
||||
|
||||
_WATCHER_STATE = {}
|
||||
|
||||
class LoadImageFromFolder:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"watch_dir": ("STRING", {"default": "input/watch"}),
|
||||
"interval_sec": ("INT", {"default": 2, "min": 1, "max": 60}),
|
||||
"wait_for_image": ("BOOLEAN", {"default": True}),
|
||||
"folder": ("STRING", {"default": "input/watch"}),
|
||||
"wait_if_folder_is_empty": ("BOOLEAN", {"default": True}),
|
||||
"rescan_interval": ("INT", {"default": 2, "min": 1, "max": 60}),
|
||||
"sort_by": (["date_modified", "date_created", "name"], {"default": "date_modified"}),
|
||||
"regex_filter": ("STRING", {"default": ".*"}),
|
||||
"delete_image": ("BOOLEAN", {"default": False, "tooltip": "If enabled, deletes the image file from disk after loading it. If disabled, cycles through all images in the folder sequentially."}),
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "BOOLEAN")
|
||||
RETURN_NAMES = ("image", "has_image")
|
||||
FUNCTION = "watch"
|
||||
CATEGORY = "🍃 FlowControl/Automation"
|
||||
DESCRIPTION = "Monitors a directory for incoming images. Can poll and wait for an image to arrive, or load if available without waiting."
|
||||
CATEGORY = "🍃 LeafFlow/Automation"
|
||||
DESCRIPTION = "Loads an image from a folder, optionally waiting if the folder is empty, with automatic sequential cycling when delete is False."
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(s, **kwargs):
|
||||
@@ -28,64 +60,129 @@ class AutoWatcherNode:
|
||||
return float("NaN")
|
||||
|
||||
def create_dummy_image(self):
|
||||
# Returns a 1x1 0-tensor for no-image state
|
||||
return torch.zeros((1, 64, 64, 3), dtype=torch.float32)
|
||||
# Returns a 512x512 standard 0-tensor for safe no-image state
|
||||
return torch.zeros((1, 512, 512, 3), dtype=torch.float32)
|
||||
|
||||
def load_and_remove_image(self, filepath):
|
||||
def load_and_remove_image(self, filepath, delete_image=False):
|
||||
with Image.open(filepath) as img:
|
||||
i = ImageOps.exif_transpose(img)
|
||||
image_array = np.array(i.convert("RGB")).astype(np.float32) / 255.0
|
||||
img_tensor = torch.from_numpy(image_array)[None,]
|
||||
|
||||
try:
|
||||
os.remove(filepath)
|
||||
except Exception as e:
|
||||
print(f"[AutoWatcher] Warning: Failed to remove processed file '{filepath}': {e}")
|
||||
if delete_image:
|
||||
try:
|
||||
os.remove(filepath)
|
||||
except Exception as e:
|
||||
print(f"[LeafFlow] Warning: Failed to remove processed file '{filepath}': {e}")
|
||||
|
||||
return img_tensor
|
||||
|
||||
def watch(self, watch_dir, interval_sec, wait_for_image=True):
|
||||
def get_filtered_files(self, folder, sort_by, regex_filter):
|
||||
valid_extensions = ('.png', '.jpg', '.jpeg', '.webp')
|
||||
try:
|
||||
regex = re.compile(regex_filter)
|
||||
except Exception as e:
|
||||
print(f"[LeafFlow] Invalid regex: {e}. Falling back to .*")
|
||||
regex = re.compile(".*")
|
||||
|
||||
if wait_for_image:
|
||||
# Poll loop: waits until an image arrives in watch_dir
|
||||
files = []
|
||||
for f in os.listdir(folder):
|
||||
filepath = os.path.join(folder, f)
|
||||
if os.path.isfile(filepath) and f.lower().endswith(valid_extensions):
|
||||
if regex.search(f) and is_file_ready(filepath):
|
||||
files.append(filepath)
|
||||
|
||||
if not files:
|
||||
return []
|
||||
|
||||
valid_files = []
|
||||
for file in files:
|
||||
try:
|
||||
if sort_by == "date_modified":
|
||||
val = os.path.getmtime(file)
|
||||
elif sort_by == "date_created":
|
||||
val = os.path.getctime(file)
|
||||
else:
|
||||
val = file
|
||||
valid_files.append((file, val))
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
if sort_by in ("date_modified", "date_created"):
|
||||
valid_files.sort(key=lambda x: x[1], reverse=True) # newest first
|
||||
elif sort_by == "name":
|
||||
valid_files.sort(key=lambda x: x[1]) # alphabetical
|
||||
|
||||
return [x[0] for x in valid_files]
|
||||
|
||||
def _select_file(self, files, delete_image, unique_id, folder):
|
||||
if not files:
|
||||
return None
|
||||
|
||||
if delete_image:
|
||||
# When deleting on each run, always consume the first available file
|
||||
return files[0]
|
||||
|
||||
# When keeping images (delete_image=False), cycle sequentially through all files!
|
||||
state_key = f"{unique_id}_{folder}"
|
||||
current_pid = get_current_prompt_id()
|
||||
|
||||
node_state = _WATCHER_STATE.get(state_key, {"index": 0, "assigned": {}})
|
||||
assigned = node_state.get("assigned", {})
|
||||
current_idx = int(node_state.get("index", 0))
|
||||
|
||||
if current_pid and current_pid in assigned:
|
||||
# Re-use exact index on persistent queue crash recovery
|
||||
idx = assigned[current_pid] % len(files)
|
||||
else:
|
||||
idx = current_idx % len(files)
|
||||
if current_pid:
|
||||
assigned[current_pid] = idx
|
||||
if len(assigned) > 50:
|
||||
for k in list(assigned.keys())[:-50]:
|
||||
del assigned[k]
|
||||
node_state["index"] = (current_idx + 1) % len(files)
|
||||
node_state["assigned"] = assigned
|
||||
_WATCHER_STATE[state_key] = node_state
|
||||
|
||||
return files[idx]
|
||||
|
||||
def watch(self, folder, wait_if_folder_is_empty, rescan_interval, sort_by, regex_filter, delete_image=False, unique_id="default", **kwargs):
|
||||
folder = sanitize_folder_path(folder, default_dir="input/watch")
|
||||
|
||||
if wait_if_folder_is_empty:
|
||||
while True:
|
||||
comfy.model_management.throw_exception_if_processing_interrupted()
|
||||
|
||||
if os.path.exists(watch_dir) and os.path.isdir(watch_dir):
|
||||
if os.path.exists(folder) and os.path.isdir(folder):
|
||||
try:
|
||||
files = [
|
||||
f for f in os.listdir(watch_dir)
|
||||
if os.path.isfile(os.path.join(watch_dir, f)) and f.lower().endswith(valid_extensions)
|
||||
]
|
||||
files = self.get_filtered_files(folder, sort_by, regex_filter)
|
||||
if files:
|
||||
filepath = os.path.join(watch_dir, files[0])
|
||||
filepath = self._select_file(files, delete_image, unique_id, folder)
|
||||
if filepath:
|
||||
try:
|
||||
img_tensor = self.load_and_remove_image(filepath, delete_image=delete_image)
|
||||
return (img_tensor, True)
|
||||
except Exception as e:
|
||||
print(f"[LeafFlow] Error processing {filepath}: {e}")
|
||||
except Exception as e:
|
||||
print(f"[LeafFlow] Error accessing directory {folder}: {e}")
|
||||
|
||||
time.sleep(rescan_interval)
|
||||
else:
|
||||
if os.path.exists(folder) and os.path.isdir(folder):
|
||||
try:
|
||||
files = self.get_filtered_files(folder, sort_by, regex_filter)
|
||||
if files:
|
||||
filepath = self._select_file(files, delete_image, unique_id, folder)
|
||||
if filepath:
|
||||
try:
|
||||
img_tensor = self.load_and_remove_image(filepath)
|
||||
img_tensor = self.load_and_remove_image(filepath, delete_image=delete_image)
|
||||
return (img_tensor, True)
|
||||
except Exception as e:
|
||||
print(f"[AutoWatcher] Error processing {filepath}: {e}")
|
||||
except Exception as e:
|
||||
print(f"[AutoWatcher] Error accessing directory {watch_dir}: {e}")
|
||||
|
||||
time.sleep(interval_sec)
|
||||
else:
|
||||
# Non-blocking check: returns immediately if no image is present
|
||||
if os.path.exists(watch_dir) and os.path.isdir(watch_dir):
|
||||
try:
|
||||
files = [
|
||||
f for f in os.listdir(watch_dir)
|
||||
if os.path.isfile(os.path.join(watch_dir, f)) and f.lower().endswith(valid_extensions)
|
||||
]
|
||||
if files:
|
||||
filepath = os.path.join(watch_dir, files[0])
|
||||
try:
|
||||
img_tensor = self.load_and_remove_image(filepath)
|
||||
return (img_tensor, True)
|
||||
except Exception as e:
|
||||
print(f"[AutoWatcher] Error processing {filepath}: {e}")
|
||||
print(f"[LeafFlow] Error processing {filepath}: {e}")
|
||||
except Exception as e:
|
||||
print(f"[AutoWatcher] Error accessing directory {watch_dir}: {e}")
|
||||
print(f"[LeafFlow] Error accessing directory {folder}: {e}")
|
||||
|
||||
# No image present or folder missing -> return dummy tensor & False immediately
|
||||
# No image present or folder missing -> return standard dummy tensor & False immediately
|
||||
return (self.create_dummy_image(), False)
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import subprocess
|
||||
from server import PromptServer
|
||||
from aiohttp import web
|
||||
import comfy.model_management
|
||||
from .utils import is_authenticated_local_request
|
||||
|
||||
class DecisionManager:
|
||||
_waiting_events = {}
|
||||
_actions = {}
|
||||
|
||||
@classmethod
|
||||
def register_wait(cls, unique_id, event):
|
||||
cls._waiting_events[unique_id] = event
|
||||
cls._actions[unique_id] = "continue"
|
||||
|
||||
@classmethod
|
||||
def unregister_wait(cls, unique_id):
|
||||
if unique_id in cls._waiting_events:
|
||||
del cls._waiting_events[unique_id]
|
||||
if unique_id in cls._actions:
|
||||
del cls._actions[unique_id]
|
||||
|
||||
@classmethod
|
||||
def get_action(cls, unique_id):
|
||||
action = cls._actions.get(unique_id, "continue")
|
||||
cls.unregister_wait(unique_id)
|
||||
return action
|
||||
|
||||
@classmethod
|
||||
def trigger_action(cls, unique_id, action):
|
||||
if unique_id in cls._waiting_events:
|
||||
cls._actions[unique_id] = action
|
||||
cls._waiting_events[unique_id].set()
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class LeafFlowDecision:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"disable": ("BOOLEAN", {"default": False}),
|
||||
"send_os_notification": ("BOOLEAN", {"default": False}),
|
||||
"timeout": ("INT", {"default": -1, "min": -1, "max": 3600}),
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("BOOLEAN",)
|
||||
RETURN_NAMES = ("cancel",)
|
||||
FUNCTION = "decide"
|
||||
CATEGORY = "🍃 LeafFlow/Utils"
|
||||
DESCRIPTION = "Pauses execution and waits for your input via the UI buttons.\n\n- 'Continue' outputs False (0).\n- 'Cancel' outputs True (1) so you can route it into a Switch node to bypass later nodes.\n- 'Stop Workflow' instantly aborts the entire ComfyUI generation queue.\n- 'OS Notification': Sends a native desktop toast (Windows/macOS/Linux) when waiting."
|
||||
|
||||
def send_notification(self, title, message):
|
||||
try:
|
||||
if sys.platform == 'win32' or os.name == 'nt':
|
||||
# Windows native PowerShell Toast Notification with XML escaping
|
||||
import xml.sax.saxutils
|
||||
esc_title = xml.sax.saxutils.escape(str(title))
|
||||
esc_msg = xml.sax.saxutils.escape(str(message))
|
||||
ps_script = f"""
|
||||
$ErrorActionPreference = 'Stop'
|
||||
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
|
||||
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null
|
||||
|
||||
$template = @"
|
||||
<toast>
|
||||
<visual>
|
||||
<binding template="ToastText02">
|
||||
<text id="1">{esc_title}</text>
|
||||
<text id="2">{esc_msg}</text>
|
||||
</binding>
|
||||
</visual>
|
||||
</toast>
|
||||
"@
|
||||
|
||||
$xml = New-Object Windows.Data.Xml.Dom.XmlDocument
|
||||
$xml.LoadXml($template)
|
||||
$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
|
||||
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("ComfyUI").Show($toast)
|
||||
"""
|
||||
subprocess.Popen(["powershell", "-NoProfile", "-Command", ps_script], creationflags=subprocess.CREATE_NO_WINDOW)
|
||||
elif sys.platform == 'darwin':
|
||||
# macOS native AppleScript Notification
|
||||
escape_title = title.replace('"', '\\"')
|
||||
escape_message = message.replace('"', '\\"')
|
||||
apple_script = f'display notification "{escape_message}" with title "{escape_title}"'
|
||||
subprocess.Popen(["osascript", "-e", apple_script])
|
||||
else:
|
||||
# Linux dbus notify-send
|
||||
subprocess.Popen(["notify-send", title, message])
|
||||
except Exception as e:
|
||||
print(f"[LeafFlow] Failed to send OS notification: {e}")
|
||||
|
||||
def decide(self, disable, send_os_notification, timeout, unique_id=None):
|
||||
if disable:
|
||||
return (False,)
|
||||
|
||||
if not unique_id:
|
||||
return (False,)
|
||||
|
||||
# Notify frontend
|
||||
PromptServer.instance.send_sync("leafflow_decision_waiting", {"node_id": unique_id})
|
||||
|
||||
if send_os_notification:
|
||||
self.send_notification("ComfyUI LeafFlow", "Workflow paused! Waiting for your decision.")
|
||||
|
||||
# Wait for user input
|
||||
event = threading.Event()
|
||||
DecisionManager.register_wait(unique_id, event)
|
||||
|
||||
import time
|
||||
try:
|
||||
if timeout > 0:
|
||||
end_time = time.time() + timeout
|
||||
while time.time() < end_time and not event.is_set():
|
||||
comfy.model_management.throw_exception_if_processing_interrupted()
|
||||
event.wait(0.5)
|
||||
if not event.is_set():
|
||||
print(f"[LeafFlow] Node {unique_id} timed out. Auto-continuing...")
|
||||
DecisionManager.unregister_wait(unique_id)
|
||||
# Notify frontend to update UI back to normal
|
||||
PromptServer.instance.send_sync("leafflow_decision_resolved", {"node_id": unique_id})
|
||||
return (False,)
|
||||
else:
|
||||
while not event.is_set():
|
||||
comfy.model_management.throw_exception_if_processing_interrupted()
|
||||
event.wait(0.5)
|
||||
except Exception:
|
||||
DecisionManager.unregister_wait(unique_id)
|
||||
PromptServer.instance.send_sync("leafflow_decision_resolved", {"node_id": unique_id})
|
||||
raise
|
||||
|
||||
# Retrieve action
|
||||
action = DecisionManager.get_action(unique_id)
|
||||
|
||||
# Notify frontend that the decision has been resolved
|
||||
PromptServer.instance.send_sync("leafflow_decision_resolved", {"node_id": unique_id})
|
||||
|
||||
if action == "cancel":
|
||||
print(f"[LeafFlow] Node {unique_id} cancelled.")
|
||||
return (True,)
|
||||
elif action == "stop":
|
||||
print(f"[LeafFlow] Node {unique_id} stopped workflow.")
|
||||
try:
|
||||
# Attempt to cancel current execution queue
|
||||
PromptServer.instance.prompt_queue.cancel_current_execution()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
comfy.model_management.interrupt_current_processing()
|
||||
except Exception:
|
||||
pass
|
||||
# Raise exception to halt this thread instantly
|
||||
try:
|
||||
raise comfy.model_management.InterruptProcessingException("Workflow stopped by LeafFlow Decision Node.")
|
||||
except AttributeError:
|
||||
raise Exception("Workflow stopped by LeafFlow Decision Node.")
|
||||
|
||||
print(f"[LeafFlow] Node {unique_id} continuing.")
|
||||
return (False,)
|
||||
|
||||
|
||||
# Register API Route
|
||||
@PromptServer.instance.routes.post("/leafflow/decision")
|
||||
async def handle_decision(request):
|
||||
if not is_authenticated_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
|
||||
try:
|
||||
data = await request.json()
|
||||
node_id = data.get("node_id")
|
||||
action = data.get("action")
|
||||
|
||||
if DecisionManager.trigger_action(node_id, action):
|
||||
return web.json_response({"status": "ok"})
|
||||
else:
|
||||
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)
|
||||
+223
-60
@@ -8,15 +8,36 @@ import piexif
|
||||
import piexif.helper
|
||||
from aiohttp import web
|
||||
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
|
||||
)
|
||||
|
||||
IMAGE_CATEGORY = "🍃 FlowControl/Loaders"
|
||||
IMAGE_CATEGORY = "🍃 LeafFlow/Loaders"
|
||||
CURRENT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
CACHE_FILE = os.path.join(CURRENT_DIR, "image_prompts_cache.json")
|
||||
USER_DIR = get_leafflow_user_dir()
|
||||
CACHE_FILE = os.path.join(USER_DIR, "image_prompts_cache.json")
|
||||
|
||||
def decode_bytes_safely(raw_bytes):
|
||||
if not isinstance(raw_bytes, bytes):
|
||||
return str(raw_bytes or "")
|
||||
for enc in ["utf-8", "utf-16le", "utf-16", "shift_jis", "gbk", "latin-1"]:
|
||||
try:
|
||||
return raw_bytes.decode(enc)
|
||||
except (UnicodeDecodeError, LookupError):
|
||||
continue
|
||||
return raw_bytes.decode("utf-8", errors="ignore")
|
||||
|
||||
def parse_positive_from_parameters(parameters):
|
||||
if not parameters:
|
||||
return ""
|
||||
lines = parameters.split("\n")
|
||||
if isinstance(parameters, bytes):
|
||||
parameters = decode_bytes_safely(parameters)
|
||||
lines = str(parameters).split("\n")
|
||||
pos_lines = []
|
||||
for line in lines:
|
||||
if line.strip().startswith("Negative prompt:") or line.strip().startswith("Steps:"):
|
||||
@@ -24,31 +45,110 @@ def parse_positive_from_parameters(parameters):
|
||||
pos_lines.append(line)
|
||||
return "\n".join(pos_lines).strip()
|
||||
|
||||
def trace_positive_prompt_from_graph(prompt_data):
|
||||
if not isinstance(prompt_data, dict):
|
||||
return ""
|
||||
|
||||
start_nodes = []
|
||||
for nid, ndata in prompt_data.items():
|
||||
if not isinstance(ndata, dict):
|
||||
continue
|
||||
ctype = ndata.get("class_type", "")
|
||||
inputs = ndata.get("inputs", {})
|
||||
if any(kw in ctype for kw in ["Sampler", "KSampler", "Guider", "CFG"]):
|
||||
for link_key in ["positive", "conditioning", "guider"]:
|
||||
link = inputs.get(link_key)
|
||||
if isinstance(link, list) and len(link) > 0:
|
||||
start_nodes.append(str(link[0]))
|
||||
|
||||
visited = set()
|
||||
found_texts = []
|
||||
|
||||
def walk(nid):
|
||||
if not nid or nid in visited:
|
||||
return
|
||||
visited.add(nid)
|
||||
ndata = prompt_data.get(nid)
|
||||
if not isinstance(ndata, dict):
|
||||
return
|
||||
inputs = ndata.get("inputs", {})
|
||||
|
||||
for tkey in ["text", "prompt", "text_g", "text_l", "text_positive", "positive_prompt", "value", "string"]:
|
||||
val = inputs.get(tkey)
|
||||
if isinstance(val, str) and val.strip():
|
||||
s = val.strip()
|
||||
if s not in found_texts:
|
||||
found_texts.append(s)
|
||||
elif isinstance(val, list) and len(val) > 0:
|
||||
walk(str(val[0]))
|
||||
|
||||
for in_name, in_val in inputs.items():
|
||||
if isinstance(in_val, list) and len(in_val) > 0:
|
||||
low = in_name.lower()
|
||||
if "negative" in low:
|
||||
continue
|
||||
if any(k in low for k in ["conditioning", "positive", "cond", "text", "prompt", "guider"]):
|
||||
walk(str(in_val[0]))
|
||||
|
||||
for sn in start_nodes:
|
||||
walk(sn)
|
||||
|
||||
if found_texts:
|
||||
return "\n".join(found_texts)
|
||||
|
||||
for nid, ndata in prompt_data.items():
|
||||
if not isinstance(ndata, dict):
|
||||
continue
|
||||
ctype = ndata.get("class_type", "")
|
||||
inputs = ndata.get("inputs", {})
|
||||
if any(k in ctype for k in ["CLIPTextEncode", "Text", "Prompt"]):
|
||||
for tkey in ["text", "prompt", "text_g", "text_l"]:
|
||||
val = inputs.get(tkey)
|
||||
if isinstance(val, str) and val.strip() and len(val.strip()) > 1:
|
||||
s = val.strip()
|
||||
if s not in found_texts:
|
||||
found_texts.append(s)
|
||||
return "\n".join(found_texts) if found_texts else ""
|
||||
|
||||
def extract_metadata_from_image(filepath):
|
||||
positive_prompt = ""
|
||||
width, height = 0, 0
|
||||
try:
|
||||
with Image.open(filepath) as img:
|
||||
width, height = img.size
|
||||
if img.format == 'PNG':
|
||||
parameters = img.info.get("parameters", "")
|
||||
info = img.info or {}
|
||||
|
||||
# 1. ComfyUI native format (JSON string in img.info["prompt"])
|
||||
prompt_str = info.get("prompt")
|
||||
if prompt_str:
|
||||
try:
|
||||
prompt_data = json.loads(prompt_str) if isinstance(prompt_str, str) else prompt_str
|
||||
positive_prompt = trace_positive_prompt_from_graph(prompt_data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2. A1111 / WebUI parameters
|
||||
if not positive_prompt and "parameters" in info:
|
||||
parameters = info.get("parameters", "")
|
||||
if parameters:
|
||||
positive_prompt = parse_positive_from_parameters(parameters)
|
||||
else:
|
||||
exif_data = img.info.get("exif")
|
||||
|
||||
# 3. EXIF UserComment
|
||||
if not positive_prompt and "exif" in info:
|
||||
exif_data = info.get("exif")
|
||||
if exif_data:
|
||||
exif_dict = piexif.load(exif_data)
|
||||
user_comment = exif_dict.get("Exif", {}).get(piexif.ExifIFD.UserComment)
|
||||
if user_comment:
|
||||
try:
|
||||
try:
|
||||
exif_dict = piexif.load(exif_data)
|
||||
user_comment = exif_dict.get("Exif", {}).get(piexif.ExifIFD.UserComment)
|
||||
if user_comment:
|
||||
parameters = piexif.helper.UserComment.load(user_comment)
|
||||
if isinstance(parameters, bytes):
|
||||
parameters = parameters.decode('utf-8', errors='ignore')
|
||||
positive_prompt = parse_positive_from_parameters(parameters)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"[FlowControl] Error extracting metadata: {e}")
|
||||
print(f"[LeafFlow] Error extracting metadata: {e}")
|
||||
return positive_prompt, width, height
|
||||
|
||||
def load_prompts_cache():
|
||||
@@ -65,18 +165,44 @@ def save_prompts_cache(cache):
|
||||
with open(CACHE_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(cache, f, indent=4, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
print(f"[FlowControl] Failed to save prompts cache: {e}")
|
||||
print(f"[LeafFlow] Failed to save prompts cache: {e}")
|
||||
|
||||
server = PromptServer.instance
|
||||
routes = server.routes
|
||||
routes = PromptServer.instance.routes if hasattr(PromptServer, "instance") and PromptServer.instance is not None else web.RouteTableDef()
|
||||
|
||||
@routes.get("/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)
|
||||
|
||||
folder = request.query.get("folder", "")
|
||||
if not folder or not os.path.exists(folder):
|
||||
if not folder:
|
||||
folder = request.query.get("folder_path", "")
|
||||
|
||||
resolved_folder = sanitize_image_loader_folder(folder)
|
||||
|
||||
if not resolved_folder or not is_safe_path(resolved_folder) or not os.path.exists(resolved_folder):
|
||||
return web.json_response({"names": [], "mapping": {}, "prompts": {}})
|
||||
|
||||
image_extensions = {".png", ".jpg", ".jpeg", ".webp", ".PNG", ".JPG", ".JPEG", ".WEBP"}
|
||||
raw_files = []
|
||||
|
||||
for root, dirs, files in os.walk(resolved_folder):
|
||||
for file in files:
|
||||
ext = os.path.splitext(file)[1]
|
||||
if ext in image_extensions:
|
||||
full_path = os.path.normpath(os.path.join(root, file))
|
||||
if not is_safe_path(full_path):
|
||||
continue
|
||||
try:
|
||||
mtime = os.path.getmtime(full_path)
|
||||
except Exception:
|
||||
mtime = 0
|
||||
rel_path = os.path.relpath(full_path, resolved_folder).replace("\\", "/")
|
||||
raw_files.append((rel_path, full_path, mtime))
|
||||
|
||||
# Sort files by newest modified first
|
||||
raw_files.sort(key=lambda x: x[2], reverse=True)
|
||||
|
||||
names = []
|
||||
mapping = {}
|
||||
prompts = {}
|
||||
@@ -84,33 +210,36 @@ async def get_images_endpoint(request):
|
||||
cache = load_prompts_cache()
|
||||
cache_modified = False
|
||||
|
||||
for root, dirs, files in os.walk(folder):
|
||||
for file in files:
|
||||
ext = os.path.splitext(file)[1]
|
||||
if ext in image_extensions:
|
||||
full_path = os.path.normpath(os.path.join(root, file))
|
||||
rel_path = os.path.relpath(full_path, folder).replace("\\", "/")
|
||||
names.append(rel_path)
|
||||
mapping[rel_path] = rel_path
|
||||
|
||||
try:
|
||||
mtime = os.path.getmtime(full_path)
|
||||
cached_entry = cache.get(full_path)
|
||||
|
||||
if cached_entry and cached_entry.get("mtime") == mtime:
|
||||
prompt_text = cached_entry.get("prompt", "")
|
||||
else:
|
||||
prompt_text, _, _ = extract_metadata_from_image(full_path)
|
||||
cache[full_path] = {
|
||||
"mtime": mtime,
|
||||
"prompt": prompt_text
|
||||
}
|
||||
cache_modified = True
|
||||
except Exception as e:
|
||||
print(f"[FlowControl] Error checking cache for {file}: {e}")
|
||||
prompt_text = ""
|
||||
|
||||
prompts[rel_path] = prompt_text
|
||||
# Extract prompt metadata for the most recent 128 images to keep cache small and fast
|
||||
METADATA_LIMIT = 128
|
||||
|
||||
for i, (rel_path, full_path, mtime) in enumerate(raw_files):
|
||||
names.append(rel_path)
|
||||
mapping[rel_path] = rel_path
|
||||
|
||||
if i < METADATA_LIMIT:
|
||||
try:
|
||||
cached_entry = cache.get(full_path)
|
||||
if cached_entry and cached_entry.get("mtime") == mtime:
|
||||
prompt_text = cached_entry.get("prompt", "")
|
||||
else:
|
||||
prompt_text, _, _ = extract_metadata_from_image(full_path)
|
||||
cache[full_path] = {
|
||||
"mtime": mtime,
|
||||
"prompt": prompt_text
|
||||
}
|
||||
cache_modified = True
|
||||
except Exception as e:
|
||||
prompt_text = ""
|
||||
prompts[rel_path] = prompt_text
|
||||
else:
|
||||
prompts[rel_path] = ""
|
||||
|
||||
# Prune cache to maximum 256 entries
|
||||
if len(cache) > 256:
|
||||
cache_keys = list(cache.keys())[-256:]
|
||||
cache = {k: cache[k] for k in cache_keys}
|
||||
cache_modified = True
|
||||
|
||||
if cache_modified:
|
||||
save_prompts_cache(cache)
|
||||
@@ -123,12 +252,25 @@ async def get_images_endpoint(request):
|
||||
|
||||
@routes.get("/image_loader/get_thumbnail")
|
||||
async def get_thumbnail_endpoint(request):
|
||||
if not is_local_request(request):
|
||||
return web.Response(status=403)
|
||||
|
||||
folder = request.query.get("folder", "")
|
||||
image_rel = request.query.get("image", "")
|
||||
if not folder:
|
||||
folder = request.query.get("folder_path", "")
|
||||
resolved_folder = sanitize_image_loader_folder(folder)
|
||||
|
||||
full_path = os.path.normpath(os.path.join(folder, image_rel))
|
||||
if not os.path.exists(full_path):
|
||||
image_rel = request.query.get("image", "")
|
||||
if not image_rel:
|
||||
return web.Response(status=400)
|
||||
|
||||
full_path = os.path.normpath(os.path.join(resolved_folder, image_rel))
|
||||
if not is_safe_path(full_path) or not os.path.exists(full_path) or not os.path.isfile(full_path):
|
||||
return web.Response(status=404)
|
||||
|
||||
image_extensions = {".png", ".jpg", ".jpeg", ".webp", ".bmp"}
|
||||
if os.path.splitext(full_path)[1].lower() not in image_extensions:
|
||||
return web.Response(status=403)
|
||||
|
||||
try:
|
||||
with Image.open(full_path) as img:
|
||||
@@ -141,21 +283,36 @@ async def get_thumbnail_endpoint(request):
|
||||
|
||||
@routes.get("/image_loader/get_full_image")
|
||||
async def get_full_image_endpoint(request):
|
||||
if not is_local_request(request):
|
||||
return web.Response(status=403)
|
||||
|
||||
folder = request.query.get("folder", "")
|
||||
image_rel = request.query.get("image", "")
|
||||
if not folder:
|
||||
folder = request.query.get("folder_path", "")
|
||||
resolved_folder = sanitize_image_loader_folder(folder)
|
||||
|
||||
full_path = os.path.normpath(os.path.join(folder, image_rel))
|
||||
if not os.path.exists(full_path):
|
||||
image_rel = request.query.get("image", "")
|
||||
if not image_rel:
|
||||
return web.Response(status=400)
|
||||
|
||||
full_path = os.path.normpath(os.path.join(resolved_folder, image_rel))
|
||||
if not is_safe_path(full_path) or not os.path.exists(full_path) or not os.path.isfile(full_path):
|
||||
return web.Response(status=404)
|
||||
|
||||
image_extensions = {".png", ".jpg", ".jpeg", ".webp", ".bmp"}
|
||||
if os.path.splitext(full_path)[1].lower() not in image_extensions:
|
||||
return web.Response(status=403)
|
||||
|
||||
return web.FileResponse(full_path)
|
||||
|
||||
class ImageLoaderVisualPrettyV2:
|
||||
class VisualImageLoader:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"folder_path": ("STRING", {"default": ""}),
|
||||
"display_mode": (["Scrollable", "Show All"], {"default": "Scrollable"}),
|
||||
"folder": ("STRING", {"default": ""}),
|
||||
"display_mode": (["Scrollable", "Show All"], {"default": "Scrollable", "advanced": True}),
|
||||
"sort_images_by": (["Name (A-Z)", "Name (Z-A)", "Date Modified (Newest First)", "Date Modified (Oldest First)"], {"default": "Date Modified (Newest First)", "advanced": True}),
|
||||
},
|
||||
"hidden": {
|
||||
"_selected_image": ("STRING", {"default": ""}),
|
||||
@@ -172,14 +329,17 @@ class ImageLoaderVisualPrettyV2:
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
return kwargs.get("_selected_image", "")
|
||||
|
||||
def load_image(self, folder_path, display_mode="Scrollable", _selected_image=""):
|
||||
if not folder_path or not _selected_image:
|
||||
empty_image = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
|
||||
def load_image(self, folder="", display_mode="Scrollable", sort_images_by="Date Modified (Newest First)", folder_path=None, _selected_image="", **kwargs):
|
||||
input_dir = folder if folder else (folder_path or "")
|
||||
resolved_folder = sanitize_image_loader_folder(input_dir)
|
||||
|
||||
if not resolved_folder or not _selected_image:
|
||||
empty_image = torch.zeros((1, 512, 512, 3), dtype=torch.float32)
|
||||
return (empty_image, "", 0, 0)
|
||||
|
||||
full_path = os.path.normpath(os.path.join(folder_path, _selected_image))
|
||||
if not os.path.exists(full_path):
|
||||
empty_image = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
|
||||
full_path = os.path.normpath(os.path.join(resolved_folder, _selected_image))
|
||||
if not is_safe_path(full_path) or not os.path.exists(full_path):
|
||||
empty_image = torch.zeros((1, 512, 512, 3), dtype=torch.float32)
|
||||
return (empty_image, "", 0, 0)
|
||||
|
||||
i = Image.open(full_path)
|
||||
@@ -194,3 +354,6 @@ class ImageLoaderVisualPrettyV2:
|
||||
image = torch.from_numpy(image)[None,]
|
||||
|
||||
return (image, positive_prompt, width, height)
|
||||
|
||||
# Alias for backward compatibility
|
||||
ImageLoaderVisualPrettyV2 = VisualImageLoader
|
||||
|
||||
+34
-18
@@ -3,14 +3,16 @@ import torch
|
||||
import numpy as np
|
||||
from PIL import Image, ImageOps
|
||||
import folder_paths
|
||||
from .utils import sanitize_folder_path
|
||||
|
||||
LOADER_CATEGORY = "🍃 FlowControl/Loaders"
|
||||
LOADER_CATEGORY = "🍃 LeafFlow/Loaders"
|
||||
|
||||
class LoadRecentOutputs:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"output_folder": ("STRING", {"default": "output"}),
|
||||
"amount": ("INT", {"default": 5, "min": 1, "max": 100, "step": 1}),
|
||||
"index": ("INT", {
|
||||
"default": 0,
|
||||
@@ -19,40 +21,54 @@ class LoadRecentOutputs:
|
||||
"step": 1,
|
||||
"control_after_generate": "increment"
|
||||
}),
|
||||
"order": (["Newest First (0 = latest)", "Chronological (Oldest First)"], {
|
||||
"default": "Newest First (0 = latest)",
|
||||
"advanced": True
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "load_single_image"
|
||||
CATEGORY = LOADER_CATEGORY
|
||||
DESCRIPTION = "Cycles through recently generated output images saved in ComfyUI output directory by step index."
|
||||
DESCRIPTION = "Cycles through recently generated output images.\n\nExplanation of Index:\n- 'amount' grabs the X newest images from the folder.\n- 'index' selects which of those X images to output (0 = newest image).\n- If index exceeds amount, it loops back around."
|
||||
|
||||
def load_single_image(self, amount, index):
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
def load_single_image(self, output_folder, amount, index, order="Newest First (0 = latest)", **kwargs):
|
||||
clean_input = (output_folder or "").strip()
|
||||
if clean_input == "output" or not clean_input:
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
else:
|
||||
output_dir = sanitize_folder_path(clean_input, default_dir=folder_paths.get_output_directory())
|
||||
|
||||
if not os.path.exists(output_dir):
|
||||
dummy = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
|
||||
dummy = torch.zeros((1, 512, 512, 3), dtype=torch.float32)
|
||||
return (dummy,)
|
||||
|
||||
valid_extensions = ('.png', '.jpg', '.jpeg', '.webp', '.bmp')
|
||||
file_list = []
|
||||
for root, _, files in os.walk(output_dir):
|
||||
for file in files:
|
||||
if file.lower().endswith(valid_extensions):
|
||||
path = os.path.join(root, file)
|
||||
try:
|
||||
mtime = os.path.getmtime(path)
|
||||
file_list.append((path, mtime))
|
||||
except OSError:
|
||||
continue
|
||||
try:
|
||||
for root, dirs, files in os.walk(output_dir):
|
||||
for file in files:
|
||||
if file.lower().endswith(valid_extensions):
|
||||
path = os.path.join(root, file)
|
||||
try:
|
||||
mtime = os.path.getmtime(path)
|
||||
file_list.append((path, mtime))
|
||||
except OSError:
|
||||
continue
|
||||
except Exception as e:
|
||||
print(f"[LeafFlow] 🍃 Error scanning output folder '{output_dir}': {e}")
|
||||
dummy = torch.zeros((1, 512, 512, 3), dtype=torch.float32)
|
||||
return (dummy,)
|
||||
|
||||
if not file_list:
|
||||
dummy = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
|
||||
dummy = torch.zeros((1, 512, 512, 3), dtype=torch.float32)
|
||||
return (dummy,)
|
||||
|
||||
file_list.sort(key=lambda x: x[1], reverse=True)
|
||||
recent_files = file_list[:amount]
|
||||
recent_files.reverse()
|
||||
if "oldest" in str(order).lower() or "chronological" in str(order).lower():
|
||||
recent_files.reverse()
|
||||
|
||||
actual_index = index % len(recent_files)
|
||||
target_file, _ = recent_files[actual_index]
|
||||
@@ -64,7 +80,7 @@ class LoadRecentOutputs:
|
||||
image = np.array(image).astype(np.float32) / 255.0
|
||||
image = torch.from_numpy(image).unsqueeze(0)
|
||||
except Exception as e:
|
||||
print(f"[FlowControl] Error loading recent output image {target_file}: {e}")
|
||||
image = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
|
||||
print(f"[LeafFlow] Error loading recent output image {target_file}: {e}")
|
||||
image = torch.zeros((1, 512, 512, 3), dtype=torch.float32)
|
||||
|
||||
return (image,)
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import shlex
|
||||
import subprocess
|
||||
import threading
|
||||
from aiohttp import web
|
||||
import folder_paths
|
||||
import comfy.model_management
|
||||
from .utils import get_env_setting, is_local_request, is_authenticated_local_request
|
||||
|
||||
AUTOMATION_CATEGORY = "🍃 LeafFlow/Automation"
|
||||
|
||||
def _safe_print(msg):
|
||||
try:
|
||||
print(msg)
|
||||
except UnicodeEncodeError:
|
||||
print(msg.encode("ascii", "replace").decode("ascii"))
|
||||
|
||||
def is_local_file_execution_enabled():
|
||||
"""Checks whether local file execution is explicitly enabled in LeafFlow settings (default False)."""
|
||||
val = get_env_setting("ENABLE_LOCAL_FILE_EXECUTION", "false").lower()
|
||||
return val in ["true", "1", "yes"]
|
||||
|
||||
def get_allowed_scripts_directory():
|
||||
"""
|
||||
Returns the strict, non-bypassable scripts directory: ComfyUI/scripts/
|
||||
"""
|
||||
base = folder_paths.base_path if hasattr(folder_paths, "base_path") and folder_paths.base_path else os.getcwd()
|
||||
scripts_dir = os.path.abspath(os.path.realpath(os.path.join(base, "scripts")))
|
||||
try:
|
||||
os.makedirs(scripts_dir, exist_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
return scripts_dir
|
||||
|
||||
def is_script_path_permitted(target_path):
|
||||
"""
|
||||
Confines target scripts strictly to ComfyUI/scripts/ using canonical realpath and commonpath.
|
||||
No setting or configuration can bypass this confinement.
|
||||
"""
|
||||
if not target_path:
|
||||
return False, "Target path is empty."
|
||||
|
||||
scripts_dir = get_allowed_scripts_directory()
|
||||
try:
|
||||
abs_target = os.path.abspath(os.path.realpath(target_path))
|
||||
if os.path.commonpath([scripts_dir, abs_target]) == scripts_dir:
|
||||
if not os.path.exists(abs_target):
|
||||
return False, f"Script does not exist: '{os.path.basename(target_path)}'"
|
||||
if not os.path.isfile(abs_target):
|
||||
return False, f"Target is not a regular file: '{os.path.basename(target_path)}'"
|
||||
return True, "Path verified within ComfyUI/scripts/."
|
||||
return False, f"Security Restriction: Script '{os.path.basename(target_path)}' must reside within ComfyUI/scripts/."
|
||||
except Exception as e:
|
||||
return False, f"Path validation error: {e}"
|
||||
|
||||
def resolve_target_path(file_path_input):
|
||||
"""
|
||||
Resolves and verifies a script path strictly within ComfyUI/scripts/.
|
||||
Rejects absolute paths, path traversal ('..'), and any outside references.
|
||||
"""
|
||||
clean = str(file_path_input or "").strip()
|
||||
if not clean:
|
||||
return None, "Script file path is empty."
|
||||
|
||||
# 1. Strictly reject absolute paths (Windows drive letters C:\, UNC \\server, or POSIX /root)
|
||||
if os.path.isabs(clean) or clean.startswith(("\\\\", "//")):
|
||||
return None, f"Security Restriction: Absolute paths are forbidden ('{clean}'). Place your script in ComfyUI/scripts/ and specify a relative name."
|
||||
|
||||
# 2. Strictly reject directory traversal
|
||||
norm = os.path.normpath(clean)
|
||||
parts = norm.replace("\\", "/").split("/")
|
||||
if ".." in parts or norm.startswith(".."):
|
||||
return None, f"Security Restriction: Directory traversal ('..') is forbidden ('{clean}')."
|
||||
|
||||
# 3. If operator wrote "scripts/run.bat" or "scripts\run.bat", normalize to relative subpath
|
||||
if parts and parts[0].lower() == "scripts":
|
||||
clean_sub = os.path.join(*parts[1:]) if len(parts) > 1 else ""
|
||||
else:
|
||||
clean_sub = norm
|
||||
|
||||
if not clean_sub:
|
||||
return None, "Invalid script path."
|
||||
|
||||
scripts_base = get_allowed_scripts_directory()
|
||||
full_target = os.path.abspath(os.path.realpath(os.path.join(scripts_base, clean_sub)))
|
||||
|
||||
# 4. Canonical commonpath confinement
|
||||
try:
|
||||
if os.path.commonpath([scripts_base, full_target]) != scripts_base:
|
||||
return None, f"Security Restriction: Script path escapes ComfyUI/scripts/ ('{clean}')."
|
||||
except Exception as e:
|
||||
return None, f"Security Restriction: Path validation error: {e}"
|
||||
|
||||
if not os.path.exists(full_target):
|
||||
return None, f"Script not found in ComfyUI/scripts/: '{clean_sub}'"
|
||||
|
||||
if not os.path.isfile(full_target):
|
||||
return None, f"Target path is not a file: '{clean_sub}'"
|
||||
|
||||
return full_target, "OK"
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# In-Memory Operator Authorization Registry
|
||||
# =========================================================================
|
||||
# Does NOT travel in workflow JSON or prompt requests.
|
||||
# Stored solely in memory and consumed upon execution.
|
||||
_AUTHORIZED_RUNS = {}
|
||||
_AUTH_LOCK = threading.Lock()
|
||||
|
||||
def authorize_node_run(node_id, file_path="", ttl_seconds=300):
|
||||
with _AUTH_LOCK:
|
||||
now = time.time()
|
||||
expired = [k for k, v in _AUTHORIZED_RUNS.items() if v.get("expires_at", 0) < now]
|
||||
for k in expired:
|
||||
_AUTHORIZED_RUNS.pop(k, None)
|
||||
|
||||
filename = os.path.basename(str(file_path).strip()) if file_path else ""
|
||||
_AUTHORIZED_RUNS[str(node_id)] = {
|
||||
"expires_at": now + ttl_seconds,
|
||||
"filename": filename
|
||||
}
|
||||
|
||||
def consume_node_authorization(node_id, resolved_path):
|
||||
if node_id is None:
|
||||
return False, "Missing node identifier. Workflow execution cannot be authorized."
|
||||
with _AUTH_LOCK:
|
||||
now = time.time()
|
||||
auth = _AUTHORIZED_RUNS.pop(str(node_id), None)
|
||||
if not auth:
|
||||
return False, "Node has not been authorized. Operator must click 'Authorize Run' on this node in the canvas before queueing."
|
||||
if auth.get("expires_at", 0) < now:
|
||||
return False, "Authorization has expired (5-minute timeout). Please click 'Authorize Run' again."
|
||||
target_base = os.path.basename(resolved_path) if resolved_path else ""
|
||||
expected_base = auth.get("filename")
|
||||
if expected_base and target_base and target_base.lower() != expected_base.lower():
|
||||
return False, f"Authorized script '{expected_base}' does not match configured script '{target_base}'. Please re-authorize."
|
||||
return True, "Authorized"
|
||||
|
||||
def is_node_authorized(node_id):
|
||||
if not node_id:
|
||||
return False, 0
|
||||
with _AUTH_LOCK:
|
||||
now = time.time()
|
||||
auth = _AUTHORIZED_RUNS.get(str(node_id))
|
||||
if auth and auth.get("expires_at", 0) >= now:
|
||||
return True, int(auth.get("expires_at", 0) - now)
|
||||
return False, 0
|
||||
|
||||
def clear_all_authorizations():
|
||||
with _AUTH_LOCK:
|
||||
_AUTHORIZED_RUNS.clear()
|
||||
|
||||
def setup_local_runner_routes(server):
|
||||
routes = server.routes
|
||||
|
||||
@routes.post("/leafflow/local_runner/authorize")
|
||||
async def authorize_endpoint(request):
|
||||
if not is_authenticated_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
|
||||
if not is_local_file_execution_enabled():
|
||||
return web.json_response({
|
||||
"error": "Local file execution is disabled. Enable 'Allow Local File Execution' in LeafFlow settings first.",
|
||||
"enabled": False
|
||||
}, status=403)
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
data = {}
|
||||
node_id = data.get("node_id")
|
||||
file_path = data.get("file_path", "")
|
||||
if node_id is None:
|
||||
return web.json_response({"error": "Missing node_id"}, status=400)
|
||||
authorize_node_run(node_id, file_path, ttl_seconds=300)
|
||||
return web.json_response({
|
||||
"success": True,
|
||||
"node_id": str(node_id),
|
||||
"expires_in": 300
|
||||
})
|
||||
|
||||
@routes.get("/leafflow/local_runner/status")
|
||||
async def status_endpoint(request):
|
||||
if not is_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local access only"}, status=403)
|
||||
node_id = request.query.get("node_id")
|
||||
is_auth, remaining = is_node_authorized(node_id)
|
||||
return web.json_response({
|
||||
"enabled": is_local_file_execution_enabled(),
|
||||
"authorized": is_auth,
|
||||
"remaining_seconds": remaining
|
||||
})
|
||||
|
||||
|
||||
class AnyType(str):
|
||||
"""Wildcard type for input and output passthrough wires."""
|
||||
def __ne__(self, __value: object) -> bool:
|
||||
return False
|
||||
|
||||
any_type = AnyType("*")
|
||||
|
||||
|
||||
class RunLocalFileNode:
|
||||
"""
|
||||
Safely executes a local file (.exe, .bat, .cmd, .ps1 on Windows; .sh or binary on Linux/macOS)
|
||||
with custom parameters and configurable working directory.
|
||||
Includes strict path confinement and in-memory operator authorization.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"file_path": ("STRING", {
|
||||
"default": "",
|
||||
"multiline": False,
|
||||
"placeholder": "Relative script path inside ComfyUI/scripts/ (e.g. run.bat or convert.sh)",
|
||||
"tooltip": "Script or binary to execute. MUST be located inside 'ComfyUI/scripts/'. Subfolders (e.g. tools/process.bat) are permitted. Absolute paths and directory traversal ('..') are strictly blocked."
|
||||
}),
|
||||
},
|
||||
"optional": {
|
||||
"parameters": ("STRING", {
|
||||
"default": "",
|
||||
"multiline": True,
|
||||
"placeholder": "Command-line arguments (e.g. --input file.png --quality 95)",
|
||||
"tooltip": "Optional command-line arguments passed to the script. Parsed safely without shell expansion."
|
||||
}),
|
||||
"working_directory": ("STRING", {
|
||||
"default": "",
|
||||
"multiline": False,
|
||||
"placeholder": "Working directory inside ComfyUI/scripts/. Leave empty to use script directory.",
|
||||
"tooltip": "Execution working directory. MUST be located inside 'ComfyUI/scripts/'. Leave empty to default to the script's own folder."
|
||||
}),
|
||||
"run_mode": (
|
||||
["Synchronous (Wait for Output)", "Asynchronous (Background)"],
|
||||
{"default": "Synchronous (Wait for Output)"}
|
||||
),
|
||||
"timeout": ("INT", {
|
||||
"default": 60,
|
||||
"min": 0,
|
||||
"max": 3600,
|
||||
"step": 5,
|
||||
"tooltip": "Max execution time in seconds (0 = unlimited). Ignored in asynchronous mode."
|
||||
}),
|
||||
"trigger": (any_type, {"tooltip": "Optional passthrough wire to sequence execution in your workflow."}),
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
"extra_pnginfo": "EXTRA_PNGINFO"
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "STRING", "INT", "BOOLEAN", any_type)
|
||||
RETURN_NAMES = ("stdout", "stderr", "exit_code", "success", "passthrough")
|
||||
FUNCTION = "run_local_file"
|
||||
CATEGORY = AUTOMATION_CATEGORY
|
||||
DESCRIPTION = (
|
||||
"Executes a local script or executable (.bat, .ps1, .exe on Windows; .sh or binary on Linux/macOS) "
|
||||
"with parameter arguments and working directory control.\n\n"
|
||||
"Security Safeguards:\n"
|
||||
"- Strictly confined to ComfyUI/scripts/ (absolute paths and directory traversal are forbidden).\n"
|
||||
"- Requires explicit in-memory operator authorization via the '⚡ Authorize Run' button.\n"
|
||||
"- Consent cannot travel in workflow JSON or image metadata.\n"
|
||||
"- Execution is single-use: each authorization expires upon run or after 5 minutes.\n"
|
||||
"- Never uses shell=True to prevent command chaining injection.\n"
|
||||
"- Disabled globally by default for operator security."
|
||||
)
|
||||
|
||||
def resolve_target_path(self, file_path_input):
|
||||
return resolve_target_path(file_path_input)[0]
|
||||
|
||||
def build_command_args(self, target_path, params_str):
|
||||
"""Builds a safe, non-shell command list suitable for subprocess.Popen(shell=False)."""
|
||||
parsed_args = []
|
||||
if params_str and params_str.strip():
|
||||
try:
|
||||
parsed_args = shlex.split(params_str.strip(), posix=(sys.platform != "win32"))
|
||||
except Exception:
|
||||
parsed_args = params_str.strip().split()
|
||||
|
||||
norm_path = os.path.normpath(target_path)
|
||||
lower_path = norm_path.lower()
|
||||
|
||||
if sys.platform.startswith("win"):
|
||||
if lower_path.endswith((".bat", ".cmd")):
|
||||
comspec = os.environ.get("COMSPEC", "cmd.exe")
|
||||
return [comspec, "/c", norm_path, *parsed_args]
|
||||
elif lower_path.endswith(".ps1"):
|
||||
# Security Note: -ExecutionPolicy Bypass is required on Windows to permit execution
|
||||
# of local user scripts (.ps1) which are blocked by default machine policies.
|
||||
# Security is strictly guaranteed by:
|
||||
# 1. Path confinement: target file MUST reside exclusively inside ComfyUI/scripts/
|
||||
# 2. Live in-memory operator authorization required on canvas prior to run
|
||||
# 3. -NoProfile flag ensuring no untrusted global PowerShell profiles are executed
|
||||
return ["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", norm_path, *parsed_args]
|
||||
else:
|
||||
return [norm_path, *parsed_args]
|
||||
else:
|
||||
if lower_path.endswith(".sh"):
|
||||
return ["/bin/bash", norm_path, *parsed_args]
|
||||
else:
|
||||
return [norm_path, *parsed_args]
|
||||
|
||||
def run_local_file(
|
||||
self,
|
||||
file_path,
|
||||
parameters="",
|
||||
working_directory="",
|
||||
run_mode="Synchronous (Wait for Output)",
|
||||
timeout=60,
|
||||
trigger=None,
|
||||
unique_id=None,
|
||||
**kwargs
|
||||
):
|
||||
passthrough_out = trigger if trigger is not None else ""
|
||||
|
||||
# Check 1: Global Security Setting (Defaults to Disabled)
|
||||
if not is_local_file_execution_enabled():
|
||||
err = (
|
||||
"Execution Blocked: Local file execution is disabled by default for security. "
|
||||
"To run local files, enable 'Allow Local File Execution' in ComfyUI Settings -> LeafFlow -> Security."
|
||||
)
|
||||
_safe_print(f"[LeafFlow LocalRunner] {err}")
|
||||
return ("", err, -1, False, passthrough_out)
|
||||
|
||||
# Check 2: Path Resolution & Strict Confinement to ComfyUI/scripts/
|
||||
resolved_path, path_msg = resolve_target_path(file_path)
|
||||
if not resolved_path:
|
||||
err = f"Execution Error: {path_msg}"
|
||||
_safe_print(f"[LeafFlow LocalRunner] {err}")
|
||||
return ("", err, -1, False, passthrough_out)
|
||||
|
||||
# Check 3: Mandatory In-Memory Operator Authorization Gate
|
||||
node_id = unique_id if unique_id is not None else kwargs.get("node_id")
|
||||
authorized, auth_msg = consume_node_authorization(node_id, resolved_path)
|
||||
if not authorized:
|
||||
err = f"Execution Blocked: {auth_msg}"
|
||||
_safe_print(f"[LeafFlow LocalRunner] {err}")
|
||||
return ("", err, -1, False, passthrough_out)
|
||||
|
||||
# Determine working directory (confined to ComfyUI/scripts/ or script directory)
|
||||
scripts_base = get_allowed_scripts_directory()
|
||||
cwd = os.path.dirname(resolved_path)
|
||||
if working_directory and working_directory.strip():
|
||||
cand_cwd = os.path.abspath(os.path.realpath(os.path.join(scripts_base, working_directory.strip())))
|
||||
try:
|
||||
if os.path.commonpath([scripts_base, cand_cwd]) == scripts_base and os.path.isdir(cand_cwd):
|
||||
cwd = cand_cwd
|
||||
else:
|
||||
_safe_print(f"[LeafFlow LocalRunner] Warning: working_directory '{working_directory}' outside scripts folder. Defaulting to script folder.")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cmd_list = self.build_command_args(resolved_path, parameters)
|
||||
_safe_print(f"[LeafFlow LocalRunner] Executing '{os.path.basename(resolved_path)}' (mode: {run_mode}, cwd: '{cwd}')...")
|
||||
|
||||
# Mode: Asynchronous (Background Detached)
|
||||
if "Asynchronous" in run_mode:
|
||||
try:
|
||||
creationflags = 0
|
||||
if sys.platform.startswith("win"):
|
||||
creationflags = subprocess.CREATE_NEW_CONSOLE if hasattr(subprocess, "CREATE_NEW_CONSOLE") else 0
|
||||
subprocess.Popen(cmd_list, cwd=cwd, creationflags=creationflags)
|
||||
else:
|
||||
subprocess.Popen(cmd_list, cwd=cwd, start_new_session=True)
|
||||
|
||||
msg = f"Started in background: '{os.path.basename(resolved_path)}' (PID assigned)"
|
||||
_safe_print(f"[LeafFlow LocalRunner] {msg}")
|
||||
return (msg, "", 0, True, passthrough_out)
|
||||
except Exception as e:
|
||||
err_msg = f"Failed to launch background process: {str(e)}"
|
||||
_safe_print(f"[LeafFlow LocalRunner] Error: {err_msg}")
|
||||
return ("", err_msg, -1, False, passthrough_out)
|
||||
|
||||
# Mode: Synchronous (Wait for Process with Output & Interrupt Polling)
|
||||
try:
|
||||
creationflags = 0
|
||||
if sys.platform.startswith("win"):
|
||||
creationflags = subprocess.CREATE_NO_WINDOW if hasattr(subprocess, "CREATE_NO_WINDOW") else 0
|
||||
|
||||
proc = subprocess.Popen(
|
||||
cmd_list,
|
||||
cwd=cwd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
creationflags=creationflags
|
||||
)
|
||||
|
||||
start_time = time.time()
|
||||
def _kill_tree():
|
||||
try:
|
||||
if sys.platform.startswith("win"):
|
||||
subprocess.run(["taskkill", "/F", "/T", "/PID", str(proc.pid)], capture_output=True)
|
||||
else:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
proc.communicate(timeout=1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
effective_timeout = timeout if timeout > 0 else None
|
||||
|
||||
# Poll for completion while honoring ComfyUI cancellation
|
||||
while proc.poll() is None:
|
||||
try:
|
||||
comfy.model_management.throw_exception_if_processing_interrupted()
|
||||
except Exception:
|
||||
_kill_tree()
|
||||
_safe_print(f"[LeafFlow LocalRunner] Execution of '{os.path.basename(resolved_path)}' interrupted by user.")
|
||||
raise
|
||||
|
||||
if effective_timeout and (time.time() - start_time) > effective_timeout:
|
||||
_kill_tree()
|
||||
err = f"Execution timed out after {timeout} seconds."
|
||||
_safe_print(f"[LeafFlow LocalRunner] {err}")
|
||||
return ("", err, -1, False, passthrough_out)
|
||||
|
||||
time.sleep(0.1)
|
||||
|
||||
stdout_data, stderr_data = proc.communicate()
|
||||
exit_code = proc.returncode
|
||||
success = (exit_code == 0)
|
||||
|
||||
_safe_print(f"[LeafFlow LocalRunner] Completed with exit code {exit_code} (success: {success}).")
|
||||
return (stdout_data or "", stderr_data or "", exit_code, success, passthrough_out)
|
||||
|
||||
except Exception as e:
|
||||
err_msg = f"Process execution error: {str(e)}"
|
||||
_safe_print(f"[LeafFlow LocalRunner] {err_msg}")
|
||||
return ("", err_msg, -1, False, passthrough_out)
|
||||
@@ -0,0 +1,165 @@
|
||||
import os
|
||||
import re
|
||||
import folder_paths
|
||||
import comfy.sd
|
||||
from comfy.utils import load_torch_file
|
||||
from .lora_loader import (
|
||||
get_filtered_loras_mapping,
|
||||
increment_lora_usage,
|
||||
LORA_CATEGORY,
|
||||
LORA_OUTPUT_FORMAT_CHOICES
|
||||
)
|
||||
from .utils import parse_pretty_name, format_lora_output_name
|
||||
|
||||
class TextLoraFinder:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"model": ("MODEL",),
|
||||
"clip": ("CLIP",),
|
||||
"folder": ("STRING", {"default": ""}),
|
||||
"search_for": ([
|
||||
"Parsed Name",
|
||||
"Filename",
|
||||
"Filename without extension",
|
||||
"Custom Regex"
|
||||
], {"default": "Parsed Name"}),
|
||||
"custom_regex": ("STRING", {"default": "", "advanced": True}),
|
||||
"search_mode": (["First match (Front)", "Last match (Back)"], {"default": "First match (Front)"}),
|
||||
"find_amount": ("INT", {"default": 1, "min": 1, "max": 20, "step": 1}),
|
||||
"strength_model": ("FLOAT", {"default": 1.0, "min": -100.0, "max": 100.0, "step": 0.01}),
|
||||
"strength_clip": ("FLOAT", {"default": 1.0, "min": -100.0, "max": 100.0, "step": 0.01}),
|
||||
"output_format": (LORA_OUTPUT_FORMAT_CHOICES, {"default": "Filename", "advanced": True}),
|
||||
},
|
||||
"optional": {
|
||||
"text": ("STRING", {"forceInput": True}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("MODEL", "CLIP", "STRING")
|
||||
RETURN_NAMES = ("MODEL", "CLIP", "loras")
|
||||
FUNCTION = "find_and_load_loras"
|
||||
CATEGORY = LORA_CATEGORY
|
||||
DESCRIPTION = "Scans input text for LoRA names, automatically loads matched LoRAs into Model/CLIP, and outputs the formatted list."
|
||||
|
||||
def find_and_load_loras(
|
||||
self,
|
||||
model,
|
||||
clip,
|
||||
folder="",
|
||||
search_for="Parsed Name",
|
||||
custom_regex="",
|
||||
search_mode="First match (Front)",
|
||||
find_amount=1,
|
||||
strength_model=1.0,
|
||||
strength_clip=1.0,
|
||||
output_format="Filename",
|
||||
text=None,
|
||||
start_from=None,
|
||||
prompt=None,
|
||||
**kwargs
|
||||
):
|
||||
active_mode = search_mode
|
||||
if start_from is not None and search_mode == "First match (Front)":
|
||||
if start_from == "Back":
|
||||
active_mode = "Last match (Back)"
|
||||
elif start_from == "Front":
|
||||
active_mode = "First match (Front)"
|
||||
|
||||
input_text = text if text is not None else prompt
|
||||
text_str = str(input_text) if input_text is not None else ""
|
||||
if not text_str:
|
||||
return (model, clip, "")
|
||||
|
||||
mapping = get_filtered_loras_mapping(folder, pretty=True)
|
||||
if not mapping:
|
||||
return (model, clip, "")
|
||||
|
||||
matches = []
|
||||
seen_paths = set()
|
||||
|
||||
if search_for == "Custom Regex":
|
||||
if custom_regex and custom_regex.strip():
|
||||
try:
|
||||
pattern = re.compile(custom_regex, re.IGNORECASE)
|
||||
for m in pattern.finditer(text_str):
|
||||
matched_text = m.group(0).strip()
|
||||
for display_name, sys_path in mapping.items():
|
||||
if display_name in ["[ NONE ]", "[ RANDOM ]"] or not sys_path or sys_path == "[ NONE ]":
|
||||
continue
|
||||
if sys_path in seen_paths:
|
||||
continue
|
||||
|
||||
pretty_val = re.sub(r'\s+V\d+(\.\d+)?$', '', parse_pretty_name(sys_path), flags=re.IGNORECASE).strip()
|
||||
fname = os.path.basename(sys_path)
|
||||
fname_noext = os.path.splitext(fname)[0]
|
||||
|
||||
if (matched_text.lower() in [pretty_val.lower(), fname.lower(), fname_noext.lower(), display_name.lower()]):
|
||||
matches.append((m.start(), display_name, sys_path))
|
||||
seen_paths.add(sys_path)
|
||||
except Exception as e:
|
||||
print(f"[LeafFlow] 🍃 Invalid custom regex pattern '{custom_regex}': {e}")
|
||||
else:
|
||||
for display_name, sys_path in mapping.items():
|
||||
if display_name in ["[ NONE ]", "[ RANDOM ]"] or not sys_path or sys_path == "[ NONE ]":
|
||||
continue
|
||||
if sys_path in seen_paths:
|
||||
continue
|
||||
|
||||
if search_for in ["Pretty Name", "Parsed Name"]:
|
||||
target_name = re.sub(r'\s+V\d+(\.\d+)?$', '', parse_pretty_name(sys_path), flags=re.IGNORECASE).strip()
|
||||
elif search_for == "Filename":
|
||||
target_name = os.path.basename(sys_path)
|
||||
else: # Filename without extension
|
||||
target_name = os.path.splitext(os.path.basename(sys_path))[0]
|
||||
|
||||
if not target_name:
|
||||
continue
|
||||
|
||||
pattern = r'(?<!\w)' + re.escape(target_name) + r'(?!\w)'
|
||||
for m in re.finditer(pattern, text_str, flags=re.IGNORECASE):
|
||||
matches.append((m.start(), display_name, sys_path))
|
||||
seen_paths.add(sys_path)
|
||||
|
||||
if not matches:
|
||||
return (model, clip, "")
|
||||
|
||||
matches.sort(key=lambda x: x[0])
|
||||
|
||||
num_to_take = max(1, int(find_amount))
|
||||
if active_mode in ["Back", "Last match (Back)"]:
|
||||
selected_matches = matches[-num_to_take:]
|
||||
else:
|
||||
selected_matches = matches[:num_to_take]
|
||||
|
||||
current_model = model
|
||||
current_clip = clip
|
||||
formatted_names = []
|
||||
|
||||
for start_idx, disp_name, sys_path in selected_matches:
|
||||
lora_path = folder_paths.get_full_path("loras", sys_path)
|
||||
if not lora_path or not os.path.exists(lora_path):
|
||||
continue
|
||||
|
||||
if strength_model != 0 or strength_clip != 0:
|
||||
try:
|
||||
lora_weights = load_torch_file(lora_path, safe_load=True)
|
||||
current_model, current_clip = comfy.sd.load_lora_for_models(
|
||||
current_model, current_clip, lora_weights, strength_model, strength_clip
|
||||
)
|
||||
increment_lora_usage(sys_path)
|
||||
except Exception as e:
|
||||
print(f"[LeafFlow] 🍃 Error loading LoRA '{sys_path}': {e}")
|
||||
continue
|
||||
else:
|
||||
increment_lora_usage(sys_path)
|
||||
|
||||
out_name = format_lora_output_name(sys_path, disp_name, output_format=output_format, custom_regex=custom_regex)
|
||||
if out_name:
|
||||
formatted_names.append(out_name)
|
||||
|
||||
return (current_model, current_clip, ", ".join(formatted_names))
|
||||
|
||||
# Alias for backward compatibility
|
||||
LoraTextFinder = TextLoraFinder
|
||||
+352
-150
@@ -2,8 +2,9 @@ import os
|
||||
import re
|
||||
import fnmatch
|
||||
import json
|
||||
import folder_paths
|
||||
import time
|
||||
import hashlib
|
||||
import folder_paths
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
import threading
|
||||
@@ -12,10 +13,56 @@ from server import PromptServer
|
||||
from comfy.utils import load_torch_file
|
||||
import comfy.sd
|
||||
|
||||
LORA_CATEGORY = "🍃 FlowControl/Loaders"
|
||||
from .utils import (
|
||||
parse_pretty_name,
|
||||
parse_pretty_name_with_version,
|
||||
format_lora_output_name,
|
||||
sanitize_folder_path,
|
||||
get_leafflow_user_dir,
|
||||
is_local_request,
|
||||
is_safe_path
|
||||
)
|
||||
|
||||
LORA_CATEGORY = "🍃 LeafFlow/Loaders"
|
||||
LORA_OUTPUT_FORMAT_CHOICES = [
|
||||
"Parsed Name",
|
||||
"Filename",
|
||||
"Filename without extension",
|
||||
"Relative Path",
|
||||
"Full Path",
|
||||
"Custom Regex"
|
||||
]
|
||||
|
||||
CURRENT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
ENV_FILE = os.path.join(CURRENT_DIR, ".env")
|
||||
USER_DIR = get_leafflow_user_dir()
|
||||
ENV_FILE = os.path.join(USER_DIR, ".env")
|
||||
LORA_STATE_FILE = os.path.join(USER_DIR, "lora_loader_state.json")
|
||||
|
||||
def ensure_user_dir():
|
||||
os.makedirs(USER_DIR, exist_ok=True)
|
||||
|
||||
def load_lora_cycle_state():
|
||||
ensure_user_dir()
|
||||
if os.path.exists(LORA_STATE_FILE):
|
||||
try:
|
||||
with open(LORA_STATE_FILE, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
def save_lora_cycle_state(state):
|
||||
ensure_user_dir()
|
||||
try:
|
||||
# Auto-prune abandoned state keys if count exceeds 100 entries
|
||||
if len(state) > 100:
|
||||
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:
|
||||
json.dump(state, f, indent=4, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
print(f"[LeafFlow] Error saving lora loader cycle state: {e}")
|
||||
|
||||
def get_api_keys():
|
||||
civitai_key = os.getenv("CIVITAI_API_KEY", "")
|
||||
@@ -31,42 +78,14 @@ def get_api_keys():
|
||||
tmdb_key = line.split("=", 1)[1].strip()
|
||||
except Exception:
|
||||
pass
|
||||
return civitai_key, tmdb_key
|
||||
|
||||
return civitai_key.strip(), tmdb_key.strip()
|
||||
|
||||
scraping_thread = None
|
||||
scraping_lock = threading.Lock()
|
||||
|
||||
def parse_pretty_name(filepath):
|
||||
base = os.path.splitext(os.path.basename(filepath))[0]
|
||||
parts = base.split('_')
|
||||
if len(parts) >= 2:
|
||||
name_part = parts[1]
|
||||
name_part = re.sub(r'(?<!^)(?=[A-Z])', ' ', name_part)
|
||||
name_part = name_part.replace('-', ' ')
|
||||
|
||||
words = name_part.split()
|
||||
formatted_words = []
|
||||
for word in words:
|
||||
if word.upper() in ["NSFW", "LORA", "V1", "V2", "V3", "V4", "FP16", "HM"]:
|
||||
formatted_words.append(word.upper())
|
||||
else:
|
||||
formatted_words.append(word.capitalize())
|
||||
return " ".join(formatted_words)
|
||||
return base.replace('-', ' ')
|
||||
|
||||
def parse_pretty_name_with_version(filepath):
|
||||
base = os.path.splitext(os.path.basename(filepath))[0]
|
||||
parts = base.split('_')
|
||||
pretty_name = parse_pretty_name(filepath)
|
||||
|
||||
if len(parts) >= 3:
|
||||
version_candidate = parts[2].strip()
|
||||
if re.match(r'^v[0-9]+(\.[0-9]+)?$', version_candidate, re.IGNORECASE):
|
||||
return f"{pretty_name} {version_candidate.upper()}"
|
||||
return pretty_name
|
||||
|
||||
def load_usage_data():
|
||||
usage_file = os.path.join(CURRENT_DIR, "lora_usage.json")
|
||||
usage_file = os.path.join(USER_DIR, "lora_usage.json")
|
||||
usage_data = {}
|
||||
if os.path.exists(usage_file):
|
||||
try:
|
||||
@@ -79,21 +98,23 @@ def load_usage_data():
|
||||
else:
|
||||
usage_data[k] = usage_data.get(k, 0) + v
|
||||
except Exception as e:
|
||||
print(f"[FlowControl] Error loading usage data: {e}")
|
||||
print(f"[LeafFlow] Error loading usage data: {e}")
|
||||
return usage_data
|
||||
|
||||
def increment_lora_usage(lora_path):
|
||||
if not lora_path or lora_path == "[ NONE ]":
|
||||
return
|
||||
if get_env_setting("ENABLE_LORA_USAGE", "true").lower() != "true":
|
||||
return
|
||||
try:
|
||||
usage_file = os.path.join(CURRENT_DIR, "lora_usage.json")
|
||||
usage_file = os.path.join(USER_DIR, "lora_usage.json")
|
||||
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:
|
||||
json.dump(usage_data, f, indent=4, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
print(f"[FlowControl] Error updating lora usage: {e}")
|
||||
print(f"[LeafFlow] Error updating lora usage: {e}")
|
||||
|
||||
def find_preview_image(lora_relative_path):
|
||||
lora_full_path = folder_paths.get_full_path("loras", lora_relative_path)
|
||||
@@ -114,6 +135,32 @@ def get_file_sha256(filepath):
|
||||
h.update(chunk)
|
||||
return h.hexdigest().upper()
|
||||
|
||||
def is_safe_external_image_url(url_str):
|
||||
"""
|
||||
Validates that a scraped image URL uses a safe web scheme (https/http)
|
||||
and does not target internal loopback, private networks, or metadata services (SSRF protection).
|
||||
"""
|
||||
if not url_str or not isinstance(url_str, str):
|
||||
return False
|
||||
try:
|
||||
parsed = urllib.parse.urlparse(url_str.strip())
|
||||
if parsed.scheme not in ("https", "http"):
|
||||
return False
|
||||
hostname = (parsed.hostname or "").lower().strip()
|
||||
if not hostname:
|
||||
return False
|
||||
disallowed_prefixes = (
|
||||
"127.", "10.", "192.168.", "172.16.", "172.17.", "172.18.", "172.19.",
|
||||
"172.20.", "172.21.", "172.22.", "172.23.", "172.24.", "172.25.",
|
||||
"172.26.", "172.27.", "172.28.", "172.29.", "172.30.", "172.31.",
|
||||
"169.254.", "0.0.0.0", "::1", "localhost"
|
||||
)
|
||||
if any(hostname == prefix or hostname.startswith(prefix) for prefix in disallowed_prefixes):
|
||||
return False
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def get_env_setting(key, default_val):
|
||||
if os.path.exists(ENV_FILE):
|
||||
try:
|
||||
@@ -127,13 +174,13 @@ def get_env_setting(key, default_val):
|
||||
|
||||
def scrape_missing_images_sync():
|
||||
try:
|
||||
civitai_enabled = get_env_setting("ENABLE_CIVITAI_SCRAPING", "true").lower() in ["true", "1", "yes"]
|
||||
tmdb_enabled = get_env_setting("ENABLE_TMDB_SCRAPING", "true").lower() in ["true", "1", "yes"]
|
||||
civitai_enabled = get_env_setting("ENABLE_CIVITAI_SCRAPING", "false").lower() in ["true", "1", "yes"]
|
||||
tmdb_enabled = get_env_setting("ENABLE_TMDB_SCRAPING", "false").lower() in ["true", "1", "yes"]
|
||||
if not civitai_enabled and not tmdb_enabled:
|
||||
return
|
||||
|
||||
civitai_key, tmdb_key = get_api_keys()
|
||||
failed_scrapes_file = os.path.join(CURRENT_DIR, "failed_scrapes.json")
|
||||
failed_scrapes_file = os.path.join(USER_DIR, "failed_scrapes.json")
|
||||
failed_scrapes = {}
|
||||
if os.path.exists(failed_scrapes_file):
|
||||
try:
|
||||
@@ -163,7 +210,6 @@ def scrape_missing_images_sync():
|
||||
|
||||
success = False
|
||||
|
||||
# 1. Try Civitai API search by file SHA256 hash if enabled
|
||||
if civitai_enabled:
|
||||
try:
|
||||
file_hash = get_file_sha256(lora_path)
|
||||
@@ -178,37 +224,65 @@ def scrape_missing_images_sync():
|
||||
images = data.get("images", [])
|
||||
if images and len(images) > 0:
|
||||
img_url = images[0].get("url")
|
||||
if img_url:
|
||||
if img_url and is_safe_external_image_url(img_url):
|
||||
dest_path = os.path.splitext(lora_path)[0] + ".jpg"
|
||||
img_req = urllib.request.Request(img_url, headers=headers)
|
||||
with urllib.request.urlopen(img_req, timeout=15) as img_resp:
|
||||
img_data = img_resp.read()
|
||||
try:
|
||||
with open(dest_path, "wb") as f:
|
||||
f.write(img_resp.read())
|
||||
print(f"[FlowControl] Scraped Civitai preview for {pretty_name}")
|
||||
success = True
|
||||
f.write(img_data)
|
||||
print(f"[LeafFlow] Scraped Civitai preview for {pretty_name}")
|
||||
success = True
|
||||
except (PermissionError, OSError) as write_err:
|
||||
print(f"[LeafFlow] Warning: Could not save preview image to '{dest_path}': {write_err}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2. Try TMDB API search if enabled and TMDB key is provided
|
||||
if not success and tmdb_enabled and tmdb_key:
|
||||
try:
|
||||
url = f"https://api.themoviedb.org/3/search/person?api_key={tmdb_key}&query={urllib.parse.quote(pretty_name)}&include_adult=true"
|
||||
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
|
||||
with urllib.request.urlopen(req, timeout=10) as response:
|
||||
data = json.loads(response.read().decode('utf-8'))
|
||||
results = data.get("results", [])
|
||||
if results and len(results) > 0:
|
||||
profile_path = results[0].get("profile_path")
|
||||
if profile_path:
|
||||
img_url = f"https://image.tmdb.org/t/p/w500{profile_path}"
|
||||
dest_path = os.path.splitext(lora_path)[0] + ".jpg"
|
||||
img_req = urllib.request.Request(img_url, headers={'User-Agent': 'Mozilla/5.0'})
|
||||
with urllib.request.urlopen(img_req, timeout=15) as img_resp:
|
||||
with open(dest_path, "wb") as f:
|
||||
f.write(img_resp.read())
|
||||
print(f"[FlowControl] Scraped TMDB preview for {pretty_name}")
|
||||
success = True
|
||||
except Exception:
|
||||
clean_person = parse_pretty_name(lora)
|
||||
search_terms = [clean_person]
|
||||
words = clean_person.split()
|
||||
if len(words) >= 2:
|
||||
search_terms.append(" ".join(words[1:]))
|
||||
|
||||
tmdb_headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'}
|
||||
if tmdb_key.startswith("eyJ"):
|
||||
tmdb_headers['Authorization'] = f"Bearer {tmdb_key}"
|
||||
|
||||
for term in search_terms:
|
||||
if success:
|
||||
break
|
||||
encoded_term = urllib.parse.quote(term)
|
||||
if tmdb_key.startswith("eyJ"):
|
||||
url = f"https://api.themoviedb.org/3/search/person?query={encoded_term}&include_adult=true"
|
||||
else:
|
||||
tmdb_headers['Authorization'] = f"Bearer {tmdb_key}"
|
||||
url = f"https://api.themoviedb.org/3/search/person?api_key={tmdb_key}&query={encoded_term}&include_adult=true"
|
||||
|
||||
req = urllib.request.Request(url, headers=tmdb_headers)
|
||||
with urllib.request.urlopen(req, timeout=10) as response:
|
||||
data = json.loads(response.read().decode('utf-8'))
|
||||
results = data.get("results", [])
|
||||
for res in results:
|
||||
profile_path = res.get("profile_path")
|
||||
if profile_path:
|
||||
img_url = f"https://image.tmdb.org/t/p/w500{profile_path}"
|
||||
if is_safe_external_image_url(img_url):
|
||||
dest_path = os.path.splitext(lora_path)[0] + ".jpg"
|
||||
img_req = urllib.request.Request(img_url, headers=tmdb_headers)
|
||||
with urllib.request.urlopen(img_req, timeout=15) as img_resp:
|
||||
img_data = img_resp.read()
|
||||
try:
|
||||
with open(dest_path, "wb") as f:
|
||||
f.write(img_data)
|
||||
print(f"[LeafFlow] Scraped TMDB preview for {pretty_name} (via '{term}')")
|
||||
success = True
|
||||
break
|
||||
except (PermissionError, OSError) as write_err:
|
||||
print(f"[LeafFlow] Warning: Could not save preview image to '{dest_path}': {write_err}")
|
||||
except Exception as tmdb_err:
|
||||
pass
|
||||
|
||||
if not success:
|
||||
@@ -222,7 +296,7 @@ def scrape_missing_images_sync():
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"[FlowControl] Error in preview scraping thread: {e}")
|
||||
print(f"[LeafFlow] Error in preview scraping thread: {e}")
|
||||
|
||||
def start_async_scraping():
|
||||
global scraping_thread
|
||||
@@ -235,7 +309,7 @@ def get_filtered_loras_mapping(folder_filter, pretty=False):
|
||||
all_loras = folder_paths.get_filename_list("loras")
|
||||
mapping = {"[ NONE ]": "[ NONE ]"}
|
||||
|
||||
raw_filter = folder_filter.replace("\\", "/").strip()
|
||||
raw_filter = (folder_filter or "").replace("\\", "/").strip()
|
||||
is_wildcard = raw_filter.endswith("*") or raw_filter == ""
|
||||
clean_filter = raw_filter.rstrip("*").strip("/")
|
||||
|
||||
@@ -250,10 +324,13 @@ def get_filtered_loras_mapping(folder_filter, pretty=False):
|
||||
display_name += parse_pretty_name_with_version(lora) if pretty else lora
|
||||
mapping[display_name] = lora
|
||||
elif is_wildcard:
|
||||
if norm_lora.lower().startswith(clean_filter.lower() + "/"):
|
||||
subfolder_path = lora_dir[len(clean_filter):].strip("/")
|
||||
sub_parts = subfolder_path.split('/')
|
||||
sub_name = sub_parts[-1] if (sub_parts and sub_parts[0]) else ""
|
||||
if clean_filter == "" or norm_lora.lower().startswith(clean_filter.lower() + "/") or norm_lora.lower() == clean_filter.lower():
|
||||
subfolder_path = lora_dir[len(clean_filter):].strip("/") if clean_filter else lora_dir
|
||||
sub_parts = [p for p in subfolder_path.split('/') if p]
|
||||
if sub_parts:
|
||||
sub_name = sub_parts[-1]
|
||||
else:
|
||||
sub_name = clean_filter.split('/')[-1] if clean_filter else ""
|
||||
|
||||
display_name = ""
|
||||
if sub_name and pretty:
|
||||
@@ -271,45 +348,63 @@ def get_filtered_loras_mapping(folder_filter, pretty=False):
|
||||
mapping[display_name] = lora
|
||||
return mapping
|
||||
|
||||
# Endpoints for visual popup UI
|
||||
server = PromptServer.instance
|
||||
routes = server.routes
|
||||
|
||||
@routes.get("/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)
|
||||
folder = request.query.get("folder", "")
|
||||
pretty = request.query.get("pretty", "false").lower() == "true"
|
||||
scrape_on_new = request.query.get("scrape_on_new", "true").lower() == "true"
|
||||
scrape_on_new = request.query.get("scrape_on_new", "false").lower() == "true"
|
||||
|
||||
mapping = get_filtered_loras_mapping(folder, pretty=pretty)
|
||||
if scrape_on_new:
|
||||
start_async_scraping()
|
||||
|
||||
usage_data = load_usage_data()
|
||||
all_loras = folder_paths.get_filename_list("loras")
|
||||
usage_enabled = get_env_setting("ENABLE_LORA_USAGE", "true").lower() == "true"
|
||||
usage_by_path = {}
|
||||
mtime_by_path = {}
|
||||
|
||||
all_loras = folder_paths.get_filename_list("loras")
|
||||
usage_data = load_usage_data() if usage_enabled else {}
|
||||
|
||||
for lora in all_loras:
|
||||
pretty_name = parse_pretty_name(lora)
|
||||
usage_by_path[lora] = usage_data.get(pretty_name, 0)
|
||||
if usage_enabled:
|
||||
usage_by_path[lora] = usage_data.get(pretty_name, 0)
|
||||
|
||||
full_path = folder_paths.get_full_path("loras", lora)
|
||||
if full_path and os.path.exists(full_path):
|
||||
try:
|
||||
mtime_by_path[lora] = os.path.getmtime(full_path)
|
||||
except OSError:
|
||||
mtime_by_path[lora] = 0
|
||||
|
||||
return web.json_response({
|
||||
"names": list(mapping.keys()),
|
||||
"mapping": mapping,
|
||||
"usage": usage_by_path
|
||||
"usage": usage_by_path,
|
||||
"mtime": mtime_by_path
|
||||
})
|
||||
|
||||
@routes.get("/folder_lora_loader/get_preview")
|
||||
async def get_preview_endpoint(request):
|
||||
if not is_local_request(request):
|
||||
return web.Response(status=403)
|
||||
system_path = request.query.get("system_path", "")
|
||||
lora_name = request.query.get("lora", "")
|
||||
folder = request.query.get("folder", "")
|
||||
pretty = request.query.get("pretty", "true").lower() == "true"
|
||||
|
||||
mapping = get_filtered_loras_mapping(folder, pretty=pretty)
|
||||
system_relative_path = mapping.get(lora_name, lora_name)
|
||||
if not system_path and lora_name:
|
||||
mapping = get_filtered_loras_mapping(folder, pretty=pretty)
|
||||
system_path = mapping.get(lora_name, lora_name)
|
||||
|
||||
if system_relative_path and system_relative_path != "[ NONE ]":
|
||||
img_path = find_preview_image(system_relative_path)
|
||||
if img_path and os.path.exists(img_path):
|
||||
if system_path and system_path != "[ NONE ]":
|
||||
img_path = find_preview_image(system_path)
|
||||
if img_path and os.path.exists(img_path) and os.path.isfile(img_path):
|
||||
return web.FileResponse(img_path)
|
||||
|
||||
return web.Response(status=404)
|
||||
@@ -325,13 +420,16 @@ class FolderLoraLoader:
|
||||
"lora_name": (["[ NONE ]"], {}),
|
||||
"strength_model": ("FLOAT", {"default": 1.0, "min": -100.0, "max": 100.0, "step": 0.01}),
|
||||
"strength_clip": ("FLOAT", {"default": 1.0, "min": -100.0, "max": 100.0, "step": 0.01}),
|
||||
"output_format": (LORA_OUTPUT_FORMAT_CHOICES, {"default": "Filename", "advanced": True}),
|
||||
"custom_regex": ("STRING", {"default": "", "advanced": True}),
|
||||
},
|
||||
"hidden": {
|
||||
"_selected_lora": ("STRING", {"default": "[ NONE ]"}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("MODEL", "CLIP")
|
||||
RETURN_TYPES = ("MODEL", "CLIP", "STRING")
|
||||
RETURN_NAMES = ("MODEL", "CLIP", "lora_name")
|
||||
FUNCTION = "load_lora"
|
||||
CATEGORY = LORA_CATEGORY
|
||||
DESCRIPTION = "LoRA Loader filtered by folder directory."
|
||||
@@ -340,35 +438,67 @@ class FolderLoraLoader:
|
||||
def VALIDATE_INPUTS(cls, **kwargs):
|
||||
return True
|
||||
|
||||
def load_lora(self, model, clip, folder, lora_name, strength_model, strength_clip, _selected_lora="[ NONE ]"):
|
||||
active_lora = _selected_lora if _selected_lora else lora_name
|
||||
def load_lora(self, model, clip, folder, lora_name, strength_model, strength_clip, output_format="Filename", custom_regex="", output_name=None, _selected_lora="[ NONE ]"):
|
||||
active_format = output_name if output_name is not None else output_format
|
||||
active_lora = _selected_lora if _selected_lora and _selected_lora != "[ NONE ]" else lora_name
|
||||
if active_lora == "[ NONE ]" or not active_lora:
|
||||
return (model, clip)
|
||||
return (model, clip, "")
|
||||
|
||||
mapping = get_filtered_loras_mapping(folder, pretty=False)
|
||||
resolved_path = mapping.get(active_lora)
|
||||
|
||||
if not resolved_path or resolved_path == "[ NONE ]":
|
||||
lora_path = folder_paths.get_full_path("loras", active_lora)
|
||||
else:
|
||||
lora_path = folder_paths.get_full_path("loras", resolved_path)
|
||||
all_loras = folder_paths.get_filename_list("loras")
|
||||
raw_filter = (folder or "").replace("\\", "/").strip().rstrip("/").lower()
|
||||
for lora in all_loras:
|
||||
norm_lora = lora.replace("\\", "/").lower()
|
||||
if raw_filter and not norm_lora.startswith(raw_filter):
|
||||
continue
|
||||
if lora == active_lora or parse_pretty_name(lora) == active_lora:
|
||||
resolved_path = lora
|
||||
break
|
||||
|
||||
out_name = format_lora_output_name(resolved_path, active_lora, output_format=active_format, custom_regex=custom_regex)
|
||||
|
||||
if not resolved_path:
|
||||
return (model, clip, out_name)
|
||||
|
||||
lora_path = folder_paths.get_full_path("loras", resolved_path)
|
||||
if not lora_path or not os.path.exists(lora_path):
|
||||
return (model, clip)
|
||||
return (model, clip, out_name)
|
||||
|
||||
lora = load_torch_file(lora_path, safe_load=True)
|
||||
model_lora, clip_lora = comfy.sd.load_lora_for_models(
|
||||
model, clip, lora, strength_model, strength_clip
|
||||
)
|
||||
return (model_lora, clip_lora)
|
||||
return (model_lora, clip_lora, out_name)
|
||||
|
||||
class FolderLoraLoaderPretty(FolderLoraLoader):
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"model": ("MODEL",),
|
||||
"clip": ("CLIP",),
|
||||
"folder": ("STRING", {"default": ""}),
|
||||
"lora_name": (["[ NONE ]"], {}),
|
||||
"strength_model": ("FLOAT", {"default": 1.0, "min": -100.0, "max": 100.0, "step": 0.01}),
|
||||
"strength_clip": ("FLOAT", {"default": 1.0, "min": -100.0, "max": 100.0, "step": 0.01}),
|
||||
"output_format": (LORA_OUTPUT_FORMAT_CHOICES, {"default": "Filename", "advanced": True}),
|
||||
"custom_regex": ("STRING", {"default": "", "advanced": True}),
|
||||
},
|
||||
"hidden": {
|
||||
"_selected_lora": ("STRING", {"default": "[ NONE ]"}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("MODEL", "CLIP", "STRING")
|
||||
RETURN_NAMES = ("MODEL", "CLIP", "pretty_name")
|
||||
RETURN_NAMES = ("MODEL", "CLIP", "lora_name")
|
||||
DESCRIPTION = "LoRA Loader filtered by folder directory with formatted pretty names."
|
||||
|
||||
def load_lora(self, model, clip, folder, lora_name, strength_model, strength_clip, _selected_lora="[ NONE ]"):
|
||||
active_lora = _selected_lora if _selected_lora else lora_name
|
||||
def load_lora(self, model, clip, folder, lora_name, strength_model, strength_clip, output_format="Filename", custom_regex="", output_name=None, _selected_lora="[ NONE ]"):
|
||||
active_format = output_name if output_name is not None else output_format
|
||||
active_lora = lora_name if lora_name != "[ NONE ]" else _selected_lora
|
||||
if active_lora == "[ NONE ]" or not active_lora:
|
||||
return (model, clip, "")
|
||||
|
||||
@@ -385,17 +515,12 @@ class FolderLoraLoaderPretty(FolderLoraLoader):
|
||||
if " - " in active_lora:
|
||||
display_name = active_lora.split(" - ", 1)[1]
|
||||
|
||||
pretty_name_output = re.sub(r'\s+V\d+(\.\d+)?$', '', display_name, flags=re.IGNORECASE).strip()
|
||||
|
||||
if strength_model == 0 and strength_clip == 0:
|
||||
return (model, clip, pretty_name_output)
|
||||
|
||||
mapping = get_filtered_loras_mapping(folder, pretty=True)
|
||||
resolved_path = mapping.get(active_lora)
|
||||
|
||||
if not resolved_path or resolved_path == "[ NONE ]":
|
||||
all_loras = folder_paths.get_filename_list("loras")
|
||||
raw_filter = folder.replace("\\", "/").strip().rstrip("/").lower()
|
||||
raw_filter = (folder or "").replace("\\", "/").strip().rstrip("/").lower()
|
||||
for lora in all_loras:
|
||||
norm_lora = lora.replace("\\", "/").lower()
|
||||
if raw_filter and not norm_lora.startswith(raw_filter):
|
||||
@@ -404,21 +529,42 @@ class FolderLoraLoaderPretty(FolderLoraLoader):
|
||||
resolved_path = lora
|
||||
break
|
||||
|
||||
out_name = format_lora_output_name(resolved_path, display_name, output_format=active_format, custom_regex=custom_regex)
|
||||
|
||||
if strength_model == 0 and strength_clip == 0:
|
||||
return (model, clip, out_name)
|
||||
|
||||
if not resolved_path:
|
||||
return (model, clip, "")
|
||||
return (model, clip, out_name)
|
||||
|
||||
lora_path = folder_paths.get_full_path("loras", resolved_path)
|
||||
if strength_model == 0 and strength_clip == 0:
|
||||
increment_lora_usage(resolved_path)
|
||||
return (model, clip, out_name)
|
||||
|
||||
if not lora_path or not os.path.exists(lora_path):
|
||||
return (model, clip, "")
|
||||
return (model, clip, out_name)
|
||||
|
||||
lora = load_torch_file(lora_path, safe_load=True)
|
||||
model_lora, clip_lora = comfy.sd.load_lora_for_models(
|
||||
model, clip, lora, strength_model, strength_clip
|
||||
)
|
||||
increment_lora_usage(resolved_path)
|
||||
return (model_lora, clip_lora, pretty_name_output)
|
||||
return (model_lora, clip_lora, out_name)
|
||||
|
||||
class FolderLoraLoaderVisualPretty(FolderLoraLoaderPretty):
|
||||
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])
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
class VisualLoraLoader(FolderLoraLoaderPretty):
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
@@ -428,59 +574,35 @@ class FolderLoraLoaderVisualPretty(FolderLoraLoaderPretty):
|
||||
"folder": ("STRING", {"default": ""}),
|
||||
"strength_model": ("FLOAT", {"default": 1.0, "min": -100.0, "max": 100.0, "step": 0.01}),
|
||||
"strength_clip": ("FLOAT", {"default": 1.0, "min": -100.0, "max": 100.0, "step": 0.01}),
|
||||
},
|
||||
"hidden": {
|
||||
"_selected_lora": ("STRING", {"default": "[ NONE ]"}),
|
||||
}
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
selected = kwargs.get("_selected_lora", "[ NONE ]")
|
||||
if selected == "[ RANDOM ]":
|
||||
import random
|
||||
return random.random()
|
||||
return ""
|
||||
|
||||
def load_lora(self, model, clip, folder, strength_model, strength_clip, _selected_lora="[ NONE ]"):
|
||||
return super().load_lora(
|
||||
model=model,
|
||||
clip=clip,
|
||||
folder=folder,
|
||||
lora_name=_selected_lora,
|
||||
strength_model=strength_model,
|
||||
strength_clip=strength_clip,
|
||||
_selected_lora=_selected_lora
|
||||
)
|
||||
|
||||
class FolderLoraLoaderVisualPrettyV2(FolderLoraLoaderVisualPretty):
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"model": ("MODEL",),
|
||||
"clip": ("CLIP",),
|
||||
"folder": ("STRING", {"default": ""}),
|
||||
"strength_model": ("FLOAT", {"default": 1.0, "min": -100.0, "max": 100.0, "step": 0.01}),
|
||||
"strength_clip": ("FLOAT", {"default": 1.0, "min": -100.0, "max": 100.0, "step": 0.01}),
|
||||
"display_mode": (["Scrollable", "Show All"], {"default": "Scrollable"}),
|
||||
"display_mode": (["Scrollable", "Show All"], {"default": "Scrollable", "advanced": True}),
|
||||
"sort_loras_by": (["Name (A-Z)", "Name (Z-A)", "Usage (High to Low)", "Usage (Low to High)", "Date Modified (Newest First)", "Date Modified (Oldest First)"], {"default": "Name (A-Z)", "advanced": True}),
|
||||
"sort_folders_by": (["Name (A-Z)", "Name (Z-A)", "Total Usage (High to Low)", "Average Usage (High to Low)", "Total LoRAs (Most First)"], {"default": "Name (A-Z)", "advanced": True}),
|
||||
"folder_position": (["Folders First", "Root LoRAs First"], {"default": "Folders First", "advanced": True}),
|
||||
"content_alignment": (["Left Aligned", "Right Aligned"], {"default": "Left Aligned", "advanced": True}),
|
||||
"output_format": (LORA_OUTPUT_FORMAT_CHOICES, {"default": "Filename", "advanced": True}),
|
||||
"custom_regex": ("STRING", {"default": "", "advanced": True}),
|
||||
},
|
||||
"hidden": {
|
||||
"_selected_lora": ("STRING", {"default": "[]"}),
|
||||
"_selection_mode": ("STRING", {"default": "All"}),
|
||||
"_selection_mode": ("STRING", {"default": "Sequential"}),
|
||||
"_scrape_on_new": ("STRING", {"default": "true"}),
|
||||
"unique_id": "UNIQUE_ID",
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("MODEL", "CLIP", "STRING")
|
||||
RETURN_NAMES = ("MODEL", "CLIP", "lora_name")
|
||||
DESCRIPTION = "Visual thumbnail LoRA browser with search, multi-selection, and ranking badges."
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
mode = kwargs.get("_selection_mode", "All")
|
||||
if mode == "Random":
|
||||
import random
|
||||
return random.random()
|
||||
if mode in ["Random", "Sequential", "Random (No Replace)"]:
|
||||
return time.time()
|
||||
return ""
|
||||
|
||||
def load_lora(self, model, clip, folder, strength_model, strength_clip, display_mode="Scrollable", _selected_lora="[]", _selection_mode="All", _scrape_on_new="true"):
|
||||
def load_lora(self, model, clip, folder, strength_model, strength_clip, display_mode="Scrollable", sort_loras_by="Name (A-Z)", sort_folders_by="Name (A-Z)", folder_position="Folders First", content_alignment="Left Aligned", output_format="Filename", custom_regex="", output_name=None, _selected_lora="[]", _selection_mode="All", _scrape_on_new="true", unique_id="default", **kwargs):
|
||||
active_format = output_name if output_name is not None else output_format
|
||||
active_lora = _selected_lora if _selected_lora else "[]"
|
||||
if active_lora == "[ NONE ]" or active_lora == "[]" or not active_lora:
|
||||
return (model, clip, "")
|
||||
@@ -494,11 +616,79 @@ class FolderLoraLoaderVisualPrettyV2(FolderLoraLoaderVisualPretty):
|
||||
else:
|
||||
loras_to_load = [active_lora]
|
||||
|
||||
if _selection_mode == "Random" and len(loras_to_load) > 0:
|
||||
import random
|
||||
current_pid = get_current_prompt_id()
|
||||
if _selection_mode in ["Random", "Sequential", "Random (No Replace)"] and len(loras_to_load) > 0:
|
||||
valid_choices = [item for item in loras_to_load if item != "[ NONE ]" and item]
|
||||
if valid_choices:
|
||||
loras_to_load = [random.choice(valid_choices)]
|
||||
if _selection_mode == "Random":
|
||||
import random
|
||||
state = load_lora_cycle_state()
|
||||
text_hash = hashlib.sha256(json.dumps(sorted(valid_choices)).encode('utf-8')).hexdigest()[:16]
|
||||
state_key = f"rand_{unique_id}_{text_hash}"
|
||||
node_state = state.get(state_key, {})
|
||||
assigned = node_state.get("assigned", {}) if isinstance(node_state, dict) else {}
|
||||
if current_pid and current_pid in assigned:
|
||||
selected_item = assigned[current_pid]
|
||||
else:
|
||||
selected_item = random.choice(valid_choices)
|
||||
if current_pid:
|
||||
assigned[current_pid] = selected_item
|
||||
state[state_key] = {"assigned": assigned}
|
||||
save_lora_cycle_state(state)
|
||||
loras_to_load = [selected_item]
|
||||
elif _selection_mode == "Sequential":
|
||||
state = load_lora_cycle_state()
|
||||
text_hash = hashlib.sha256(json.dumps(sorted(valid_choices)).encode('utf-8')).hexdigest()[:16]
|
||||
state_key = f"seq_{unique_id}_{text_hash}"
|
||||
node_state = state.get(state_key, {})
|
||||
if isinstance(node_state, dict):
|
||||
idx = int(node_state.get("index", 0))
|
||||
assigned = node_state.get("assigned", {})
|
||||
else:
|
||||
idx = int(node_state) if isinstance(node_state, int) else 0
|
||||
assigned = {}
|
||||
|
||||
if current_pid and current_pid in assigned:
|
||||
selected_item = assigned[current_pid]
|
||||
else:
|
||||
selected_item = valid_choices[idx % len(valid_choices)]
|
||||
if current_pid:
|
||||
assigned[current_pid] = selected_item
|
||||
state[state_key] = {
|
||||
"index": idx + 1,
|
||||
"assigned": assigned
|
||||
}
|
||||
save_lora_cycle_state(state)
|
||||
loras_to_load = [selected_item]
|
||||
elif _selection_mode == "Random (No Replace)":
|
||||
state = load_lora_cycle_state()
|
||||
text_hash = hashlib.sha256(json.dumps(sorted(valid_choices)).encode('utf-8')).hexdigest()[:16]
|
||||
state_key = f"noreplace_{unique_id}_{text_hash}"
|
||||
node_state = state.get(state_key, {})
|
||||
if isinstance(node_state, dict):
|
||||
pool = node_state.get("pool", [])
|
||||
assigned = node_state.get("assigned", {})
|
||||
else:
|
||||
pool = node_state if isinstance(node_state, list) else []
|
||||
assigned = {}
|
||||
|
||||
if current_pid and current_pid in assigned:
|
||||
selected_item = assigned[current_pid]
|
||||
else:
|
||||
pool = [x for x in pool if x in valid_choices]
|
||||
if not pool:
|
||||
import random
|
||||
pool = list(valid_choices)
|
||||
random.shuffle(pool)
|
||||
selected_item = pool.pop(0)
|
||||
if current_pid:
|
||||
assigned[current_pid] = selected_item
|
||||
state[state_key] = {
|
||||
"pool": pool,
|
||||
"assigned": assigned
|
||||
}
|
||||
save_lora_cycle_state(state)
|
||||
loras_to_load = [selected_item]
|
||||
else:
|
||||
loras_to_load = []
|
||||
|
||||
@@ -519,18 +709,26 @@ class FolderLoraLoaderVisualPrettyV2(FolderLoraLoaderVisualPretty):
|
||||
|
||||
if not resolved_path or resolved_path == "[ NONE ]":
|
||||
all_loras = folder_paths.get_filename_list("loras")
|
||||
raw_filter = folder.replace("\\", "/").strip().rstrip("/").lower()
|
||||
raw_filter = (folder or "").replace("\\", "/").strip().rstrip("/").rstrip("*").strip("/").lower()
|
||||
for lora in all_loras:
|
||||
norm_lora = lora.replace("\\", "/").lower()
|
||||
if raw_filter and not norm_lora.startswith(raw_filter):
|
||||
continue
|
||||
if parse_pretty_name(lora) == display_name:
|
||||
if lora == item or parse_pretty_name(lora) == display_name or parse_pretty_name_with_version(lora) == display_name:
|
||||
resolved_path = lora
|
||||
break
|
||||
|
||||
if not resolved_path:
|
||||
continue
|
||||
|
||||
# Fast-path: If strength is 0 for both model and clip, don't load gigabytes into RAM/VRAM
|
||||
if strength_model == 0 and strength_clip == 0:
|
||||
increment_lora_usage(resolved_path)
|
||||
out_name = format_lora_output_name(resolved_path, display_name, output_format=active_format, custom_regex=custom_regex)
|
||||
if out_name:
|
||||
loaded_names.append(out_name)
|
||||
continue
|
||||
|
||||
lora_path = folder_paths.get_full_path("loras", resolved_path)
|
||||
if not lora_path or not os.path.exists(lora_path):
|
||||
continue
|
||||
@@ -541,8 +739,12 @@ class FolderLoraLoaderVisualPrettyV2(FolderLoraLoaderVisualPretty):
|
||||
)
|
||||
increment_lora_usage(resolved_path)
|
||||
|
||||
clean_display_name = re.sub(r'\s+V\d+(\.\d+)?$', '', display_name, flags=re.IGNORECASE).strip()
|
||||
loaded_names.append(clean_display_name)
|
||||
out_name = format_lora_output_name(resolved_path, display_name, output_format=active_format, custom_regex=custom_regex)
|
||||
if out_name:
|
||||
loaded_names.append(out_name)
|
||||
|
||||
pretty_name_str = ", ".join(loaded_names) if loaded_names else ""
|
||||
return (current_model, current_clip, pretty_name_str)
|
||||
|
||||
# Alias for backward compatibility
|
||||
FolderLoraLoaderVisualPrettyV2 = VisualLoraLoader
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
PREVIEW_CATEGORY = "🍃 FlowControl/Previews"
|
||||
PREVIEW_CATEGORY = "🍃 LeafFlow/Previews"
|
||||
|
||||
class PreviewLatentLiveNode:
|
||||
@classmethod
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
from .prompt_iterator import parse_prompt_blocks
|
||||
|
||||
class PromptCounter:
|
||||
"""
|
||||
LeafFlow Prompt Counter:
|
||||
Multiline text prompt input node with real-time prompt counting according to
|
||||
selectable separator ("Newline", ">1 Empty Line", ">2 Empty Lines", "Custom Regex").
|
||||
Outputs STRING (unmodified text) and count (integer count of prompts).
|
||||
"""
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"text": ("STRING", {"default": "", "multiline": True, "placeholder": "Enter multiline prompts here..."}),
|
||||
},
|
||||
"optional": {
|
||||
"separator": ([
|
||||
">1 Empty Line",
|
||||
"Newline",
|
||||
">2 Empty Lines",
|
||||
"Custom Regex"
|
||||
], {"default": ">1 Empty Line", "advanced": True}),
|
||||
"custom_regex": ("STRING", {"default": "", "advanced": True, "placeholder": "Custom regex delimiter (e.g. \\n---\\n)"}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "INT")
|
||||
RETURN_NAMES = ("STRING", "count")
|
||||
FUNCTION = "count_prompts"
|
||||
CATEGORY = "🍃 LeafFlow/Utils"
|
||||
DESCRIPTION = "Multiline prompt text input with live prompt count display according to selectable separator or custom regex."
|
||||
|
||||
def count_prompts(self, text="", separator=">1 Empty Line", custom_regex="", **kwargs):
|
||||
text_str = str(text) if text is not None else ""
|
||||
blocks = parse_prompt_blocks(text_str, separator, custom_regex)
|
||||
return (text_str, len(blocks))
|
||||
@@ -0,0 +1,368 @@
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import random
|
||||
import time
|
||||
import hashlib
|
||||
import platform
|
||||
import subprocess
|
||||
from aiohttp import web
|
||||
from server import PromptServer
|
||||
import folder_paths
|
||||
from .utils import get_leafflow_user_dir, is_authenticated_local_request
|
||||
|
||||
CURRENT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
USER_DIR = get_leafflow_user_dir()
|
||||
STATE_FILE = os.path.join(USER_DIR, "prompt_iterator_state.json")
|
||||
ENV_FILE = os.path.join(USER_DIR, ".env")
|
||||
|
||||
def get_env_setting(key, default_val):
|
||||
if os.path.exists(ENV_FILE):
|
||||
try:
|
||||
with open(ENV_FILE, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
if line.strip().startswith(f"{key}="):
|
||||
return line.strip().split("=", 1)[1].strip()
|
||||
except Exception:
|
||||
pass
|
||||
return default_val
|
||||
|
||||
def is_clear_prompt_iterator_on_launch():
|
||||
val = get_env_setting("CLEAR_PROMPT_ITERATOR_ON_LAUNCH", "false").lower()
|
||||
return val in ["true", "1", "yes"]
|
||||
|
||||
def ensure_user_dir():
|
||||
os.makedirs(USER_DIR, exist_ok=True)
|
||||
|
||||
def load_state():
|
||||
ensure_user_dir()
|
||||
if os.path.exists(STATE_FILE):
|
||||
try:
|
||||
with open(STATE_FILE, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
except Exception as e:
|
||||
print(f"[LeafFlow] 🍃 Warning reading prompt iterator state: {e}")
|
||||
return {}
|
||||
|
||||
def save_state(state):
|
||||
ensure_user_dir()
|
||||
try:
|
||||
# Auto-prune abandoned state keys if count exceeds 100 entries
|
||||
if len(state) > 100:
|
||||
excess_keys = list(state.keys())[:-100]
|
||||
for k in excess_keys:
|
||||
del state[k]
|
||||
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)
|
||||
except Exception as e:
|
||||
print(f"[LeafFlow] 🍃 Error saving prompt iterator state: {e}")
|
||||
|
||||
def clear_state():
|
||||
ensure_user_dir()
|
||||
try:
|
||||
with open(STATE_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump({}, f, indent=4)
|
||||
print("[LeafFlow] 🍃 Prompt iterator state cleared.")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[LeafFlow] 🍃 Error clearing prompt iterator state: {e}")
|
||||
return False
|
||||
|
||||
# Clear state on startup if privacy setting enabled
|
||||
if is_clear_prompt_iterator_on_launch():
|
||||
clear_state()
|
||||
|
||||
# Register API endpoints
|
||||
try:
|
||||
routes = PromptServer.instance.routes
|
||||
|
||||
@routes.post("/leafflow/prompt_iterator/reset_node")
|
||||
async def reset_prompt_iterator_node(request):
|
||||
if not is_authenticated_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
|
||||
try:
|
||||
data = await request.json()
|
||||
node_id = str(data.get("node_id", ""))
|
||||
state = load_state()
|
||||
matched = False
|
||||
for k in list(state.keys()):
|
||||
if node_id == "all" or k.startswith(f"node_{node_id}_") or k == f"node_{node_id}":
|
||||
if isinstance(state[k], dict):
|
||||
state[k]["index"] = 0
|
||||
else:
|
||||
state[k] = {"index": 0, "total": 0}
|
||||
matched = True
|
||||
save_state(state)
|
||||
if node_id and node_id != "all":
|
||||
try:
|
||||
PromptServer.instance.send_sync("leafflow_prompt_iterator_progress", {
|
||||
"node_id": node_id,
|
||||
"current_run": 0,
|
||||
"total_runs": 0,
|
||||
"status_text": "Reset (0 / --)"
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return web.json_response({"status": "ok", "reset": matched})
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
@routes.post("/leafflow/prompt_iterator/clear")
|
||||
@routes.post("/flow_control/prompt_iterator/clear")
|
||||
async def clear_prompt_iterator_endpoint(request):
|
||||
if not is_authenticated_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
|
||||
success = clear_state()
|
||||
return web.json_response({"status": "ok" if success else "error"})
|
||||
|
||||
@routes.post("/leafflow/prompt_iterator/open_file")
|
||||
async def open_prompt_iterator_file(request):
|
||||
if not is_authenticated_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
|
||||
ensure_user_dir()
|
||||
if not os.path.exists(STATE_FILE):
|
||||
save_state({})
|
||||
|
||||
# Security: verify canonical state file path is strictly within USER_DIR
|
||||
canonical_state = os.path.realpath(STATE_FILE)
|
||||
canonical_user = os.path.realpath(USER_DIR)
|
||||
try:
|
||||
if os.path.commonpath([canonical_user, canonical_state]) != canonical_user:
|
||||
return web.json_response({"status": "error", "message": "Security: State file escapes user directory"}, status=403)
|
||||
except Exception:
|
||||
return web.json_response({"status": "error", "message": "Security path validation error"}, status=403)
|
||||
|
||||
try:
|
||||
sys_name = platform.system()
|
||||
if sys_name == "Windows":
|
||||
os.startfile(canonical_state)
|
||||
elif sys_name == "Darwin":
|
||||
subprocess.Popen(["open", canonical_state])
|
||||
else:
|
||||
subprocess.Popen(["xdg-open", canonical_state])
|
||||
return web.json_response({"status": "ok", "path": canonical_state})
|
||||
except Exception as e:
|
||||
print(f"[LeafFlow] 🍃 Error opening state file: {e}")
|
||||
return web.json_response({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def parse_prompt_blocks(text_str, separator, custom_regex=""):
|
||||
if not text_str or not text_str.strip():
|
||||
return []
|
||||
|
||||
clean_text = text_str.replace('\r\n', '\n').replace('\r', '\n')
|
||||
sep_lower = str(separator).lower() if separator else ">1 empty line"
|
||||
|
||||
if "regex" in sep_lower:
|
||||
pattern = custom_regex.strip() if (custom_regex and custom_regex.strip()) else r'\n\s*\n+'
|
||||
try:
|
||||
regex = re.compile(pattern)
|
||||
raw_blocks = regex.split(clean_text)
|
||||
results = []
|
||||
for b in raw_blocks:
|
||||
b_str = b.strip()
|
||||
if b_str and not regex.fullmatch(b):
|
||||
results.append(b_str)
|
||||
return results
|
||||
except Exception as e:
|
||||
print(f"[LeafFlow Prompt] Invalid custom regex pattern '{pattern}': {e}. Falling back to empty line separator.")
|
||||
raw_blocks = re.split(r'\n\s*\n+', clean_text)
|
||||
return [b.strip() for b in raw_blocks if b.strip()]
|
||||
elif "newline" in sep_lower:
|
||||
return [line.strip() for line in clean_text.split('\n') if line.strip()]
|
||||
elif ">2" in sep_lower or "2 empty" in sep_lower:
|
||||
raw_blocks = re.split(r'(?:\n\s*){3,}', clean_text)
|
||||
return [b.strip() for b in raw_blocks if b.strip()]
|
||||
else: # ">1 Empty Line", ">1 Emptyline"
|
||||
raw_blocks = re.split(r'\n\s*\n+', clean_text)
|
||||
return [b.strip() for b in raw_blocks if b.strip()]
|
||||
|
||||
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])
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
class PromptQueueIterator:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"pop_mode": ([
|
||||
"Sequential (Loop on End)",
|
||||
"Sequential (Stop on End)",
|
||||
"Random (Keep)",
|
||||
"Random (Cycle)"
|
||||
], {"default": "Sequential (Loop on End)"}),
|
||||
"separator": ([
|
||||
">1 Empty Line",
|
||||
"Newline",
|
||||
">2 Empty Lines",
|
||||
"Custom Regex"
|
||||
], {"default": ">1 Empty Line"}),
|
||||
"text": ("STRING", {"default": "", "multiline": True}),
|
||||
},
|
||||
"optional": {
|
||||
"custom_regex": ("STRING", {"default": "", "placeholder": "Custom regex delimiter (e.g. \\n---\\n)"}),
|
||||
"prompt": ("STRING", {"forceInput": True}),
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "STRING", "INT")
|
||||
RETURN_NAMES = ("prompt", "remaining_text", "remaining_count")
|
||||
FUNCTION = "process_queue"
|
||||
CATEGORY = "🍃 LeafFlow/Utils"
|
||||
DESCRIPTION = "Deterministically iterates over multiline prompt text blocks per queue run with live progress display and index controls."
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
# Force re-execution so each queue batch item advances the pointer
|
||||
return time.time()
|
||||
|
||||
def process_queue(
|
||||
self,
|
||||
pop_mode="Sequential (Loop on End)",
|
||||
separator=">1 Empty Line",
|
||||
text="",
|
||||
custom_regex="",
|
||||
prompt=None,
|
||||
prompt_text=None,
|
||||
unique_id="default",
|
||||
**kwargs
|
||||
):
|
||||
raw_input = text if text else (prompt if prompt is not None else prompt_text)
|
||||
text_str = str(raw_input) if raw_input is not None else ""
|
||||
if not text_str.strip():
|
||||
return ("", "", 0)
|
||||
|
||||
original_blocks = parse_prompt_blocks(text_str, separator, custom_regex)
|
||||
total_count = len(original_blocks)
|
||||
if total_count == 0:
|
||||
return ("", "", 0)
|
||||
|
||||
# Calculate stable hash of the complete input text
|
||||
text_hash = hashlib.sha256(text_str.strip().encode('utf-8')).hexdigest()[:16]
|
||||
state_key = f"node_{unique_id}_{text_hash}"
|
||||
|
||||
current_prompt_id = get_current_prompt_id()
|
||||
|
||||
# Load state fresh from disk (no stale memory cache)
|
||||
state = load_state()
|
||||
node_state = state.get(state_key)
|
||||
|
||||
current_index = 0
|
||||
assigned_prompts = {}
|
||||
if isinstance(node_state, dict):
|
||||
current_index = int(node_state.get("index", 0))
|
||||
raw_assigned = node_state.get("assigned_prompts", {})
|
||||
if isinstance(raw_assigned, dict):
|
||||
assigned_prompts = dict(raw_assigned)
|
||||
elif isinstance(node_state, int):
|
||||
current_index = node_state
|
||||
|
||||
# Check if this prompt_id was already assigned an index (e.g. recovered from persistent queue / retry)
|
||||
if current_prompt_id and current_prompt_id in assigned_prompts:
|
||||
idx = assigned_prompts[current_prompt_id] % total_count
|
||||
selected_prompt = original_blocks[idx]
|
||||
display_run = idx + 1
|
||||
next_index = current_index # Keep current index without double-advancing
|
||||
else:
|
||||
# Select prompt based on mode and calculate next index
|
||||
if pop_mode in ["Sequential (Loop on End)", "Cycle / Loop", "Pop Top & Delete"]:
|
||||
idx = current_index % total_count
|
||||
selected_prompt = original_blocks[idx]
|
||||
next_index = (current_index + 1) % total_count
|
||||
display_run = idx + 1
|
||||
elif pop_mode in ["Sequential (Stop on End)"]:
|
||||
if current_index >= total_count:
|
||||
idx = total_count - 1
|
||||
selected_prompt = original_blocks[idx]
|
||||
next_index = total_count
|
||||
display_run = total_count
|
||||
else:
|
||||
idx = current_index
|
||||
selected_prompt = original_blocks[idx]
|
||||
next_index = current_index + 1
|
||||
display_run = idx + 1
|
||||
elif pop_mode in ["Random (Keep)", "Random (Delete)"]:
|
||||
idx = random.randint(0, total_count - 1)
|
||||
selected_prompt = original_blocks[idx]
|
||||
next_index = current_index + 1
|
||||
display_run = (current_index % total_count) + 1
|
||||
else: # Default fallback
|
||||
idx = current_index % total_count
|
||||
selected_prompt = original_blocks[idx]
|
||||
next_index = (current_index + 1) % total_count
|
||||
display_run = idx + 1
|
||||
|
||||
if current_prompt_id:
|
||||
assigned_prompts[current_prompt_id] = idx
|
||||
if len(assigned_prompts) > 50:
|
||||
for old_k in list(assigned_prompts.keys())[:-50]:
|
||||
del assigned_prompts[old_k]
|
||||
|
||||
# Calculate remaining items
|
||||
remaining_count = max(0, total_count - display_run)
|
||||
sep_lower = str(separator).lower() if separator else ""
|
||||
if "newline" in sep_lower:
|
||||
join_delim = "\n"
|
||||
elif ">2" in sep_lower or "2 empty" in sep_lower:
|
||||
join_delim = "\n\n\n"
|
||||
elif "regex" in sep_lower:
|
||||
if custom_regex and not any(c in custom_regex for c in r"*+?{}[\]^$|()\\"):
|
||||
join_delim = custom_regex
|
||||
else:
|
||||
join_delim = "\n\n"
|
||||
else:
|
||||
join_delim = "\n\n"
|
||||
remaining_text = join_delim.join(original_blocks[display_run:]) if display_run < total_count else ""
|
||||
|
||||
# Update state dictionary
|
||||
state[state_key] = {
|
||||
"index": next_index,
|
||||
"total": total_count,
|
||||
"last_run": display_run,
|
||||
"last_updated": int(time.time()),
|
||||
"preview": selected_prompt[:60].replace('\n', ' '),
|
||||
"assigned_prompts": assigned_prompts
|
||||
}
|
||||
|
||||
# Keep at most 3 most recent text hashes per node_id
|
||||
node_prefix = f"node_{unique_id}_"
|
||||
node_keys = [k for k in state.keys() if k.startswith(node_prefix)]
|
||||
if len(node_keys) > 3:
|
||||
node_keys.sort(key=lambda k: state[k].get("last_updated", 0) if isinstance(state[k], dict) else 0)
|
||||
for old_k in node_keys[:-3]:
|
||||
del state[old_k]
|
||||
|
||||
save_state(state)
|
||||
|
||||
# Send live progress update to UI
|
||||
try:
|
||||
PromptServer.instance.send_sync("leafflow_prompt_iterator_progress", {
|
||||
"node_id": str(unique_id),
|
||||
"current_run": display_run,
|
||||
"total_runs": total_count,
|
||||
"status_text": f"Run {display_run} / {total_count}"
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return (selected_prompt, remaining_text, remaining_count)
|
||||
+530
-131
@@ -1,16 +1,22 @@
|
||||
import os
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
import threading
|
||||
import asyncio
|
||||
from aiohttp import web
|
||||
from server import PromptServer
|
||||
import nodes
|
||||
from .tray_icon import TrayIconManager, is_tray_icon_enabled
|
||||
from .assets_restore import assets_restore_manager, is_assets_restore_enabled, get_assets_restore_count
|
||||
from .utils import get_leafflow_user_dir, is_local_request, is_authenticated_local_request
|
||||
|
||||
QUEUE_CATEGORY = "🍃 FlowControl/Queue"
|
||||
QUEUE_CATEGORY = "🍃 LeafFlow/Queue"
|
||||
|
||||
CURRENT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
PERSISTENT_FILE = os.path.join(CURRENT_DIR, "persistent_queue.json")
|
||||
ENV_FILE = os.path.join(CURRENT_DIR, ".env")
|
||||
USER_DIR = get_leafflow_user_dir()
|
||||
PERSISTENT_FILE = os.path.join(USER_DIR, "persistent_queue.json")
|
||||
ENV_FILE = os.path.join(USER_DIR, ".env")
|
||||
|
||||
def get_env_setting(key, default_val):
|
||||
if os.path.exists(ENV_FILE):
|
||||
@@ -27,27 +33,9 @@ def is_persistent_queue_enabled():
|
||||
val = get_env_setting("ENABLE_PERSISTENT_QUEUE", "true").lower()
|
||||
return val in ["true", "1", "yes"]
|
||||
|
||||
class PauseQueueNode:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {"required": {}}
|
||||
RETURN_TYPES = ()
|
||||
FUNCTION = "noop"
|
||||
CATEGORY = QUEUE_CATEGORY
|
||||
DESCRIPTION = "Settings & status anchor for Pause Queue toolbar features."
|
||||
def noop(self):
|
||||
return ()
|
||||
|
||||
class PersistentQueueNode:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {"required": {}}
|
||||
RETURN_TYPES = ()
|
||||
FUNCTION = "noop"
|
||||
CATEGORY = QUEUE_CATEGORY
|
||||
DESCRIPTION = "Settings & status anchor for Persistent Queue auto-recovery features."
|
||||
def noop(self):
|
||||
return ()
|
||||
def is_process_management_enabled():
|
||||
val = get_env_setting("ALLOW_PROCESS_MANAGEMENT", "false").lower()
|
||||
return val in ["true", "1", "yes"]
|
||||
|
||||
class PauseQueueManager:
|
||||
def __init__(self):
|
||||
@@ -67,57 +55,68 @@ class PauseQueueManager:
|
||||
def patch_all(self):
|
||||
if self._patched:
|
||||
return
|
||||
server = PromptServer.instance
|
||||
server = getattr(PromptServer, "instance", None)
|
||||
if not server:
|
||||
print("[LeafFlow] Queue control: Skipped pause patching (PromptServer instance not ready).")
|
||||
return
|
||||
|
||||
if hasattr(server, "prompt_queue"):
|
||||
queue = server.prompt_queue
|
||||
original_get = queue.get
|
||||
if not hasattr(server, "prompt_queue") or not hasattr(server.prompt_queue, "get"):
|
||||
print("[LeafFlow] Queue control: Skipped patching prompt_queue.get (incompatible ComfyUI version).")
|
||||
return
|
||||
|
||||
def patched_get(*args, **kwargs):
|
||||
while True:
|
||||
if self.paused:
|
||||
if not self.is_waiting:
|
||||
self.is_waiting = True
|
||||
print("[PauseQueue] Workflow paused after this run: Current workflow finished, queue paused.")
|
||||
self.notify_clients()
|
||||
while self.paused:
|
||||
self.event.wait(0.2)
|
||||
self.is_waiting = False
|
||||
self.notify_clients()
|
||||
queue = server.prompt_queue
|
||||
original_get = queue.get
|
||||
|
||||
item = original_get(*args, **kwargs)
|
||||
|
||||
if self.paused:
|
||||
if not self.is_waiting:
|
||||
self.is_waiting = True
|
||||
print("[PauseQueue] Workflow paused after this run: Current workflow finished, queue paused.")
|
||||
self.notify_clients()
|
||||
while self.paused:
|
||||
self.event.wait(0.2)
|
||||
self.is_waiting = False
|
||||
self.notify_clients()
|
||||
|
||||
return item
|
||||
|
||||
queue.get = patched_get
|
||||
|
||||
original_send_sync = server.send_sync
|
||||
|
||||
def patched_send_sync(event, data, sid=None):
|
||||
if event == "executing" and isinstance(data, dict) and data.get("node") is not None:
|
||||
if self.paused and self.mode == "instantly":
|
||||
def patched_get(*args, **kwargs):
|
||||
while True:
|
||||
if self.paused:
|
||||
if not self.is_waiting:
|
||||
self.is_waiting = True
|
||||
print(f"[PauseQueue] Workflow Paused Instant: Paused before executing node '{data.get('node')}'.")
|
||||
print("[PauseQueue] Workflow paused after this run: Current workflow finished, queue paused.")
|
||||
self.notify_clients()
|
||||
while self.paused and self.mode == "instantly":
|
||||
while self.paused:
|
||||
self.event.wait(0.2)
|
||||
self.is_waiting = False
|
||||
self.notify_clients()
|
||||
return original_send_sync(event, data, sid)
|
||||
|
||||
server.send_sync = patched_send_sync
|
||||
item = original_get(*args, **kwargs)
|
||||
|
||||
if self.paused:
|
||||
if not self.is_waiting:
|
||||
self.is_waiting = True
|
||||
print("[PauseQueue] Workflow paused after this run: Current workflow finished, queue paused.")
|
||||
self.notify_clients()
|
||||
while self.paused:
|
||||
self.event.wait(0.2)
|
||||
self.is_waiting = False
|
||||
self.notify_clients()
|
||||
|
||||
return item
|
||||
|
||||
queue.get = patched_get
|
||||
|
||||
if hasattr(server, "send_sync") and callable(server.send_sync):
|
||||
original_send_sync = server.send_sync
|
||||
|
||||
def patched_send_sync(event, data, sid=None):
|
||||
if event == "executing" and isinstance(data, dict) and data.get("node") is not None:
|
||||
if self.paused and self.mode == "instantly":
|
||||
if not self.is_waiting:
|
||||
self.is_waiting = True
|
||||
print(f"[PauseQueue] Workflow Paused Instant: Paused before executing node '{data.get('node')}'.")
|
||||
self.notify_clients()
|
||||
while self.paused and self.mode == "instantly":
|
||||
self.event.wait(0.2)
|
||||
self.is_waiting = False
|
||||
self.notify_clients()
|
||||
return original_send_sync(event, data, sid)
|
||||
|
||||
server.send_sync = patched_send_sync
|
||||
else:
|
||||
print("[LeafFlow] Queue control: Skipped patching send_sync (not found on server).")
|
||||
|
||||
self._patched = True
|
||||
print("[LeafFlow] Queue control: Pause manager initialized successfully.")
|
||||
|
||||
def is_currently_executing(self):
|
||||
try:
|
||||
@@ -169,12 +168,20 @@ class PauseQueueManager:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
if 'tray_manager' in globals() and tray_manager:
|
||||
tray_manager.update_status()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
class PersistentQueueManager:
|
||||
def __init__(self):
|
||||
self.lock = threading.RLock()
|
||||
self.persistent_items = []
|
||||
self.batch_meta = {}
|
||||
self.is_restoring = False
|
||||
self._patched = False
|
||||
self.has_claimed_once = False
|
||||
self.load_from_file()
|
||||
|
||||
def load_from_file(self):
|
||||
@@ -182,14 +189,37 @@ class PersistentQueueManager:
|
||||
if os.path.exists(PERSISTENT_FILE):
|
||||
try:
|
||||
with open(PERSISTENT_FILE, "r", encoding="utf-8") as f:
|
||||
self.persistent_items = json.load(f)
|
||||
print(f"[PersistentQueue] Loaded {len(self.persistent_items)} saved queue item(s).")
|
||||
data = json.load(f)
|
||||
if isinstance(data, list):
|
||||
self.persistent_items = [
|
||||
x for x in data
|
||||
if isinstance(x, dict) and "prompt_id" in x and isinstance(x.get("item"), (list, tuple))
|
||||
]
|
||||
for x in self.persistent_items:
|
||||
if "batch_info" in x and "prompt_id" in x:
|
||||
self.batch_meta[str(x["prompt_id"])] = x["batch_info"]
|
||||
print(f"[PersistentQueue] Loaded {len(self.persistent_items)} saved queue item(s).")
|
||||
else:
|
||||
self.persistent_items = []
|
||||
except Exception as e:
|
||||
print(f"[PersistentQueue] Error loading persistent queue file: {e}")
|
||||
self.persistent_items = []
|
||||
else:
|
||||
self.persistent_items = []
|
||||
|
||||
def set_batch_info(self, prompt_id, batch_info):
|
||||
if not prompt_id:
|
||||
return
|
||||
pid_str = str(prompt_id)
|
||||
with self.lock:
|
||||
self.batch_meta[pid_str] = batch_info
|
||||
for item in self.persistent_items:
|
||||
if str(item.get("prompt_id")) == pid_str:
|
||||
item["batch_info"] = batch_info
|
||||
break
|
||||
if is_persistent_queue_enabled():
|
||||
self.save_to_file()
|
||||
|
||||
def save_to_file(self):
|
||||
if not is_persistent_queue_enabled():
|
||||
return
|
||||
@@ -197,7 +227,7 @@ class PersistentQueueManager:
|
||||
try:
|
||||
temp_file = PERSISTENT_FILE + ".tmp"
|
||||
with open(temp_file, "w", encoding="utf-8") as f:
|
||||
json.dump(self.persistent_items, f, indent=2)
|
||||
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}")
|
||||
@@ -211,84 +241,59 @@ class PersistentQueueManager:
|
||||
prompt_id = item_list[1] if len(item_list) > 1 else None
|
||||
if not prompt_id:
|
||||
return
|
||||
pid_str = str(prompt_id)
|
||||
entry = {
|
||||
"prompt_id": prompt_id,
|
||||
"prompt_id": pid_str,
|
||||
"item": item_list
|
||||
}
|
||||
if pid_str in self.batch_meta:
|
||||
entry["batch_info"] = self.batch_meta[pid_str]
|
||||
with self.lock:
|
||||
self.persistent_items = [x for x in self.persistent_items if x.get("prompt_id") != prompt_id]
|
||||
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 {prompt_id} to disk.")
|
||||
print(f"[PersistentQueue] Persisted prompt {pid_str} to disk.")
|
||||
except Exception as e:
|
||||
print(f"[PersistentQueue] Error formatting queue item for persistence: {e}")
|
||||
|
||||
def remove_item(self, prompt_id):
|
||||
if not prompt_id:
|
||||
return
|
||||
pid_str = str(prompt_id)
|
||||
with self.lock:
|
||||
initial_count = len(self.persistent_items)
|
||||
self.persistent_items = [x for x in self.persistent_items if x.get("prompt_id") != prompt_id]
|
||||
self.persistent_items = [x for x in self.persistent_items if str(x.get("prompt_id")) != pid_str]
|
||||
self.batch_meta.pop(pid_str, None)
|
||||
changed = len(self.persistent_items) != initial_count
|
||||
if changed and is_persistent_queue_enabled():
|
||||
self.save_to_file()
|
||||
print(f"[PersistentQueue] Removed prompt {prompt_id} from disk.")
|
||||
print(f"[PersistentQueue] Removed prompt {pid_str} from disk.")
|
||||
|
||||
def wipe_all(self):
|
||||
with self.lock:
|
||||
self.persistent_items = []
|
||||
self.batch_meta = {}
|
||||
if is_persistent_queue_enabled():
|
||||
self.save_to_file()
|
||||
print("[PersistentQueue] Cleared all saved queue items.")
|
||||
|
||||
def sync_client_id(self, active_client_id):
|
||||
if not active_client_id:
|
||||
return
|
||||
server = PromptServer.instance
|
||||
if not hasattr(server, "prompt_queue"):
|
||||
return
|
||||
|
||||
queue = server.prompt_queue
|
||||
updated = False
|
||||
with queue.mutex:
|
||||
new_queue = []
|
||||
for item in queue.queue:
|
||||
item_list = list(item)
|
||||
if len(item_list) > 3 and isinstance(item_list[3], dict):
|
||||
extra_data = dict(item_list[3])
|
||||
old_client_id = extra_data.get("client_id")
|
||||
if old_client_id != active_client_id:
|
||||
extra_data["client_id"] = active_client_id
|
||||
item_list[3] = extra_data
|
||||
updated = True
|
||||
new_queue.append(tuple(item_list))
|
||||
|
||||
if updated:
|
||||
queue.queue.clear()
|
||||
queue.queue.extend(new_queue)
|
||||
|
||||
if updated:
|
||||
with self.lock:
|
||||
for entry in self.persistent_items:
|
||||
if isinstance(entry.get("item"), list) and len(entry["item"]) > 3:
|
||||
if isinstance(entry["item"][3], dict):
|
||||
entry["item"][3]["client_id"] = active_client_id
|
||||
self.save_to_file()
|
||||
|
||||
print(f"[PersistentQueue] Re-assigned restored queue items to active client ID '{active_client_id}'.")
|
||||
try:
|
||||
if hasattr(server, "get_queue_status"):
|
||||
server.send_sync("status", {"status": server.get_queue_status()})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def patch_server(self):
|
||||
if self._patched:
|
||||
return
|
||||
server = PromptServer.instance
|
||||
if not hasattr(server, "prompt_queue"):
|
||||
if not is_persistent_queue_enabled():
|
||||
print("[LeafFlow] Persistent queue disabled in settings; core prompt_queue patching skipped.")
|
||||
return
|
||||
server = getattr(PromptServer, "instance", None)
|
||||
if not server or not hasattr(server, "prompt_queue"):
|
||||
print("[LeafFlow] Queue control: Skipped persistent queue patching (prompt_queue not found on server).")
|
||||
return
|
||||
|
||||
queue = server.prompt_queue
|
||||
if not hasattr(queue, "put") or not hasattr(queue, "task_done"):
|
||||
print("[LeafFlow] Queue control: Skipped persistent queue patching (incompatible queue interface).")
|
||||
return
|
||||
|
||||
# 1. Patch queue.put
|
||||
original_put = queue.put
|
||||
def patched_put(item, *args, **kwargs):
|
||||
res = original_put(item, *args, **kwargs)
|
||||
@@ -296,12 +301,34 @@ class PersistentQueueManager:
|
||||
return res
|
||||
queue.put = patched_put
|
||||
|
||||
# 2. Patch queue.task_done (Guaranteed cleanup on task completion / error / interrupt)
|
||||
if hasattr(queue, "task_done"):
|
||||
original_task_done = queue.task_done
|
||||
def patched_task_done(item_id, history_result, status, process_item=None):
|
||||
prompt_id = None
|
||||
try:
|
||||
with queue.mutex:
|
||||
if item_id in queue.currently_running:
|
||||
running_item = queue.currently_running[item_id]
|
||||
if isinstance(running_item, (tuple, list)) and len(running_item) > 1:
|
||||
prompt_id = running_item[1]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
res = original_task_done(item_id, history_result, status, process_item)
|
||||
|
||||
if prompt_id:
|
||||
self.remove_item(prompt_id)
|
||||
return res
|
||||
queue.task_done = patched_task_done
|
||||
|
||||
# 3. Patch queue.delete_queue_item
|
||||
if hasattr(queue, "delete_queue_item"):
|
||||
original_delete = queue.delete_queue_item
|
||||
def patched_delete(fn, *args, **kwargs):
|
||||
try:
|
||||
with queue.mutex:
|
||||
to_delete = [item[1] for item in queue.queue if fn(item)]
|
||||
to_delete = [item[1] for item in queue.queue if fn(item) and len(item) > 1]
|
||||
for pid in to_delete:
|
||||
self.remove_item(pid)
|
||||
except Exception:
|
||||
@@ -309,6 +336,7 @@ class PersistentQueueManager:
|
||||
return original_delete(fn, *args, **kwargs)
|
||||
queue.delete_queue_item = patched_delete
|
||||
|
||||
# 4. Patch queue.wipe_queue
|
||||
if hasattr(queue, "wipe_queue"):
|
||||
original_wipe = queue.wipe_queue
|
||||
def patched_wipe(*args, **kwargs):
|
||||
@@ -316,33 +344,37 @@ class PersistentQueueManager:
|
||||
return original_wipe(*args, **kwargs)
|
||||
queue.wipe_queue = patched_wipe
|
||||
|
||||
# 5. Redundant websocket event hooks
|
||||
original_send_sync = server.send_sync
|
||||
def patched_send_sync(event, data, sid=None):
|
||||
try:
|
||||
if event == "executing" and isinstance(data, dict):
|
||||
node = data.get("node")
|
||||
prompt_id = data.get("prompt_id")
|
||||
if node is None and prompt_id:
|
||||
self.remove_item(prompt_id)
|
||||
elif event == "execution_interrupted" and isinstance(data, dict):
|
||||
if isinstance(data, dict):
|
||||
prompt_id = data.get("prompt_id")
|
||||
if prompt_id:
|
||||
self.remove_item(prompt_id)
|
||||
if event == "executing" and data.get("node") is None:
|
||||
self.remove_item(prompt_id)
|
||||
elif event in ("execution_interrupted", "execution_error"):
|
||||
self.remove_item(prompt_id)
|
||||
except Exception:
|
||||
pass
|
||||
return original_send_sync(event, data, sid)
|
||||
server.send_sync = patched_send_sync
|
||||
|
||||
self._patched = True
|
||||
print("[PersistentQueue] Server queue hooks patched successfully.")
|
||||
print("[LeafFlow] Queue control: Persistent queue manager initialized successfully.")
|
||||
|
||||
def restore_queue(self, active_client_id=None):
|
||||
if self.has_claimed_once:
|
||||
return
|
||||
|
||||
def restore_queue(self):
|
||||
if not is_persistent_queue_enabled():
|
||||
print("[PersistentQueue] Auto-recovery is disabled in settings.")
|
||||
self.has_claimed_once = True
|
||||
return
|
||||
|
||||
server = PromptServer.instance
|
||||
if not hasattr(server, "prompt_queue") or not self.persistent_items:
|
||||
self.has_claimed_once = True
|
||||
return
|
||||
|
||||
restored_state_setting = get_env_setting("PERSISTENT_QUEUE_RESTORED_STATE", "Match Default")
|
||||
@@ -351,30 +383,389 @@ 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)...")
|
||||
print(f"[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
|
||||
try:
|
||||
history = server.prompt_queue.get_history() if hasattr(server.prompt_queue, "get_history") else {}
|
||||
for entry in list(self.persistent_items):
|
||||
try:
|
||||
item_tuple = tuple(entry["item"])
|
||||
server.prompt_queue.put(item_tuple)
|
||||
pid = entry.get("prompt_id")
|
||||
# If this prompt is already in history, it finished previously - drop it
|
||||
if pid and pid in history:
|
||||
self.remove_item(pid)
|
||||
continue
|
||||
|
||||
item_list = list(entry.get("item", []))
|
||||
if len(item_list) < 3 or not item_list[1] or not isinstance(item_list[2], dict):
|
||||
# Malformed entry, drop from disk
|
||||
if pid:
|
||||
self.remove_item(pid)
|
||||
continue
|
||||
|
||||
if active_client_id and len(item_list) > 3 and isinstance(item_list[3], dict):
|
||||
item_list[3]["client_id"] = active_client_id
|
||||
|
||||
if isinstance(item_list[0], (int, float)):
|
||||
max_number = max(max_number, int(item_list[0]))
|
||||
|
||||
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}")
|
||||
finally:
|
||||
self.is_restoring = False
|
||||
self.has_claimed_once = True
|
||||
|
||||
# Keep server sequence counter above restored items to avoid priority collisions
|
||||
if hasattr(server, "number") and isinstance(server.number, (int, float)):
|
||||
server.number = max(server.number, max_number + 1)
|
||||
|
||||
# Sync the updated client_id to the persistent file
|
||||
if active_client_id:
|
||||
with self.lock:
|
||||
for entry in self.persistent_items:
|
||||
if isinstance(entry.get("item"), list) and len(entry["item"]) > 3:
|
||||
if isinstance(entry["item"][3], dict):
|
||||
entry["item"][3]["client_id"] = active_client_id
|
||||
self.save_to_file()
|
||||
|
||||
# Broadcast updated queue state to client UI
|
||||
try:
|
||||
if hasattr(server, "queue_updated"):
|
||||
server.queue_updated()
|
||||
if hasattr(server, "get_queue_status"):
|
||||
server.send_sync("status", {"status": server.get_queue_status()})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
class PowerControlManager:
|
||||
def __init__(self, pause_manager):
|
||||
self.pause_manager = pause_manager
|
||||
self.pending_action = None # None | "restart" | "shutdown"
|
||||
self.armed_at = None
|
||||
self.lock = threading.Lock()
|
||||
self._executing = False
|
||||
self._loop_thread = None
|
||||
|
||||
def start_watcher(self):
|
||||
if self._loop_thread is None:
|
||||
self._loop_thread = threading.Thread(target=self._watch_loop, daemon=True)
|
||||
self._loop_thread.start()
|
||||
|
||||
def _watch_loop(self):
|
||||
import time
|
||||
while True:
|
||||
time.sleep(1.0)
|
||||
if self.pending_action:
|
||||
self.check_and_execute()
|
||||
|
||||
def arm(self, action):
|
||||
with self.lock:
|
||||
if action in ["restart", "shutdown"]:
|
||||
if not is_process_management_enabled():
|
||||
print(f"[LeafFlow Power] Arm rejected: Process Management is disabled in LeafFlow settings.")
|
||||
return False
|
||||
self.pending_action = action
|
||||
import time
|
||||
self.armed_at = time.time()
|
||||
print(f"[LeafFlow Power] Armed action '{action}' after queue completion.")
|
||||
else:
|
||||
self.pending_action = None
|
||||
self.armed_at = None
|
||||
print("[LeafFlow Power] Cancelled armed queue power action.")
|
||||
self.notify_clients()
|
||||
return True
|
||||
|
||||
def get_status(self):
|
||||
with self.lock:
|
||||
return {
|
||||
"pending_action": self.pending_action,
|
||||
"armed_at": self.armed_at,
|
||||
"is_paused": getattr(self.pause_manager, "paused", False) or getattr(self.pause_manager, "is_waiting", False),
|
||||
"enabled": is_process_management_enabled()
|
||||
}
|
||||
|
||||
def notify_clients(self):
|
||||
try:
|
||||
PromptServer.instance.send_sync("leafflow_power_status", self.get_status())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def check_and_execute(self):
|
||||
with self.lock:
|
||||
if not self.pending_action or self._executing:
|
||||
return
|
||||
|
||||
# CRITICAL: If process management is disabled, cancel and abort
|
||||
if not is_process_management_enabled():
|
||||
self.pending_action = None
|
||||
return
|
||||
|
||||
# CRITICAL: If pause mode is active or waiting, DO NOT execute!
|
||||
is_paused = getattr(self.pause_manager, "paused", False) or getattr(self.pause_manager, "is_waiting", False)
|
||||
if is_paused:
|
||||
return
|
||||
|
||||
server = PromptServer.instance
|
||||
if not hasattr(server, "prompt_queue"):
|
||||
return
|
||||
|
||||
queue = server.prompt_queue
|
||||
tasks_remaining = queue.get_tasks_remaining()
|
||||
currently_running = len(getattr(queue, "currently_running", {}))
|
||||
|
||||
if tasks_remaining == 0 and currently_running == 0:
|
||||
action = self.pending_action
|
||||
self._executing = True
|
||||
self.pending_action = None
|
||||
print(f"[LeafFlow Power] Queue is empty and idle. Executing '{action}' now...")
|
||||
|
||||
if action == "restart":
|
||||
self.execute_restart()
|
||||
elif action == "shutdown":
|
||||
self.execute_shutdown()
|
||||
|
||||
def execute_restart(self):
|
||||
if not is_process_management_enabled():
|
||||
print("[LeafFlow Power] Rejected restart: Process Management is disabled in LeafFlow settings.")
|
||||
return False
|
||||
|
||||
def _run():
|
||||
import subprocess, sys, os, time
|
||||
print("[LeafFlow Power] Restarting ComfyUI server process...")
|
||||
time.sleep(0.6)
|
||||
cmd = [sys.executable]
|
||||
if getattr(sys.flags, "no_user_site", 0):
|
||||
cmd.append("-s")
|
||||
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)
|
||||
os._exit(0)
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
return True
|
||||
|
||||
def execute_shutdown(self):
|
||||
if not is_process_management_enabled():
|
||||
print("[LeafFlow Power] Rejected shutdown: Process Management is disabled in LeafFlow settings.")
|
||||
return False
|
||||
|
||||
def _run():
|
||||
import os, time
|
||||
print("[LeafFlow Power] Shutting down ComfyUI server...")
|
||||
time.sleep(0.6)
|
||||
os._exit(0)
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
return True
|
||||
|
||||
_POWER_TICKETS = {}
|
||||
_POWER_TICKET_LOCK = threading.Lock()
|
||||
|
||||
def _issue_power_ticket(action):
|
||||
with _POWER_TICKET_LOCK:
|
||||
now = time.time()
|
||||
expired = [t for t, (exp, _) in _POWER_TICKETS.items() if exp < now]
|
||||
for t in expired:
|
||||
_POWER_TICKETS.pop(t, None)
|
||||
ticket = secrets.token_urlsafe(24)
|
||||
_POWER_TICKETS[ticket] = (now + 30.0, action)
|
||||
return ticket
|
||||
|
||||
def _consume_power_ticket(ticket, expected_action=None):
|
||||
if not ticket or not isinstance(ticket, str):
|
||||
return False
|
||||
with _POWER_TICKET_LOCK:
|
||||
now = time.time()
|
||||
if ticket not in _POWER_TICKETS:
|
||||
return False
|
||||
exp, act = _POWER_TICKETS[ticket]
|
||||
if exp < now:
|
||||
_POWER_TICKETS.pop(ticket, None)
|
||||
return False
|
||||
if expected_action and act != expected_action:
|
||||
return False
|
||||
_POWER_TICKETS.pop(ticket, None)
|
||||
return True
|
||||
|
||||
pause_manager = PauseQueueManager()
|
||||
persistent_manager = PersistentQueueManager()
|
||||
tray_manager = TrayIconManager(pause_manager)
|
||||
power_manager = PowerControlManager(pause_manager)
|
||||
|
||||
def setup_queue_control_routes(server):
|
||||
pause_manager.patch_all()
|
||||
persistent_manager.patch_server()
|
||||
persistent_manager.restore_queue()
|
||||
power_manager.start_watcher()
|
||||
|
||||
if is_tray_icon_enabled():
|
||||
tray_manager.start()
|
||||
|
||||
if is_assets_restore_enabled():
|
||||
assets_restore_manager.restore_on_launch(server)
|
||||
|
||||
routes = server.routes
|
||||
|
||||
@routes.get("/leafflow/power/status")
|
||||
async def get_power_status(request):
|
||||
if not is_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local access only"}, status=403)
|
||||
return web.json_response(power_manager.get_status())
|
||||
|
||||
@routes.post("/leafflow/power/arm")
|
||||
async def arm_power_action(request):
|
||||
if not is_authenticated_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
|
||||
if not is_process_management_enabled():
|
||||
return web.json_response({
|
||||
"error": "Process Management is disabled. Enable 'Allow Process Management' in LeafFlow settings to use restart or shutdown.",
|
||||
"enabled": False
|
||||
}, status=403)
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
data = {}
|
||||
action = data.get("action")
|
||||
power_manager.arm(action)
|
||||
return web.json_response(power_manager.get_status())
|
||||
|
||||
@routes.post("/leafflow/power/request_token")
|
||||
async def request_power_token(request):
|
||||
if not is_authenticated_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
|
||||
if not is_process_management_enabled():
|
||||
return web.json_response({
|
||||
"error": "Process Management is disabled. Enable 'Allow Process Management' in LeafFlow settings.",
|
||||
"enabled": False
|
||||
}, status=403)
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
data = {}
|
||||
action = data.get("action")
|
||||
if action not in ["restart", "shutdown"]:
|
||||
return web.json_response({"error": "Invalid action. Must be 'restart' or 'shutdown'."}, status=400)
|
||||
ticket = _issue_power_ticket(action)
|
||||
return web.json_response({
|
||||
"success": True,
|
||||
"ticket": ticket,
|
||||
"action": action,
|
||||
"expires_in": 30
|
||||
})
|
||||
|
||||
@routes.post("/leafflow/power/confirm_action")
|
||||
async def confirm_power_action(request):
|
||||
if not is_authenticated_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
|
||||
if not is_process_management_enabled():
|
||||
return web.json_response({
|
||||
"error": "Process Management is disabled. Enable 'Allow Process Management' in LeafFlow settings.",
|
||||
"enabled": False
|
||||
}, status=403)
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
data = {}
|
||||
ticket = data.get("ticket")
|
||||
action = data.get("action")
|
||||
if not _consume_power_ticket(ticket, action):
|
||||
return web.json_response({"error": "Invalid or expired confirmation ticket. Operator confirmation required."}, status=403)
|
||||
if action == "restart":
|
||||
power_manager.execute_restart()
|
||||
return web.json_response({"status": "restarting", "success": True})
|
||||
elif action == "shutdown":
|
||||
power_manager.execute_shutdown()
|
||||
return web.json_response({"status": "shutting_down", "success": True})
|
||||
return web.json_response({"error": "Unknown action"}, status=400)
|
||||
|
||||
@routes.post("/leafflow/power/restart")
|
||||
async def trigger_restart(request):
|
||||
if not is_authenticated_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
|
||||
if not is_process_management_enabled():
|
||||
return web.json_response({
|
||||
"error": "Process Management is disabled. Enable 'Allow Process Management' in LeafFlow settings to use restart.",
|
||||
"enabled": False
|
||||
}, status=403)
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
data = {}
|
||||
ticket = data.get("ticket")
|
||||
if not _consume_power_ticket(ticket, "restart"):
|
||||
return web.json_response({"error": "Confirmation ticket required. Use /leafflow/power/request_token first."}, status=403)
|
||||
power_manager.execute_restart()
|
||||
return web.json_response({"status": "restarting"})
|
||||
|
||||
@routes.post("/leafflow/power/shutdown")
|
||||
async def trigger_shutdown(request):
|
||||
if not is_authenticated_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
|
||||
if not is_process_management_enabled():
|
||||
return web.json_response({
|
||||
"error": "Process Management is disabled. Enable 'Allow Process Management' in LeafFlow settings to use shutdown.",
|
||||
"enabled": False
|
||||
}, status=403)
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
data = {}
|
||||
ticket = data.get("ticket")
|
||||
if not _consume_power_ticket(ticket, "shutdown"):
|
||||
return web.json_response({"error": "Confirmation ticket required. Use /leafflow/power/request_token first."}, status=403)
|
||||
power_manager.execute_shutdown()
|
||||
return web.json_response({"status": "shutting_down"})
|
||||
|
||||
@routes.post("/leafflow/assets/restore")
|
||||
async def restore_assets_endpoint(request):
|
||||
if not is_authenticated_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
data = {}
|
||||
limit = data.get("limit")
|
||||
force = bool(data.get("force", False))
|
||||
count = assets_restore_manager.restore_on_launch(server, limit=limit, force=force)
|
||||
return web.json_response({"success": True, "restored": count, "debug": assets_restore_manager.last_debug_report})
|
||||
|
||||
@routes.get("/leafflow/assets/debug")
|
||||
async def get_assets_debug(request):
|
||||
if not is_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local access only"}, status=403)
|
||||
return web.json_response(assets_restore_manager.last_debug_report)
|
||||
|
||||
@routes.get("/leafflow/batch_queue/data")
|
||||
async def get_batch_queue_data(request):
|
||||
if not is_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local access only"}, status=403)
|
||||
return web.json_response(persistent_manager.batch_meta)
|
||||
|
||||
@routes.post("/leafflow/batch_queue/sync")
|
||||
async def sync_batch_queue_data(request):
|
||||
if not is_authenticated_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
|
||||
try:
|
||||
data = await request.json()
|
||||
pid = data.get("prompt_id")
|
||||
info = data.get("batch_info")
|
||||
if pid and info:
|
||||
persistent_manager.set_batch_info(pid, info)
|
||||
return web.json_response({"success": True})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
return web.json_response({"success": False}, status=400)
|
||||
|
||||
@routes.get("/pause_queue/status")
|
||||
async def get_status(request):
|
||||
if not is_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local access only"}, status=403)
|
||||
pause_manager.patch_all()
|
||||
return web.json_response({
|
||||
"paused": pause_manager.paused,
|
||||
@@ -384,6 +775,8 @@ def setup_queue_control_routes(server):
|
||||
|
||||
@routes.post("/pause_queue/toggle")
|
||||
async def toggle_pause(request):
|
||||
if not is_authenticated_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
@@ -399,6 +792,8 @@ def setup_queue_control_routes(server):
|
||||
|
||||
@routes.post("/pause_queue/mode")
|
||||
async def set_mode_route(request):
|
||||
if not is_authenticated_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
@@ -413,6 +808,8 @@ def setup_queue_control_routes(server):
|
||||
|
||||
@routes.post("/pause_queue/continue")
|
||||
async def continue_queue(request):
|
||||
if not is_authenticated_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
|
||||
pause_manager.set_pause(False)
|
||||
return web.json_response({
|
||||
"paused": pause_manager.paused,
|
||||
@@ -422,11 +819,13 @@ def setup_queue_control_routes(server):
|
||||
|
||||
@routes.post("/persistent_queue/claim")
|
||||
async def claim_queue(request):
|
||||
if not is_authenticated_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local authenticated access only"}, status=403)
|
||||
try:
|
||||
data = await request.json()
|
||||
client_id = data.get("client_id")
|
||||
if client_id:
|
||||
persistent_manager.sync_client_id(client_id)
|
||||
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}")
|
||||
return web.json_response({"status": "ok"})
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import re
|
||||
|
||||
def parse_find_targets(find_str):
|
||||
if not find_str:
|
||||
return []
|
||||
raw_items = []
|
||||
# Split strictly by comma
|
||||
parts = find_str.split(',')
|
||||
for part in parts:
|
||||
item = part.strip()
|
||||
if (item.startswith('"') and item.endswith('"')) or (item.startswith("'") and item.endswith("'")):
|
||||
item = item[1:-1].strip()
|
||||
if item:
|
||||
raw_items.append(item)
|
||||
return raw_items
|
||||
|
||||
class MultiTextReplacer:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"find": ("STRING", {"default": "", "multiline": True}),
|
||||
"replace": ("STRING", {"default": "", "multiline": False}),
|
||||
"case_sensitive": ("BOOLEAN", {"default": False, "advanced": True}),
|
||||
"search_mode": (["Comma Separated List", "Regex Pattern"], {"default": "Comma Separated List", "advanced": True}),
|
||||
},
|
||||
"optional": {
|
||||
"text": ("STRING", {"default": "", "multiline": True, "forceInput": True}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "INT")
|
||||
RETURN_NAMES = ("text", "replaced_count")
|
||||
FUNCTION = "replace_text"
|
||||
CATEGORY = "🍃 LeafFlow/Utils"
|
||||
DESCRIPTION = "Searches input text for multiple search targets specified in a comma-separated list or regex, and replaces all matches with a replacement string."
|
||||
|
||||
def replace_text(
|
||||
self,
|
||||
find="",
|
||||
replace="",
|
||||
case_sensitive=False,
|
||||
search_mode="Comma Separated List",
|
||||
text=None
|
||||
):
|
||||
input_text = str(text) if text is not None else ""
|
||||
if not input_text or not find:
|
||||
return (input_text, 0)
|
||||
|
||||
flags = 0 if case_sensitive else re.IGNORECASE
|
||||
replaced_count = 0
|
||||
result_text = input_text
|
||||
|
||||
if search_mode == "Comma Separated List":
|
||||
targets = parse_find_targets(find)
|
||||
if not targets:
|
||||
return (input_text, 0)
|
||||
|
||||
# Sort targets by length descending so longer phrases match first
|
||||
targets.sort(key=len, reverse=True)
|
||||
|
||||
# Build single unified regex pattern to guarantee 100% protection against recursive replacement loops
|
||||
escaped_targets = [re.escape(t) for t in targets]
|
||||
unified_pattern = "(?:" + "|".join(escaped_targets) + ")"
|
||||
|
||||
# Count total matches
|
||||
replaced_count = len(re.findall(unified_pattern, result_text, flags=flags))
|
||||
# Perform single-pass substitution
|
||||
result_text = re.sub(unified_pattern, lambda m: replace, result_text, flags=flags)
|
||||
else: # Regex Pattern
|
||||
lines = [line.strip() for line in find.splitlines() if line.strip()]
|
||||
for pattern_str in lines:
|
||||
try:
|
||||
count = len(re.findall(pattern_str, result_text, flags=flags))
|
||||
replaced_count += count
|
||||
result_text = re.sub(pattern_str, lambda m: replace, result_text, flags=flags)
|
||||
except Exception as e:
|
||||
print(f"[LeafFlow] 🍃 Invalid regex pattern '{pattern_str}': {e}")
|
||||
|
||||
return (result_text, replaced_count)
|
||||
@@ -0,0 +1,87 @@
|
||||
import re
|
||||
|
||||
class LeafFlowTextSplit:
|
||||
"""
|
||||
LeafFlow Text Node:
|
||||
Splits input text into two parts (text1 and text2) by either a literal character sequence
|
||||
or a regular expression pattern, supporting forward (from start) and backward (from end) splitting.
|
||||
"""
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"text": ("STRING", {"default": "", "multiline": True, "placeholder": "Text to split..."}),
|
||||
"split_by": ("STRING", {"default": "--", "multiline": False, "placeholder": "Character sequence or regex pattern"}),
|
||||
},
|
||||
"optional": {
|
||||
"use_regex": ("BOOLEAN", {"default": False, "tooltip": "Interpret 'split_by' as a Regular Expression instead of a literal string."}),
|
||||
"split_direction": (["forward (first occurrence from start)", "backward (last occurrence from end)"], {"default": "forward (first occurrence from start)", "advanced": True, "tooltip": "Choose whether to split on the first match from the beginning or the last match from the end."}),
|
||||
"strip_whitespace": ("BOOLEAN", {"default": False, "advanced": True, "tooltip": "Strip leading/trailing whitespace from the resulting text1 and text2."}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "STRING")
|
||||
RETURN_NAMES = ("text1", "text2")
|
||||
FUNCTION = "split_text"
|
||||
CATEGORY = "🍃 LeafFlow/Utils"
|
||||
DESCRIPTION = "Splits input text into two parts (text1 and text2) by a character sequence or regex pattern, with forward/backward splitting."
|
||||
|
||||
def split_text(
|
||||
self,
|
||||
text="",
|
||||
split_by="--",
|
||||
use_regex=False,
|
||||
split_direction="forward (first occurrence from start)",
|
||||
strip_whitespace=False
|
||||
):
|
||||
input_text = str(text) if text is not None else ""
|
||||
delimiter = str(split_by) if split_by is not None else ""
|
||||
|
||||
if not input_text or not delimiter:
|
||||
t1 = input_text.strip() if strip_whitespace else input_text
|
||||
return (t1, "")
|
||||
|
||||
is_forward = "forward" in str(split_direction).lower()
|
||||
|
||||
if use_regex:
|
||||
try:
|
||||
if is_forward:
|
||||
match = re.search(delimiter, input_text)
|
||||
if match:
|
||||
start, end = match.span()
|
||||
t1 = input_text[:start]
|
||||
t2 = input_text[end:]
|
||||
else:
|
||||
t1, t2 = input_text, ""
|
||||
else:
|
||||
# Backward: find all matches and take the last match
|
||||
matches = list(re.finditer(delimiter, input_text))
|
||||
if matches:
|
||||
last_match = matches[-1]
|
||||
start, end = last_match.span()
|
||||
t1 = input_text[:start]
|
||||
t2 = input_text[end:]
|
||||
else:
|
||||
t1, t2 = input_text, ""
|
||||
except Exception as e:
|
||||
print(f"[LeafFlow Text Split] Regex error: {e}. Falling back to literal string split.")
|
||||
if is_forward:
|
||||
parts = input_text.split(delimiter, 1)
|
||||
else:
|
||||
parts = input_text.rsplit(delimiter, 1)
|
||||
t1 = parts[0]
|
||||
t2 = parts[1] if len(parts) > 1 else ""
|
||||
else:
|
||||
# Literal string sequence split
|
||||
if is_forward:
|
||||
parts = input_text.split(delimiter, 1)
|
||||
else:
|
||||
parts = input_text.rsplit(delimiter, 1)
|
||||
t1 = parts[0]
|
||||
t2 = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
if strip_whitespace:
|
||||
t1 = t1.strip()
|
||||
t2 = t2.strip()
|
||||
|
||||
return (t1, t2)
|
||||
@@ -0,0 +1,271 @@
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import webbrowser
|
||||
from PIL import Image, ImageDraw
|
||||
from .utils import get_leafflow_user_dir
|
||||
|
||||
USER_DIR = get_leafflow_user_dir()
|
||||
ENV_FILE = os.path.join(USER_DIR, ".env")
|
||||
|
||||
def get_env_setting(key, default_val):
|
||||
if os.path.exists(ENV_FILE):
|
||||
try:
|
||||
with open(ENV_FILE, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
if line.strip().startswith(f"{key}="):
|
||||
return line.strip().split("=", 1)[1].strip()
|
||||
except Exception:
|
||||
pass
|
||||
return default_val
|
||||
|
||||
def is_tray_icon_enabled():
|
||||
val = get_env_setting("ENABLE_TRAY_ICON", "false").lower()
|
||||
if val not in ["true", "1", "yes"]:
|
||||
return False
|
||||
try:
|
||||
import pystray
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
class TrayIconManager:
|
||||
def __init__(self, pause_manager=None):
|
||||
self.pause_manager = pause_manager
|
||||
self._icon = None
|
||||
self._thread = None
|
||||
self._lock = threading.RLock()
|
||||
self._is_running = False
|
||||
|
||||
def create_icon_image(self, color_hex="#059669", symbol="pause"):
|
||||
size = 64
|
||||
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Draw rounded rectangle matching toolbar button styling
|
||||
draw.rounded_rectangle(
|
||||
[2, 2, size - 3, size - 3],
|
||||
radius=16,
|
||||
fill=color_hex,
|
||||
outline=(0, 0, 0, 60),
|
||||
width=1
|
||||
)
|
||||
|
||||
if symbol == "play":
|
||||
# Play triangle icon (white)
|
||||
draw.polygon([(24, 18), (24, 46), (46, 32)], fill="white")
|
||||
else:
|
||||
# Pause two-bar icon (white)
|
||||
draw.rectangle([20, 18, 28, 46], fill="white")
|
||||
draw.rectangle([36, 18, 44, 46], fill="white")
|
||||
|
||||
return img
|
||||
|
||||
def get_current_colors(self):
|
||||
unpaused_color = get_env_setting("PAUSE_BUTTON_UNPAUSED_COLOR", "#059669")
|
||||
paused_color = get_env_setting("PAUSE_BUTTON_PAUSED_COLOR", "#ea580c")
|
||||
return unpaused_color, paused_color
|
||||
|
||||
def get_status_info(self):
|
||||
if not self.pause_manager:
|
||||
return "Running", "#059669", "pause", "ComfyUI LeafFlow: Running"
|
||||
|
||||
unpaused_color, paused_color = self.get_current_colors()
|
||||
is_paused = self.pause_manager.paused
|
||||
is_waiting = self.pause_manager.is_waiting
|
||||
mode = self.pause_manager.mode
|
||||
mode_label = "Instant" if mode == "instantly" else "Finish"
|
||||
|
||||
if is_paused:
|
||||
if is_waiting:
|
||||
status_text = "Paused (Waiting)"
|
||||
color = paused_color
|
||||
symbol = "play"
|
||||
title = f"ComfyUI LeafFlow: Paused ({mode_label}) - Click to Continue"
|
||||
else:
|
||||
status_text = f"Pausing ({mode_label})..."
|
||||
color = paused_color
|
||||
symbol = "pause"
|
||||
title = f"ComfyUI LeafFlow: Pausing ({mode_label})... (Waiting to complete)"
|
||||
else:
|
||||
status_text = f"Running [Next Pause: {mode_label}]"
|
||||
color = unpaused_color
|
||||
symbol = "pause"
|
||||
title = f"ComfyUI LeafFlow: Running (Mode: {mode_label})"
|
||||
|
||||
return status_text, color, symbol, title
|
||||
|
||||
def build_menu(self):
|
||||
try:
|
||||
import pystray
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
status_text, _, _, _ = self.get_status_info()
|
||||
is_paused = bool(self.pause_manager and self.pause_manager.paused)
|
||||
is_waiting = bool(self.pause_manager and self.pause_manager.is_waiting)
|
||||
current_mode = self.pause_manager.mode if self.pause_manager else "after_finish"
|
||||
|
||||
def on_toggle_click(icon, item):
|
||||
if not self.pause_manager:
|
||||
return
|
||||
if self.pause_manager.paused:
|
||||
self.pause_manager.set_pause(False)
|
||||
else:
|
||||
self.pause_manager.set_pause(True)
|
||||
|
||||
def on_pause_finish_click(icon, item):
|
||||
if self.pause_manager:
|
||||
self.pause_manager.set_pause(True, mode="after_finish")
|
||||
|
||||
def on_pause_instant_click(icon, item):
|
||||
if self.pause_manager:
|
||||
self.pause_manager.set_pause(True, mode="instantly")
|
||||
|
||||
def on_continue_click(icon, item):
|
||||
if self.pause_manager:
|
||||
self.pause_manager.set_pause(False)
|
||||
|
||||
def on_set_mode_finish(icon, item):
|
||||
if self.pause_manager:
|
||||
self.pause_manager.set_mode("after_finish")
|
||||
|
||||
def on_set_mode_instant(icon, item):
|
||||
if self.pause_manager:
|
||||
self.pause_manager.set_mode("instantly")
|
||||
|
||||
def on_open_browser(icon, item):
|
||||
try:
|
||||
server_url = "http://127.0.0.1:8188"
|
||||
from server import PromptServer
|
||||
if hasattr(PromptServer, "instance") and hasattr(PromptServer.instance, "address"):
|
||||
addr = PromptServer.instance.address
|
||||
if addr and len(addr) >= 2:
|
||||
host = addr[0] or "127.0.0.1"
|
||||
port = addr[1]
|
||||
server_url = f"http://{host}:{port}"
|
||||
webbrowser.open(server_url)
|
||||
except Exception as e:
|
||||
print(f"[LeafFlow] Error opening browser: {e}")
|
||||
|
||||
def on_disable_tray(icon, item):
|
||||
self.disable_and_stop()
|
||||
|
||||
if is_paused:
|
||||
mode_desc = "Instant" if current_mode == "instantly" else "After Current"
|
||||
status_display = f"Paused ({mode_desc})"
|
||||
if is_waiting:
|
||||
status_display = f"Pausing ({mode_desc})..."
|
||||
|
||||
menu_items = [
|
||||
pystray.MenuItem(f"● Status: {status_display}", None, enabled=False),
|
||||
pystray.MenuItem("▶ Resume Queue", on_continue_click, default=True),
|
||||
]
|
||||
else:
|
||||
menu_items = [
|
||||
pystray.MenuItem("● Status: Running", None, enabled=False),
|
||||
pystray.MenuItem("⏸ Pause (Instant)", on_pause_instant_click, default=False),
|
||||
pystray.MenuItem("⏳ Pause (After Current)", on_pause_finish_click, default=False),
|
||||
]
|
||||
|
||||
return pystray.Menu(*menu_items)
|
||||
|
||||
def start(self):
|
||||
with self._lock:
|
||||
if self._is_running or self._icon is not None:
|
||||
return
|
||||
|
||||
try:
|
||||
import pystray
|
||||
except ImportError:
|
||||
print("[LeafFlow] System tray icon disabled: 'pystray' is not installed. Install pystray ('pip install pystray') to enable system tray support.")
|
||||
return
|
||||
|
||||
_, color, symbol, title = self.get_status_info()
|
||||
icon_img = self.create_icon_image(color, symbol)
|
||||
menu = self.build_menu()
|
||||
|
||||
self._icon = pystray.Icon(
|
||||
name="ComfyUI-LeafFlow",
|
||||
icon=icon_img,
|
||||
title=title,
|
||||
menu=menu
|
||||
)
|
||||
|
||||
def _run():
|
||||
try:
|
||||
self._is_running = True
|
||||
print("[LeafFlow] System tray icon started.")
|
||||
self._icon.run()
|
||||
except Exception as e:
|
||||
print(f"[LeafFlow] System tray icon error: {e}")
|
||||
finally:
|
||||
self._is_running = False
|
||||
self._icon = None
|
||||
|
||||
self._thread = threading.Thread(target=_run, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
with self._lock:
|
||||
if self._icon is not None:
|
||||
try:
|
||||
self._icon.stop()
|
||||
except Exception:
|
||||
pass
|
||||
self._icon = None
|
||||
self._is_running = False
|
||||
print("[LeafFlow] System tray icon stopped.")
|
||||
|
||||
def update_status(self):
|
||||
with self._lock:
|
||||
if not self._is_running or self._icon is None:
|
||||
return
|
||||
|
||||
try:
|
||||
_, color, symbol, title = self.get_status_info()
|
||||
self._icon.icon = self.create_icon_image(color, symbol)
|
||||
self._icon.title = title
|
||||
self._icon.menu = self.build_menu()
|
||||
except Exception as e:
|
||||
print(f"[LeafFlow] Error updating system tray status: {e}")
|
||||
|
||||
def set_enabled(self, enabled):
|
||||
if enabled:
|
||||
if not self._is_running:
|
||||
self.start()
|
||||
else:
|
||||
self.update_status()
|
||||
else:
|
||||
if self._is_running:
|
||||
self.stop()
|
||||
|
||||
def disable_and_stop(self):
|
||||
# Update .env setting and stop tray
|
||||
lines = []
|
||||
if os.path.exists(ENV_FILE):
|
||||
try:
|
||||
with open(ENV_FILE, "r", encoding="utf-8") as f:
|
||||
lines = f.readlines()
|
||||
except Exception:
|
||||
lines = []
|
||||
|
||||
new_lines = []
|
||||
has_tray = False
|
||||
for line in lines:
|
||||
if line.strip().startswith("ENABLE_TRAY_ICON="):
|
||||
new_lines.append("ENABLE_TRAY_ICON=false\n")
|
||||
has_tray = True
|
||||
else:
|
||||
new_lines.append(line)
|
||||
|
||||
if not has_tray:
|
||||
new_lines.append("ENABLE_TRAY_ICON=false\n")
|
||||
|
||||
try:
|
||||
with open(ENV_FILE, "w", encoding="utf-8") as f:
|
||||
f.writelines(new_lines)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.stop()
|
||||
@@ -1,80 +0,0 @@
|
||||
import os
|
||||
import re
|
||||
import folder_paths
|
||||
|
||||
UTILS_CATEGORY = "🍃 FlowControl/Utils"
|
||||
|
||||
def parse_pretty_name(filepath):
|
||||
base = os.path.splitext(os.path.basename(filepath))[0]
|
||||
parts = base.split('_')
|
||||
if len(parts) >= 2:
|
||||
name_part = parts[1]
|
||||
name_part = re.sub(r'(?<!^)(?=[A-Z])', ' ', name_part)
|
||||
name_part = name_part.replace('-', ' ')
|
||||
|
||||
words = name_part.split()
|
||||
formatted_words = []
|
||||
for word in words:
|
||||
if word.upper() in ["NSFW", "LORA", "V1", "V2", "V3", "V4", "FP16", "HM"]:
|
||||
formatted_words.append(word.upper())
|
||||
else:
|
||||
formatted_words.append(word.capitalize())
|
||||
return " ".join(formatted_words)
|
||||
return base.replace('-', ' ')
|
||||
|
||||
def get_pretty_names_for_folder(folder):
|
||||
all_loras = folder_paths.get_filename_list("loras")
|
||||
raw_filter = os.path.normpath(folder).lower().replace("\\", "/")
|
||||
raw_filter = raw_filter.rstrip("/* ")
|
||||
|
||||
pretty_names = set()
|
||||
for lora in all_loras:
|
||||
lora_path = folder_paths.get_full_path("loras", lora)
|
||||
if not lora_path:
|
||||
continue
|
||||
|
||||
abs_lora = os.path.normpath(lora_path).lower().replace("\\", "/")
|
||||
rel_lora = lora.lower().replace("\\", "/")
|
||||
|
||||
if raw_filter:
|
||||
if not abs_lora.startswith(raw_filter) and not rel_lora.startswith(raw_filter):
|
||||
continue
|
||||
|
||||
pretty = parse_pretty_name(lora)
|
||||
if pretty and pretty not in ["[ NONE ]", "[ RANDOM ]"]:
|
||||
pretty_names.add(pretty)
|
||||
|
||||
return sorted(list(pretty_names), key=len, reverse=True)
|
||||
|
||||
class UndoPlaceholder:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"prompt": ("STRING", {"forceInput": True}),
|
||||
"lora_folder": ("STRING", {"default": ""}),
|
||||
"placeholder": ("STRING", {"default": "%celeb%"}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("prompt",)
|
||||
FUNCTION = "undo_placeholder"
|
||||
CATEGORY = UTILS_CATEGORY
|
||||
DESCRIPTION = "Scans prompt string for names matching LoRA files in the given folder and replaces them back with a placeholder token (e.g. %celeb%)."
|
||||
|
||||
def undo_placeholder(self, prompt, lora_folder, placeholder):
|
||||
if not prompt or not lora_folder:
|
||||
return (prompt,)
|
||||
|
||||
pretty_names = get_pretty_names_for_folder(lora_folder)
|
||||
if not pretty_names:
|
||||
return (prompt,)
|
||||
|
||||
modified_prompt = prompt
|
||||
for name in pretty_names:
|
||||
escaped_name = re.escape(name)
|
||||
pattern = re.compile(rf'\b{escaped_name}\b', re.IGNORECASE)
|
||||
modified_prompt = pattern.sub(placeholder, modified_prompt)
|
||||
|
||||
return (modified_prompt,)
|
||||
+345
@@ -0,0 +1,345 @@
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
from urllib.parse import urlparse
|
||||
import folder_paths
|
||||
|
||||
def get_leafflow_user_dir():
|
||||
"""
|
||||
Returns the centralized user data directory: ComfyUI/user/default/LeafFlow/
|
||||
"""
|
||||
base_user = None
|
||||
try:
|
||||
if hasattr(folder_paths, "get_user_directory"):
|
||||
user_base = folder_paths.get_user_directory()
|
||||
if user_base:
|
||||
# ComfyUI get_user_directory() returns '.../ComfyUI/user'
|
||||
# Ensure the 'default' user profile directory is used
|
||||
norm_base = os.path.normpath(user_base)
|
||||
if os.path.basename(norm_base).lower() == "user":
|
||||
base_user = os.path.join(norm_base, "default")
|
||||
else:
|
||||
base_user = norm_base
|
||||
except Exception:
|
||||
base_user = None
|
||||
|
||||
if not base_user:
|
||||
# Fallback to ComfyUI/user/default
|
||||
root_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||
base_user = os.path.join(root_dir, "user", "default")
|
||||
|
||||
leafflow_dir = os.path.join(base_user, "LeafFlow")
|
||||
os.makedirs(leafflow_dir, exist_ok=True)
|
||||
return leafflow_dir
|
||||
|
||||
_SESSION_CSRF_TOKEN = secrets.token_hex(32)
|
||||
|
||||
def get_csrf_token():
|
||||
"""Returns the ephemeral cryptographically random session CSRF token generated on server boot."""
|
||||
return _SESSION_CSRF_TOKEN
|
||||
|
||||
def is_same_origin_or_direct(request):
|
||||
"""
|
||||
Validates that a request originates from the local ComfyUI interface and not from a visited web page (CSRF defense).
|
||||
"""
|
||||
if request is None:
|
||||
return True
|
||||
|
||||
# 1. Reject cross-site requests via Fetch Metadata (modern browsers always send this; cannot be spoofed by JS)
|
||||
sec_fetch_site = getattr(request, "headers", {}).get("Sec-Fetch-Site")
|
||||
if sec_fetch_site and sec_fetch_site.lower() == "cross-site":
|
||||
return False
|
||||
|
||||
# 2. Check Origin header if present
|
||||
origin = getattr(request, "headers", {}).get("Origin")
|
||||
if origin:
|
||||
try:
|
||||
parsed = urlparse(origin)
|
||||
host = parsed.hostname
|
||||
if host not in ["127.0.0.1", "localhost", "::1", "0.0.0.0", "testclient"]:
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# 3. Check Referer header if present
|
||||
referer = getattr(request, "headers", {}).get("Referer")
|
||||
if referer:
|
||||
try:
|
||||
parsed_ref = urlparse(referer)
|
||||
ref_host = parsed_ref.hostname
|
||||
if ref_host not in ["127.0.0.1", "localhost", "::1", "0.0.0.0", "testclient"]:
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def is_authenticated_local_request(request):
|
||||
"""
|
||||
Guarantees user-initiated actions and protects against CSRF / drive-by requests from open browser tabs:
|
||||
1. Originates strictly from loopback IP (is_local_request)
|
||||
2. Not a cross-site drive-by request (is_same_origin_or_direct)
|
||||
3. Contains a valid X-LeafFlow-CSRF-Token header matching the session secret.
|
||||
"""
|
||||
if not is_local_request(request):
|
||||
return False
|
||||
if not is_same_origin_or_direct(request):
|
||||
return False
|
||||
|
||||
headers = getattr(request, "headers", {})
|
||||
token = headers.get("X-LeafFlow-CSRF-Token")
|
||||
if not token or not secrets.compare_digest(token, _SESSION_CSRF_TOKEN):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def is_local_request(request):
|
||||
"""
|
||||
Returns True if the HTTP request originates strictly from loopback (localhost / 127.0.0.1 / ::1).
|
||||
Used to protect sensitive endpoints (power control, system settings, file browsing).
|
||||
"""
|
||||
if request is None:
|
||||
return True
|
||||
remote = getattr(request, "remote", None)
|
||||
if not remote:
|
||||
transport = getattr(request, "transport", None)
|
||||
if transport:
|
||||
peername = transport.get_extra_info("peername")
|
||||
if peername and isinstance(peername, tuple):
|
||||
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:
|
||||
return True
|
||||
try:
|
||||
import ipaddress
|
||||
ip = ipaddress.ip_address(remote)
|
||||
return ip.is_loopback
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def get_allowed_image_directories():
|
||||
"""
|
||||
Returns approved base directories for image loader operations: strictly ComfyUI input, output, and temp dirs.
|
||||
"""
|
||||
allowed = []
|
||||
try:
|
||||
inp = folder_paths.get_input_directory()
|
||||
if inp:
|
||||
allowed.append(os.path.abspath(os.path.realpath(inp)))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
out = folder_paths.get_output_directory()
|
||||
if out:
|
||||
allowed.append(os.path.abspath(os.path.realpath(out)))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
tmp = folder_paths.get_temp_directory()
|
||||
if tmp:
|
||||
allowed.append(os.path.abspath(os.path.realpath(tmp)))
|
||||
except Exception:
|
||||
pass
|
||||
return allowed
|
||||
|
||||
def is_safe_path(target_path, allowed_bases=None):
|
||||
"""
|
||||
Confines file access strictly within approved base directories.
|
||||
Prevents path traversal, directory escape, and arbitrary system file reads.
|
||||
"""
|
||||
if not target_path:
|
||||
return False
|
||||
if allowed_bases is None:
|
||||
allowed_bases = get_allowed_image_directories()
|
||||
try:
|
||||
abs_target = os.path.abspath(os.path.realpath(target_path))
|
||||
for base in allowed_bases:
|
||||
abs_base = os.path.abspath(os.path.realpath(base))
|
||||
if os.path.commonpath([abs_base, abs_target]) == abs_base:
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
|
||||
def sanitize_image_loader_folder(folder_input, default_to_output=True):
|
||||
"""
|
||||
Resolves folder input strictly within ComfyUI input/output/temp directories.
|
||||
If folder_input attempts to escape or points outside allowed bases, falls back safely.
|
||||
"""
|
||||
allowed_bases = get_allowed_image_directories()
|
||||
input_dir = folder_paths.get_input_directory()
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
default_dir = output_dir if default_to_output else input_dir
|
||||
|
||||
if not folder_input or not str(folder_input).strip():
|
||||
return default_dir
|
||||
|
||||
clean = str(folder_input).strip().rstrip("\\/")
|
||||
|
||||
if clean.lower() == "input" or clean.lower().startswith("input/") or clean.lower().startswith("input\\"):
|
||||
sub = clean[5:].lstrip("\\/")
|
||||
resolved = os.path.join(input_dir, sub) if sub else input_dir
|
||||
elif clean.lower() == "output" or clean.lower().startswith("output/") or clean.lower().startswith("output\\"):
|
||||
sub = clean[6:].lstrip("\\/")
|
||||
resolved = os.path.join(output_dir, sub) if sub else output_dir
|
||||
elif os.path.isabs(clean):
|
||||
resolved = clean
|
||||
else:
|
||||
cand_out = os.path.join(output_dir, clean)
|
||||
cand_inp = os.path.join(input_dir, clean)
|
||||
if os.path.exists(cand_inp) and not os.path.exists(cand_out):
|
||||
resolved = cand_inp
|
||||
else:
|
||||
resolved = cand_out
|
||||
|
||||
if is_safe_path(resolved, allowed_bases):
|
||||
return os.path.normpath(resolved)
|
||||
|
||||
return default_dir
|
||||
|
||||
def get_env_setting(key, default_val):
|
||||
"""
|
||||
Reads a key setting from ComfyUI/user/default/LeafFlow/.env safely.
|
||||
"""
|
||||
user_dir = get_leafflow_user_dir()
|
||||
env_file = os.path.join(user_dir, ".env")
|
||||
if os.path.exists(env_file):
|
||||
try:
|
||||
with open(env_file, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
if line.strip().startswith(f"{key}="):
|
||||
return line.strip().split("=", 1)[1].strip()
|
||||
except Exception:
|
||||
pass
|
||||
return default_val
|
||||
|
||||
def parse_pretty_name(filepath):
|
||||
if not filepath or filepath in ["[ NONE ]", "[ RANDOM ]"]:
|
||||
return ""
|
||||
base = os.path.splitext(os.path.basename(filepath))[0]
|
||||
parts = base.split('_')
|
||||
if len(parts) >= 2:
|
||||
name_part = parts[1]
|
||||
name_part = re.sub(r'(?<=[a-z])(?=[A-Z])', ' ', name_part)
|
||||
name_part = name_part.replace('-', ' ')
|
||||
|
||||
words = name_part.split()
|
||||
formatted_words = []
|
||||
for word in words:
|
||||
if word.upper() in ["NSFW", "LORA", "V1", "V2", "V3", "V4", "FP16", "HM"]:
|
||||
formatted_words.append(word.upper())
|
||||
else:
|
||||
formatted_words.append(word.capitalize())
|
||||
return " ".join(formatted_words)
|
||||
return base.replace('-', ' ')
|
||||
|
||||
def parse_pretty_name_with_version(filepath):
|
||||
if not filepath or filepath in ["[ NONE ]", "[ RANDOM ]"]:
|
||||
return ""
|
||||
base = os.path.splitext(os.path.basename(filepath))[0]
|
||||
parts = base.split('_')
|
||||
pretty_name = parse_pretty_name(filepath)
|
||||
|
||||
if len(parts) >= 3:
|
||||
version_candidate = parts[2].strip()
|
||||
if re.match(r'^v[0-9]+(\.[0-9]+)?$', version_candidate, re.IGNORECASE):
|
||||
return f"{pretty_name} {version_candidate.upper()}"
|
||||
return pretty_name
|
||||
|
||||
def sanitize_folder_path(folder_input, default_dir=None):
|
||||
if folder_input is None:
|
||||
folder_input = ""
|
||||
clean_folder = str(folder_input).strip()
|
||||
|
||||
# Strip any trailing wildcards (e.g. "krea2\*" -> "krea2", "krea2/*" -> "krea2")
|
||||
clean_folder = re.sub(r'[\*\?]+$', '', clean_folder).rstrip("\\/")
|
||||
|
||||
if not clean_folder and default_dir:
|
||||
clean_folder = default_dir
|
||||
|
||||
if not clean_folder:
|
||||
return ""
|
||||
|
||||
# 1. If absolute path, verify directly with case-insensitive check
|
||||
if os.path.isabs(clean_folder):
|
||||
norm_path = os.path.normpath(clean_folder)
|
||||
if os.path.exists(norm_path):
|
||||
return norm_path
|
||||
parent_dir = os.path.dirname(norm_path)
|
||||
base_name = os.path.basename(norm_path)
|
||||
if os.path.exists(parent_dir):
|
||||
try:
|
||||
for entry in os.listdir(parent_dir):
|
||||
if entry.lower() == base_name.lower():
|
||||
return os.path.join(parent_dir, entry)
|
||||
except Exception:
|
||||
pass
|
||||
return norm_path
|
||||
|
||||
# 2. If relative path, check output directory, input directory, then base path
|
||||
candidates = []
|
||||
try:
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
if output_dir:
|
||||
candidates.append(os.path.normpath(os.path.join(output_dir, clean_folder)))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
input_dir = folder_paths.get_input_directory()
|
||||
if input_dir:
|
||||
candidates.append(os.path.normpath(os.path.join(input_dir, clean_folder)))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
if folder_paths.base_path:
|
||||
candidates.append(os.path.normpath(os.path.join(folder_paths.base_path, clean_folder)))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for cand in candidates:
|
||||
if os.path.exists(cand):
|
||||
return cand
|
||||
|
||||
for cand in candidates:
|
||||
parent_dir = os.path.dirname(cand)
|
||||
base_name = os.path.basename(cand)
|
||||
if os.path.exists(parent_dir):
|
||||
try:
|
||||
for entry in os.listdir(parent_dir):
|
||||
if entry.lower() == base_name.lower():
|
||||
return os.path.join(parent_dir, entry)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return candidates[0] if candidates else os.path.normpath(os.path.join(folder_paths.base_path, clean_folder))
|
||||
|
||||
def format_lora_output_name(resolved_path, display_name, output_format="Parsed Name", custom_regex=""):
|
||||
if not resolved_path or resolved_path in ["[ NONE ]", "[ RANDOM ]"]:
|
||||
return ""
|
||||
|
||||
if output_format == "Filename":
|
||||
return os.path.basename(resolved_path)
|
||||
elif output_format == "Filename without extension":
|
||||
return os.path.splitext(os.path.basename(resolved_path))[0]
|
||||
elif output_format == "Relative Path":
|
||||
return resolved_path.replace("\\", "/")
|
||||
elif output_format == "Full Path":
|
||||
full_p = folder_paths.get_full_path("loras", resolved_path)
|
||||
return full_p.replace("\\", "/") if full_p else resolved_path
|
||||
elif output_format == "Custom Regex" and custom_regex and custom_regex.strip():
|
||||
try:
|
||||
m = re.search(custom_regex, resolved_path)
|
||||
if m:
|
||||
return m.group(0)
|
||||
except Exception:
|
||||
pass
|
||||
return os.path.splitext(os.path.basename(resolved_path))[0]
|
||||
else: # "Parsed Name" (default)
|
||||
clean_name = display_name
|
||||
if " - " in display_name:
|
||||
clean_name = display_name.split(" - ", 1)[1]
|
||||
return re.sub(r'\s+V\d+(\.\d+)?$', '', clean_name, flags=re.IGNORECASE).strip()
|
||||
+10
-6
@@ -1,14 +1,18 @@
|
||||
[project]
|
||||
name = "comfyui-flowcontrol"
|
||||
name = "comfyui-leafflow"
|
||||
description = "Essential workflow controls, mid-generation pausing, real-time queue persistence, folder watcher, and visual model loaders for ComfyUI."
|
||||
version = "1.0.0"
|
||||
version = "2.3.1"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
dependencies = ["torch", "pillow", "numpy", "piexif"]
|
||||
dependencies = ["pillow", "numpy", "piexif"]
|
||||
|
||||
[project.optional-dependencies]
|
||||
tray = ["pystray"]
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/KOFiblto/ComfyUI-FlowControl"
|
||||
Repository = "https://github.com/KOFiblto/ComfyUI-LeafFlow"
|
||||
|
||||
[tool.comfy]
|
||||
PublisherId = "KOFiblto"
|
||||
DisplayName = "ComfyUI-FlowControl"
|
||||
Icon = ""
|
||||
DisplayName = "ComfyUI-LeafFlow"
|
||||
Icon = "https://raw.githubusercontent.com/KOFiblto/ComfyUI-LeafFlow/main/web/logo.png"
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
Pillow
|
||||
piexif
|
||||
numpy
|
||||
# pystray # Optional: install manually if desktop system tray icon integration is enabled
|
||||
@@ -0,0 +1,8 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Ensure LeafFlow 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)
|
||||
sys.path.insert(0, ROOT)
|
||||
@@ -0,0 +1,12 @@
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
if test_dir not in sys.path:
|
||||
sys.path.insert(0, test_dir)
|
||||
suite = unittest.defaultTestLoader.discover(test_dir, pattern="test_*.py")
|
||||
runner = unittest.TextTestRunner(verbosity=2)
|
||||
result = runner.run(suite)
|
||||
sys.exit(0 if result.wasSuccessful() else 1)
|
||||
@@ -0,0 +1,136 @@
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import test_helper
|
||||
|
||||
from nodes.aspect_ratio import TextAspectRatioFinder, PreviewImageSizeAspectRatio
|
||||
|
||||
class TestAspectRatioNodes(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.finder = TextAspectRatioFinder()
|
||||
self.preview_node = PreviewImageSizeAspectRatio()
|
||||
|
||||
def test_find_16_9_standard(self):
|
||||
w, h, ratio = self.finder.find_aspect_ratio(
|
||||
text="A cinematic landscape, 16:9 aspect ratio, 8k",
|
||||
target_mp=1.0,
|
||||
multiple_of=8
|
||||
)
|
||||
self.assertEqual(ratio, "16:9")
|
||||
self.assertGreater(w, h)
|
||||
self.assertEqual(w % 8, 0)
|
||||
self.assertEqual(h % 8, 0)
|
||||
mp = (w * h) / 1_000_000.0
|
||||
self.assertAlmostEqual(mp, 1.0, delta=0.1)
|
||||
|
||||
def test_find_9_16_portrait(self):
|
||||
w, h, ratio = self.finder.find_aspect_ratio(
|
||||
text="Portrait photo, 9:16 vertical format",
|
||||
target_mp=1.0,
|
||||
multiple_of=8
|
||||
)
|
||||
self.assertEqual(ratio, "9:16")
|
||||
self.assertGreater(h, w)
|
||||
self.assertEqual(w % 8, 0)
|
||||
self.assertEqual(h % 8, 0)
|
||||
|
||||
def test_search_mode_front_vs_back(self):
|
||||
text = "Start with 16:9 but override with 1:1 at the end"
|
||||
w_front, h_front, r_front = self.finder.find_aspect_ratio(
|
||||
text=text,
|
||||
search_mode="First match (Front)"
|
||||
)
|
||||
self.assertEqual(r_front, "16:9")
|
||||
|
||||
w_back, h_back, r_back = self.finder.find_aspect_ratio(
|
||||
text=text,
|
||||
search_mode="Last match (Back)"
|
||||
)
|
||||
self.assertEqual(r_back, "1:1")
|
||||
|
||||
def test_fallback_to_default_when_no_ratio_found(self):
|
||||
w, h, ratio = self.finder.find_aspect_ratio(
|
||||
text="No ratio mentioned here at all",
|
||||
default_aspect_ratio="4:3",
|
||||
target_mp=1.0
|
||||
)
|
||||
self.assertEqual(ratio, "4:3")
|
||||
|
||||
def test_edge_case_empty_or_none_text(self):
|
||||
w1, h1, r1 = self.finder.find_aspect_ratio(text="", default_aspect_ratio="1:1")
|
||||
self.assertEqual(r1, "1:1")
|
||||
|
||||
w2, h2, r2 = self.finder.find_aspect_ratio(text=None, default_aspect_ratio="1:1")
|
||||
self.assertEqual(r2, "1:1")
|
||||
|
||||
def test_custom_default_aspect_ratio(self):
|
||||
w, h, ratio = self.finder.find_aspect_ratio(
|
||||
text="No ratio mentioned",
|
||||
default_aspect_ratio="5:4",
|
||||
target_mp=1.0
|
||||
)
|
||||
self.assertEqual(ratio, "5:4")
|
||||
|
||||
w2, h2, ratio2 = self.finder.find_aspect_ratio(
|
||||
text="No ratio mentioned",
|
||||
default_aspect_ratio="30:1",
|
||||
target_mp=1.0
|
||||
)
|
||||
self.assertEqual(ratio2, "30:1")
|
||||
|
||||
def test_corrupt_default_aspect_ratio_falls_back_to_1_1(self):
|
||||
w, h, ratio = self.finder.find_aspect_ratio(
|
||||
text="No ratio mentioned",
|
||||
default_aspect_ratio="corrupted:invalid_ratio",
|
||||
target_mp=1.0
|
||||
)
|
||||
self.assertEqual(ratio, "1:1")
|
||||
|
||||
def test_custom_allowed_ratios_whitelist(self):
|
||||
# When aspect_ratios is configured with "16:9, 9:16", any other ratio in text like "4:3" or "5:7" must be ignored!
|
||||
w, h, ratio = self.finder.find_aspect_ratio(
|
||||
text="First 4:3 then 16:9 and also 5:7",
|
||||
aspect_ratios="16:9, 9:16",
|
||||
default_aspect_ratio="1:1"
|
||||
)
|
||||
self.assertEqual(ratio, "16:9")
|
||||
|
||||
# When prompt only contains unapproved ratios, fall back to default
|
||||
w2, h2, ratio2 = self.finder.find_aspect_ratio(
|
||||
text="A photo in 5:7 format with 12:34 crop",
|
||||
aspect_ratios="16:9, 9:16, 1:1",
|
||||
default_aspect_ratio="1:1"
|
||||
)
|
||||
self.assertEqual(ratio2, "1:1")
|
||||
|
||||
def test_empty_aspect_ratios_accepts_any_ratio(self):
|
||||
# When aspect_ratios is empty, accept any valid aspect ratio found in text
|
||||
w, h, ratio = self.finder.find_aspect_ratio(
|
||||
text="A photo in custom 5:7 format",
|
||||
aspect_ratios="",
|
||||
default_aspect_ratio="1:1"
|
||||
)
|
||||
self.assertEqual(ratio, "5:7")
|
||||
|
||||
w2, h2, ratio2 = self.finder.find_aspect_ratio(
|
||||
text="Anamorphic 2.39:1 movie frame",
|
||||
aspect_ratios=" ",
|
||||
default_aspect_ratio="1:1"
|
||||
)
|
||||
self.assertEqual(ratio2, "2.39:1")
|
||||
|
||||
def test_preview_process(self):
|
||||
with patch("nodes.aspect_ratio.PromptServer.instance.send_sync") as mock_send:
|
||||
res = self.preview_node.process_preview(
|
||||
width=1920,
|
||||
height=1080,
|
||||
unique_id="test_node"
|
||||
)
|
||||
self.assertEqual(res, {})
|
||||
mock_send.assert_called_once()
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,92 @@
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
import tempfile
|
||||
import shutil
|
||||
from PIL import Image
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import test_helper
|
||||
|
||||
from nodes.auto_watcher import LoadImageFromFolder
|
||||
|
||||
class TestAutoWatcher(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.node = LoadImageFromFolder()
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def test_create_dummy_image(self):
|
||||
dummy = self.node.create_dummy_image()
|
||||
self.assertEqual(dummy.shape, (1, 512, 512, 3))
|
||||
|
||||
def test_get_filtered_files_by_regex(self):
|
||||
img = Image.new("RGB", (64, 64), color="blue")
|
||||
f1 = os.path.join(self.temp_dir, "test_01.png")
|
||||
f2 = os.path.join(self.temp_dir, "test_02.jpg")
|
||||
f3 = os.path.join(self.temp_dir, "other.png")
|
||||
f4 = os.path.join(self.temp_dir, "ignored.txt")
|
||||
img.save(f1)
|
||||
img.save(f2)
|
||||
img.save(f3)
|
||||
with open(f4, "w") as f:
|
||||
f.write("text file")
|
||||
|
||||
files = self.node.get_filtered_files(self.temp_dir, sort_by="name", regex_filter=r"test_\d+")
|
||||
self.assertEqual(len(files), 2)
|
||||
self.assertIn(f1, files)
|
||||
self.assertIn(f2, files)
|
||||
self.assertNotIn(f3, files)
|
||||
self.assertNotIn(f4, files)
|
||||
|
||||
def test_empty_folder_returns_dummy(self):
|
||||
empty_dir = os.path.join(self.temp_dir, "empty")
|
||||
os.makedirs(empty_dir, exist_ok=True)
|
||||
img_tensor, has_image = self.node.watch(
|
||||
folder=empty_dir,
|
||||
wait_if_folder_is_empty=False,
|
||||
rescan_interval=1,
|
||||
sort_by="date_modified",
|
||||
regex_filter=".*"
|
||||
)
|
||||
self.assertFalse(has_image)
|
||||
self.assertEqual(img_tensor.shape, (1, 512, 512, 3))
|
||||
|
||||
def test_sequential_cycle_when_delete_false(self):
|
||||
"""Verify that when delete_image=False, consecutive runs cycle through files instead of loading the same file forever."""
|
||||
f1 = os.path.join(self.temp_dir, "img_a.png")
|
||||
f2 = os.path.join(self.temp_dir, "img_b.png")
|
||||
Image.new("RGB", (64, 64), color="red").save(f1)
|
||||
Image.new("RGB", (64, 64), color="green").save(f2)
|
||||
|
||||
# Run 1 -> loads img_a
|
||||
t1, has1 = self.node.watch(folder=self.temp_dir, wait_if_folder_is_empty=False, rescan_interval=1, sort_by="name", regex_filter=".*", delete_image=False, unique_id="cycle_test")
|
||||
self.assertTrue(has1)
|
||||
|
||||
# Run 2 -> loads img_b (cycled!)
|
||||
t2, has2 = self.node.watch(folder=self.temp_dir, wait_if_folder_is_empty=False, rescan_interval=1, sort_by="name", regex_filter=".*", delete_image=False, unique_id="cycle_test")
|
||||
self.assertTrue(has2)
|
||||
|
||||
# Run 3 -> loops back to img_a
|
||||
t3, has3 = self.node.watch(folder=self.temp_dir, wait_if_folder_is_empty=False, rescan_interval=1, sort_by="name", regex_filter=".*", delete_image=False, unique_id="cycle_test")
|
||||
self.assertTrue(has3)
|
||||
|
||||
def test_load_and_remove_image_with_delete_toggle(self):
|
||||
img = Image.new("RGB", (64, 64), color="red")
|
||||
img_path = os.path.join(self.temp_dir, "test_del.png")
|
||||
img.save(img_path)
|
||||
|
||||
# 1. delete_image = False (default: file must remain on disk)
|
||||
tensor = self.node.load_and_remove_image(img_path, delete_image=False)
|
||||
self.assertEqual(tensor.shape, (1, 64, 64, 3))
|
||||
self.assertTrue(os.path.exists(img_path), "File should not be deleted when delete_image=False")
|
||||
|
||||
# 2. delete_image = True (file must be removed from disk)
|
||||
tensor2 = self.node.load_and_remove_image(img_path, delete_image=True)
|
||||
self.assertEqual(tensor2.shape, (1, 64, 64, 3))
|
||||
self.assertFalse(os.path.exists(img_path), "File should be deleted when delete_image=True")
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,195 @@
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
import json
|
||||
import tempfile
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import test_helper
|
||||
|
||||
from nodes.queue_control import PersistentQueueManager
|
||||
|
||||
# 36 Colors matching web/batch_queue.js
|
||||
BATCH_COLORS = [
|
||||
"#10b981", "#3b82f6", "#8b5cf6", "#ec4899", "#f59e0b", "#06b6d4",
|
||||
"#ef4444", "#14b8a6", "#6366f1", "#f97316", "#84cc16", "#d946ef",
|
||||
"#0ea5e9", "#a855f7", "#e11d48", "#22c55e", "#eab308", "#64748b",
|
||||
"#2dd4bf", "#fb7185", "#38bdf8", "#c084fc", "#f43f5e", "#4ade80",
|
||||
"#fbbf24", "#818cf8", "#fb923c", "#a3e635", "#f472b6", "#22d3ee",
|
||||
"#e879f9", "#a7f3d0", "#fde047", "#fda4af", "#93c5fd", "#c4b5fd"
|
||||
]
|
||||
|
||||
def calculate_segment_types(ordered_pids, registry):
|
||||
"""
|
||||
Python mirror of the segment calculation logic in web/batch_queue.js
|
||||
"""
|
||||
segments = {}
|
||||
for pid in ordered_pids:
|
||||
info = registry.get(pid)
|
||||
if not info or not info.get("batchId"):
|
||||
segments[pid] = None
|
||||
continue
|
||||
|
||||
batch_id = info["batchId"]
|
||||
occurrences = [p for p in ordered_pids if registry.get(p, {}).get("batchId") == batch_id]
|
||||
|
||||
if len(occurrences) <= 1:
|
||||
batch_count = info.get("batchCount", 1)
|
||||
if batch_count == 1:
|
||||
segments[pid] = "single"
|
||||
else:
|
||||
item_index = info.get("itemIndex", 0)
|
||||
if item_index == 0:
|
||||
segments[pid] = "first"
|
||||
elif item_index == (batch_count - 1):
|
||||
segments[pid] = "last"
|
||||
else:
|
||||
segments[pid] = "middle"
|
||||
continue
|
||||
|
||||
first_pid = occurrences[0]
|
||||
last_pid = occurrences[-1]
|
||||
|
||||
if pid == first_pid:
|
||||
segments[pid] = "first"
|
||||
elif pid == last_pid:
|
||||
segments[pid] = "last"
|
||||
else:
|
||||
segments[pid] = "middle"
|
||||
|
||||
return segments
|
||||
|
||||
|
||||
class TestBatchQueueLogic(unittest.TestCase):
|
||||
def test_36_color_cycling(self):
|
||||
self.assertEqual(len(BATCH_COLORS), 36)
|
||||
|
||||
# Batch 1 -> color index 0
|
||||
self.assertEqual((1 - 1) % 36, 0)
|
||||
self.assertEqual(BATCH_COLORS[(1 - 1) % 36], "#10b981")
|
||||
|
||||
# Batch 36 -> color index 35
|
||||
self.assertEqual((36 - 1) % 36, 35)
|
||||
self.assertEqual(BATCH_COLORS[(36 - 1) % 36], "#c4b5fd")
|
||||
|
||||
# Batch 37 -> cycles back to color index 0
|
||||
self.assertEqual((37 - 1) % 36, 0)
|
||||
self.assertEqual(BATCH_COLORS[(37 - 1) % 36], "#10b981")
|
||||
|
||||
# Batch 73 -> cycles back to color index 0
|
||||
self.assertEqual((73 - 1) % 36, 0)
|
||||
|
||||
def test_single_item_batch(self):
|
||||
registry = {
|
||||
"p1": {"batchId": "b1", "batchCount": 1, "itemIndex": 0}
|
||||
}
|
||||
segments = calculate_segment_types(["p1"], registry)
|
||||
self.assertEqual(segments["p1"], "single")
|
||||
|
||||
def test_intact_contiguous_batch(self):
|
||||
registry = {
|
||||
"p1": {"batchId": "b1", "batchCount": 3, "itemIndex": 0},
|
||||
"p2": {"batchId": "b1", "batchCount": 3, "itemIndex": 1},
|
||||
"p3": {"batchId": "b1", "batchCount": 3, "itemIndex": 2},
|
||||
}
|
||||
segments = calculate_segment_types(["p1", "p2", "p3"], registry)
|
||||
self.assertEqual(segments["p1"], "first")
|
||||
self.assertEqual(segments["p2"], "middle")
|
||||
self.assertEqual(segments["p3"], "last")
|
||||
|
||||
def test_interrupted_batch_inserted_in_between(self):
|
||||
# Batch A was [pA1, pA2], but prompt B was inserted in-between: [pA1, pB, pA2]
|
||||
registry = {
|
||||
"pA1": {"batchId": "bA", "batchCount": 2, "itemIndex": 0},
|
||||
"pB": {"batchId": "bB", "batchCount": 1, "itemIndex": 0},
|
||||
"pA2": {"batchId": "bA", "batchCount": 2, "itemIndex": 1},
|
||||
}
|
||||
segments = calculate_segment_types(["pA1", "pB", "pA2"], registry)
|
||||
# pA1 is the start of Batch A (curves top, open bottom)
|
||||
self.assertEqual(segments["pA1"], "first")
|
||||
# pB is a standalone single item (curves top and bottom)
|
||||
self.assertEqual(segments["pB"], "single")
|
||||
# pA2 is the end of Batch A (open top, curves bottom)
|
||||
self.assertEqual(segments["pA2"], "last")
|
||||
|
||||
def test_multiple_interruptions(self):
|
||||
# [pA1, pB, pA2, pC, pA3]
|
||||
registry = {
|
||||
"pA1": {"batchId": "bA", "batchCount": 3, "itemIndex": 0},
|
||||
"pB": {"batchId": "bB", "batchCount": 1, "itemIndex": 0},
|
||||
"pA2": {"batchId": "bA", "batchCount": 3, "itemIndex": 1},
|
||||
"pC": {"batchId": "bC", "batchCount": 1, "itemIndex": 0},
|
||||
"pA3": {"batchId": "bA", "batchCount": 3, "itemIndex": 2},
|
||||
}
|
||||
segments = calculate_segment_types(["pA1", "pB", "pA2", "pC", "pA3"], registry)
|
||||
self.assertEqual(segments["pA1"], "first")
|
||||
self.assertEqual(segments["pB"], "single")
|
||||
self.assertEqual(segments["pA2"], "middle")
|
||||
self.assertEqual(segments["pC"], "single")
|
||||
self.assertEqual(segments["pA3"], "last")
|
||||
|
||||
|
||||
class TestPersistentQueueBatchMetadata(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.temp_file = os.path.join(self.temp_dir.name, "test_persistent_queue.json")
|
||||
self.patcher = patch("nodes.queue_control.PERSISTENT_FILE", self.temp_file)
|
||||
self.patcher.start()
|
||||
self.enabled_patcher = patch("nodes.queue_control.is_persistent_queue_enabled", return_value=True)
|
||||
self.enabled_patcher.start()
|
||||
|
||||
def tearDown(self):
|
||||
self.enabled_patcher.stop()
|
||||
self.patcher.stop()
|
||||
self.temp_dir.cleanup()
|
||||
|
||||
def test_save_and_restore_batch_info(self):
|
||||
mgr = PersistentQueueManager()
|
||||
|
||||
# Set batch info for a prompt
|
||||
batch_data = {
|
||||
"batchId": "b_12345",
|
||||
"batchNumber": 5,
|
||||
"batchCount": 3,
|
||||
"itemIndex": 0,
|
||||
"color": "#10b981",
|
||||
"colorIndex": 4
|
||||
}
|
||||
mgr.set_batch_info("prompt_99", batch_data)
|
||||
self.assertIn("prompt_99", mgr.batch_meta)
|
||||
|
||||
# Add item to persistent queue
|
||||
item_tuple = (0, "prompt_99", {"prompt": {}}, {}, {})
|
||||
mgr.add_item(item_tuple)
|
||||
|
||||
# Verify batch_info is attached inside persistent_items
|
||||
saved_entry = [x for x in mgr.persistent_items if x["prompt_id"] == "prompt_99"][0]
|
||||
self.assertEqual(saved_entry.get("batch_info"), batch_data)
|
||||
|
||||
# Simulate reload from disk
|
||||
new_mgr = PersistentQueueManager()
|
||||
self.assertIn("prompt_99", new_mgr.batch_meta)
|
||||
self.assertEqual(new_mgr.batch_meta["prompt_99"]["batchId"], "b_12345")
|
||||
|
||||
def test_remove_item_cleans_batch_meta(self):
|
||||
mgr = PersistentQueueManager()
|
||||
mgr.set_batch_info("p1", {"batchId": "b1"})
|
||||
mgr.add_item((0, "p1", {}, {}, {}))
|
||||
self.assertIn("p1", mgr.batch_meta)
|
||||
|
||||
mgr.remove_item("p1")
|
||||
self.assertNotIn("p1", mgr.batch_meta)
|
||||
self.assertEqual(len(mgr.persistent_items), 0)
|
||||
|
||||
def test_wipe_all_cleans_batch_meta(self):
|
||||
mgr = PersistentQueueManager()
|
||||
mgr.set_batch_info("p1", {"batchId": "b1"})
|
||||
mgr.set_batch_info("p2", {"batchId": "b2"})
|
||||
mgr.wipe_all()
|
||||
self.assertEqual(len(mgr.batch_meta), 0)
|
||||
self.assertEqual(len(mgr.persistent_items), 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,38 @@
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
import threading
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import test_helper
|
||||
|
||||
from nodes.decision_node import DecisionManager, LeafFlowDecision
|
||||
|
||||
class TestDecisionNode(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.node = LeafFlowDecision()
|
||||
|
||||
def test_decision_manager_register_and_trigger(self):
|
||||
uid = "test_decision_123"
|
||||
event = threading.Event()
|
||||
DecisionManager.register_wait(uid, event)
|
||||
self.assertIn(uid, DecisionManager._waiting_events)
|
||||
|
||||
triggered = DecisionManager.trigger_action(uid, "cancel")
|
||||
self.assertTrue(triggered)
|
||||
self.assertTrue(event.is_set())
|
||||
|
||||
action = DecisionManager.get_action(uid)
|
||||
self.assertEqual(action, "cancel")
|
||||
self.assertNotIn(uid, DecisionManager._waiting_events)
|
||||
|
||||
def test_decision_disabled_mode(self):
|
||||
res = self.node.decide(disable=True, send_os_notification=False, timeout=-1, unique_id="disabled_node")
|
||||
self.assertEqual(res, (False,))
|
||||
|
||||
def test_decision_manager_unknown_id_default(self):
|
||||
action = DecisionManager.get_action("non_existent_id")
|
||||
self.assertEqual(action, "continue")
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,83 @@
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
LEAFFLOW_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)
|
||||
|
||||
# If 'nodes' in sys.modules is ComfyUI's root nodes.py file, replace it with LeafFlow's 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):
|
||||
del sys.modules["nodes"]
|
||||
|
||||
# Provide lightweight mock stubs for ComfyUI environment if running in standalone CI / test runner
|
||||
if "aiohttp" not in sys.modules:
|
||||
try:
|
||||
import aiohttp
|
||||
except ImportError:
|
||||
aiohttp_mock = types.ModuleType("aiohttp")
|
||||
web_mock = types.ModuleType("aiohttp.web")
|
||||
web_mock.json_response = MagicMock(return_value={})
|
||||
web_mock.Response = MagicMock()
|
||||
aiohttp_mock.web = web_mock
|
||||
sys.modules["aiohttp"] = aiohttp_mock
|
||||
sys.modules["aiohttp.web"] = web_mock
|
||||
|
||||
if "piexif" not in sys.modules:
|
||||
try:
|
||||
import piexif
|
||||
except ImportError:
|
||||
piexif_mock = types.ModuleType("piexif")
|
||||
piexif_mock.load = MagicMock(return_value={})
|
||||
piexif_mock.dump = MagicMock(return_value=b"")
|
||||
piexif_mock.insert = MagicMock()
|
||||
sys.modules["piexif"] = piexif_mock
|
||||
|
||||
if "pystray" not in sys.modules:
|
||||
try:
|
||||
import pystray
|
||||
except ImportError:
|
||||
pystray_mock = types.ModuleType("pystray")
|
||||
sys.modules["pystray"] = pystray_mock
|
||||
|
||||
if "comfy" not in sys.modules:
|
||||
comfy_mock = types.ModuleType("comfy")
|
||||
mm_mock = types.ModuleType("comfy.model_management")
|
||||
mm_mock.throw_exception_if_processing_interrupted = MagicMock()
|
||||
comfy_mock.model_management = mm_mock
|
||||
|
||||
sd_mock = types.ModuleType("comfy.sd")
|
||||
sd_mock.load_lora_for_models = MagicMock(side_effect=lambda m, c, l, sm, sc: (m, c))
|
||||
comfy_mock.sd = sd_mock
|
||||
|
||||
utils_mock = types.ModuleType("comfy.utils")
|
||||
utils_mock.load_torch_file = MagicMock(return_value={})
|
||||
comfy_mock.utils = utils_mock
|
||||
|
||||
sys.modules["comfy"] = comfy_mock
|
||||
sys.modules["comfy.model_management"] = mm_mock
|
||||
sys.modules["comfy.sd"] = sd_mock
|
||||
sys.modules["comfy.utils"] = utils_mock
|
||||
|
||||
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_filename_list = MagicMock(return_value=[])
|
||||
fp_mock.get_full_path = MagicMock(return_value="")
|
||||
fp_mock.base_path = LEAFFLOW_ROOT
|
||||
sys.modules["folder_paths"] = fp_mock
|
||||
|
||||
if "server" not in sys.modules:
|
||||
server_mock = types.ModuleType("server")
|
||||
ps_mock = MagicMock()
|
||||
ps_mock.instance.routes = MagicMock()
|
||||
server_mock.PromptServer = ps_mock
|
||||
sys.modules["server"] = server_mock
|
||||
@@ -0,0 +1,162 @@
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import test_helper
|
||||
|
||||
from nodes.image_loader import trace_positive_prompt_from_graph, parse_positive_from_parameters
|
||||
|
||||
class TestPromptExtraction(unittest.TestCase):
|
||||
def test_standard_ksampler_graph(self):
|
||||
graph = {
|
||||
"3": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"positive": ["6", 0],
|
||||
"negative": ["7", 0],
|
||||
"model": ["4", 0]
|
||||
}
|
||||
},
|
||||
"6": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"text": "a majestic lion in the savanna at golden hour"
|
||||
}
|
||||
},
|
||||
"7": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"text": "ugly, blurry, low quality"
|
||||
}
|
||||
}
|
||||
}
|
||||
res = trace_positive_prompt_from_graph(graph)
|
||||
self.assertEqual(res, "a majestic lion in the savanna at golden hour")
|
||||
|
||||
def test_flux_guidance_graph(self):
|
||||
graph = {
|
||||
"1": {
|
||||
"class_type": "KSamplerSelect",
|
||||
"inputs": {}
|
||||
},
|
||||
"2": {
|
||||
"class_type": "SamplerCustomAdvanced",
|
||||
"inputs": {
|
||||
"guider": ["3", 0]
|
||||
}
|
||||
},
|
||||
"3": {
|
||||
"class_type": "BasicGuider",
|
||||
"inputs": {
|
||||
"conditioning": ["4", 0]
|
||||
}
|
||||
},
|
||||
"4": {
|
||||
"class_type": "FluxGuidance",
|
||||
"inputs": {
|
||||
"guidance": 3.5,
|
||||
"conditioning": ["5", 0]
|
||||
}
|
||||
},
|
||||
"5": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"text": "a cyberpunk futuristic street in Tokyo, neon reflections"
|
||||
}
|
||||
}
|
||||
}
|
||||
res = trace_positive_prompt_from_graph(graph)
|
||||
self.assertEqual(res, "a cyberpunk futuristic street in Tokyo, neon reflections")
|
||||
|
||||
def test_sdxl_text_g_and_text_l(self):
|
||||
graph = {
|
||||
"10": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"positive": ["20", 0],
|
||||
"negative": ["21", 0]
|
||||
}
|
||||
},
|
||||
"20": {
|
||||
"class_type": "CLIPTextEncodeSDXL",
|
||||
"inputs": {
|
||||
"text_g": "an astronaut riding a horse",
|
||||
"text_l": "photorealistic, 8k resolution"
|
||||
}
|
||||
},
|
||||
"21": {
|
||||
"class_type": "CLIPTextEncodeSDXL",
|
||||
"inputs": {
|
||||
"text_g": "blurry",
|
||||
"text_l": "cartoon"
|
||||
}
|
||||
}
|
||||
}
|
||||
res = trace_positive_prompt_from_graph(graph)
|
||||
self.assertIn("an astronaut riding a horse", res)
|
||||
self.assertIn("photorealistic, 8k resolution", res)
|
||||
self.assertNotIn("blurry", res)
|
||||
|
||||
def test_conditioning_combine(self):
|
||||
graph = {
|
||||
"1": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"positive": ["2", 0],
|
||||
"negative": ["9", 0]
|
||||
}
|
||||
},
|
||||
"2": {
|
||||
"class_type": "ConditioningCombine",
|
||||
"inputs": {
|
||||
"conditioning_1": ["3", 0],
|
||||
"conditioning_2": ["4", 0]
|
||||
}
|
||||
},
|
||||
"3": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": "part 1 of positive prompt"}
|
||||
},
|
||||
"4": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": "part 2 of positive prompt"}
|
||||
}
|
||||
}
|
||||
res = trace_positive_prompt_from_graph(graph)
|
||||
self.assertIn("part 1 of positive prompt", res)
|
||||
self.assertIn("part 2 of positive prompt", res)
|
||||
|
||||
def test_primitive_string_link(self):
|
||||
graph = {
|
||||
"1": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"positive": ["2", 0]
|
||||
}
|
||||
},
|
||||
"2": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"text": ["3", 0]
|
||||
}
|
||||
},
|
||||
"3": {
|
||||
"class_type": "PrimitiveString",
|
||||
"inputs": {
|
||||
"value": "string from primitive node"
|
||||
}
|
||||
}
|
||||
}
|
||||
res = trace_positive_prompt_from_graph(graph)
|
||||
self.assertEqual(res, "string from primitive node")
|
||||
|
||||
def test_a1111_parameters_parsing(self):
|
||||
param_str = """A portrait of an ancient wizard with a glowing crystal staff
|
||||
Negative prompt: ugly, deformed, bad anatomy
|
||||
Steps: 30, Sampler: DPM++ 2M Karras, CFG scale: 7, Seed: 12345, Size: 512x768"""
|
||||
res = parse_positive_from_parameters(param_str)
|
||||
self.assertEqual(res, "A portrait of an ancient wizard with a glowing crystal staff")
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,41 @@
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
import tempfile
|
||||
import shutil
|
||||
from PIL import Image
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import test_helper
|
||||
|
||||
from nodes.load_recent import LoadRecentOutputs
|
||||
|
||||
class TestLoadRecent(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.node = LoadRecentOutputs()
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def test_load_single_image_finds_recent_image(self):
|
||||
img = Image.new("RGB", (64, 64), color="red")
|
||||
f1 = os.path.join(self.temp_dir, "recent_01.png")
|
||||
f2 = os.path.join(self.temp_dir, "recent_02.png")
|
||||
img.save(f1)
|
||||
img.save(f2)
|
||||
|
||||
with patch("nodes.load_recent.folder_paths.get_output_directory", return_value=self.temp_dir):
|
||||
out_tensor, = self.node.load_single_image(output_folder=self.temp_dir, amount=2, index=0)
|
||||
self.assertEqual(out_tensor.shape[0], 1)
|
||||
self.assertEqual(out_tensor.shape[1], 64)
|
||||
self.assertEqual(out_tensor.shape[2], 64)
|
||||
|
||||
def test_load_recent_empty_directory_returns_dummy(self):
|
||||
with patch("nodes.load_recent.folder_paths.get_output_directory", return_value=self.temp_dir):
|
||||
out_tensor, = self.node.load_single_image(output_folder=self.temp_dir, amount=5, index=0)
|
||||
self.assertEqual(out_tensor.shape, (1, 512, 512, 3))
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,185 @@
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import test_helper
|
||||
from nodes.local_runner import (
|
||||
RunLocalFileNode,
|
||||
get_allowed_scripts_directory,
|
||||
is_script_path_permitted,
|
||||
resolve_target_path,
|
||||
authorize_node_run,
|
||||
consume_node_authorization,
|
||||
clear_all_authorizations
|
||||
)
|
||||
|
||||
class TestLocalRunner(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.node = RunLocalFileNode()
|
||||
clear_all_authorizations()
|
||||
|
||||
def tearDown(self):
|
||||
clear_all_authorizations()
|
||||
|
||||
def test_operator_authorization_required(self):
|
||||
with patch("nodes.local_runner.is_local_file_execution_enabled", return_value=True):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
script_path = os.path.join(temp_dir, "test.bat")
|
||||
with open(script_path, "w") as f:
|
||||
f.write("@echo off\n")
|
||||
|
||||
with patch("nodes.local_runner.get_allowed_scripts_directory", return_value=temp_dir):
|
||||
stdout, stderr, exit_code, success, passthrough = self.node.run_local_file(
|
||||
file_path="test.bat",
|
||||
unique_id="node_99",
|
||||
trigger="trigger_data"
|
||||
)
|
||||
self.assertEqual(exit_code, -1)
|
||||
self.assertFalse(success)
|
||||
self.assertIn("Authorize Run", stderr)
|
||||
self.assertEqual(passthrough, "trigger_data")
|
||||
|
||||
def test_global_security_setting_disabled_by_default(self):
|
||||
with patch("nodes.local_runner.is_local_file_execution_enabled", return_value=False):
|
||||
stdout, stderr, exit_code, success, _ = self.node.run_local_file(
|
||||
file_path="some_script.bat",
|
||||
unique_id="node_99"
|
||||
)
|
||||
self.assertEqual(exit_code, -1)
|
||||
self.assertFalse(success)
|
||||
self.assertIn("disabled by default for security", stderr)
|
||||
|
||||
def test_path_confinement_rejects_absolute_paths(self):
|
||||
resolved, msg = resolve_target_path("C:\\Windows\\System32\\cmd.exe")
|
||||
self.assertIsNone(resolved)
|
||||
self.assertIn("Absolute paths are forbidden", msg)
|
||||
|
||||
resolved_posix, msg_posix = resolve_target_path("/bin/sh")
|
||||
self.assertIsNone(resolved_posix)
|
||||
self.assertIn("Absolute paths are forbidden", msg_posix)
|
||||
|
||||
def test_path_confinement_rejects_traversal(self):
|
||||
resolved, msg = resolve_target_path("../../../etc/passwd")
|
||||
self.assertIsNone(resolved)
|
||||
self.assertIn("Directory traversal", msg)
|
||||
|
||||
resolved_win, msg_win = resolve_target_path("scripts/../../sensitive.txt")
|
||||
self.assertIsNone(resolved_win)
|
||||
self.assertIn("Directory traversal", msg_win)
|
||||
|
||||
def test_single_use_authorization_consumed_on_run(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
if sys.platform.startswith("win"):
|
||||
script_path = os.path.join(temp_dir, "single_use.bat")
|
||||
with open(script_path, "w") as f:
|
||||
f.write("@echo off\necho Consumed\n")
|
||||
else:
|
||||
script_path = os.path.join(temp_dir, "single_use.sh")
|
||||
with open(script_path, "w") as f:
|
||||
f.write('#!/bin/bash\necho "Consumed"\n')
|
||||
|
||||
with patch("nodes.local_runner.get_allowed_scripts_directory", return_value=temp_dir), \
|
||||
patch("nodes.local_runner.is_local_file_execution_enabled", return_value=True):
|
||||
|
||||
# 1. Authorize for node_42
|
||||
filename = os.path.basename(script_path)
|
||||
authorize_node_run("node_42", filename)
|
||||
|
||||
# First run must succeed
|
||||
stdout, stderr, exit_code, success, _ = self.node.run_local_file(
|
||||
file_path=filename,
|
||||
unique_id="node_42"
|
||||
)
|
||||
self.assertEqual(exit_code, 0)
|
||||
self.assertTrue(success)
|
||||
|
||||
# Second run with same node ID must be rejected (authorization consumed)
|
||||
stdout2, stderr2, exit_code2, success2, _ = self.node.run_local_file(
|
||||
file_path=filename,
|
||||
unique_id="node_42"
|
||||
)
|
||||
self.assertEqual(exit_code2, -1)
|
||||
self.assertFalse(success2)
|
||||
self.assertIn("Node has not been authorized", stderr2)
|
||||
|
||||
def test_script_mismatch_authorization_rejected(self):
|
||||
with patch("nodes.local_runner.is_local_file_execution_enabled", return_value=True):
|
||||
authorize_node_run("node_1", "approved_tool.bat")
|
||||
authorized, msg = consume_node_authorization("node_1", "C:\\scripts\\malicious_tool.bat")
|
||||
self.assertFalse(authorized)
|
||||
self.assertIn("does not match", msg)
|
||||
|
||||
def test_build_command_args(self):
|
||||
target = "test_script.bat" if sys.platform.startswith("win") else "test_script.sh"
|
||||
cmd = self.node.build_command_args(target, '--arg1 "value with spaces" --num 42')
|
||||
if sys.platform.startswith("win"):
|
||||
self.assertIn("/c", cmd)
|
||||
self.assertIn("--arg1", cmd)
|
||||
self.assertTrue(any("value with spaces" in x for x in cmd))
|
||||
self.assertIn("42", cmd)
|
||||
else:
|
||||
self.assertIn("/bin/bash", cmd)
|
||||
self.assertIn("--arg1", cmd)
|
||||
self.assertIn("value with spaces", cmd)
|
||||
|
||||
def test_execution_synchronous(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
if sys.platform.startswith("win"):
|
||||
script_path = os.path.join(temp_dir, "echo_test.bat")
|
||||
with open(script_path, "w") as f:
|
||||
f.write("@echo off\necho Output: %1 %2\n")
|
||||
else:
|
||||
script_path = os.path.join(temp_dir, "echo_test.sh")
|
||||
with open(script_path, "w") as f:
|
||||
f.write('#!/bin/bash\necho "Output: $1 $2"\n')
|
||||
|
||||
filename = os.path.basename(script_path)
|
||||
authorize_node_run("node_sync", filename)
|
||||
|
||||
with patch("nodes.local_runner.get_allowed_scripts_directory", return_value=temp_dir), \
|
||||
patch("nodes.local_runner.is_local_file_execution_enabled", return_value=True):
|
||||
stdout, stderr, exit_code, success, passthrough = self.node.run_local_file(
|
||||
file_path=filename,
|
||||
parameters="Hello World",
|
||||
run_mode="Synchronous (Wait for Output)",
|
||||
trigger="flow_pass",
|
||||
unique_id="node_sync"
|
||||
)
|
||||
self.assertEqual(exit_code, 0)
|
||||
self.assertTrue(success)
|
||||
self.assertIn("Hello World", stdout)
|
||||
self.assertEqual(passthrough, "flow_pass")
|
||||
|
||||
def test_timeout_cancellation(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
if sys.platform.startswith("win"):
|
||||
script_path = os.path.join(temp_dir, "sleep_test.bat")
|
||||
with open(script_path, "w") as f:
|
||||
f.write(f"@echo off\n\"{sys.executable}\" -c \"import time; time.sleep(5)\"\n")
|
||||
else:
|
||||
script_path = os.path.join(temp_dir, "sleep_test.sh")
|
||||
with open(script_path, "w") as f:
|
||||
f.write("#!/bin/bash\nsleep 3\n")
|
||||
|
||||
filename = os.path.basename(script_path)
|
||||
authorize_node_run("node_timeout", filename)
|
||||
|
||||
with patch("nodes.local_runner.get_allowed_scripts_directory", return_value=temp_dir), \
|
||||
patch("nodes.local_runner.is_local_file_execution_enabled", return_value=True):
|
||||
stdout, stderr, exit_code, success, _ = self.node.run_local_file(
|
||||
file_path=filename,
|
||||
timeout=1,
|
||||
run_mode="Synchronous (Wait for Output)",
|
||||
unique_id="node_timeout"
|
||||
)
|
||||
self.assertEqual(exit_code, -1)
|
||||
self.assertFalse(success)
|
||||
self.assertIn("timed out", stderr)
|
||||
import time
|
||||
time.sleep(0.5)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,58 @@
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import test_helper
|
||||
|
||||
from nodes.lora_finder import TextLoraFinder
|
||||
|
||||
class TestLoraFinder(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.finder = TextLoraFinder()
|
||||
|
||||
def test_empty_or_none_text(self):
|
||||
model, clip, loras = self.finder.find_and_load_loras(
|
||||
model="MODEL_OBJ",
|
||||
clip="CLIP_OBJ",
|
||||
text=""
|
||||
)
|
||||
self.assertEqual(model, "MODEL_OBJ")
|
||||
self.assertEqual(clip, "CLIP_OBJ")
|
||||
self.assertEqual(loras, "")
|
||||
|
||||
m2, c2, l2 = self.finder.find_and_load_loras(
|
||||
model="MODEL_OBJ",
|
||||
clip="CLIP_OBJ",
|
||||
text=None
|
||||
)
|
||||
self.assertEqual(l2, "")
|
||||
|
||||
@patch("nodes.lora_finder.get_filtered_loras_mapping")
|
||||
@patch("nodes.lora_finder.folder_paths.get_full_path")
|
||||
@patch("nodes.lora_finder.os.path.exists")
|
||||
@patch("nodes.lora_finder.load_torch_file")
|
||||
@patch("nodes.lora_finder.comfy.sd.load_lora_for_models")
|
||||
def test_find_and_load_lora_match(self, mock_load_lora, mock_load_torch, mock_exists, mock_get_path, mock_mapping):
|
||||
mock_mapping.return_value = {
|
||||
"Ana De Armas": "celebrities/krea2_ana-de-armas_v1.safetensors"
|
||||
}
|
||||
mock_get_path.return_value = "/models/loras/celebrities/krea2_ana-de-armas_v1.safetensors"
|
||||
mock_exists.return_value = True
|
||||
mock_load_torch.return_value = {}
|
||||
mock_load_lora.return_value = ("PATCHED_MODEL", "PATCHED_CLIP")
|
||||
|
||||
model, clip, loras = self.finder.find_and_load_loras(
|
||||
model="MODEL",
|
||||
clip="CLIP",
|
||||
text="A photo of Ana De Armas in evening dress",
|
||||
search_for="Parsed Name",
|
||||
output_format="Parsed Name"
|
||||
)
|
||||
self.assertEqual(model, "PATCHED_MODEL")
|
||||
self.assertEqual(clip, "PATCHED_CLIP")
|
||||
self.assertIn("Ana De Armas", loras)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,123 @@
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import test_helper
|
||||
|
||||
from nodes.queue_control import (
|
||||
PowerControlManager,
|
||||
PauseQueueManager,
|
||||
_issue_power_ticket,
|
||||
_consume_power_ticket,
|
||||
_POWER_TICKETS,
|
||||
_POWER_TICKET_LOCK
|
||||
)
|
||||
|
||||
class TestPowerControlManager(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.pause_manager = PauseQueueManager()
|
||||
self.pause_manager.paused = False
|
||||
self.pause_manager.is_waiting = False
|
||||
self.power_manager = PowerControlManager(self.pause_manager)
|
||||
with _POWER_TICKET_LOCK:
|
||||
_POWER_TICKETS.clear()
|
||||
|
||||
def tearDown(self):
|
||||
with _POWER_TICKET_LOCK:
|
||||
_POWER_TICKETS.clear()
|
||||
|
||||
def test_arm_disabled_by_default(self):
|
||||
with patch("nodes.queue_control.is_process_management_enabled", return_value=False):
|
||||
result = self.power_manager.arm("restart")
|
||||
self.assertFalse(result)
|
||||
self.assertIsNone(self.power_manager.pending_action)
|
||||
self.assertIsNone(self.power_manager.armed_at)
|
||||
status = self.power_manager.get_status()
|
||||
self.assertFalse(status["enabled"])
|
||||
self.assertFalse(self.power_manager.execute_restart())
|
||||
self.assertFalse(self.power_manager.execute_shutdown())
|
||||
|
||||
def test_arm_and_disarm(self):
|
||||
with patch("nodes.queue_control.is_process_management_enabled", return_value=True):
|
||||
result = self.power_manager.arm("restart")
|
||||
self.assertTrue(result)
|
||||
self.assertEqual(self.power_manager.pending_action, "restart")
|
||||
self.assertIsNotNone(self.power_manager.armed_at)
|
||||
|
||||
status = self.power_manager.get_status()
|
||||
self.assertEqual(status["pending_action"], "restart")
|
||||
self.assertTrue(status["enabled"])
|
||||
|
||||
self.power_manager.arm("shutdown")
|
||||
self.assertEqual(self.power_manager.pending_action, "shutdown")
|
||||
|
||||
self.power_manager.arm(None)
|
||||
self.assertIsNone(self.power_manager.pending_action)
|
||||
self.assertIsNone(self.power_manager.armed_at)
|
||||
|
||||
def test_power_ticket_single_use_handshake(self):
|
||||
# 1. Issue ticket for restart
|
||||
ticket = _issue_power_ticket("restart")
|
||||
self.assertIsInstance(ticket, str)
|
||||
self.assertGreater(len(ticket), 16)
|
||||
|
||||
# 2. Consume ticket with wrong action -> Rejected
|
||||
self.assertFalse(_consume_power_ticket(ticket, "shutdown"))
|
||||
|
||||
# 3. Consume ticket with correct action -> Accepted
|
||||
self.assertTrue(_consume_power_ticket(ticket, "restart"))
|
||||
|
||||
# 4. Consume ticket again -> Rejected (single-use)
|
||||
self.assertFalse(_consume_power_ticket(ticket, "restart"))
|
||||
|
||||
def test_power_ticket_expiration(self):
|
||||
ticket = _issue_power_ticket("shutdown")
|
||||
# Artificially expire the ticket
|
||||
with _POWER_TICKET_LOCK:
|
||||
_POWER_TICKETS[ticket] = (time.time() - 1.0, "shutdown")
|
||||
|
||||
self.assertFalse(_consume_power_ticket(ticket, "shutdown"))
|
||||
|
||||
def test_does_not_execute_when_paused(self):
|
||||
with patch("nodes.queue_control.is_process_management_enabled", return_value=True):
|
||||
self.power_manager.arm("restart")
|
||||
self.pause_manager.paused = True
|
||||
|
||||
with patch.object(self.power_manager, "execute_restart") as mock_restart:
|
||||
self.power_manager.check_and_execute()
|
||||
mock_restart.assert_not_called()
|
||||
self.assertEqual(self.power_manager.pending_action, "restart")
|
||||
|
||||
def test_does_not_execute_when_tasks_remaining(self):
|
||||
with patch("nodes.queue_control.is_process_management_enabled", return_value=True):
|
||||
self.power_manager.arm("restart")
|
||||
mock_queue = MagicMock()
|
||||
mock_queue.get_tasks_remaining.return_value = 2
|
||||
mock_queue.currently_running = {"1": True}
|
||||
|
||||
with patch("server.PromptServer.instance") as mock_server:
|
||||
mock_server.prompt_queue = mock_queue
|
||||
with patch.object(self.power_manager, "execute_restart") as mock_restart:
|
||||
self.power_manager.check_and_execute()
|
||||
mock_restart.assert_not_called()
|
||||
self.assertEqual(self.power_manager.pending_action, "restart")
|
||||
|
||||
def test_executes_when_queue_empty_and_idle(self):
|
||||
with patch("nodes.queue_control.is_process_management_enabled", return_value=True):
|
||||
self.power_manager.arm("restart")
|
||||
mock_queue = MagicMock()
|
||||
mock_queue.get_tasks_remaining.return_value = 0
|
||||
mock_queue.currently_running = {}
|
||||
|
||||
with patch("server.PromptServer.instance") as mock_server:
|
||||
mock_server.prompt_queue = mock_queue
|
||||
with patch.object(self.power_manager, "execute_restart") as mock_restart:
|
||||
self.power_manager.check_and_execute()
|
||||
mock_restart.assert_called_once()
|
||||
self.assertIsNone(self.power_manager.pending_action)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,66 @@
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import test_helper
|
||||
|
||||
from nodes.prompt_counter import PromptCounter
|
||||
|
||||
class TestPromptCounter(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.node = PromptCounter()
|
||||
|
||||
def test_empty_and_whitespace(self):
|
||||
txt, count = self.node.count_prompts("")
|
||||
self.assertEqual(txt, "")
|
||||
self.assertEqual(count, 0)
|
||||
|
||||
txt, count = self.node.count_prompts(" \n\n \t ")
|
||||
self.assertEqual(txt, " \n\n \t ")
|
||||
self.assertEqual(count, 0)
|
||||
|
||||
def test_single_prompt(self):
|
||||
sample = "A magnificent tree standing on a hill, photorealistic 8k"
|
||||
txt, count = self.node.count_prompts(sample)
|
||||
self.assertEqual(txt, sample)
|
||||
self.assertEqual(count, 1)
|
||||
|
||||
def test_newline_mode(self):
|
||||
sample = "Prompt 1\nPrompt 2\nPrompt 3\n\nPrompt 4"
|
||||
txt, count = self.node.count_prompts(sample, separator="Newline")
|
||||
self.assertEqual(txt, sample)
|
||||
self.assertEqual(count, 4)
|
||||
|
||||
def test_empty_line_mode(self):
|
||||
sample = "Line 1 of prompt 1\nLine 2 of prompt 1\n\nPrompt 2\n\nPrompt 3"
|
||||
txt, count = self.node.count_prompts(sample, separator=">1 Empty Line")
|
||||
self.assertEqual(txt, sample)
|
||||
self.assertEqual(count, 3)
|
||||
|
||||
def test_gt_two_empty_lines_mode(self):
|
||||
sample = "Prompt 1\n\nStill prompt 1\n\n\nPrompt 2"
|
||||
txt, count = self.node.count_prompts(sample, separator=">2 Empty Lines")
|
||||
self.assertEqual(txt, sample)
|
||||
self.assertEqual(count, 2)
|
||||
|
||||
def test_custom_regex(self):
|
||||
sample = "Prompt A\n---\nPrompt B\n---\nPrompt C"
|
||||
txt, count = self.node.count_prompts(sample, separator="Custom Regex", custom_regex=r"\n---\n")
|
||||
self.assertEqual(txt, sample)
|
||||
self.assertEqual(count, 3)
|
||||
|
||||
def test_custom_regex_capturing_group(self):
|
||||
sample = "Prompt A\n===\nPrompt B\n===\nPrompt C"
|
||||
txt, count = self.node.count_prompts(sample, separator="Custom Regex", custom_regex=r"(\n===\n)")
|
||||
self.assertEqual(txt, sample)
|
||||
self.assertEqual(count, 3)
|
||||
|
||||
def test_custom_regex_invalid_fallback(self):
|
||||
sample = "Prompt 1\n\nPrompt 2"
|
||||
txt, count = self.node.count_prompts(sample, separator="Custom Regex", custom_regex=r"[invalid-regex")
|
||||
self.assertEqual(txt, sample)
|
||||
self.assertEqual(count, 2)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,239 @@
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
import tempfile
|
||||
import json
|
||||
import shutil
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import test_helper
|
||||
|
||||
from nodes.prompt_iterator import PromptQueueIterator, parse_prompt_blocks, load_state, save_state
|
||||
|
||||
class TestPromptQueueIterator(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.node = PromptQueueIterator()
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.state_file = os.path.join(self.temp_dir, "test_state.json")
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def test_parse_prompt_blocks_newline(self):
|
||||
text = "Prompt 1\nPrompt 2\n\nPrompt 3"
|
||||
blocks = parse_prompt_blocks(text, separator="Newline")
|
||||
self.assertEqual(blocks, ["Prompt 1", "Prompt 2", "Prompt 3"])
|
||||
|
||||
def test_parse_prompt_blocks_empty_lines(self):
|
||||
text = "Block 1 line 1\nBlock 1 line 2\n\nBlock 2"
|
||||
blocks = parse_prompt_blocks(text, separator=">1 Empty Line")
|
||||
self.assertEqual(len(blocks), 2)
|
||||
self.assertEqual(blocks[0], "Block 1 line 1\nBlock 1 line 2")
|
||||
self.assertEqual(blocks[1], "Block 2")
|
||||
|
||||
def test_parse_prompt_blocks_custom_regex(self):
|
||||
text = "Prompt A\n---\nPrompt B\n---\nPrompt C"
|
||||
blocks = parse_prompt_blocks(text, separator="Custom Regex", custom_regex=r"\n---\n")
|
||||
self.assertEqual(blocks, ["Prompt A", "Prompt B", "Prompt C"])
|
||||
|
||||
def test_parse_prompt_blocks_custom_regex_capturing_group(self):
|
||||
text = "Prompt A\n---\nPrompt B\n---\nPrompt C"
|
||||
blocks = parse_prompt_blocks(text, separator="Custom Regex", custom_regex=r"(\n---\n)")
|
||||
self.assertEqual(blocks, ["Prompt A", "Prompt B", "Prompt C"])
|
||||
|
||||
def test_parse_prompt_blocks_custom_regex_invalid_fallback(self):
|
||||
text = "Prompt 1\n\nPrompt 2"
|
||||
# Invalid regex [unterminated class
|
||||
blocks = parse_prompt_blocks(text, separator="Custom Regex", custom_regex=r"[unterminated")
|
||||
self.assertEqual(blocks, ["Prompt 1", "Prompt 2"])
|
||||
|
||||
def test_sequential_10_batch_execution(self):
|
||||
"""
|
||||
Verify that 10 prompts are executed in exact sequential order (0 to 9).
|
||||
"""
|
||||
prompts_10 = "\n\n".join([f"Prompt A{i}" for i in range(10)])
|
||||
state_db = {}
|
||||
|
||||
def mock_load():
|
||||
return dict(state_db)
|
||||
|
||||
def mock_save(new_state):
|
||||
nonlocal state_db
|
||||
state_db = dict(new_state)
|
||||
|
||||
with patch("nodes.prompt_iterator.load_state", side_effect=mock_load), \
|
||||
patch("nodes.prompt_iterator.save_state", side_effect=mock_save), \
|
||||
patch("nodes.prompt_iterator.PromptServer.instance.send_sync"):
|
||||
|
||||
for expected_idx in range(10):
|
||||
p, rem_text, rem_count = self.node.process_queue(
|
||||
pop_mode="Sequential (Loop on End)",
|
||||
separator=">1 Empty Line",
|
||||
text=prompts_10,
|
||||
unique_id="node_10_test"
|
||||
)
|
||||
self.assertEqual(p, f"Prompt A{expected_idx}")
|
||||
self.assertEqual(rem_count, 10 - (expected_idx + 1))
|
||||
|
||||
def test_multi_pack_isolation_pack_a_and_pack_b(self):
|
||||
"""
|
||||
Verify Pack A (10 prompts) and Pack B (5 prompts) run with complete isolation.
|
||||
"""
|
||||
pack_a = "\n\n".join([f"PackA_{i}" for i in range(10)])
|
||||
pack_b = "\n\n".join([f"PackB_{i}" for i in range(5)])
|
||||
state_db = {}
|
||||
|
||||
def mock_load():
|
||||
return dict(state_db)
|
||||
|
||||
def mock_save(new_state):
|
||||
nonlocal state_db
|
||||
state_db = dict(new_state)
|
||||
|
||||
with patch("nodes.prompt_iterator.load_state", side_effect=mock_load), \
|
||||
patch("nodes.prompt_iterator.save_state", side_effect=mock_save), \
|
||||
patch("nodes.prompt_iterator.PromptServer.instance.send_sync"):
|
||||
|
||||
# Run 3 items of Pack A
|
||||
p0, _, _ = self.node.process_queue(text=pack_a, unique_id="shared_node")
|
||||
p1, _, _ = self.node.process_queue(text=pack_a, unique_id="shared_node")
|
||||
p2, _, _ = self.node.process_queue(text=pack_a, unique_id="shared_node")
|
||||
self.assertEqual(p0, "PackA_0")
|
||||
self.assertEqual(p1, "PackA_1")
|
||||
self.assertEqual(p2, "PackA_2")
|
||||
|
||||
# Run Pack B -> Must start cleanly at PackB_0!
|
||||
pb0, _, _ = self.node.process_queue(text=pack_b, unique_id="shared_node")
|
||||
pb1, _, _ = self.node.process_queue(text=pack_b, unique_id="shared_node")
|
||||
self.assertEqual(pb0, "PackB_0")
|
||||
self.assertEqual(pb1, "PackB_1")
|
||||
|
||||
# Switch back to Pack A -> Must continue at PackA_3!
|
||||
p3, _, _ = self.node.process_queue(text=pack_a, unique_id="shared_node")
|
||||
self.assertEqual(p3, "PackA_3")
|
||||
|
||||
def test_text_modification_resets_counter_to_zero(self):
|
||||
"""
|
||||
Changing the prompt text (deleting 3 items) creates a new hash and starts at 0.
|
||||
"""
|
||||
pack_10 = "\n\n".join([f"Item_{i}" for i in range(10)])
|
||||
pack_7 = "\n\n".join([f"Item_{i}" for i in range(7)]) # 3 deleted
|
||||
state_db = {}
|
||||
|
||||
def mock_load():
|
||||
return dict(state_db)
|
||||
|
||||
def mock_save(new_state):
|
||||
nonlocal state_db
|
||||
state_db = dict(new_state)
|
||||
|
||||
with patch("nodes.prompt_iterator.load_state", side_effect=mock_load), \
|
||||
patch("nodes.prompt_iterator.save_state", side_effect=mock_save), \
|
||||
patch("nodes.prompt_iterator.PromptServer.instance.send_sync"):
|
||||
|
||||
# Run 2 items of 10-pack
|
||||
self.node.process_queue(text=pack_10, unique_id="edit_node")
|
||||
self.node.process_queue(text=pack_10, unique_id="edit_node")
|
||||
|
||||
# Switch to edited 7-pack -> Must start at Item_0!
|
||||
first_of_7, _, rem_count = self.node.process_queue(text=pack_7, unique_id="edit_node")
|
||||
self.assertEqual(first_of_7, "Item_0")
|
||||
self.assertEqual(rem_count, 6)
|
||||
|
||||
def test_forced_counter_reset(self):
|
||||
"""
|
||||
Manual counter reset forces next execution back to 0.
|
||||
"""
|
||||
pack = "P0\n\nP1\n\nP2\n\nP3"
|
||||
state_db = {}
|
||||
|
||||
def mock_load():
|
||||
return dict(state_db)
|
||||
|
||||
def mock_save(new_state):
|
||||
nonlocal state_db
|
||||
state_db = dict(new_state)
|
||||
|
||||
with patch("nodes.prompt_iterator.load_state", side_effect=mock_load), \
|
||||
patch("nodes.prompt_iterator.save_state", side_effect=mock_save), \
|
||||
patch("nodes.prompt_iterator.PromptServer.instance.send_sync"):
|
||||
|
||||
# Run P0, P1
|
||||
p0, _, _ = self.node.process_queue(text=pack, unique_id="reset_test")
|
||||
p1, _, _ = self.node.process_queue(text=pack, unique_id="reset_test")
|
||||
self.assertEqual(p0, "P0")
|
||||
self.assertEqual(p1, "P1")
|
||||
|
||||
# Simulate Reset Counter (set index to 0)
|
||||
for k in list(state_db.keys()):
|
||||
if "reset_test" in k:
|
||||
state_db[k]["index"] = 0
|
||||
|
||||
# Next run must be P0 again!
|
||||
p0_again, _, _ = self.node.process_queue(text=pack, unique_id="reset_test")
|
||||
self.assertEqual(p0_again, "P0")
|
||||
|
||||
def test_crash_recovery_assigned_prompt_id_retains_index(self):
|
||||
"""
|
||||
Verify that if a prompt is interrupted/restored with the same prompt_id,
|
||||
it re-executes the exact same prompt index instead of skipping ahead.
|
||||
"""
|
||||
pack = "Prompt 0\n\nPrompt 1\n\nPrompt 2"
|
||||
state_db = {}
|
||||
|
||||
def mock_load():
|
||||
return dict(state_db)
|
||||
|
||||
def mock_save(new_state):
|
||||
nonlocal state_db
|
||||
state_db = dict(new_state)
|
||||
|
||||
with patch("nodes.prompt_iterator.load_state", side_effect=mock_load), \
|
||||
patch("nodes.prompt_iterator.save_state", side_effect=mock_save), \
|
||||
patch("nodes.prompt_iterator.PromptServer.instance.send_sync"):
|
||||
|
||||
# 1. First execution with prompt_id = "job-uuid-1" -> gets Prompt 0
|
||||
with patch("nodes.prompt_iterator.get_current_prompt_id", return_value="job-uuid-1"):
|
||||
p0, _, _ = self.node.process_queue(text=pack, unique_id="recover_node")
|
||||
self.assertEqual(p0, "Prompt 0")
|
||||
|
||||
# 2. Workflow is interrupted mid-way and restored by persistent queue (same prompt_id "job-uuid-1")
|
||||
with patch("nodes.prompt_iterator.get_current_prompt_id", return_value="job-uuid-1"):
|
||||
p0_retry, _, _ = self.node.process_queue(text=pack, unique_id="recover_node")
|
||||
# Must STILL be Prompt 0, NOT Prompt 1!
|
||||
self.assertEqual(p0_retry, "Prompt 0")
|
||||
|
||||
# 3. Next workflow run with new prompt_id = "job-uuid-2" -> gets Prompt 1!
|
||||
with patch("nodes.prompt_iterator.get_current_prompt_id", return_value="job-uuid-2"):
|
||||
p1, _, _ = self.node.process_queue(text=pack, unique_id="recover_node")
|
||||
self.assertEqual(p1, "Prompt 1")
|
||||
|
||||
def test_per_node_hash_pruning(self):
|
||||
"""
|
||||
Verify that editing text in a node cleans up old abandoned text hashes beyond 3 entries.
|
||||
"""
|
||||
state_db = {}
|
||||
|
||||
def mock_load():
|
||||
return dict(state_db)
|
||||
|
||||
def mock_save(new_state):
|
||||
nonlocal state_db
|
||||
state_db = dict(new_state)
|
||||
|
||||
with patch("nodes.prompt_iterator.load_state", side_effect=mock_load), \
|
||||
patch("nodes.prompt_iterator.save_state", side_effect=mock_save), \
|
||||
patch("nodes.prompt_iterator.PromptServer.instance.send_sync"):
|
||||
|
||||
# Create 6 text variants on the same node
|
||||
for i in range(6):
|
||||
text_variant = f"Variant {i} text"
|
||||
self.node.process_queue(text=text_variant, unique_id="prune_node")
|
||||
|
||||
# Only at most 3 entries for 'prune_node' should remain in state
|
||||
matching_keys = [k for k in state_db.keys() if k.startswith("node_prune_node_")]
|
||||
self.assertLessEqual(len(matching_keys), 3)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,93 @@
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import test_helper
|
||||
|
||||
from nodes.text_replacer import MultiTextReplacer, parse_find_targets
|
||||
|
||||
class TestMultiTextReplacer(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.node = MultiTextReplacer()
|
||||
|
||||
def test_parse_find_targets_basic(self):
|
||||
targets = parse_find_targets("dog, cat, bird")
|
||||
self.assertEqual(targets, ["dog", "cat", "bird"])
|
||||
|
||||
def test_parse_find_targets_with_quotes(self):
|
||||
targets = parse_find_targets('"dog", "cat", "bird"')
|
||||
self.assertEqual(targets, ["dog", "cat", "bird"])
|
||||
|
||||
def test_replace_comma_separated_list(self):
|
||||
text, count = self.node.replace_text(
|
||||
find="dog, cat, bird",
|
||||
replace="animal",
|
||||
case_sensitive=False,
|
||||
search_mode="Comma Separated List",
|
||||
text="I have a dog and a cat and a bird."
|
||||
)
|
||||
self.assertEqual(text, "I have a animal and a animal and a animal.")
|
||||
self.assertEqual(count, 3)
|
||||
|
||||
def test_replace_longest_match_first(self):
|
||||
text, count = self.node.replace_text(
|
||||
find="dog, hot dog",
|
||||
replace="food",
|
||||
search_mode="Comma Separated List",
|
||||
text="I ate a hot dog."
|
||||
)
|
||||
self.assertEqual(text, "I ate a food.")
|
||||
self.assertEqual(count, 1)
|
||||
|
||||
def test_case_sensitivity(self):
|
||||
text_ci, count_ci = self.node.replace_text(
|
||||
find="cat",
|
||||
replace="feline",
|
||||
case_sensitive=False,
|
||||
text="Cat and cat"
|
||||
)
|
||||
self.assertEqual(text_ci, "feline and feline")
|
||||
self.assertEqual(count_ci, 2)
|
||||
|
||||
text_cs, count_cs = self.node.replace_text(
|
||||
find="cat",
|
||||
replace="feline",
|
||||
case_sensitive=True,
|
||||
text="Cat and cat"
|
||||
)
|
||||
self.assertEqual(text_cs, "Cat and feline")
|
||||
self.assertEqual(count_cs, 1)
|
||||
|
||||
def test_regex_mode(self):
|
||||
text, count = self.node.replace_text(
|
||||
find=r"\b\d{4}\b",
|
||||
replace="YEAR",
|
||||
search_mode="Regex Pattern",
|
||||
text="In 1999 and 2024."
|
||||
)
|
||||
self.assertEqual(text, "In YEAR and YEAR.")
|
||||
self.assertEqual(count, 2)
|
||||
|
||||
def test_edge_case_empty_text(self):
|
||||
text, count = self.node.replace_text(find="test", replace="new", text="")
|
||||
self.assertEqual(text, "")
|
||||
self.assertEqual(count, 0)
|
||||
|
||||
def test_edge_case_none_text(self):
|
||||
text, count = self.node.replace_text(find="test", replace="new", text=None)
|
||||
self.assertEqual(text, "")
|
||||
self.assertEqual(count, 0)
|
||||
|
||||
def test_edge_case_empty_find(self):
|
||||
text, count = self.node.replace_text(find="", replace="new", text="Hello")
|
||||
self.assertEqual(text, "Hello")
|
||||
self.assertEqual(count, 0)
|
||||
|
||||
def test_edge_case_deletion_empty_replace(self):
|
||||
text, count = self.node.replace_text(find="badword", replace="", text="badword good text")
|
||||
self.assertEqual(text, " good text")
|
||||
self.assertEqual(count, 1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,114 @@
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import test_helper
|
||||
|
||||
from nodes.text_split import LeafFlowTextSplit
|
||||
|
||||
class TestLeafFlowTextSplit(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.node = LeafFlowTextSplit()
|
||||
|
||||
def test_forward_literal_split_basic(self):
|
||||
# User standard case: "Hello --- Bye" with delimiter "--"
|
||||
t1, t2 = self.node.split_text(
|
||||
text="Hello --- Bye",
|
||||
split_by="--",
|
||||
use_regex=False,
|
||||
split_direction="forward (first occurrence from start)",
|
||||
strip_whitespace=False
|
||||
)
|
||||
self.assertEqual(t1, "Hello ")
|
||||
self.assertEqual(t2, "- Bye")
|
||||
|
||||
def test_backward_literal_split_basic(self):
|
||||
# Splitting from end
|
||||
t1, t2 = self.node.split_text(
|
||||
text="Hello --- Bye",
|
||||
split_by="--",
|
||||
use_regex=False,
|
||||
split_direction="backward (last occurrence from end)",
|
||||
strip_whitespace=False
|
||||
)
|
||||
self.assertEqual(t1, "Hello -")
|
||||
self.assertEqual(t2, " Bye")
|
||||
|
||||
def test_strip_whitespace(self):
|
||||
t1, t2 = self.node.split_text(
|
||||
text=" Left Part --- Right Part ",
|
||||
split_by="---",
|
||||
use_regex=False,
|
||||
strip_whitespace=True
|
||||
)
|
||||
self.assertEqual(t1, "Left Part")
|
||||
self.assertEqual(t2, "Right Part")
|
||||
|
||||
def test_forward_regex_split(self):
|
||||
t1, t2 = self.node.split_text(
|
||||
text="prompt_v1__step_100__seed_42",
|
||||
split_by=r"__step_\d+__",
|
||||
use_regex=True,
|
||||
split_direction="forward (first occurrence from start)"
|
||||
)
|
||||
self.assertEqual(t1, "prompt_v1")
|
||||
self.assertEqual(t2, "seed_42")
|
||||
|
||||
def test_backward_regex_split(self):
|
||||
t1, t2 = self.node.split_text(
|
||||
text="tag1#123tag2#456tag3",
|
||||
split_by=r"#\d+",
|
||||
use_regex=True,
|
||||
split_direction="backward (last occurrence from end)"
|
||||
)
|
||||
self.assertEqual(t1, "tag1#123tag2")
|
||||
self.assertEqual(t2, "tag3")
|
||||
|
||||
def test_edge_case_empty_text(self):
|
||||
t1, t2 = self.node.split_text(text="", split_by="--")
|
||||
self.assertEqual(t1, "")
|
||||
self.assertEqual(t2, "")
|
||||
|
||||
def test_edge_case_none_text(self):
|
||||
t1, t2 = self.node.split_text(text=None, split_by="--")
|
||||
self.assertEqual(t1, "")
|
||||
self.assertEqual(t2, "")
|
||||
|
||||
def test_edge_case_empty_delimiter(self):
|
||||
t1, t2 = self.node.split_text(text="Some text", split_by="")
|
||||
self.assertEqual(t1, "Some text")
|
||||
self.assertEqual(t2, "")
|
||||
|
||||
def test_edge_case_delimiter_not_found(self):
|
||||
t1, t2 = self.node.split_text(text="Hello World", split_by="---")
|
||||
self.assertEqual(t1, "Hello World")
|
||||
self.assertEqual(t2, "")
|
||||
|
||||
def test_edge_case_delimiter_at_start(self):
|
||||
t1, t2 = self.node.split_text(text="--Rest of message", split_by="--")
|
||||
self.assertEqual(t1, "")
|
||||
self.assertEqual(t2, "Rest of message")
|
||||
|
||||
def test_edge_case_delimiter_at_end(self):
|
||||
t1, t2 = self.node.split_text(text="First part--", split_by="--")
|
||||
self.assertEqual(t1, "First part")
|
||||
self.assertEqual(t2, "")
|
||||
|
||||
def test_edge_case_invalid_regex_fallback(self):
|
||||
t1, t2 = self.node.split_text(
|
||||
text="Hello [unclosed bracket World",
|
||||
split_by="[unclosed bracket",
|
||||
use_regex=True
|
||||
)
|
||||
self.assertEqual(t1, "Hello ")
|
||||
self.assertEqual(t2, " World")
|
||||
|
||||
def test_multiline_text_split(self):
|
||||
text = "Line 1\nLine 2\n---\nLine 3\nLine 4"
|
||||
t1, t2 = self.node.split_text(text=text, split_by="\n---\n")
|
||||
self.assertEqual(t1, "Line 1\nLine 2")
|
||||
self.assertEqual(t2, "Line 3\nLine 4")
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,159 @@
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import test_helper
|
||||
|
||||
from nodes.utils import (
|
||||
parse_pretty_name,
|
||||
parse_pretty_name_with_version,
|
||||
sanitize_folder_path,
|
||||
format_lora_output_name,
|
||||
is_local_request,
|
||||
is_safe_path,
|
||||
sanitize_image_loader_folder,
|
||||
get_csrf_token,
|
||||
is_same_origin_or_direct,
|
||||
is_authenticated_local_request
|
||||
)
|
||||
|
||||
class TestUtils(unittest.TestCase):
|
||||
def test_is_local_request(self):
|
||||
class DummyReq:
|
||||
def __init__(self, ip):
|
||||
self.remote = ip
|
||||
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.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")))
|
||||
|
||||
def test_csrf_token_generation(self):
|
||||
token = get_csrf_token()
|
||||
self.assertIsInstance(token, str)
|
||||
self.assertEqual(len(token), 64)
|
||||
# Session token is consistent across calls in same session
|
||||
self.assertEqual(get_csrf_token(), token)
|
||||
|
||||
def test_is_same_origin_or_direct(self):
|
||||
class DummyReq:
|
||||
def __init__(self, headers=None):
|
||||
self.headers = headers or {}
|
||||
|
||||
# 1. Direct or same-origin
|
||||
self.assertTrue(is_same_origin_or_direct(DummyReq()))
|
||||
self.assertTrue(is_same_origin_or_direct(DummyReq({"Origin": "http://127.0.0.1:8188"})))
|
||||
self.assertTrue(is_same_origin_or_direct(DummyReq({"Origin": "http://localhost:8188"})))
|
||||
|
||||
# 2. Block cross-site via Sec-Fetch-Site
|
||||
self.assertFalse(is_same_origin_or_direct(DummyReq({"Sec-Fetch-Site": "cross-site"})))
|
||||
|
||||
# 3. Block malicious cross-origin
|
||||
self.assertFalse(is_same_origin_or_direct(DummyReq({"Origin": "http://attacker-site.com"})))
|
||||
self.assertFalse(is_same_origin_or_direct(DummyReq({"Referer": "http://malicious.org/exploit.html"})))
|
||||
|
||||
def test_is_authenticated_local_request(self):
|
||||
class DummyReq:
|
||||
def __init__(self, ip="127.0.0.1", headers=None):
|
||||
self.remote = ip
|
||||
self.headers = headers or {}
|
||||
|
||||
valid_token = get_csrf_token()
|
||||
|
||||
# Valid local request with matching token
|
||||
valid_req = DummyReq("127.0.0.1", {
|
||||
"Origin": "http://127.0.0.1:8188",
|
||||
"X-LeafFlow-CSRF-Token": valid_token
|
||||
})
|
||||
self.assertTrue(is_authenticated_local_request(valid_req))
|
||||
|
||||
# Missing token
|
||||
no_token_req = DummyReq("127.0.0.1", {"Origin": "http://127.0.0.1:8188"})
|
||||
self.assertFalse(is_authenticated_local_request(no_token_req))
|
||||
|
||||
# Wrong token
|
||||
wrong_token_req = DummyReq("127.0.0.1", {
|
||||
"Origin": "http://127.0.0.1:8188",
|
||||
"X-LeafFlow-CSRF-Token": "invalid_forged_token"
|
||||
})
|
||||
self.assertFalse(is_authenticated_local_request(wrong_token_req))
|
||||
|
||||
# Remote IP attempting to forge or replay token
|
||||
remote_req = DummyReq("192.168.1.100", {
|
||||
"Origin": "http://127.0.0.1:8188",
|
||||
"X-LeafFlow-CSRF-Token": valid_token
|
||||
})
|
||||
self.assertFalse(is_authenticated_local_request(remote_req))
|
||||
|
||||
# Cross-site drive-by request (Sec-Fetch-Site: cross-site)
|
||||
drive_by_req = DummyReq("127.0.0.1", {
|
||||
"Sec-Fetch-Site": "cross-site",
|
||||
"X-LeafFlow-CSRF-Token": valid_token
|
||||
})
|
||||
self.assertFalse(is_authenticated_local_request(drive_by_req))
|
||||
|
||||
def test_is_safe_path(self):
|
||||
import tempfile
|
||||
base1 = tempfile.mkdtemp()
|
||||
sub = os.path.join(base1, "images", "test.png")
|
||||
escape = os.path.join(base1, "..", "passwords.txt")
|
||||
self.assertTrue(is_safe_path(sub, allowed_bases=[base1]))
|
||||
self.assertFalse(is_safe_path(escape, allowed_bases=[base1]))
|
||||
import shutil
|
||||
shutil.rmtree(base1, ignore_errors=True)
|
||||
|
||||
def test_parse_pretty_name(self):
|
||||
name = parse_pretty_name("krea2_ana-de-armas_v1.safetensors")
|
||||
self.assertEqual(name, "Ana De Armas")
|
||||
|
||||
def test_parse_pretty_name_special_keywords(self):
|
||||
name = parse_pretty_name("tag_cyberpunk-nsfw-v2.safetensors")
|
||||
self.assertIn("NSFW", name)
|
||||
self.assertIn("V2", name)
|
||||
|
||||
def test_parse_pretty_name_with_version(self):
|
||||
name_v = parse_pretty_name_with_version("krea2_ana-de-armas_v1.safetensors")
|
||||
self.assertEqual(name_v, "Ana De Armas V1")
|
||||
|
||||
def test_format_lora_output_name_modes(self):
|
||||
rel_path = os.path.join("celebrities", "ana_de_armas.safetensors")
|
||||
|
||||
parsed = format_lora_output_name(rel_path, "Ana De Armas", "Parsed Name")
|
||||
self.assertEqual(parsed, "Ana De Armas")
|
||||
|
||||
filename = format_lora_output_name(rel_path, "Ana De Armas", "Filename")
|
||||
self.assertEqual(filename, "ana_de_armas.safetensors")
|
||||
|
||||
no_ext = format_lora_output_name(rel_path, "Ana De Armas", "Filename without extension")
|
||||
self.assertEqual(no_ext, "ana_de_armas")
|
||||
|
||||
custom_reg = format_lora_output_name(rel_path, "Ana De Armas", "Custom Regex", custom_regex=r"ana_([a-z]+)")
|
||||
self.assertEqual(custom_reg, "ana_de")
|
||||
|
||||
def test_sanitize_folder_path_wildcard_stripping(self):
|
||||
clean = sanitize_folder_path("input/watch/*")
|
||||
self.assertNotIn("*", clean)
|
||||
|
||||
def test_is_safe_external_image_url(self):
|
||||
from nodes.lora_loader import is_safe_external_image_url
|
||||
self.assertTrue(is_safe_external_image_url("https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/test.jpg"))
|
||||
self.assertTrue(is_safe_external_image_url("https://image.tmdb.org/t/p/w500/sample.jpg"))
|
||||
self.assertFalse(is_safe_external_image_url("file:///etc/passwd"))
|
||||
self.assertFalse(is_safe_external_image_url("file:///C:/Windows/system32/cmd.exe"))
|
||||
self.assertFalse(is_safe_external_image_url("http://127.0.0.1:8188/secret"))
|
||||
self.assertFalse(is_safe_external_image_url("http://localhost:8080/admin"))
|
||||
self.assertFalse(is_safe_external_image_url("http://169.254.169.254/latest/meta-data/"))
|
||||
self.assertFalse(is_safe_external_image_url("ftp://server/image.png"))
|
||||
self.assertFalse(is_safe_external_image_url(""))
|
||||
self.assertFalse(is_safe_external_image_url(None))
|
||||
|
||||
def test_edge_cases_empty_or_none(self):
|
||||
self.assertEqual(parse_pretty_name(""), "")
|
||||
self.assertEqual(parse_pretty_name(None), "")
|
||||
self.assertEqual(parse_pretty_name("[ NONE ]"), "")
|
||||
self.assertEqual(parse_pretty_name("[ RANDOM ]"), "")
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,526 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { api } from "/scripts/api.js";
|
||||
import { authenticatedFetch } from "./js/auth_helper.js";
|
||||
|
||||
// 36 Curated Accessible, High-Contrast Cycling Colors for Batch Visual Grouping
|
||||
const BATCH_COLORS = [
|
||||
"#10b981", // 1. Emerald
|
||||
"#3b82f6", // 2. Blue
|
||||
"#8b5cf6", // 3. Purple
|
||||
"#ec4899", // 4. Pink
|
||||
"#f59e0b", // 5. Amber
|
||||
"#06b6d4", // 6. Cyan
|
||||
"#ef4444", // 7. Red
|
||||
"#14b8a6", // 8. Teal
|
||||
"#6366f1", // 9. Indigo
|
||||
"#f97316", // 10. Orange
|
||||
"#84cc16", // 11. Lime
|
||||
"#d946ef", // 12. Fuchsia
|
||||
"#0ea5e9", // 13. Sky
|
||||
"#a855f7", // 14. Violet
|
||||
"#e11d48", // 15. Rose
|
||||
"#22c55e", // 16. Green
|
||||
"#eab308", // 17. Yellow
|
||||
"#64748b", // 18. Slate
|
||||
"#2dd4bf", // 19. Turquoise
|
||||
"#fb7185", // 20. Coral
|
||||
"#38bdf8", // 21. Light Blue
|
||||
"#c084fc", // 22. Lavender
|
||||
"#f43f5e", // 23. Ruby
|
||||
"#4ade80", // 24. Mint
|
||||
"#fbbf24", // 25. Gold
|
||||
"#818cf8", // 26. Periwinkle
|
||||
"#fb923c", // 27. Tangerine
|
||||
"#a3e635", // 28. Chartreuse
|
||||
"#f472b6", // 29. Hot Pink
|
||||
"#22d3ee", // 30. Aqua
|
||||
"#e879f9", // 31. Orchid
|
||||
"#a7f3d0", // 32. Seafoam
|
||||
"#fde047", // 33. Lemon
|
||||
"#fda4af", // 34. Blush
|
||||
"#93c5fd", // 35. Cornflower
|
||||
"#c4b5fd" // 36. Lilac
|
||||
];
|
||||
|
||||
const STORAGE_KEY = "leafflow_batch_queue_meta";
|
||||
const BATCH_COUNTER_KEY = "leafflow_batch_counter";
|
||||
const SETTING_ID = "LeafFlow.BatchQueue.Enabled";
|
||||
const SETTING_SNAPSHOT_GUARD = "LeafFlow.BatchQueue.SnapshotGuard";
|
||||
|
||||
// In-memory registry mapping prompt_id -> batch metadata
|
||||
let batchRegistry = new Map();
|
||||
let activeBatchContext = null;
|
||||
let batchCounter = 0;
|
||||
|
||||
function loadStorage() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) {
|
||||
for (const item of parsed) {
|
||||
if (item && item.prompt_id) {
|
||||
batchRegistry.set(String(item.prompt_id), item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const savedCounter = localStorage.getItem(BATCH_COUNTER_KEY);
|
||||
if (savedCounter) {
|
||||
batchCounter = parseInt(savedCounter, 10) || 0;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[LeafFlow BatchQueue] Failed to load batch storage:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function saveStorage() {
|
||||
try {
|
||||
// Prune entries to last 250 items to keep localStorage small
|
||||
const allEntries = Array.from(batchRegistry.values());
|
||||
const pruned = allEntries.slice(-250);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(pruned));
|
||||
localStorage.setItem(BATCH_COUNTER_KEY, String(batchCounter));
|
||||
} catch (e) {
|
||||
// Storage full or private mode
|
||||
}
|
||||
}
|
||||
|
||||
function getNextBatch(batchCount = 1) {
|
||||
batchCounter++;
|
||||
const colorIndex = (batchCounter - 1) % BATCH_COLORS.length;
|
||||
const color = BATCH_COLORS[colorIndex];
|
||||
const batchId = `batch_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`;
|
||||
|
||||
const batch = {
|
||||
batchId,
|
||||
batchNumber: batchCounter,
|
||||
batchCount: Math.max(1, batchCount),
|
||||
color,
|
||||
colorIndex,
|
||||
queuedAt: Date.now(),
|
||||
prompts: []
|
||||
};
|
||||
return batch;
|
||||
}
|
||||
|
||||
// Inject CSS for the 1D git-graph style line
|
||||
function injectBatchStyles() {
|
||||
if (typeof document === "undefined") return;
|
||||
const styleId = "leafflow-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 {
|
||||
position: absolute;
|
||||
left: -9px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 9px;
|
||||
pointer-events: none;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.leafflow-batch-line-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Ensure parent card has relative positioning and allows line in left margin */
|
||||
[data-testid="job-assets-list"] [data-job-id] {
|
||||
position: relative !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
/* Support for classic frontend v1 queue table rows */
|
||||
.comfy-table tr[data-job-id],
|
||||
.comfy-queue-item {
|
||||
position: relative !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
// Build 1D SVG line path according to segment type:
|
||||
// 'single': bracket (rounded top & bottom curving right to card)
|
||||
// 'first': rounded top curving right, straight all the way down
|
||||
// 'middle': straight line top to bottom
|
||||
// 'last': straight from top, rounded bottom curving right to card
|
||||
function buildSvgPath(segmentType) {
|
||||
// Coordinate system: width=10, height=48. Curve bends toward the card (x=9)
|
||||
switch (segmentType) {
|
||||
case "single":
|
||||
return "M 9,6 Q 2,6 2,16 L 2,32 Q 2,42 9,42";
|
||||
case "first":
|
||||
return "M 9,6 Q 2,6 2,16 L 2,48";
|
||||
case "middle":
|
||||
return "M 2,0 L 2,48";
|
||||
case "last":
|
||||
return "M 2,0 L 2,32 Q 2,42 9,42";
|
||||
default:
|
||||
return "M 9,6 Q 2,6 2,16 L 2,32 Q 2,42 9,42";
|
||||
}
|
||||
}
|
||||
|
||||
function renderBatchLineOnElement(element, segmentType, color) {
|
||||
if (!element) return;
|
||||
|
||||
let wrap = element.querySelector(".leafflow-batch-line-wrap");
|
||||
if (!wrap) {
|
||||
wrap = document.createElement("div");
|
||||
wrap.className = "leafflow-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">
|
||||
<path d="${dPath}" stroke="${color}" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" fill="none" />
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
function removeBatchLineFromElement(element) {
|
||||
if (!element) return;
|
||||
const wrap = element.querySelector(".leafflow-batch-line-wrap");
|
||||
if (wrap) {
|
||||
wrap.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Check if LeafFlow Batch Queue is enabled in settings
|
||||
function isBatchQueueEnabled() {
|
||||
try {
|
||||
if (app.extensionManager?.setting?.get) {
|
||||
return app.extensionManager.setting.get(SETTING_ID) !== false;
|
||||
}
|
||||
} catch (e) {}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isSnapshotGuardEnabled() {
|
||||
try {
|
||||
if (app.extensionManager?.setting?.get) {
|
||||
return app.extensionManager.setting.get(SETTING_SNAPSHOT_GUARD) !== false;
|
||||
}
|
||||
} catch (e) {}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isSeedInput(key, val, nodeInputs) {
|
||||
const k = String(key).toLowerCase();
|
||||
// 1. Direct seed properties (seed, noise_seed, sampler_seed, seed_num, random_seed, etc.)
|
||||
if (k.includes("seed")) {
|
||||
return typeof val === "number" || (typeof val === "string" && /^-?\d+$/.test(val.trim()));
|
||||
}
|
||||
// 2. Dynamic random integer/number properties (random_value, random_int, etc.)
|
||||
if (k.includes("random") && typeof val === "number") {
|
||||
return true;
|
||||
}
|
||||
// 3. ComfyUI PrimitiveNode controlling a seed widget (contains control_after_generate or control_mode)
|
||||
if (key === "value" && nodeInputs && (nodeInputs.control_after_generate !== undefined || nodeInputs.control_mode !== undefined)) {
|
||||
return typeof val === "number" || (typeof val === "string" && /^-?\d+$/.test(String(val).trim()));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function mergeDynamicSeeds(snapPrompt, livePrompt) {
|
||||
if (!snapPrompt) return livePrompt;
|
||||
try {
|
||||
const cloned = JSON.parse(JSON.stringify(snapPrompt));
|
||||
if (livePrompt && livePrompt.output && cloned.output) {
|
||||
for (const [nodeId, liveNode] of Object.entries(livePrompt.output)) {
|
||||
const snapNode = cloned.output[nodeId];
|
||||
if (snapNode && snapNode.inputs && liveNode.inputs) {
|
||||
for (const [key, val] of Object.entries(liveNode.inputs)) {
|
||||
if (isSeedInput(key, val, liveNode.inputs)) {
|
||||
snapNode.inputs[key] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Also sync seed values in workflow metadata so saved PNG metadata matches generated seeds
|
||||
if (livePrompt && livePrompt.workflow && Array.isArray(livePrompt.workflow.nodes) && cloned.workflow && Array.isArray(cloned.workflow.nodes)) {
|
||||
const liveWorkflowMap = new Map();
|
||||
for (const n of livePrompt.workflow.nodes) {
|
||||
if (n && n.id !== undefined) liveWorkflowMap.set(String(n.id), n);
|
||||
}
|
||||
for (const snapNode of cloned.workflow.nodes) {
|
||||
if (!snapNode || snapNode.id === undefined) continue;
|
||||
const liveWfNode = liveWorkflowMap.get(String(snapNode.id));
|
||||
if (liveWfNode && Array.isArray(liveWfNode.widgets_values) && Array.isArray(snapNode.widgets_values)) {
|
||||
for (let i = 0; i < liveWfNode.widgets_values.length; i++) {
|
||||
const liveVal = liveWfNode.widgets_values[i];
|
||||
const snapVal = snapNode.widgets_values[i];
|
||||
if (typeof liveVal === "number" && typeof snapVal === "number" && liveVal !== snapVal) {
|
||||
snapNode.widgets_values[i] = liveVal;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return cloned;
|
||||
} catch (e) {
|
||||
console.warn("[LeafFlow BatchQueue] Error merging dynamic seeds into snapshot:", e);
|
||||
return snapPrompt;
|
||||
}
|
||||
}
|
||||
|
||||
// Sync with server if PersistentQueue is available
|
||||
async function syncBatchToServer(promptId, batchInfo) {
|
||||
try {
|
||||
await authenticatedFetch("/leafflow/batch_queue/sync", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
prompt_id: promptId,
|
||||
batch_info: batchInfo
|
||||
})
|
||||
}).catch(() => null);
|
||||
} catch (e) {
|
||||
// Silently ignore if server endpoint is not present
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBatchFromServer() {
|
||||
try {
|
||||
if (!api || !api.fetchApi) return;
|
||||
const res = await api.fetchApi("/leafflow/batch_queue/data", { cache: "no-store" }).catch(() => null);
|
||||
if (res && res.ok) {
|
||||
const data = await res.json();
|
||||
if (data && typeof data === "object") {
|
||||
for (const [pid, info] of Object.entries(data)) {
|
||||
if (pid && info) {
|
||||
batchRegistry.set(String(pid), info);
|
||||
}
|
||||
}
|
||||
saveStorage();
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// 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());
|
||||
return;
|
||||
}
|
||||
|
||||
// Find all visible queue item elements in DOM (supports v2 job cards and v1 rows)
|
||||
const elements = Array.from(document.querySelectorAll('[data-testid="job-assets-list"] [data-job-id], .comfy-table tr[data-job-id]'));
|
||||
if (!elements.length) return;
|
||||
|
||||
// Elements are strictly in visual screen order from top to bottom
|
||||
const visiblePromptIds = elements.map(el => el.getAttribute("data-job-id")).filter(Boolean);
|
||||
|
||||
// Build map of promptId -> element for visible items
|
||||
const elMap = new Map();
|
||||
for (const el of elements) {
|
||||
const pid = el.getAttribute("data-job-id");
|
||||
if (pid) elMap.set(pid, el);
|
||||
}
|
||||
|
||||
// For every prompt on screen, calculate segment type based on visual screen order:
|
||||
// - Top-most card on screen for Batch A curves at the top (┌)
|
||||
// - Bottom-most card on screen for Batch A curves at the bottom (└)
|
||||
// - 1-item Batch curves at both top and bottom (()
|
||||
// - Anything in between connects with straight lines (│)
|
||||
for (const pid of visiblePromptIds) {
|
||||
const el = elMap.get(pid);
|
||||
if (!el) continue;
|
||||
|
||||
const info = batchRegistry.get(pid);
|
||||
if (!info || !info.batchId) {
|
||||
removeBatchLineFromElement(el);
|
||||
continue;
|
||||
}
|
||||
|
||||
const batchId = info.batchId;
|
||||
const color = info.color || BATCH_COLORS[0];
|
||||
|
||||
// Find all visible items of this batch in top-to-bottom visual order
|
||||
const occurrencesOnScreen = visiblePromptIds.filter(p => {
|
||||
const b = batchRegistry.get(p);
|
||||
return b && b.batchId === batchId;
|
||||
});
|
||||
|
||||
if (occurrencesOnScreen.length <= 1) {
|
||||
// Only 1 item of this batch currently visible on screen
|
||||
if ((info.batchCount || 1) === 1) {
|
||||
renderBatchLineOnElement(el, "single", color);
|
||||
} else {
|
||||
// Batch originally had multiple items, but only 1 is currently visible/unexecuted
|
||||
if (info.itemIndex === 0) {
|
||||
renderBatchLineOnElement(el, "first", color);
|
||||
} else if (info.itemIndex === (info.batchCount - 1)) {
|
||||
renderBatchLineOnElement(el, "last", color);
|
||||
} else {
|
||||
renderBatchLineOnElement(el, "middle", color);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const topPidOnScreen = occurrencesOnScreen[0];
|
||||
const bottomPidOnScreen = occurrencesOnScreen[occurrencesOnScreen.length - 1];
|
||||
|
||||
if (pid === topPidOnScreen) {
|
||||
// Top card on screen -> curves at top (┌)
|
||||
renderBatchLineOnElement(el, "first", color);
|
||||
} else if (pid === bottomPidOnScreen) {
|
||||
// Bottom card on screen -> curves at bottom (└)
|
||||
renderBatchLineOnElement(el, "last", color);
|
||||
} else {
|
||||
// Middle cards on screen -> straight line (│)
|
||||
renderBatchLineOnElement(el, "middle", color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let batchSubmissionQueue = Promise.resolve();
|
||||
|
||||
// Hook queuePrompt to track batch execution and guard multi-item batches
|
||||
function setupQueueHooks() {
|
||||
loadStorage();
|
||||
|
||||
// 1. Hook app.queuePrompt to know when a batch is initiated
|
||||
if (app && typeof app.queuePrompt === "function") {
|
||||
const origQueuePrompt = app.queuePrompt.bind(app);
|
||||
app.queuePrompt = async function(number, batchCount = 1, queueNodeIds) {
|
||||
const count = Math.max(1, parseInt(batchCount, 10) || 1);
|
||||
|
||||
// Chain onto batchSubmissionQueue to ensure sequential batch submissions
|
||||
const turn = batchSubmissionQueue.then(async () => {
|
||||
const batch = getNextBatch(count);
|
||||
activeBatchContext = batch;
|
||||
saveStorage();
|
||||
try {
|
||||
return await origQueuePrompt(number, batchCount, queueNodeIds);
|
||||
} finally {
|
||||
activeBatchContext = null;
|
||||
}
|
||||
});
|
||||
batchSubmissionQueue = turn.catch(() => {});
|
||||
return turn;
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Hook api.queuePrompt to record every generated prompt_id & enforce snapshot guard
|
||||
if (api && typeof api.queuePrompt === "function") {
|
||||
const origApiQueuePrompt = api.queuePrompt.bind(api);
|
||||
api.queuePrompt = async function(number, prompt, targets) {
|
||||
let promptToSend = prompt;
|
||||
|
||||
// Batch Graph Snapshot Guard:
|
||||
// When queueing a batch with batchCount > 1, snapshot on iteration 0 in the background
|
||||
// so canvas edits (text, LoRA selection, widgets) don't bleed into subsequent items.
|
||||
if (isSnapshotGuardEnabled() && activeBatchContext && activeBatchContext.batchCount > 1) {
|
||||
const itemIndex = activeBatchContext.prompts.length;
|
||||
if (itemIndex === 0) {
|
||||
try {
|
||||
activeBatchContext.snapshotPrompt = JSON.parse(JSON.stringify(prompt));
|
||||
} catch (e) {
|
||||
console.warn("[LeafFlow BatchQueue] Failed to snapshot prompt:", e);
|
||||
}
|
||||
} else if (activeBatchContext.snapshotPrompt) {
|
||||
promptToSend = mergeDynamicSeeds(activeBatchContext.snapshotPrompt, prompt);
|
||||
}
|
||||
}
|
||||
|
||||
const res = await origApiQueuePrompt(number, promptToSend, targets);
|
||||
if (res && res.prompt_id) {
|
||||
const pid = String(res.prompt_id);
|
||||
if (!activeBatchContext) {
|
||||
activeBatchContext = getNextBatch(1);
|
||||
}
|
||||
|
||||
const itemIndex = activeBatchContext.prompts.length;
|
||||
const batchInfo = {
|
||||
prompt_id: pid,
|
||||
batchId: activeBatchContext.batchId,
|
||||
batchNumber: activeBatchContext.batchNumber,
|
||||
batchCount: activeBatchContext.batchCount,
|
||||
itemIndex: itemIndex,
|
||||
color: activeBatchContext.color,
|
||||
colorIndex: activeBatchContext.colorIndex,
|
||||
isQueueFront: number < 0,
|
||||
queuedAt: Date.now()
|
||||
};
|
||||
|
||||
activeBatchContext.prompts.push(pid);
|
||||
batchRegistry.set(pid, batchInfo);
|
||||
saveStorage();
|
||||
syncBatchToServer(pid, batchInfo);
|
||||
|
||||
// If this batch completed its expected prompt count, clear context
|
||||
if (activeBatchContext.prompts.length >= activeBatchContext.batchCount) {
|
||||
activeBatchContext = null;
|
||||
}
|
||||
|
||||
setTimeout(updateQueueBatchVisuals, 100);
|
||||
}
|
||||
return res;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfyUI.LeafFlow.BatchQueue",
|
||||
async setup() {
|
||||
injectBatchStyles();
|
||||
setupQueueHooks();
|
||||
loadBatchFromServer();
|
||||
|
||||
// Listen for queue changes
|
||||
if (api && api.addEventListener) {
|
||||
api.addEventListener("status", () => {
|
||||
setTimeout(updateQueueBatchVisuals, 100);
|
||||
});
|
||||
api.addEventListener("execution_start", () => {
|
||||
setTimeout(updateQueueBatchVisuals, 80);
|
||||
});
|
||||
api.addEventListener("executing", () => {
|
||||
setTimeout(updateQueueBatchVisuals, 150);
|
||||
});
|
||||
}
|
||||
|
||||
// Set up MutationObserver on body to detect queue sidebar rendering
|
||||
let debounceTimer = null;
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
let shouldUpdate = false;
|
||||
for (const m of mutations) {
|
||||
if (m.target && m.target.nodeType === 1) {
|
||||
if (m.target.closest?.('[data-testid="job-assets-list"], .comfy-table') ||
|
||||
m.target.matches?.('[data-testid="job-assets-list"], [data-job-id]')) {
|
||||
shouldUpdate = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (shouldUpdate) {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(updateQueueBatchVisuals, 60);
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
// Initial check after load
|
||||
setTimeout(updateQueueBatchVisuals, 800);
|
||||
setTimeout(updateQueueBatchVisuals, 2000);
|
||||
}
|
||||
});
|
||||
|
||||
export { BATCH_COLORS, buildSvgPath };
|
||||
@@ -1,23 +0,0 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
|
||||
const COLOR_MAP = {
|
||||
"PauseQueueNode": { color: "#059669", bgcolor: "#047857" },
|
||||
"PersistentQueueNode": { color: "#059669", bgcolor: "#047857" },
|
||||
"FolderLoraLoaderVisualPrettyV2": { color: "#059669", bgcolor: "#047857" },
|
||||
"ImageLoaderVisualPrettyV2": { color: "#059669", bgcolor: "#047857" },
|
||||
"LoadRecentOutputs": { color: "#059669", bgcolor: "#047857" },
|
||||
"AutoWatcherNode": { color: "#d97706", bgcolor: "#b45309" },
|
||||
"UndoPlaceholder": { color: "#d97706", bgcolor: "#b45309" },
|
||||
"PreviewLatentLive": { color: "#7c3aed", bgcolor: "#6d28d9" }
|
||||
};
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfyUI.FlowControl.Colors",
|
||||
async nodeCreated(node) {
|
||||
if (node && node.type && COLOR_MAP[node.type]) {
|
||||
const theme = COLOR_MAP[node.type];
|
||||
node.color = theme.color;
|
||||
node.bgcolor = theme.bgcolor;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,254 +0,0 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { api } from "/scripts/api.js";
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfyUI.FlowControl.Settings",
|
||||
async setup() {
|
||||
// 1. Enable Civitai Scraping Toggle
|
||||
app.ui.settings.addSetting({
|
||||
id: "FlowControl.EnableCivitaiScraping",
|
||||
name: "🍃 FlowControl: Enable Civitai Auto-Scraping",
|
||||
type: "boolean",
|
||||
defaultValue: true,
|
||||
tooltip: "Enable/disable downloading LoRA preview thumbnails from Civitai.",
|
||||
onChange(value) {
|
||||
api.fetchApi("/flow_control/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enable_civitai_scraping: value ? "true" : "false" })
|
||||
}).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Civitai API Key Setting with Password Masking & Eye Icon Toggle
|
||||
app.ui.settings.addSetting({
|
||||
id: "FlowControl.CivitaiAPIKey",
|
||||
name: "🍃 FlowControl: Civitai API Key",
|
||||
type: "text",
|
||||
defaultValue: "",
|
||||
tooltip: "Optional. Civitai API works publicly without a key for normal models. Only required for NSFW models, private models, or higher API rate limits.",
|
||||
onChange(value) {
|
||||
api.fetchApi("/flow_control/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ civitai_api_key: value })
|
||||
}).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Enable TMDB Scraping Toggle
|
||||
app.ui.settings.addSetting({
|
||||
id: "FlowControl.EnableTMDBScraping",
|
||||
name: "🍃 FlowControl: Enable TMDB Auto-Scraping",
|
||||
type: "boolean",
|
||||
defaultValue: true,
|
||||
tooltip: "Enable/disable downloading celebrity preview thumbnails from TMDB.",
|
||||
onChange(value) {
|
||||
api.fetchApi("/flow_control/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enable_tmdb_scraping: value ? "true" : "false" })
|
||||
}).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
// 4. TMDB API Key Setting with Password Masking & Eye Icon Toggle
|
||||
app.ui.settings.addSetting({
|
||||
id: "FlowControl.TMDBAPIKey",
|
||||
name: "🍃 FlowControl: TMDB API Key",
|
||||
type: "text",
|
||||
defaultValue: "",
|
||||
tooltip: "Optional. Only needed if auto-scraping celebrity preview thumbnails from TMDB.",
|
||||
onChange(value) {
|
||||
api.fetchApi("/flow_control/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ tmdb_api_key: value })
|
||||
}).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
// 5. Default Pause Queue State (Paused vs Running)
|
||||
app.ui.settings.addSetting({
|
||||
id: "FlowControl.DefaultPauseState",
|
||||
name: "🍃 FlowControl: Default Pause Queue State on Launch",
|
||||
type: "combo",
|
||||
options: ["Paused", "Running"],
|
||||
defaultValue: "Paused",
|
||||
tooltip: "Choose whether the queue starts in Paused state or Running state on normal ComfyUI startup.",
|
||||
onChange(value) {
|
||||
api.fetchApi("/flow_control/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ default_pause_state: value })
|
||||
}).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
// 6. Default Pause Queue Mode (Finish vs Instant)
|
||||
app.ui.settings.addSetting({
|
||||
id: "FlowControl.DefaultPauseMode",
|
||||
name: "🍃 FlowControl: Default Pause Queue Mode on Launch",
|
||||
type: "combo",
|
||||
options: ["Pause (Finish)", "Pause (Instant)"],
|
||||
defaultValue: "Pause (Finish)",
|
||||
tooltip: "Choose default pause behavior mode on ComfyUI startup.",
|
||||
onChange(value) {
|
||||
const modeKey = value === "Pause (Instant)" ? "instantly" : "after_finish";
|
||||
api.fetchApi("/flow_control/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ default_pause_mode: modeKey })
|
||||
}).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
// 7. Persistent Queue Restored Launch State Override
|
||||
app.ui.settings.addSetting({
|
||||
id: "FlowControl.PersistentQueueRestoredState",
|
||||
name: "🍃 FlowControl: Persistent Queue Restored Launch State",
|
||||
type: "combo",
|
||||
options: ["Match Default", "Force Paused", "Force Running"],
|
||||
defaultValue: "Match Default",
|
||||
tooltip: "Override launch state when unfinished queue items are restored on startup (e.g., keep normal launch Running, but force Restored queue startup Paused).",
|
||||
onChange(value) {
|
||||
api.fetchApi("/flow_control/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ persistent_queue_restored_state: value })
|
||||
}).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
// 8. Toolbar Button Unpaused Color
|
||||
app.ui.settings.addSetting({
|
||||
id: "FlowControl.PauseButtonUnpausedColor",
|
||||
name: "🍃 FlowControl: Pause Button Unpaused Color",
|
||||
type: "text",
|
||||
defaultValue: "#059669",
|
||||
tooltip: "Hex color code for the Pause toolbar button when execution is unpaused/running (default: #059669).",
|
||||
onChange(value) {
|
||||
document.documentElement.style.setProperty("--pq-unpaused-color", value || "#059669");
|
||||
}
|
||||
});
|
||||
|
||||
// 9. Toolbar Button Paused Color
|
||||
app.ui.settings.addSetting({
|
||||
id: "FlowControl.PauseButtonPausedColor",
|
||||
name: "🍃 FlowControl: Pause Button Paused Color",
|
||||
type: "text",
|
||||
defaultValue: "#ea580c",
|
||||
tooltip: "Hex color code for the Pause toolbar button when execution is paused (default: #ea580c).",
|
||||
onChange(value) {
|
||||
document.documentElement.style.setProperty("--pq-paused-color", value || "#ea580c");
|
||||
}
|
||||
});
|
||||
|
||||
// 10. Pause Queue Toolbar Toggle Setting
|
||||
app.ui.settings.addSetting({
|
||||
id: "FlowControl.EnablePauseQueue",
|
||||
name: "🍃 FlowControl: Enable Pause Queue Toolbar Button",
|
||||
type: "boolean",
|
||||
defaultValue: true,
|
||||
tooltip: "Displays the green/orange Pause & Continue button group in top action bar.",
|
||||
onChange(value) {
|
||||
const group = document.querySelector(".pq-button-group");
|
||||
if (group) {
|
||||
group.style.display = value ? "inline-flex" : "none";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 11. Enable Persistent Queue Setting
|
||||
app.ui.settings.addSetting({
|
||||
id: "FlowControl.EnablePersistentQueue",
|
||||
name: "🍃 FlowControl: Enable Persistent Queue (Auto-Recovery)",
|
||||
type: "boolean",
|
||||
defaultValue: true,
|
||||
tooltip: "Automatically saves unfinished queue items to disk and restores them after restart/crash.",
|
||||
onChange(value) {
|
||||
api.fetchApi("/flow_control/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enable_persistent_queue: value ? "true" : "false" })
|
||||
}).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
// Masking password inputs and adding color pickers in ComfyUI settings dialog
|
||||
const applyKeyMasking = () => {
|
||||
const inputs = document.querySelectorAll('input');
|
||||
inputs.forEach(input => {
|
||||
const settingRow = input.closest('tr') || input.parentElement;
|
||||
if (!settingRow) return;
|
||||
const text = settingRow.textContent || "";
|
||||
|
||||
// Password masking for API keys
|
||||
if ((text.includes("Civitai API Key") || text.includes("TMDB API Key")) && !input.dataset.masked) {
|
||||
input.dataset.masked = "true";
|
||||
input.type = "password";
|
||||
input.style.paddingRight = "2.2rem";
|
||||
|
||||
const toggleBtn = document.createElement("button");
|
||||
toggleBtn.type = "button";
|
||||
toggleBtn.innerHTML = "👁️";
|
||||
toggleBtn.style.cssText = "position: absolute; right: 0.5rem; background: none; border: none; cursor: pointer; font-size: 1rem; color: #a1a1aa; padding: 2px;";
|
||||
toggleBtn.title = "Toggle Show/Hide Key";
|
||||
|
||||
toggleBtn.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (input.type === "password") {
|
||||
input.type = "text";
|
||||
toggleBtn.innerHTML = "🙈";
|
||||
} else {
|
||||
input.type = "password";
|
||||
toggleBtn.innerHTML = "👁️";
|
||||
}
|
||||
};
|
||||
|
||||
const container = input.parentElement;
|
||||
if (container) {
|
||||
container.style.position = "relative";
|
||||
container.appendChild(toggleBtn);
|
||||
}
|
||||
}
|
||||
|
||||
// Color picker popup for Pause button colors
|
||||
if ((text.includes("Pause Button Unpaused Color") || text.includes("Pause Button Paused Color")) && !input.dataset.colorPickerAttached) {
|
||||
input.dataset.colorPickerAttached = "true";
|
||||
const isUnpaused = text.includes("Unpaused Color");
|
||||
|
||||
const colorPicker = document.createElement("input");
|
||||
colorPicker.type = "color";
|
||||
colorPicker.value = input.value || (isUnpaused ? "#059669" : "#ea580c");
|
||||
colorPicker.style.cssText = "width: 28px; height: 28px; border: none; border-radius: 4px; cursor: pointer; padding: 0; background: none; vertical-align: middle; margin-left: 8px;";
|
||||
|
||||
colorPicker.oninput = (e) => {
|
||||
input.value = e.target.value;
|
||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
const varName = isUnpaused ? "--pq-unpaused-color" : "--pq-paused-color";
|
||||
document.documentElement.style.setProperty(varName, e.target.value);
|
||||
};
|
||||
|
||||
input.oninput = (e) => {
|
||||
colorPicker.value = e.target.value;
|
||||
const varName = isUnpaused ? "--pq-unpaused-color" : "--pq-paused-color";
|
||||
document.documentElement.style.setProperty(varName, e.target.value);
|
||||
};
|
||||
|
||||
const container = input.parentElement;
|
||||
if (container) {
|
||||
container.style.display = "flex";
|
||||
container.style.alignItems = "center";
|
||||
container.appendChild(colorPicker);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
setInterval(() => {
|
||||
applyKeyMasking();
|
||||
}, 1000);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { api } from "/scripts/api.js";
|
||||
|
||||
let _cachedCsrfToken = null;
|
||||
let _tokenPromise = null;
|
||||
|
||||
export async function getCsrfToken() {
|
||||
if (_cachedCsrfToken) {
|
||||
return _cachedCsrfToken;
|
||||
}
|
||||
if (_tokenPromise) {
|
||||
return _tokenPromise;
|
||||
}
|
||||
_tokenPromise = (async () => {
|
||||
try {
|
||||
const resp = await api.fetchApi("/leafflow/auth/token");
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
_cachedCsrfToken = data.csrf_token || "";
|
||||
return _cachedCsrfToken;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[LeafFlow Auth] Could not fetch CSRF token:", e);
|
||||
} finally {
|
||||
_tokenPromise = null;
|
||||
}
|
||||
return "";
|
||||
})();
|
||||
return _tokenPromise;
|
||||
}
|
||||
|
||||
export async function authenticatedFetch(endpoint, options = {}) {
|
||||
const token = await getCsrfToken();
|
||||
const opts = { ...options };
|
||||
opts.headers = { ...(opts.headers || {}) };
|
||||
if (token) {
|
||||
opts.headers["X-LeafFlow-CSRF-Token"] = token;
|
||||
}
|
||||
return api.fetchApi(endpoint, opts);
|
||||
}
|
||||
|
||||
// Expose on window for all LeafFlow extensions
|
||||
if (typeof window !== "undefined") {
|
||||
window._LeafFlowAuth = {
|
||||
getToken: getCsrfToken,
|
||||
fetch: authenticatedFetch
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,828 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { api } from "/scripts/api.js";
|
||||
|
||||
/**
|
||||
* Fallback clipboard copy helper that works in non-secure HTTP and headless contexts.
|
||||
*/
|
||||
async function copyToClipboard(text) {
|
||||
if (!text) return false;
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.warn("[LeafFlow] Navigator clipboard failed, trying textarea fallback:", e);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.left = "-9999px";
|
||||
textarea.style.top = "-9999px";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
const successful = document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
return successful;
|
||||
} catch (err) {
|
||||
console.error("[LeafFlow] Clipboard copy fallback failed:", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isCopyEnabled(settingKey, defaultVal = true) {
|
||||
try {
|
||||
let val;
|
||||
if (app.extensionManager?.setting?.get) {
|
||||
val = app.extensionManager.setting.get(settingKey);
|
||||
}
|
||||
if ((val === undefined || val === null || val === "") && app.ui?.settings?.getSettingValue) {
|
||||
val = app.ui.settings.getSettingValue(settingKey);
|
||||
}
|
||||
if (val === undefined || val === null || val === "") {
|
||||
return defaultVal;
|
||||
}
|
||||
return val === true || val === "true" || val === 1 || val === "1";
|
||||
} catch (_) {
|
||||
return defaultVal;
|
||||
}
|
||||
}
|
||||
|
||||
const BOOKMARK_SVG = `<svg class="size-4 pointer-events-none" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z"/></svg>`;
|
||||
const BOOKMARK_SUCCESS_SVG = `<svg class="size-4 text-emerald-600 pointer-events-none" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`;
|
||||
const BOOKMARK_FAIL_SVG = `<svg class="size-4 text-rose-600 pointer-events-none" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
|
||||
const INSPECT_SVG = `<svg class="size-4 pointer-events-none" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>`;
|
||||
|
||||
function updateButtonGroupBorders(container) {
|
||||
if (!container) return;
|
||||
const buttons = Array.from(container.children).filter(el => el.tagName === "BUTTON");
|
||||
if (buttons.length === 0) return;
|
||||
if (buttons.length === 1) {
|
||||
buttons[0].classList.remove("rounded-l-lg", "rounded-r-lg", "rounded-none", "rounded-l-none", "rounded-r-none", "border-r", "border-modal-card-badge-border");
|
||||
buttons[0].classList.add("rounded-lg");
|
||||
return;
|
||||
}
|
||||
buttons.forEach((btn, index) => {
|
||||
btn.classList.remove("rounded-lg", "rounded-l-lg", "rounded-r-lg", "rounded-none", "rounded-l-none", "rounded-r-none", "border-r", "border-modal-card-badge-border");
|
||||
if (index === 0) {
|
||||
btn.classList.add("rounded-l-lg", "rounded-r-none", "border-r", "border-modal-card-badge-border");
|
||||
} else if (index === buttons.length - 1) {
|
||||
btn.classList.add("rounded-r-lg", "rounded-l-none");
|
||||
} else {
|
||||
btn.classList.add("rounded-none", "border-r", "border-modal-card-badge-border");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side binary PNG chunk parser to extract prompt/parameters directly from ArrayBuffer.
|
||||
*/
|
||||
async function extractPromptFromImageUrl(imgSrc) {
|
||||
try {
|
||||
const resp = await fetch(imgSrc);
|
||||
if (!resp.ok) return null;
|
||||
const arrayBuffer = await resp.arrayBuffer();
|
||||
const dataView = new DataView(arrayBuffer);
|
||||
|
||||
// Verify PNG magic header: 0x89504E47 0x0D0A1A0A
|
||||
if (dataView.byteLength < 16) return null;
|
||||
if (dataView.getUint32(0) !== 0x89504E47 || dataView.getUint32(4) !== 0x0D0A1A0A) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let offset = 8;
|
||||
const textDecoder = new TextDecoder("utf-8");
|
||||
const metadata = {};
|
||||
|
||||
while (offset < dataView.byteLength - 8) {
|
||||
const length = dataView.getUint32(offset);
|
||||
const type = String.fromCharCode(
|
||||
dataView.getUint8(offset + 4),
|
||||
dataView.getUint8(offset + 5),
|
||||
dataView.getUint8(offset + 6),
|
||||
dataView.getUint8(offset + 7)
|
||||
);
|
||||
|
||||
if (type === "tEXt" || type === "iTXt") {
|
||||
const chunkData = new Uint8Array(arrayBuffer, offset + 8, length);
|
||||
let nullIndex = 0;
|
||||
while (nullIndex < chunkData.length && chunkData[nullIndex] !== 0) nullIndex++;
|
||||
const keyword = textDecoder.decode(chunkData.subarray(0, nullIndex));
|
||||
|
||||
let textValue = "";
|
||||
if (type === "tEXt") {
|
||||
textValue = textDecoder.decode(chunkData.subarray(nullIndex + 1));
|
||||
} else if (type === "iTXt") {
|
||||
const compFlag = chunkData[nullIndex + 1];
|
||||
let textStart = nullIndex + 3;
|
||||
while (textStart < chunkData.length && chunkData[textStart] !== 0) textStart++;
|
||||
textStart++;
|
||||
while (textStart < chunkData.length && chunkData[textStart] !== 0) textStart++;
|
||||
textStart++;
|
||||
if (compFlag === 0) {
|
||||
textValue = textDecoder.decode(chunkData.subarray(textStart));
|
||||
}
|
||||
}
|
||||
if (keyword && textValue) {
|
||||
metadata[keyword.toLowerCase()] = textValue;
|
||||
}
|
||||
}
|
||||
offset += 12 + length;
|
||||
}
|
||||
|
||||
// 1. Check A1111 / WebUI / Forge / Civitai parameters
|
||||
if (metadata["parameters"]) {
|
||||
const lines = metadata["parameters"].split("\n");
|
||||
const pos = [];
|
||||
for (const line of lines) {
|
||||
if (line.trim().startsWith("Negative prompt:") || line.trim().startsWith("Steps:")) break;
|
||||
pos.push(line);
|
||||
}
|
||||
const res = pos.join("\n").trim();
|
||||
if (res) return res;
|
||||
}
|
||||
|
||||
// 2. Check native ComfyUI prompt graph JSON
|
||||
if (metadata["prompt"]) {
|
||||
try {
|
||||
const promptData = JSON.parse(metadata["prompt"]);
|
||||
if (typeof promptData === "object" && promptData !== null) {
|
||||
const startNodes = [];
|
||||
for (const [nid, ndata] of Object.entries(promptData)) {
|
||||
const ctype = ndata?.class_type || "";
|
||||
const inputs = ndata?.inputs || {};
|
||||
if (ctype.includes("Sampler") || ctype.includes("KSampler") || ctype.includes("Guider") || ctype.includes("CFG")) {
|
||||
for (const linkKey of ["positive", "conditioning", "guider"]) {
|
||||
const link = inputs[linkKey];
|
||||
if (Array.isArray(link) && link.length > 0) {
|
||||
startNodes.push(String(link[0]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const visited = new Set();
|
||||
const texts = [];
|
||||
|
||||
function walk(nid) {
|
||||
if (!nid || visited.has(nid)) return;
|
||||
visited.add(nid);
|
||||
const ndata = promptData[nid];
|
||||
if (!ndata || typeof ndata !== "object") return;
|
||||
const inputs = ndata.inputs || {};
|
||||
|
||||
for (const tkey of ["text", "prompt", "text_g", "text_l", "text_positive", "positive_prompt", "value", "string"]) {
|
||||
const val = inputs[tkey];
|
||||
if (typeof val === "string" && val.trim()) {
|
||||
const s = val.trim();
|
||||
if (!texts.includes(s)) texts.push(s);
|
||||
} else if (Array.isArray(val) && val.length > 0) {
|
||||
walk(String(val[0]));
|
||||
}
|
||||
}
|
||||
|
||||
for (const [inName, inVal] of Object.entries(inputs)) {
|
||||
if (Array.isArray(inVal) && inVal.length > 0) {
|
||||
const low = inName.toLowerCase();
|
||||
if (low.includes("negative")) continue;
|
||||
if (["conditioning", "positive", "cond", "text", "prompt", "guider"].some(k => low.includes(k))) {
|
||||
walk(String(inVal[0]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const sn of startNodes) {
|
||||
walk(sn);
|
||||
}
|
||||
|
||||
if (texts.length) return texts.join("\n");
|
||||
|
||||
// Fallback: search all text / prompt nodes
|
||||
for (const [nid, ndata] of Object.entries(promptData)) {
|
||||
const ctype = ndata?.class_type || "";
|
||||
const inputs = ndata?.inputs || {};
|
||||
if (ctype.includes("CLIPTextEncode") || ctype.includes("Text") || ctype.includes("Prompt")) {
|
||||
for (const tkey of ["text", "prompt", "text_g", "text_l"]) {
|
||||
const t = inputs[tkey];
|
||||
if (typeof t === "string" && t.trim().length > 1) {
|
||||
const s = t.trim();
|
||||
if (!texts.includes(s)) texts.push(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (texts.length) return texts.join("\n");
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[LeafFlow] Direct PNG metadata extraction failed:", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to fetch image metadata and extract the positive prompt text.
|
||||
*/
|
||||
|
||||
async function getImagePromptAndMeta(imgSrc) {
|
||||
if (!imgSrc) return null;
|
||||
let url = new URL(imgSrc, window.location.origin);
|
||||
let filename = url.searchParams.get("filename");
|
||||
let type = url.searchParams.get("type") || "output";
|
||||
let subfolder = url.searchParams.get("subfolder") || "";
|
||||
|
||||
if (!filename) {
|
||||
const parts = url.pathname.split("/");
|
||||
filename = decodeURIComponent(parts[parts.length - 1]);
|
||||
}
|
||||
|
||||
let promptText = null;
|
||||
try {
|
||||
promptText = await extractPromptFromImageUrl(imgSrc);
|
||||
} catch (_) {}
|
||||
|
||||
if (!promptText && filename) {
|
||||
try {
|
||||
const promptUrl = `/leafflow/get_image_prompt?filename=${encodeURIComponent(filename)}&type=${encodeURIComponent(type)}&subfolder=${encodeURIComponent(subfolder)}`;
|
||||
const response = await api.fetchApi(promptUrl);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data && data.prompt) promptText = data.prompt;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
return {
|
||||
promptText: promptText || "",
|
||||
filename: filename || "bookmark",
|
||||
subfolder: subfolder || "",
|
||||
type: type || "output"
|
||||
};
|
||||
}
|
||||
|
||||
async function saveImageToPromptBookmarks(imgSrc) {
|
||||
const meta = await getImagePromptAndMeta(imgSrc);
|
||||
if (!meta) return false;
|
||||
|
||||
const event = new CustomEvent("prompt-bookmarks-create", {
|
||||
detail: {
|
||||
name: meta.filename.replace(/\.[^/.]+$/, ""),
|
||||
text: meta.promptText,
|
||||
media: [{
|
||||
filename: meta.filename,
|
||||
subfolder: meta.subfolder,
|
||||
type: meta.type,
|
||||
media_type: "image"
|
||||
}]
|
||||
}
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function copyImagePrompt(imgSrc) {
|
||||
if (!imgSrc) return false;
|
||||
let promptText = null;
|
||||
|
||||
// 1. Try client-side direct binary extraction first
|
||||
try {
|
||||
promptText = await extractPromptFromImageUrl(imgSrc);
|
||||
} catch (_) {}
|
||||
|
||||
// 2. Fallback to server endpoint
|
||||
if (!promptText) {
|
||||
try {
|
||||
let url = new URL(imgSrc, window.location.origin);
|
||||
let filename = url.searchParams.get("filename");
|
||||
let type = url.searchParams.get("type") || "output";
|
||||
let subfolder = url.searchParams.get("subfolder") || "";
|
||||
|
||||
if (!filename) {
|
||||
const parts = url.pathname.split("/");
|
||||
filename = decodeURIComponent(parts[parts.length - 1]);
|
||||
}
|
||||
|
||||
if (filename) {
|
||||
const promptUrl = `/leafflow/get_image_prompt?filename=${encodeURIComponent(filename)}&type=${encodeURIComponent(type)}&subfolder=${encodeURIComponent(subfolder)}`;
|
||||
const response = await api.fetchApi(promptUrl);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data && data.prompt) {
|
||||
promptText = data.prompt;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[LeafFlow] Direct prompt fetch failed:", e);
|
||||
}
|
||||
}
|
||||
|
||||
if (promptText) {
|
||||
const copied = await copyToClipboard(promptText);
|
||||
if (copied && app.extensionManager?.toast?.add) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: "success",
|
||||
summary: "📋 Prompt Copied",
|
||||
detail: promptText.length > 80 ? promptText.slice(0, 80) + "..." : promptText,
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
return copied;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 1. Hook into Node Context Menu (Right Click)
|
||||
app.registerExtension({
|
||||
name: "ComfyUI.LeafFlow.CopyPrompt",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
const origGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
|
||||
nodeType.prototype.getExtraMenuOptions = function (_, options) {
|
||||
if (origGetExtraMenuOptions) origGetExtraMenuOptions.apply(this, arguments);
|
||||
|
||||
if (!isCopyEnabled("LeafFlow.3 - 📋 Prompt Actions.02_EnableContextMenuCopyPrompt")) return;
|
||||
|
||||
const imgs = this.imgs || this.images || (this.widgets && this.widgets.filter(w => w.type === "image" || w.name === "image"));
|
||||
if (imgs && imgs.length > 0) {
|
||||
options.push({
|
||||
content: "📋 Copy Prompt",
|
||||
callback: async () => {
|
||||
const img = imgs[this.imageIndex || 0];
|
||||
const src = typeof img === "string" ? img : (img?.src || img?.value);
|
||||
if (!src) return;
|
||||
const success = await copyImagePrompt(src);
|
||||
if (!success) {
|
||||
if (app.extensionManager?.toast?.add) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: "warn",
|
||||
summary: "LeafFlow Copy Prompt",
|
||||
detail: "Could not extract positive prompt from this image.",
|
||||
life: 3000
|
||||
});
|
||||
} else {
|
||||
alert("LeafFlow: Could not extract positive prompt from this image.");
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (isCopyEnabled("LeafFlow.3 - 📋 Prompt Actions.03_EnableSaveToPromptSaver")) {
|
||||
options.push({
|
||||
content: "🔖 Bookmark in Prompt Saver",
|
||||
callback: async () => {
|
||||
const img = imgs[this.imageIndex || 0];
|
||||
const src = typeof img === "string" ? img : (img?.src || img?.value);
|
||||
if (!src) return;
|
||||
await saveImageToPromptBookmarks(src);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Active Interaction Tracking & Image URL Resolution
|
||||
let lastInteractedAssetCard = null;
|
||||
|
||||
function isQueueItemElement(el) {
|
||||
if (!el || typeof el.closest !== "function") return false;
|
||||
// An asset card is explicitly an asset card in the gallery/history, never a queue job row
|
||||
if (el.closest("div[data-virtual-grid-item], [data-asset-id], .asset-card, [data-testid='asset-card']")) {
|
||||
return !!el.closest("[data-job-id]");
|
||||
}
|
||||
return !!el.closest("[data-job-id], .comfy-queue-item, .queue-item, .queue-list-item, .queue-entry");
|
||||
}
|
||||
|
||||
function registerAssetInteraction(target) {
|
||||
if (!target) return;
|
||||
const card = target.closest?.(
|
||||
"div[data-virtual-grid-item], [data-asset-id], .asset-card, [data-testid='asset-card'], [data-node-id], .lg-node, .comfy-image-preview, .group"
|
||||
);
|
||||
if (card && !isQueueItemElement(card)) {
|
||||
lastInteractedAssetCard = card;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("pointerdown", (e) => registerAssetInteraction(e.target), true);
|
||||
document.addEventListener("click", (e) => registerAssetInteraction(e.target), true);
|
||||
document.addEventListener("contextmenu", (e) => registerAssetInteraction(e.target), true);
|
||||
|
||||
function getActiveImageSrc() {
|
||||
// 1. From last clicked / right-clicked / interacted asset card
|
||||
if (lastInteractedAssetCard) {
|
||||
const img = lastInteractedAssetCard.querySelector("img");
|
||||
if (img && img.src) return img.src;
|
||||
}
|
||||
// 2. From currently selected asset card in DOM
|
||||
const selected = document.querySelector(
|
||||
'div[data-virtual-grid-item] [data-selected="true"], [data-asset-id][data-selected="true"], .group[data-selected="true"]'
|
||||
);
|
||||
if (selected) {
|
||||
const img = selected.querySelector("img");
|
||||
if (img && img.src) return img.src;
|
||||
}
|
||||
// 3. Any active hover card
|
||||
const hovered = document.querySelector("div[data-virtual-grid-item]:hover, .asset-card:hover, .group:hover");
|
||||
if (hovered) {
|
||||
const img = hovered.querySelector("img");
|
||||
if (img && img.src) return img.src;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3. Inject Copy Prompt, Bookmark & Inspect Directly Next to Download Button on Asset Cards
|
||||
function injectCopyPromptNextToDownload(downloadBtn) {
|
||||
if (!downloadBtn || !downloadBtn.parentElement) return;
|
||||
|
||||
if (isQueueItemElement(downloadBtn)) return;
|
||||
|
||||
const card = downloadBtn.closest(
|
||||
"div[data-virtual-grid-item], [data-asset-id], .asset-card, [data-testid='asset-card'], [data-node-id], .lg-node, .comfy-image-preview, .group"
|
||||
);
|
||||
if (!card) return;
|
||||
if (isQueueItemElement(card)) return;
|
||||
|
||||
const img = card.querySelector("img");
|
||||
if (!img || !img.src) return;
|
||||
if (img.classList.contains("size-8") || img.closest(".size-8, .size-10, .h-12")) return;
|
||||
|
||||
const parent = downloadBtn.parentElement;
|
||||
const enableCopy = isCopyEnabled("LeafFlow.3 - 📋 Prompt Actions.01_EnableAssetsCopyPromptButton");
|
||||
const enableBookmark = isCopyEnabled("LeafFlow.3 - 📋 Prompt Actions.03_EnableSaveToPromptSaver");
|
||||
const enableInspect = isCopyEnabled("LeafFlow.3 - 📋 Prompt Actions.04_EnableInspectAssetButton", false);
|
||||
|
||||
if (!enableCopy && !enableBookmark && !enableInspect) return;
|
||||
|
||||
let baseClasses = downloadBtn.className
|
||||
.replace(/\brounded-[a-z0-9-]+\b/g, "")
|
||||
.replace(/\brounded\b/g, "")
|
||||
.replace(/\bborder-r\b/g, "")
|
||||
.replace(/\bborder-modal-card-badge-border\b/g, "")
|
||||
.trim();
|
||||
|
||||
if (!baseClasses || baseClasses.length < 5) {
|
||||
baseClasses = "inline-flex items-center justify-center font-medium font-inter transition-colors focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50 border border-transparent shadow-xs cursor-pointer bg-modal-card-badge-background text-modal-card-badge-foreground hover:bg-modal-card-badge-background-hover size-8 p-0";
|
||||
}
|
||||
|
||||
const hasModernIcons = !!(
|
||||
downloadBtn.querySelector("[class*='icon-']") ||
|
||||
document.querySelector("[class*='icon-[lucide']")
|
||||
);
|
||||
|
||||
let lastBtn = downloadBtn;
|
||||
|
||||
// 1. Copy Prompt Button
|
||||
let copyBtn = parent.querySelector(".leafflow-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`;
|
||||
|
||||
if (hasModernIcons) {
|
||||
copyBtn.innerHTML = `<i class="icon-[lucide--copy] size-4 pointer-events-none"></i>`;
|
||||
} else {
|
||||
copyBtn.innerHTML = `<svg class="size-4 pointer-events-none" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`;
|
||||
}
|
||||
|
||||
copyBtn.onclick = async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const activeImg = card.querySelector("img") || img;
|
||||
const success = await copyImagePrompt(activeImg.src);
|
||||
if (hasModernIcons) {
|
||||
copyBtn.innerHTML = success
|
||||
? `<i class="icon-[lucide--check] size-4 text-emerald-600 pointer-events-none"></i>`
|
||||
: `<i class="icon-[lucide--x] size-4 text-rose-600 pointer-events-none"></i>`;
|
||||
setTimeout(() => {
|
||||
copyBtn.innerHTML = `<i class="icon-[lucide--copy] size-4 pointer-events-none"></i>`;
|
||||
}, 2000);
|
||||
} else {
|
||||
copyBtn.innerHTML = success
|
||||
? `<svg class="size-4 text-emerald-600 pointer-events-none" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>`
|
||||
: `<svg class="size-4 text-rose-600 pointer-events-none" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
|
||||
setTimeout(() => {
|
||||
copyBtn.innerHTML = `<svg class="size-4 pointer-events-none" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`;
|
||||
}, 2000);
|
||||
}
|
||||
};
|
||||
parent.insertBefore(copyBtn, lastBtn.nextSibling);
|
||||
}
|
||||
lastBtn = copyBtn;
|
||||
} else if (copyBtn) {
|
||||
copyBtn.remove();
|
||||
}
|
||||
|
||||
// 2. Bookmark Button
|
||||
let bookmarkBtn = parent.querySelector(".leafflow-hover-bookmark");
|
||||
if (enableBookmark) {
|
||||
if (!bookmarkBtn) {
|
||||
bookmarkBtn = document.createElement("button");
|
||||
bookmarkBtn.type = "button";
|
||||
bookmarkBtn.title = "Save to Prompt Bookmarks";
|
||||
bookmarkBtn.setAttribute("aria-label", "Save to Prompt Bookmarks");
|
||||
bookmarkBtn.className = `leafflow-hover-btn leafflow-hover-bookmark ${baseClasses} shrink-0`;
|
||||
bookmarkBtn.innerHTML = BOOKMARK_SVG;
|
||||
|
||||
bookmarkBtn.onclick = async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const activeImg = card.querySelector("img") || img;
|
||||
const success = await saveImageToPromptBookmarks(activeImg.src);
|
||||
bookmarkBtn.innerHTML = success ? BOOKMARK_SUCCESS_SVG : BOOKMARK_FAIL_SVG;
|
||||
setTimeout(() => {
|
||||
bookmarkBtn.innerHTML = BOOKMARK_SVG;
|
||||
}, 2000);
|
||||
};
|
||||
parent.insertBefore(bookmarkBtn, lastBtn.nextSibling);
|
||||
}
|
||||
lastBtn = bookmarkBtn;
|
||||
} else if (bookmarkBtn) {
|
||||
bookmarkBtn.remove();
|
||||
}
|
||||
|
||||
// 3. Inspect Asset (Zoom) Button
|
||||
let inspectBtn = parent.querySelector(".leafflow-hover-inspect");
|
||||
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.innerHTML = INSPECT_SVG;
|
||||
|
||||
inspectBtn.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const target = card.querySelector(".aspect-square, [class*='aspect-square'], img") || img;
|
||||
if (target) {
|
||||
target.dispatchEvent(new MouseEvent("dblclick", { bubbles: true, cancelable: true, view: window }));
|
||||
}
|
||||
};
|
||||
parent.insertBefore(inspectBtn, lastBtn.nextSibling);
|
||||
}
|
||||
lastBtn = inspectBtn;
|
||||
} else if (inspectBtn) {
|
||||
inspectBtn.remove();
|
||||
}
|
||||
|
||||
updateButtonGroupBorders(parent);
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
let downloadLi = contextMenu.querySelector(
|
||||
'li[aria-label="Download"], li[aria-label*="ownload" i], [data-pc-section="item"][aria-label*="ownload" i]'
|
||||
);
|
||||
if (!downloadLi) {
|
||||
const items = contextMenu.querySelectorAll('li, [data-pc-section="item"]');
|
||||
for (const item of items) {
|
||||
const text = item.textContent?.trim().toLowerCase() || "";
|
||||
if (text.includes("download") || item.querySelector('.icon-[lucide--download], [class*="download" i]')) {
|
||||
downloadLi = item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!downloadLi || !downloadLi.parentElement) return;
|
||||
|
||||
const refBtn = downloadLi.querySelector("button");
|
||||
const btnClass = refBtn
|
||||
? refBtn.className
|
||||
: "relative inline-flex items-center gap-2 cursor-pointer touch-manipulation whitespace-nowrap appearance-none border-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 text-secondary-foreground bg-secondary-background hover:bg-secondary-background-hover h-8 rounded-lg p-2 text-xs w-full justify-start p-contextmenu-item-link";
|
||||
|
||||
const hasModernIcons = !!(
|
||||
downloadLi.querySelector("[class*='icon-']") ||
|
||||
document.querySelector("[class*='icon-[lucide']")
|
||||
);
|
||||
|
||||
const copyLi = document.createElement("li");
|
||||
copyLi.className = "p-contextmenu-item leafflow-contextmenu-copy";
|
||||
copyLi.setAttribute("role", "menuitem");
|
||||
copyLi.setAttribute("aria-label", "Copy prompt");
|
||||
copyLi.setAttribute("data-pc-section", "item");
|
||||
copyLi.setAttribute("data-p-active", "false");
|
||||
copyLi.setAttribute("data-p-focused", "false");
|
||||
|
||||
const copyIconHtml = hasModernIcons
|
||||
? `<i class="icon-[lucide--copy] size-4"></i>`
|
||||
: `<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`;
|
||||
|
||||
copyLi.innerHTML = `
|
||||
<div class="p-contextmenu-item-content" data-pc-section="itemcontent">
|
||||
<button class="${btnClass}" tabindex="-1" data-pc-section="itemlink">
|
||||
${copyIconHtml}
|
||||
<span>Copy prompt</span>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
copyLi.addEventListener("click", async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const span = copyLi.querySelector("span");
|
||||
const src = getActiveImageSrc();
|
||||
if (src) {
|
||||
const success = await copyImagePrompt(src);
|
||||
if (span) span.textContent = success ? "Copied! ✅" : "Failed to copy ❌";
|
||||
} else {
|
||||
if (span) span.textContent = "No image found ❌";
|
||||
}
|
||||
setTimeout(() => {
|
||||
contextMenu.style.display = "none";
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||
}, 500);
|
||||
});
|
||||
|
||||
downloadLi.parentElement.insertBefore(copyLi, downloadLi.nextSibling);
|
||||
|
||||
if (isCopyEnabled("LeafFlow.3 - 📋 Prompt Actions.03_EnableSaveToPromptSaver") && !contextMenu.querySelector(".leafflow-contextmenu-bookmark")) {
|
||||
const bookmarkLi = document.createElement("li");
|
||||
bookmarkLi.className = "p-contextmenu-item leafflow-contextmenu-bookmark";
|
||||
bookmarkLi.setAttribute("role", "menuitem");
|
||||
bookmarkLi.setAttribute("aria-label", "Save to prompt bookmarks");
|
||||
bookmarkLi.setAttribute("data-pc-section", "item");
|
||||
bookmarkLi.setAttribute("data-p-active", "false");
|
||||
bookmarkLi.setAttribute("data-p-focused", "false");
|
||||
|
||||
const bIconHtml = BOOKMARK_SVG;
|
||||
|
||||
bookmarkLi.innerHTML = `
|
||||
<div class="p-contextmenu-item-content" data-pc-section="itemcontent">
|
||||
<button class="${btnClass}" tabindex="-1" data-pc-section="itemlink">
|
||||
${bIconHtml}
|
||||
<span>Save to prompt bookmarks</span>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
bookmarkLi.addEventListener("click", async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const span = bookmarkLi.querySelector("span");
|
||||
const src = getActiveImageSrc();
|
||||
if (src) {
|
||||
const success = await saveImageToPromptBookmarks(src);
|
||||
if (span) span.textContent = success ? "Bookmarked! ✅" : "Failed ❌";
|
||||
}
|
||||
setTimeout(() => {
|
||||
contextMenu.style.display = "none";
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||
}, 500);
|
||||
});
|
||||
|
||||
downloadLi.parentElement.insertBefore(bookmarkLi, copyLi.nextSibling);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Fallback Hook for Older Frontend Action Bars
|
||||
function injectHoverCopyAction(overlayBar) {
|
||||
if (!overlayBar || overlayBar.querySelector(".leafflow-hover-copy")) return;
|
||||
if (!isCopyEnabled("LeafFlow.3 - 📋 Prompt Actions.01_EnableAssetsCopyPromptButton")) return;
|
||||
|
||||
if (isQueueItemElement(overlayBar)) return;
|
||||
|
||||
const downloadBtn = overlayBar.querySelector('button[aria-label="Download"], button[aria-label*="ownload" i]');
|
||||
if (downloadBtn) {
|
||||
injectCopyPromptNextToDownload(downloadBtn);
|
||||
return;
|
||||
}
|
||||
|
||||
const iconGroup =
|
||||
overlayBar.querySelector(".flex.shrink-0") ||
|
||||
overlayBar.querySelector('button[aria-label="Zoom in"]')?.parentElement ||
|
||||
overlayBar.querySelector('button[aria-label*="zoom" i]')?.parentElement ||
|
||||
overlayBar;
|
||||
|
||||
const parentCard = overlayBar.closest(
|
||||
"div[data-virtual-grid-item], .asset-card, [data-testid='asset-card'], [data-node-id], .lg-node, .comfy-image-preview"
|
||||
);
|
||||
if (!parentCard) return;
|
||||
if (isQueueItemElement(parentCard)) return;
|
||||
|
||||
const img = parentCard.querySelector("img");
|
||||
if (!img || !img.src) return;
|
||||
if (img.classList.contains("size-8") || img.closest(".size-8, .size-10, .h-12")) return;
|
||||
|
||||
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";
|
||||
copyBtn.title = "Copy Prompt";
|
||||
copyBtn.setAttribute("aria-label", "Copy Prompt");
|
||||
copyBtn.setAttribute("data-pd-tooltip", "true");
|
||||
copyBtn.innerHTML = "<span class='text-sm pointer-events-none'>📋</span>";
|
||||
|
||||
copyBtn.onclick = async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const success = await copyImagePrompt(img.src);
|
||||
if (success) {
|
||||
copyBtn.innerHTML = "<span class='text-sm pointer-events-none'>✅</span>";
|
||||
setTimeout(() => (copyBtn.innerHTML = "<span class='text-sm pointer-events-none'>📋</span>"), 2000);
|
||||
} else {
|
||||
copyBtn.innerHTML = "<span class='text-sm pointer-events-none'>❌</span>";
|
||||
setTimeout(() => (copyBtn.innerHTML = "<span class='text-sm pointer-events-none'>📋</span>"), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const moreBtn =
|
||||
iconGroup.querySelector('button[aria-label="More options"]') ||
|
||||
iconGroup.querySelector('button[aria-label="More"]') ||
|
||||
iconGroup.lastElementChild;
|
||||
|
||||
if (moreBtn && moreBtn.parentElement === iconGroup) {
|
||||
iconGroup.insertBefore(copyBtn, moreBtn);
|
||||
} else {
|
||||
iconGroup.appendChild(copyBtn);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Active Scanner for Dynamic Virtual Grid & Context Menus
|
||||
function scanAndInject() {
|
||||
const downloadBtns = document.querySelectorAll(
|
||||
'button[aria-label="Download"], button[aria-label*="ownload" i]'
|
||||
);
|
||||
downloadBtns.forEach(injectCopyPromptNextToDownload);
|
||||
|
||||
const menus = document.querySelectorAll('.p-contextmenu, .p-menu, .p-tieredmenu, [data-pc-name="contextmenu"], [data-pc-name="menu"], [data-pc-name="tieredmenu"]');
|
||||
menus.forEach(injectContextMenuCopy);
|
||||
|
||||
const olderOverlays = document.querySelectorAll(
|
||||
'[data-testid="asset-card-actions"], .asset-card-overlay, .asset-item-overlay'
|
||||
);
|
||||
olderOverlays.forEach(injectHoverCopyAction);
|
||||
}
|
||||
|
||||
// Delegated hover listener for instant injection during virtual scrolling
|
||||
document.addEventListener("pointerover", (e) => {
|
||||
const btn = e.target.closest?.('button[aria-label="Download"], button[aria-label*="ownload" i]');
|
||||
if (btn) {
|
||||
injectCopyPromptNextToDownload(btn);
|
||||
return;
|
||||
}
|
||||
const card = e.target.closest?.('div[data-virtual-grid-item], [data-asset-id], .asset-card, .group');
|
||||
if (card) {
|
||||
registerAssetInteraction(card);
|
||||
const cardDl = card.querySelector('button[aria-label="Download"], button[aria-label*="ownload" i]');
|
||||
if (cardDl) injectCopyPromptNextToDownload(cardDl);
|
||||
}
|
||||
const menu = e.target.closest?.('.p-contextmenu, .p-menu, .p-tieredmenu, [data-pc-name="contextmenu"], [data-pc-name="menu"], [data-pc-name="tieredmenu"]');
|
||||
if (menu) {
|
||||
injectContextMenuCopy(menu);
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
// MutationObserver for DOM changes
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
if (mutation.type === "childList") {
|
||||
for (const node of mutation.addedNodes) {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
if (isQueueItemElement(node)) continue;
|
||||
|
||||
if (node.matches?.('button[aria-label="Download"], button[aria-label*="ownload" i]')) {
|
||||
injectCopyPromptNextToDownload(node);
|
||||
} else if (node.matches?.('.p-contextmenu, .p-menu, .p-tieredmenu, [data-pc-name="contextmenu"], [data-pc-name="menu"], [data-pc-name="tieredmenu"]')) {
|
||||
injectContextMenuCopy(node);
|
||||
} else if (node.matches?.('[data-testid="asset-card-actions"], .asset-card-overlay, .asset-item-overlay')) {
|
||||
injectHoverCopyAction(node);
|
||||
} else if (node.querySelectorAll) {
|
||||
const dlBtns = node.querySelectorAll('button[aria-label="Download"], button[aria-label*="ownload" i]');
|
||||
dlBtns.forEach(injectCopyPromptNextToDownload);
|
||||
|
||||
const ctxMenus = node.querySelectorAll('.p-contextmenu, .p-menu, .p-tieredmenu, [data-pc-name="contextmenu"], [data-pc-name="menu"], [data-pc-name="tieredmenu"]');
|
||||
ctxMenus.forEach(injectContextMenuCopy);
|
||||
|
||||
const overlays = node.querySelectorAll('[data-testid="asset-card-actions"], .asset-card-overlay, .asset-item-overlay');
|
||||
overlays.forEach(injectHoverCopyAction);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
// Run scanner immediately, on DOM ready, and periodic sweep
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", scanAndInject);
|
||||
} else {
|
||||
scanAndInject();
|
||||
}
|
||||
setInterval(scanAndInject, 500);
|
||||
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { api } from "/scripts/api.js";
|
||||
import { authenticatedFetch } from "./auth_helper.js";
|
||||
|
||||
// Create global styles for the decision buttons
|
||||
const styles = document.createElement("style");
|
||||
styles.textContent = `
|
||||
.leafflow-decision-btn {
|
||||
margin: 4px;
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
color: white;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.leafflow-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; }
|
||||
|
||||
.leafflow-decision-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 4px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(styles);
|
||||
|
||||
app.registerExtension({
|
||||
name: "Comfy.LeafFlowDecision",
|
||||
async setup() {
|
||||
api.addEventListener("leafflow_decision_waiting", (event) => {
|
||||
const data = event.detail;
|
||||
if (data && data.node_id) {
|
||||
const node = app.graph.getNodeById(data.node_id);
|
||||
if (node && node.enableDecisionButtons) {
|
||||
node.enableDecisionButtons();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
api.addEventListener("leafflow_decision_resolved", (event) => {
|
||||
const data = event.detail;
|
||||
if (data && data.node_id) {
|
||||
const node = app.graph.getNodeById(data.node_id);
|
||||
if (node && node.disableDecisionButtons) {
|
||||
node.disableDecisionButtons();
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
async nodeCreated(node) {
|
||||
if (node.comfyClass === "LeafFlowDecision") {
|
||||
const container = document.createElement("div");
|
||||
container.className = "leafflow-decision-container";
|
||||
|
||||
const btnContinue = document.createElement("button");
|
||||
btnContinue.className = "leafflow-decision-btn leafflow-btn-continue";
|
||||
btnContinue.innerText = "Continue Workflow";
|
||||
btnContinue.disabled = true;
|
||||
|
||||
const btnCancel = document.createElement("button");
|
||||
btnCancel.className = "leafflow-decision-btn leafflow-btn-cancel";
|
||||
btnCancel.innerText = "Cancel (Return True)";
|
||||
btnCancel.disabled = true;
|
||||
|
||||
const btnStop = document.createElement("button");
|
||||
btnStop.className = "leafflow-decision-btn leafflow-btn-stop";
|
||||
btnStop.innerText = "Stop Workflow";
|
||||
btnStop.disabled = true;
|
||||
|
||||
container.appendChild(btnContinue);
|
||||
container.appendChild(btnCancel);
|
||||
container.appendChild(btnStop);
|
||||
|
||||
const sendDecision = async (action) => {
|
||||
node.disableDecisionButtons();
|
||||
try {
|
||||
await authenticatedFetch("/leafflow/decision", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
node_id: node.id.toString(),
|
||||
action: action
|
||||
}),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[LeafFlow] Failed to send decision:", e);
|
||||
}
|
||||
};
|
||||
|
||||
btnContinue.addEventListener("click", () => sendDecision("continue"));
|
||||
btnCancel.addEventListener("click", () => sendDecision("cancel"));
|
||||
btnStop.addEventListener("click", () => sendDecision("stop"));
|
||||
|
||||
const domWidget = node.addDOMWidget("decision_ui", "HTML", container, {
|
||||
serialize: false
|
||||
});
|
||||
|
||||
// Adjust node size to fit buttons
|
||||
node.size = [300, 240];
|
||||
const originalComputeSize = node.computeSize;
|
||||
node.computeSize = function() {
|
||||
const sz = originalComputeSize ? originalComputeSize.apply(this, arguments) : [300, 240];
|
||||
return [Math.max(300, sz[0]), Math.max(240, sz[1])];
|
||||
};
|
||||
|
||||
domWidget.computeSize = function() {
|
||||
return [node.size[0] - 20, 120];
|
||||
};
|
||||
|
||||
node.enableDecisionButtons = () => {
|
||||
btnContinue.disabled = false;
|
||||
btnCancel.disabled = false;
|
||||
btnStop.disabled = false;
|
||||
node.color = "#3a4a15";
|
||||
node.bgcolor = "#4a6311";
|
||||
if (app.graph) app.graph.setDirtyCanvas(true, true);
|
||||
};
|
||||
|
||||
node.disableDecisionButtons = () => {
|
||||
btnContinue.disabled = true;
|
||||
btnCancel.disabled = true;
|
||||
btnStop.disabled = true;
|
||||
node.color = "";
|
||||
node.bgcolor = "";
|
||||
if (app.graph) app.graph.setDirtyCanvas(true, true);
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
+482
-93
@@ -20,17 +20,62 @@ function getFolderBorderColor(folderName) {
|
||||
// Global CSS injection for the visual node styling
|
||||
const visualStyles = document.createElement("style");
|
||||
visualStyles.textContent = `
|
||||
.lora-visual-container.right-aligned-mode .lora-folder-grid,
|
||||
.lora-visual-container.right-aligned-mode .lora-none-container {
|
||||
display: flex !important;
|
||||
flex-wrap: wrap !important;
|
||||
justify-content: flex-end !important;
|
||||
gap: 8px !important;
|
||||
}
|
||||
.lora-visual-container .lora-folder-grid,
|
||||
.lora-visual-container .lora-none-container {
|
||||
display: flex !important;
|
||||
flex-wrap: wrap !important;
|
||||
justify-content: flex-start !important;
|
||||
gap: 8px !important;
|
||||
}
|
||||
.lora-visual-container .lora-tile {
|
||||
width: var(--lora-tile-size, 80px) !important;
|
||||
flex-shrink: 0 !important;
|
||||
}
|
||||
.lora-visual-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 150px;
|
||||
overflow-y: auto;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #111;
|
||||
border: 2px solid #222;
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
.lora-grid-container {
|
||||
flex: 1 1 0px;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding-right: 4px;
|
||||
}
|
||||
.lora-grid-container::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.lora-grid-container::-webkit-scrollbar-track {
|
||||
background: rgba(0,0,0,0.1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.lora-grid-container::-webkit-scrollbar-thumb {
|
||||
background: rgba(255,255,255,0.15);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.lora-grid-container::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255,255,255,0.3);
|
||||
}
|
||||
.lora-visual-container.grid-layout {
|
||||
display: grid;
|
||||
@@ -40,20 +85,6 @@ visualStyles.textContent = `
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.lora-visual-container::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.lora-visual-container::-webkit-scrollbar-track {
|
||||
background: rgba(0,0,0,0.1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.lora-visual-container::-webkit-scrollbar-thumb {
|
||||
background: rgba(255,255,255,0.15);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.lora-visual-container::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255,255,255,0.3);
|
||||
}
|
||||
.lora-none-container {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
@@ -160,6 +191,23 @@ visualStyles.textContent = `
|
||||
gap: 8px;
|
||||
padding: 4px 2px;
|
||||
}
|
||||
.right-aligned-mode .lora-folder-grid {
|
||||
direction: rtl;
|
||||
}
|
||||
.right-aligned-mode .lora-tile {
|
||||
direction: ltr;
|
||||
}
|
||||
.right-aligned-mode .lora-folder-header {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
.right-aligned-mode .lora-folder-toggle {
|
||||
margin-right: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.right-aligned-mode .lora-folder-count {
|
||||
margin-right: 0;
|
||||
margin-left: 10px;
|
||||
}
|
||||
.lora-folder-container.collapsed .lora-folder-grid {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -266,6 +314,71 @@ visualStyles.textContent = `
|
||||
border: 1px solid #fff !important;
|
||||
box-shadow: 0 0 6px rgba(205, 127, 50, 0.8) !important;
|
||||
}
|
||||
|
||||
/* Over 100x Special Effects & Animations */
|
||||
@keyframes pulseGold100 {
|
||||
0% { box-shadow: 0 0 8px #ffd700, inset 0 0 4px #ffe600; border-color: #ffd700; }
|
||||
50% { box-shadow: 0 0 20px #ffaa00, inset 0 0 10px #ffea00; border-color: #ffffff; }
|
||||
100% { box-shadow: 0 0 8px #ffd700, inset 0 0 4px #ffe600; border-color: #ffd700; }
|
||||
}
|
||||
@keyframes pulseSilver100 {
|
||||
0% { box-shadow: 0 0 8px #e2e8f0, inset 0 0 4px #ffffff; border-color: #cbd5e1; }
|
||||
50% { box-shadow: 0 0 20px #ffffff, inset 0 0 10px #f8fafc; border-color: #ffffff; }
|
||||
100% { box-shadow: 0 0 8px #e2e8f0, inset 0 0 4px #ffffff; border-color: #cbd5e1; }
|
||||
}
|
||||
@keyframes pulseBronze100 {
|
||||
0% { box-shadow: 0 0 8px #cd7f32, inset 0 0 4px #d97706; border-color: #cd7f32; }
|
||||
50% { box-shadow: 0 0 20px #f59e0b, inset 0 0 10px #fbbf24; border-color: #ffedd5; }
|
||||
100% { box-shadow: 0 0 8px #cd7f32, inset 0 0 4px #d97706; border-color: #cd7f32; }
|
||||
}
|
||||
@keyframes pulseOver100 {
|
||||
0% { box-shadow: 0 0 6px #a855f7; border-color: #a855f7; }
|
||||
50% { box-shadow: 0 0 16px #c084fc; border-color: #e9d5ff; }
|
||||
100% { box-shadow: 0 0 6px #a855f7; border-color: #a855f7; }
|
||||
}
|
||||
|
||||
.lora-tile.rank-1-100:not(.selected) {
|
||||
animation: pulseGold100 2s infinite ease-in-out !important;
|
||||
}
|
||||
.lora-tile.rank-2-100:not(.selected) {
|
||||
animation: pulseSilver100 2s infinite ease-in-out !important;
|
||||
}
|
||||
.lora-tile.rank-3-100:not(.selected) {
|
||||
animation: pulseBronze100 2s infinite ease-in-out !important;
|
||||
}
|
||||
.lora-tile.over-100:not(.selected) {
|
||||
animation: pulseOver100 2s infinite ease-in-out !important;
|
||||
}
|
||||
|
||||
.lora-tile-badge.rank-1-100 {
|
||||
background: linear-gradient(135deg, #fff700, #ff8800) !important;
|
||||
color: #000 !important;
|
||||
border: 1.5px solid #ffffff !important;
|
||||
box-shadow: 0 0 10px #ffd700 !important;
|
||||
font-weight: 900 !important;
|
||||
}
|
||||
.lora-tile-badge.rank-2-100 {
|
||||
background: linear-gradient(135deg, #ffffff, #64748b) !important;
|
||||
color: #000 !important;
|
||||
border: 1.5px solid #ffffff !important;
|
||||
box-shadow: 0 0 10px #f8fafc !important;
|
||||
font-weight: 900 !important;
|
||||
}
|
||||
.lora-tile-badge.rank-3-100 {
|
||||
background: linear-gradient(135deg, #f59e0b, #78350f) !important;
|
||||
color: #fff !important;
|
||||
border: 1.5px solid #ffffff !important;
|
||||
box-shadow: 0 0 10px #f59e0b !important;
|
||||
font-weight: 900 !important;
|
||||
}
|
||||
.lora-tile-badge.over-100 {
|
||||
background: linear-gradient(135deg, #a855f7, #581c87) !important;
|
||||
color: #fff !important;
|
||||
border: 1.5px solid #f3e8ff !important;
|
||||
box-shadow: 0 0 10px #c084fc !important;
|
||||
font-weight: 900 !important;
|
||||
}
|
||||
|
||||
/* Top 3 Glow styling for non-selected tiles */
|
||||
.lora-tile.rank-1:not(.selected) {
|
||||
border-color: #ffd700 !important;
|
||||
@@ -286,15 +399,17 @@ app.registerExtension({
|
||||
name: "Comfy.FolderLoraLoader",
|
||||
async nodeCreated(node) {
|
||||
// --- 1. ORIGINAL DROPDOWN NODES ---
|
||||
if (node.comfyClass === "FolderLoraLoader" || node.comfyClass === "FolderLoraLoaderPretty") {
|
||||
if (node.comfyClass === "FolderLoraLoader" || node.comfyClass === "FolderLoraLoaderPretty" || node.type === "FolderLoraLoader" || node.type === "FolderLoraLoaderPretty") {
|
||||
const folderWidget = node.widgets.find(w => w.name === "folder");
|
||||
const loraWidget = node.widgets.find(w => w.name === "lora_name");
|
||||
|
||||
let hiddenWidget = node.widgets.find(w => w.name === "_selected_lora");
|
||||
if (!hiddenWidget) {
|
||||
hiddenWidget = node.addWidget("text", "_selected_lora", "[ NONE ]", () => {}, { serialize: true });
|
||||
hiddenWidget.type = "hidden";
|
||||
}
|
||||
hiddenWidget.type = "hidden";
|
||||
hiddenWidget.hidden = true;
|
||||
hiddenWidget.computeSize = () => [0, 0];
|
||||
|
||||
if (folderWidget && loraWidget) {
|
||||
let activeRequest = null;
|
||||
@@ -383,27 +498,33 @@ app.registerExtension({
|
||||
|
||||
const folderWidget = node.widgets.find(w => w.name === "folder");
|
||||
|
||||
const getHiddenWidget = () => {
|
||||
const widgets = node.widgets.filter(w => w.name === "_selected_lora");
|
||||
const getHiddenWidget = (name, defaultVal) => {
|
||||
const widgets = node.widgets.filter(w => w.name === name);
|
||||
if (widgets.length > 1) {
|
||||
const keep = widgets.find(w => w.value && w.value !== "[ NONE ]") || widgets[0];
|
||||
node.widgets = node.widgets.filter(w => w.name !== "_selected_lora" || w === keep);
|
||||
return keep;
|
||||
for (let i = 1; i < widgets.length; i++) {
|
||||
node.widgets.splice(node.widgets.indexOf(widgets[i]), 1);
|
||||
}
|
||||
}
|
||||
if (widgets.length === 1) {
|
||||
widgets[0].hidden = true;
|
||||
widgets[0].computeSize = () => [0, 0];
|
||||
return widgets[0];
|
||||
}
|
||||
const w = node.addWidget("text", "_selected_lora", "[ NONE ]", () => {}, { serialize: true });
|
||||
w.type = "hidden";
|
||||
w.computeSize = () => [0, -4];
|
||||
const w = node.addWidget("text", name, defaultVal, () => {}, { serialize: true });
|
||||
w.hidden = true;
|
||||
w.computeSize = () => [0, 0];
|
||||
return w;
|
||||
};
|
||||
const hiddenWidget = getHiddenWidget();
|
||||
const hiddenWidget = getHiddenWidget("_selected_lora", "[ NONE ]");
|
||||
|
||||
// Create Visual HTML DOM Element
|
||||
const viewContainer = document.createElement("div");
|
||||
viewContainer.className = "lora-visual-container grid-layout";
|
||||
const initialZoom = localStorage.getItem("comfy_lora_picker_zoom") || "80";
|
||||
node.properties = node.properties || {};
|
||||
const initialZoom = node.properties["tile_size"] != null
|
||||
? node.properties["tile_size"]
|
||||
: (localStorage.getItem("comfy_lora_picker_zoom") || "80");
|
||||
node.properties["tile_size"] = parseInt(initialZoom);
|
||||
viewContainer.style.setProperty("--lora-tile-size", `${initialZoom}px`);
|
||||
|
||||
// Embed DOM Widget inside the node container
|
||||
@@ -413,6 +534,22 @@ app.registerExtension({
|
||||
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`;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Adjust sizing constraints dynamically
|
||||
domWidget.computeSize = function() {
|
||||
return [node.size[0] - 30, node.size[1] - 160];
|
||||
@@ -424,8 +561,10 @@ app.registerExtension({
|
||||
"tile_size",
|
||||
parseInt(initialZoom),
|
||||
(val) => {
|
||||
localStorage.setItem("comfy_lora_picker_zoom", val);
|
||||
node.properties = node.properties || {};
|
||||
node.properties["tile_size"] = val;
|
||||
viewContainer.style.setProperty("--lora-tile-size", `${val}px`);
|
||||
if (app.graph) app.graph.setDirtyCanvas(true, true);
|
||||
},
|
||||
{ min: 50, max: 180, step: 1 }
|
||||
);
|
||||
@@ -568,22 +707,30 @@ app.registerExtension({
|
||||
}
|
||||
tilePrettyName = tilePrettyName.replace(/\s+V\d+(\.\d+)?$/i, "").trim();
|
||||
|
||||
const systemPath = data.mapping[loraName];
|
||||
const usageCount = (data.usage && systemPath) ? (data.usage[systemPath] || 0) : 0;
|
||||
const isOver100 = usageCount >= 100;
|
||||
|
||||
let rankClass = "";
|
||||
if (ranks.rank1.includes(tilePrettyName)) rankClass = "rank-1";
|
||||
else if (ranks.rank2.includes(tilePrettyName)) rankClass = "rank-2";
|
||||
else if (ranks.rank3.includes(tilePrettyName)) rankClass = "rank-3";
|
||||
if (ranks.rank1.includes(tilePrettyName)) {
|
||||
rankClass = isOver100 ? "rank-1-100" : "rank-1";
|
||||
} else if (ranks.rank2.includes(tilePrettyName)) {
|
||||
rankClass = isOver100 ? "rank-2-100" : "rank-2";
|
||||
} else if (ranks.rank3.includes(tilePrettyName)) {
|
||||
rankClass = isOver100 ? "rank-3-100" : "rank-3";
|
||||
} else if (isOver100) {
|
||||
rankClass = "over-100";
|
||||
}
|
||||
|
||||
if (rankClass) {
|
||||
tile.classList.add(rankClass);
|
||||
}
|
||||
|
||||
const systemPath = data.mapping[loraName];
|
||||
const usageCount = (data.usage && systemPath) ? (data.usage[systemPath] || 0) : 0;
|
||||
if (usageCount > 0) {
|
||||
const badge = document.createElement("div");
|
||||
badge.className = "lora-tile-badge";
|
||||
if (rankClass) badge.classList.add(rankClass);
|
||||
badge.innerText = usageCount;
|
||||
badge.innerText = isOver100 ? `🔥 ${usageCount}` : usageCount;
|
||||
tile.appendChild(badge);
|
||||
}
|
||||
|
||||
@@ -628,6 +775,21 @@ app.registerExtension({
|
||||
const originalOnConfigure = node.onConfigure;
|
||||
node.onConfigure = function(config) {
|
||||
if (originalOnConfigure) originalOnConfigure.apply(this, arguments);
|
||||
|
||||
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);
|
||||
|
||||
if (savedTileSize != null) {
|
||||
node.properties = node.properties || {};
|
||||
node.properties["tile_size"] = savedTileSize;
|
||||
if (zoomWidget) {
|
||||
zoomWidget.value = parseInt(savedTileSize);
|
||||
}
|
||||
if (viewContainer && viewContainer.style) {
|
||||
viewContainer.style.setProperty("--lora-tile-size", `${savedTileSize}px`);
|
||||
}
|
||||
}
|
||||
|
||||
const curWidget = getHiddenWidget();
|
||||
const widgetIndex = node.widgets.indexOf(curWidget);
|
||||
@@ -643,11 +805,15 @@ app.registerExtension({
|
||||
}
|
||||
|
||||
// --- 3. VISUAL PRETTY PICKER V2 (Collapsible folders & Multi-selection) ---
|
||||
if (node.comfyClass === "FolderLoraLoaderVisualPrettyV2") {
|
||||
if (node.comfyClass === "FolderLoraLoaderVisualPrettyV2" || node.comfyClass === "VisualLoraLoader" || node.type === "FolderLoraLoaderVisualPrettyV2" || node.type === "VisualLoraLoader") {
|
||||
node.size = [380, 360];
|
||||
|
||||
node.computeSize = function() {
|
||||
return [380, 320];
|
||||
const displayModeWidget = node.widgets ? node.widgets.find(w => w.name === "display_mode") : null;
|
||||
if (displayModeWidget && displayModeWidget.value === "Show All") {
|
||||
const viewContainer = document.querySelector(".lora-visual-container");
|
||||
}
|
||||
return [node.size[0], Math.max(360, node.size[1])];
|
||||
};
|
||||
|
||||
const folderWidget = node.widgets.find(w => w.name === "folder");
|
||||
@@ -657,14 +823,17 @@ app.registerExtension({
|
||||
if (widgets.length > 1) {
|
||||
const keep = widgets.find(w => w.value && w.value !== "[ NONE ]" && w.value !== "[]") || widgets[0];
|
||||
node.widgets = node.widgets.filter(w => w.name !== "_selected_lora" || w === keep);
|
||||
keep.hidden = true;
|
||||
return keep;
|
||||
}
|
||||
if (widgets.length === 1) {
|
||||
widgets[0].hidden = true;
|
||||
return widgets[0];
|
||||
}
|
||||
const w = node.addWidget("text", "_selected_lora", "[]", () => {}, { serialize: true });
|
||||
w.type = "hidden";
|
||||
w.computeSize = () => [0, -4];
|
||||
w.hidden = true;
|
||||
w.computeSize = () => [0, 0];
|
||||
return w;
|
||||
};
|
||||
const hiddenWidget = getHiddenWidget();
|
||||
@@ -674,14 +843,17 @@ app.registerExtension({
|
||||
if (widgets.length > 1) {
|
||||
const keep = widgets.find(w => w.value && w.value !== "All") || widgets[0];
|
||||
node.widgets = node.widgets.filter(w => w.name !== "_selection_mode" || w === keep);
|
||||
keep.hidden = true;
|
||||
return keep;
|
||||
}
|
||||
if (widgets.length === 1) {
|
||||
widgets[0].hidden = true;
|
||||
return widgets[0];
|
||||
}
|
||||
const w = node.addWidget("text", "_selection_mode", "All", () => {}, { serialize: true });
|
||||
const w = node.addWidget("text", "_selection_mode", "Sequential", () => {}, { serialize: true });
|
||||
w.type = "hidden";
|
||||
w.computeSize = () => [0, -4];
|
||||
w.hidden = true;
|
||||
w.computeSize = () => [0, 0];
|
||||
return w;
|
||||
};
|
||||
const hiddenModeWidget = getHiddenModeWidget();
|
||||
@@ -691,29 +863,93 @@ app.registerExtension({
|
||||
if (widgets.length > 1) {
|
||||
const keep = widgets.find(w => w.value && (w.value === "true" || w.value === "false")) || widgets[0];
|
||||
node.widgets = node.widgets.filter(w => w.name !== "_scrape_on_new" || w === keep);
|
||||
keep.hidden = true;
|
||||
return keep;
|
||||
}
|
||||
if (widgets.length === 1) {
|
||||
widgets[0].hidden = true;
|
||||
return widgets[0];
|
||||
}
|
||||
const w = node.addWidget("text", "_scrape_on_new", "true", () => {}, { serialize: true });
|
||||
w.type = "hidden";
|
||||
w.computeSize = () => [0, -4];
|
||||
w.hidden = true;
|
||||
w.computeSize = () => [0, 0];
|
||||
return w;
|
||||
};
|
||||
const hiddenScrapeWidget = getHiddenScrapeWidget();
|
||||
|
||||
const viewContainer = document.createElement("div");
|
||||
viewContainer.className = "lora-visual-container flex-layout";
|
||||
const initialZoom = localStorage.getItem("comfy_lora_picker_zoom") || "80";
|
||||
node.properties = node.properties || {};
|
||||
const initialZoom = node.properties["tile_size"] != null
|
||||
? node.properties["tile_size"]
|
||||
: (localStorage.getItem("comfy_lora_picker_zoom") || "80");
|
||||
node.properties["tile_size"] = parseInt(initialZoom);
|
||||
viewContainer.style.setProperty("--lora-tile-size", `${initialZoom}px`);
|
||||
|
||||
const gridContainer = document.createElement("div");
|
||||
gridContainer.className = "lora-grid-container";
|
||||
|
||||
// Isolate mouse wheel scroll events from triggering canvas zooming/panning
|
||||
viewContainer.addEventListener("wheel", (e) => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
// --- Control Bar for 2-Way HTML Toggle Button ---
|
||||
let userSavedHeight = 420;
|
||||
|
||||
const applyDisplayMode = (mode) => {
|
||||
const isShowAll = mode === "Show All";
|
||||
if (isShowAll) {
|
||||
viewContainer.style.setProperty("height", "auto", "important");
|
||||
viewContainer.style.setProperty("max-height", "none", "important");
|
||||
viewContainer.style.setProperty("overflow-y", "visible", "important");
|
||||
|
||||
gridContainer.style.setProperty("height", "auto", "important");
|
||||
gridContainer.style.setProperty("max-height", "none", "important");
|
||||
gridContainer.style.setProperty("overflow-y", "visible", "important");
|
||||
gridContainer.style.setProperty("flex", "none", "important");
|
||||
|
||||
setTimeout(() => {
|
||||
const contentH = gridContainer.scrollHeight || 0;
|
||||
const neededHeight = contentH + 160;
|
||||
node.setSize([node.size[0], Math.max(360, neededHeight)]);
|
||||
if (app.graph) app.graph.setDirtyCanvas(true, true);
|
||||
}, 50);
|
||||
} else {
|
||||
viewContainer.classList.remove("show-all-mode");
|
||||
viewContainer.classList.add("scrollable-mode");
|
||||
const restoredH = node.userCustomHeight || userSavedHeight || 420;
|
||||
const containerH = Math.max(200, restoredH - 100);
|
||||
|
||||
viewContainer.style.setProperty("height", `${containerH}px`, "important");
|
||||
viewContainer.style.setProperty("max-height", `${containerH}px`, "important");
|
||||
viewContainer.style.setProperty("overflow-y", "hidden", "important");
|
||||
|
||||
gridContainer.style.setProperty("height", "100%", "important");
|
||||
gridContainer.style.setProperty("max-height", "100%", "important");
|
||||
gridContainer.style.setProperty("overflow-y", "auto", "important");
|
||||
gridContainer.style.setProperty("flex", "1 1 0px", "important");
|
||||
|
||||
node.setSize([node.size[0], Math.max(360, restoredH)]);
|
||||
if (app.graph) app.graph.setDirtyCanvas(true, true);
|
||||
}
|
||||
};
|
||||
|
||||
node.computeSize = function() {
|
||||
const displayModeWidget = node.widgets ? node.widgets.find(w => w.name === "display_mode") : null;
|
||||
if (displayModeWidget && displayModeWidget.value === "Show All" && gridContainer) {
|
||||
return [node.size[0], Math.max(360, (gridContainer.scrollHeight || 0) + 160)];
|
||||
}
|
||||
return [node.size[0], Math.max(360, node.size[1])];
|
||||
};
|
||||
|
||||
const updateNodeSize = () => {
|
||||
const displayModeWidget = node.widgets ? node.widgets.find(w => w.name === "display_mode") : null;
|
||||
const mode = displayModeWidget ? displayModeWidget.value : "Scrollable";
|
||||
applyDisplayMode(mode);
|
||||
};
|
||||
|
||||
// --- Control Bar for Mode Selector ---
|
||||
const controlBar = document.createElement("div");
|
||||
controlBar.className = "lora-control-bar";
|
||||
|
||||
@@ -725,39 +961,47 @@ app.registerExtension({
|
||||
toggleLabel.innerText = "Mode:";
|
||||
toggleContainer.appendChild(toggleLabel);
|
||||
|
||||
const allBtn = document.createElement("button");
|
||||
allBtn.className = "lora-toggle-btn active";
|
||||
allBtn.innerText = "All Loras";
|
||||
const modeSelect = document.createElement("select");
|
||||
modeSelect.className = "lora-mode-select";
|
||||
modeSelect.style.background = "#252525";
|
||||
modeSelect.style.border = "1px solid #353535";
|
||||
modeSelect.style.color = "#fff";
|
||||
modeSelect.style.fontSize = "10px";
|
||||
modeSelect.style.fontWeight = "bold";
|
||||
modeSelect.style.padding = "2px 4px";
|
||||
modeSelect.style.borderRadius = "4px";
|
||||
modeSelect.style.cursor = "pointer";
|
||||
|
||||
const randomBtn = document.createElement("button");
|
||||
randomBtn.className = "lora-toggle-btn";
|
||||
randomBtn.innerText = "Random Lora";
|
||||
const modes = [
|
||||
{ value: "All", label: "All Loras" },
|
||||
{ value: "Random", label: "Random (With Dupes)" },
|
||||
{ value: "Sequential", label: "Sequential Cycle" },
|
||||
{ value: "Random (No Replace)", label: "Random (No Dupes)" }
|
||||
];
|
||||
|
||||
modes.forEach(m => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = m.value;
|
||||
opt.innerText = m.label;
|
||||
modeSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
const syncModeToggleUI = () => {
|
||||
const curModeWidget = getHiddenModeWidget();
|
||||
if (curModeWidget.value === "Random") {
|
||||
randomBtn.classList.add("active");
|
||||
allBtn.classList.remove("active");
|
||||
if (curModeWidget.value && modes.some(m => m.value === curModeWidget.value)) {
|
||||
modeSelect.value = curModeWidget.value;
|
||||
} else {
|
||||
allBtn.classList.add("active");
|
||||
randomBtn.classList.remove("active");
|
||||
modeSelect.value = "Sequential";
|
||||
}
|
||||
};
|
||||
|
||||
allBtn.addEventListener("click", () => {
|
||||
getHiddenModeWidget().value = "All";
|
||||
modeSelect.addEventListener("change", (e) => {
|
||||
getHiddenModeWidget().value = e.target.value;
|
||||
syncModeToggleUI();
|
||||
node.triggerSlotEvent?.(0);
|
||||
});
|
||||
|
||||
randomBtn.addEventListener("click", () => {
|
||||
getHiddenModeWidget().value = "Random";
|
||||
syncModeToggleUI();
|
||||
node.triggerSlotEvent?.(0);
|
||||
});
|
||||
|
||||
toggleContainer.appendChild(allBtn);
|
||||
toggleContainer.appendChild(randomBtn);
|
||||
toggleContainer.appendChild(modeSelect);
|
||||
controlBar.appendChild(toggleContainer);
|
||||
|
||||
// Scrape Toggle Container
|
||||
@@ -805,6 +1049,7 @@ app.registerExtension({
|
||||
controlBar.appendChild(scrapeToggleContainer);
|
||||
|
||||
viewContainer.appendChild(controlBar);
|
||||
viewContainer.appendChild(gridContainer);
|
||||
|
||||
const domWidget = node.addDOMWidget("lora_visual_picker", "HTML", viewContainer, {
|
||||
getValue() { return getHiddenWidget().value; },
|
||||
@@ -816,13 +1061,31 @@ app.registerExtension({
|
||||
return [node.size[0] - 30, node.size[1] - 190];
|
||||
};
|
||||
|
||||
const onResize = node.onResize;
|
||||
node.onResize = function(size) {
|
||||
if (onResize) onResize.apply(this, arguments);
|
||||
const displayModeWidget = node.widgets ? node.widgets.find(w => w.name === "display_mode") : null;
|
||||
const isShowAll = displayModeWidget && displayModeWidget.value === "Show All";
|
||||
if (!isShowAll && size) {
|
||||
node.userCustomHeight = size[1];
|
||||
userSavedHeight = size[1];
|
||||
if (viewContainer && viewContainer.style) {
|
||||
const h = Math.max(200, size[1] - 100);
|
||||
viewContainer.style.setProperty("height", `${h}px`, "important");
|
||||
viewContainer.style.setProperty("max-height", `${h}px`, "important");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const zoomWidget = node.addWidget(
|
||||
"slider",
|
||||
"tile_size",
|
||||
parseInt(initialZoom),
|
||||
(val) => {
|
||||
localStorage.setItem("comfy_lora_picker_zoom", val);
|
||||
node.properties = node.properties || {};
|
||||
node.properties["tile_size"] = val;
|
||||
viewContainer.style.setProperty("--lora-tile-size", `${val}px`);
|
||||
if (app.graph) app.graph.setDirtyCanvas(true, true);
|
||||
},
|
||||
{ min: 50, max: 180, step: 1 }
|
||||
);
|
||||
@@ -917,11 +1180,7 @@ app.registerExtension({
|
||||
if (activeRequest !== currentRequest) return;
|
||||
const data = await response.json();
|
||||
|
||||
// Keep the control bar and clear the rest
|
||||
const noneContainerExist = viewContainer.querySelector(".lora-none-container");
|
||||
if (noneContainerExist) noneContainerExist.remove();
|
||||
const foldersExist = viewContainer.querySelectorAll(".lora-folder-container");
|
||||
foldersExist.forEach(f => f.remove());
|
||||
gridContainer.innerHTML = "";
|
||||
|
||||
foldersMap = {};
|
||||
foldersMap["Root"] = [];
|
||||
@@ -961,6 +1220,7 @@ app.registerExtension({
|
||||
const ranks = getTop3Ranks();
|
||||
|
||||
// --- Group items by folder ---
|
||||
const mtimeMap = data.mtime || {};
|
||||
data.names.forEach(loraName => {
|
||||
if (loraName === "[ NONE ]") return;
|
||||
const parts = loraName.split(" - ");
|
||||
@@ -975,13 +1235,95 @@ app.registerExtension({
|
||||
foldersMap[subFolder].push({ loraName, prettyName, usageCount, systemPath });
|
||||
});
|
||||
|
||||
// Sort folders: Root first, others alphabetically
|
||||
const sortedFolders = Object.keys(foldersMap).sort((a, b) => {
|
||||
if (a === "Root") return -1;
|
||||
if (b === "Root") return 1;
|
||||
return a.localeCompare(b);
|
||||
// Read sorting widgets
|
||||
const getWidgetVal = (wName, defaultVal) => {
|
||||
const w = node.widgets ? node.widgets.find(x => x.name === wName) : null;
|
||||
return w ? w.value : defaultVal;
|
||||
};
|
||||
const sortLorasMode = getWidgetVal("sort_loras_by", "Name (A-Z)");
|
||||
const sortFoldersMode = getWidgetVal("sort_folders_by", "Name (A-Z)");
|
||||
const folderPosMode = getWidgetVal("folder_position", "Folders First");
|
||||
const contentAlignMode = getWidgetVal("content_alignment", "Left Aligned");
|
||||
|
||||
if (contentAlignMode === "Right Aligned") {
|
||||
viewContainer.classList.add("right-aligned-mode");
|
||||
} else {
|
||||
viewContainer.classList.remove("right-aligned-mode");
|
||||
}
|
||||
|
||||
// Sort individual LoRAs inside each folder
|
||||
Object.keys(foldersMap).forEach(subFolder => {
|
||||
foldersMap[subFolder].sort((a, b) => {
|
||||
if (sortLorasMode === "Name (Z-A)") {
|
||||
return b.prettyName.localeCompare(a.prettyName);
|
||||
} else if (sortLorasMode === "Usage (High to Low)") {
|
||||
return (b.usageCount || 0) - (a.usageCount || 0) || a.prettyName.localeCompare(b.prettyName);
|
||||
} else if (sortLorasMode === "Usage (Low to High)") {
|
||||
return (a.usageCount || 0) - (b.usageCount || 0) || a.prettyName.localeCompare(b.prettyName);
|
||||
} else if (sortLorasMode === "Date Modified (Newest First)") {
|
||||
const timeA = mtimeMap[a.systemPath] || 0;
|
||||
const timeB = mtimeMap[b.systemPath] || 0;
|
||||
return timeB - timeA || a.prettyName.localeCompare(b.prettyName);
|
||||
} else if (sortLorasMode === "Date Modified (Oldest First)") {
|
||||
const timeA = mtimeMap[a.systemPath] || 0;
|
||||
const timeB = mtimeMap[b.systemPath] || 0;
|
||||
return timeA - timeB || a.prettyName.localeCompare(b.prettyName);
|
||||
} else { // "Name (A-Z)" default
|
||||
return a.prettyName.localeCompare(b.prettyName);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Sort folders (excluding Root for position ordering)
|
||||
const nonRootFolders = Object.keys(foldersMap).filter(f => f !== "Root").sort((a, b) => {
|
||||
const itemsA = foldersMap[a] || [];
|
||||
const itemsB = foldersMap[b] || [];
|
||||
const totalA = itemsA.reduce((sum, i) => sum + (i.usageCount || 0), 0);
|
||||
const totalB = itemsB.reduce((sum, i) => sum + (i.usageCount || 0), 0);
|
||||
const avgA = itemsA.length > 0 ? totalA / itemsA.length : 0;
|
||||
const avgB = itemsB.length > 0 ? totalB / itemsB.length : 0;
|
||||
|
||||
if (sortFoldersMode === "Name (Z-A)") {
|
||||
return b.localeCompare(a);
|
||||
} else if (sortFoldersMode === "Total Usage (High to Low)") {
|
||||
return totalB - totalA || a.localeCompare(b);
|
||||
} else if (sortFoldersMode === "Average Usage (High to Low)") {
|
||||
return avgB - avgA || a.localeCompare(b);
|
||||
} else if (sortFoldersMode === "Total LoRAs (Most First)") {
|
||||
return itemsB.length - itemsA.length || a.localeCompare(b);
|
||||
} else { // "Name (A-Z)" default
|
||||
return a.localeCompare(b);
|
||||
}
|
||||
});
|
||||
|
||||
// Combine folder keys based on folder_position
|
||||
let sortedFolders = [];
|
||||
const hasRoot = foldersMap["Root"] && foldersMap["Root"].length > 0;
|
||||
if (folderPosMode === "Root LoRAs First") {
|
||||
if (hasRoot) sortedFolders.push("Root");
|
||||
sortedFolders.push(...nonRootFolders);
|
||||
} else {
|
||||
sortedFolders.push(...nonRootFolders);
|
||||
if (hasRoot) sortedFolders.push("Root");
|
||||
}
|
||||
|
||||
// Atomic DOM Fragment construction for maximum performance
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
// Setup IntersectionObserver for high-performance lazy image decoding
|
||||
const imageObserver = new IntersectionObserver((entries, observer) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const img = entry.target;
|
||||
if (img.dataset.src) {
|
||||
img.src = img.dataset.src;
|
||||
img.removeAttribute("data-src");
|
||||
}
|
||||
observer.unobserve(img);
|
||||
}
|
||||
});
|
||||
}, { root: gridContainer, rootMargin: "150px" });
|
||||
|
||||
// --- Render folder groups ---
|
||||
sortedFolders.forEach(subFolder => {
|
||||
const items = foldersMap[subFolder];
|
||||
@@ -1022,7 +1364,6 @@ app.registerExtension({
|
||||
checkbox.className = "lora-folder-checkbox";
|
||||
checkbox.title = "Select/Deselect all in folder";
|
||||
|
||||
// Prevent header toggle click when clicking checkbox
|
||||
checkbox.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const isChecked = checkbox.checked;
|
||||
@@ -1043,7 +1384,6 @@ app.registerExtension({
|
||||
|
||||
header.appendChild(checkbox);
|
||||
|
||||
// Collapse/Expand functionality
|
||||
header.addEventListener("click", () => {
|
||||
const collapsed = folderContainer.classList.toggle("collapsed");
|
||||
header.classList.toggle("collapsed", collapsed);
|
||||
@@ -1073,24 +1413,32 @@ app.registerExtension({
|
||||
}
|
||||
|
||||
const img = document.createElement("img");
|
||||
img.src = `/folder_lora_loader/get_preview?lora=${encodeURIComponent(item.loraName)}&folder=${encodeURIComponent(folder)}&pretty=true`;
|
||||
img.loading = "lazy";
|
||||
img.dataset.src = `/folder_lora_loader/get_preview?system_path=${encodeURIComponent(item.systemPath)}`;
|
||||
imageObserver.observe(img);
|
||||
|
||||
img.onerror = () => {
|
||||
img.remove();
|
||||
const fallback = document.createElement("div");
|
||||
fallback.className = "lora-tile-fallback";
|
||||
fallback.innerText = item.prettyName; // V2 shows only prettyName inside the folder section!
|
||||
fallback.innerText = item.prettyName;
|
||||
tile.prepend(fallback);
|
||||
};
|
||||
tile.appendChild(img);
|
||||
|
||||
// Rank classification based on versionless pretty name
|
||||
let tilePrettyName = item.prettyName.replace(/\s+V\d+(\.\d+)?$/i, "").trim();
|
||||
const isOver100 = item.usageCount >= 100;
|
||||
|
||||
let rankClass = "";
|
||||
if (ranks.rank1.includes(tilePrettyName)) rankClass = "rank-1";
|
||||
else if (ranks.rank2.includes(tilePrettyName)) rankClass = "rank-2";
|
||||
else if (ranks.rank3.includes(tilePrettyName)) rankClass = "rank-3";
|
||||
if (ranks.rank1.includes(tilePrettyName)) {
|
||||
rankClass = isOver100 ? "rank-1-100" : "rank-1";
|
||||
} else if (ranks.rank2.includes(tilePrettyName)) {
|
||||
rankClass = isOver100 ? "rank-2-100" : "rank-2";
|
||||
} else if (ranks.rank3.includes(tilePrettyName)) {
|
||||
rankClass = isOver100 ? "rank-3-100" : "rank-3";
|
||||
} else if (isOver100) {
|
||||
rankClass = "over-100";
|
||||
}
|
||||
|
||||
if (rankClass) {
|
||||
tile.classList.add(rankClass);
|
||||
@@ -1100,16 +1448,15 @@ app.registerExtension({
|
||||
const badge = document.createElement("div");
|
||||
badge.className = "lora-tile-badge";
|
||||
if (rankClass) badge.classList.add(rankClass);
|
||||
badge.innerText = item.usageCount;
|
||||
badge.innerText = isOver100 ? `🔥 ${item.usageCount}` : item.usageCount;
|
||||
tile.appendChild(badge);
|
||||
}
|
||||
|
||||
const label = document.createElement("div");
|
||||
label.className = "lora-tile-label";
|
||||
label.innerText = item.prettyName; // V2 shows only prettyName inside the folder section!
|
||||
label.innerText = item.prettyName;
|
||||
tile.appendChild(label);
|
||||
|
||||
// Tile Click Handler (supporting Multi-Selection via Ctrl / Meta key)
|
||||
tile.addEventListener("click", (e) => {
|
||||
let currentSelected = getSelectedLoras();
|
||||
|
||||
@@ -1134,11 +1481,15 @@ app.registerExtension({
|
||||
});
|
||||
|
||||
folderContainer.appendChild(grid);
|
||||
viewContainer.appendChild(folderContainer);
|
||||
fragment.appendChild(folderContainer);
|
||||
});
|
||||
|
||||
// Append entire DocumentFragment to gridContainer
|
||||
gridContainer.appendChild(fragment);
|
||||
|
||||
// Set initial checkboxes state
|
||||
updateFolderCheckboxes();
|
||||
setTimeout(() => updateNodeSize(), 50);
|
||||
} catch (e) {
|
||||
console.error("Failed to build visual picker v2", e);
|
||||
}
|
||||
@@ -1149,10 +1500,36 @@ app.registerExtension({
|
||||
debounceTimer = setTimeout(() => updateVisualGrid(), 350);
|
||||
};
|
||||
|
||||
["sort_loras_by", "sort_folders_by", "folder_position", "content_alignment"].forEach(wName => {
|
||||
const w = node.widgets ? node.widgets.find(x => x.name === wName) : null;
|
||||
if (w) {
|
||||
const origCb = w.callback;
|
||||
w.callback = function() {
|
||||
if (origCb) origCb.apply(this, arguments);
|
||||
updateVisualGrid();
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const originalOnConfigure = node.onConfigure;
|
||||
node.onConfigure = function(config) {
|
||||
if (originalOnConfigure) originalOnConfigure.apply(this, arguments);
|
||||
|
||||
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);
|
||||
|
||||
if (savedTileSize != null) {
|
||||
node.properties = node.properties || {};
|
||||
node.properties["tile_size"] = savedTileSize;
|
||||
if (zoomWidget) {
|
||||
zoomWidget.value = parseInt(savedTileSize);
|
||||
}
|
||||
if (viewContainer && viewContainer.style) {
|
||||
viewContainer.style.setProperty("--lora-tile-size", `${savedTileSize}px`);
|
||||
}
|
||||
}
|
||||
|
||||
const curWidget = getHiddenWidget();
|
||||
const widgetIndex = node.widgets.indexOf(curWidget);
|
||||
const savedValue = (config.widgets_values && widgetIndex !== -1) ? config.widgets_values[widgetIndex] : curWidget.value;
|
||||
@@ -1181,7 +1558,19 @@ app.registerExtension({
|
||||
|
||||
syncModeToggleUI();
|
||||
syncScrapeToggleUI();
|
||||
setTimeout(() => updateVisualGrid(), 100);
|
||||
|
||||
setTimeout(() => {
|
||||
const displayModeWidget = node.widgets ? node.widgets.find(w => w.name === "display_mode") : null;
|
||||
if (displayModeWidget) {
|
||||
const origCallback = displayModeWidget.callback;
|
||||
displayModeWidget.callback = function(val) {
|
||||
if (origCallback) origCallback.apply(this, arguments);
|
||||
applyDisplayMode(val || displayModeWidget.value);
|
||||
};
|
||||
applyDisplayMode(displayModeWidget.value || "Scrollable");
|
||||
}
|
||||
updateVisualGrid();
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
});
|
||||
+178
-25
@@ -6,10 +6,6 @@ const visualStyles = document.createElement("style");
|
||||
visualStyles.textContent = `
|
||||
.img-visual-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
@@ -18,6 +14,10 @@ visualStyles.textContent = `
|
||||
border: 1px solid #222;
|
||||
border-radius: 6px;
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
.img-visual-container::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
@@ -57,6 +57,7 @@ visualStyles.textContent = `
|
||||
}
|
||||
.img-grid-container {
|
||||
flex: 1 1 0px;
|
||||
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
@@ -202,31 +203,49 @@ document.head.appendChild(visualStyles);
|
||||
app.registerExtension({
|
||||
name: "Comfy.ImageLoaderCustom",
|
||||
async nodeCreated(node) {
|
||||
if (node.comfyClass === "ImageLoaderVisualPrettyV2" || node.comfyClass === "ImageLoaderCustom") {
|
||||
const isVisualImageLoader = [
|
||||
"VisualImageLoader",
|
||||
"ImageLoaderVisualPrettyV2",
|
||||
"ImageLoaderCustom",
|
||||
].includes(node.comfyClass) || [
|
||||
"VisualImageLoader",
|
||||
"ImageLoaderVisualPrettyV2",
|
||||
"ImageLoaderCustom",
|
||||
].includes(node.type);
|
||||
|
||||
if (isVisualImageLoader) {
|
||||
node.size = [380, 480];
|
||||
|
||||
node.computeSize = function() {
|
||||
return [380, 320];
|
||||
const displayModeWidget = node.widgets ? node.widgets.find(w => w.name === "display_mode") : null;
|
||||
if (displayModeWidget && displayModeWidget.value === "Show All") {
|
||||
// Logic added below
|
||||
}
|
||||
return [node.size[0], Math.max(480, node.size[1])];
|
||||
};
|
||||
|
||||
const folderWidget = node.widgets ? node.widgets.find(w => w.name === "folder_path") : null;
|
||||
|
||||
const getHiddenWidget = () => {
|
||||
const widgets = node.widgets.filter(w => w.name === "_selected_image");
|
||||
const getHiddenWidget = (name, defaultVal) => {
|
||||
const widgets = node.widgets.filter(w => w.name === name);
|
||||
if (widgets.length > 1) {
|
||||
const keep = widgets.find(w => w.value && w.value !== "") || widgets[0];
|
||||
node.widgets = node.widgets.filter(w => w.name !== "_selected_image" || w === keep);
|
||||
return keep;
|
||||
for (let i = 1; i < widgets.length; i++) {
|
||||
node.widgets.splice(node.widgets.indexOf(widgets[i]), 1);
|
||||
}
|
||||
}
|
||||
if (widgets.length === 1) {
|
||||
widgets[0].type = "hidden";
|
||||
widgets[0].hidden = true;
|
||||
widgets[0].computeSize = () => [0, 0];
|
||||
return widgets[0];
|
||||
}
|
||||
const w = node.addWidget("text", "_selected_image", "", () => {}, { serialize: true });
|
||||
const w = node.addWidget("text", name, defaultVal, () => {}, { serialize: true });
|
||||
w.type = "hidden";
|
||||
w.computeSize = () => [0, -4];
|
||||
w.hidden = true;
|
||||
w.computeSize = () => [0, 0];
|
||||
return w;
|
||||
};
|
||||
const hiddenWidget = getHiddenWidget();
|
||||
const hiddenWidget = getHiddenWidget("_selected_image", "");
|
||||
|
||||
// Create Visual HTML DOM Layout
|
||||
const viewContainer = document.createElement("div");
|
||||
@@ -239,11 +258,29 @@ app.registerExtension({
|
||||
|
||||
const controlBar = document.createElement("div");
|
||||
controlBar.className = "img-control-bar";
|
||||
controlBar.style.display = "flex";
|
||||
controlBar.style.flexDirection = "row";
|
||||
controlBar.style.gap = "6px";
|
||||
|
||||
const refreshBtn = document.createElement("button");
|
||||
refreshBtn.innerHTML = "↻";
|
||||
refreshBtn.title = "Refresh Images";
|
||||
refreshBtn.style.background = "#1c1c1c";
|
||||
refreshBtn.style.border = "1px solid #333";
|
||||
refreshBtn.style.borderRadius = "4px";
|
||||
refreshBtn.style.color = "#fff";
|
||||
refreshBtn.style.cursor = "pointer";
|
||||
refreshBtn.style.padding = "4px 8px";
|
||||
refreshBtn.style.fontSize = "14px";
|
||||
refreshBtn.onmouseover = () => refreshBtn.style.background = "#333";
|
||||
refreshBtn.onmouseout = () => refreshBtn.style.background = "#1c1c1c";
|
||||
controlBar.appendChild(refreshBtn);
|
||||
|
||||
const searchBar = document.createElement("input");
|
||||
searchBar.type = "text";
|
||||
searchBar.className = "img-search-bar";
|
||||
searchBar.placeholder = "Search images or positive prompts...";
|
||||
searchBar.style.flex = "1";
|
||||
controlBar.appendChild(searchBar);
|
||||
viewContainer.appendChild(controlBar);
|
||||
|
||||
@@ -251,16 +288,92 @@ app.registerExtension({
|
||||
gridContainer.className = "img-grid-container";
|
||||
viewContainer.appendChild(gridContainer);
|
||||
|
||||
const initialZoom = localStorage.getItem("comfy_img_picker_zoom") || "80";
|
||||
node.computeSize = function() {
|
||||
const displayModeWidget = node.widgets ? node.widgets.find(w => w.name === "display_mode") : null;
|
||||
let userSavedHeight = 480;
|
||||
|
||||
const applyDisplayMode = (mode) => {
|
||||
const isShowAll = mode === "Show All";
|
||||
if (isShowAll) {
|
||||
viewContainer.style.setProperty("height", "auto", "important");
|
||||
viewContainer.style.setProperty("max-height", "none", "important");
|
||||
viewContainer.style.setProperty("overflow-y", "visible", "important");
|
||||
|
||||
gridContainer.style.setProperty("height", "auto", "important");
|
||||
gridContainer.style.setProperty("max-height", "none", "important");
|
||||
gridContainer.style.setProperty("overflow-y", "visible", "important");
|
||||
gridContainer.style.setProperty("flex", "none", "important");
|
||||
|
||||
setTimeout(() => {
|
||||
const contentH = gridContainer.scrollHeight || 0;
|
||||
const neededHeight = contentH + 160;
|
||||
node.setSize([node.size[0], Math.max(420, neededHeight)]);
|
||||
if (app.graph) app.graph.setDirtyCanvas(true, true);
|
||||
}, 50);
|
||||
} else {
|
||||
viewContainer.classList.remove("show-all-mode");
|
||||
viewContainer.classList.add("scrollable-mode");
|
||||
const restoredH = node.userCustomHeight || userSavedHeight || 480;
|
||||
const containerH = Math.max(200, restoredH - 100);
|
||||
|
||||
viewContainer.style.setProperty("height", `${containerH}px`, "important");
|
||||
viewContainer.style.setProperty("max-height", `${containerH}px`, "important");
|
||||
viewContainer.style.setProperty("overflow-y", "hidden", "important");
|
||||
|
||||
gridContainer.style.setProperty("height", "100%", "important");
|
||||
gridContainer.style.setProperty("max-height", "100%", "important");
|
||||
gridContainer.style.setProperty("overflow-y", "auto", "important");
|
||||
gridContainer.style.setProperty("flex", "1 1 0px", "important");
|
||||
|
||||
node.setSize([node.size[0], Math.max(420, restoredH)]);
|
||||
if (app.graph) app.graph.setDirtyCanvas(true, true);
|
||||
}
|
||||
};
|
||||
|
||||
node.computeSize = function() {
|
||||
const displayModeWidget = node.widgets ? node.widgets.find(w => w.name === "display_mode") : null;
|
||||
if (displayModeWidget && displayModeWidget.value === "Show All" && gridContainer) {
|
||||
return [node.size[0], Math.max(420, (gridContainer.scrollHeight || 0) + 160)];
|
||||
}
|
||||
return [node.size[0], Math.max(420, node.size[1])];
|
||||
};
|
||||
|
||||
const updateNodeSize = () => {
|
||||
const displayModeWidget = node.widgets ? node.widgets.find(w => w.name === "display_mode") : null;
|
||||
const mode = displayModeWidget ? displayModeWidget.value : "Scrollable";
|
||||
applyDisplayMode(mode);
|
||||
};
|
||||
|
||||
node.properties = node.properties || {};
|
||||
const initialZoom = node.properties["tile_size"] != null
|
||||
? node.properties["tile_size"]
|
||||
: (localStorage.getItem("comfy_img_picker_zoom") || "80");
|
||||
node.properties["tile_size"] = parseInt(initialZoom);
|
||||
viewContainer.style.setProperty("--img-tile-size", `${initialZoom}px`);
|
||||
|
||||
// Embed DOM Widget inside the node container
|
||||
const domWidget = node.addDOMWidget("img_visual_picker", "HTML", viewContainer, {
|
||||
getValue() { return getHiddenWidget().value; },
|
||||
setValue(val) { getHiddenWidget().value = val; },
|
||||
getValue() { return getHiddenWidget("_selected_image", "").value; },
|
||||
setValue(val) { getHiddenWidget("_selected_image", "").value = val; },
|
||||
serialize: false
|
||||
});
|
||||
|
||||
const onResize = node.onResize;
|
||||
node.onResize = function(size) {
|
||||
if (onResize) onResize.apply(this, arguments);
|
||||
const displayModeWidget = node.widgets ? node.widgets.find(w => w.name === "display_mode") : null;
|
||||
const isShowAll = displayModeWidget && displayModeWidget.value === "Show All";
|
||||
if (!isShowAll && size) {
|
||||
node.userCustomHeight = size[1];
|
||||
userSavedHeight = size[1];
|
||||
if (viewContainer && viewContainer.style) {
|
||||
const h = Math.max(200, size[1] - 100);
|
||||
viewContainer.style.setProperty("height", `${h}px`, "important");
|
||||
viewContainer.style.setProperty("max-height", `${h}px`, "important");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
domWidget.computeSize = function() {
|
||||
return [node.size[0] - 30, node.size[1] - 140];
|
||||
};
|
||||
@@ -271,8 +384,10 @@ app.registerExtension({
|
||||
"tile_size",
|
||||
parseInt(initialZoom),
|
||||
(val) => {
|
||||
localStorage.setItem("comfy_img_picker_zoom", val);
|
||||
node.properties = node.properties || {};
|
||||
node.properties["tile_size"] = val;
|
||||
viewContainer.style.setProperty("--img-tile-size", `${val}px`);
|
||||
if (app.graph) app.graph.setDirtyCanvas(true, true);
|
||||
},
|
||||
{ min: 50, max: 200, step: 1 }
|
||||
);
|
||||
@@ -302,8 +417,8 @@ app.registerExtension({
|
||||
gridContainer.innerHTML = "";
|
||||
lazyObserver.disconnect();
|
||||
|
||||
const folder = folderWidget ? (folderWidget.value || "") : "";
|
||||
const selectedVal = getHiddenWidget().value;
|
||||
let folder = folderWidget ? (folderWidget.value || "") : "";
|
||||
const selectedVal = getHiddenWidget("_selected_image", "").value;
|
||||
|
||||
// Group items by subfolder
|
||||
const foldersMap = {};
|
||||
@@ -404,7 +519,8 @@ app.registerExtension({
|
||||
}
|
||||
|
||||
const img = document.createElement("img");
|
||||
img.dataset.src = `/image_loader/get_thumbnail?folder=${encodeURIComponent(folder)}&image=${encodeURIComponent(item.relPath)}`;
|
||||
let fetchFolder = folder;
|
||||
img.dataset.src = `/image_loader/get_thumbnail?folder=${encodeURIComponent(fetchFolder)}&image=${encodeURIComponent(item.relPath)}`;
|
||||
img.onload = () => img.classList.add("loaded");
|
||||
|
||||
img.onerror = () => {
|
||||
@@ -426,7 +542,7 @@ app.registerExtension({
|
||||
allTiles.forEach(t => t.classList.remove("selected"));
|
||||
|
||||
tile.classList.add("selected");
|
||||
const curWidget = getHiddenWidget();
|
||||
const curWidget = getHiddenWidget("_selected_image", "");
|
||||
curWidget.value = item.relPath;
|
||||
if (curWidget.callback) curWidget.callback(item.relPath);
|
||||
|
||||
@@ -446,10 +562,11 @@ app.registerExtension({
|
||||
|
||||
gridContainer.appendChild(folderContainer);
|
||||
});
|
||||
setTimeout(() => updateNodeSize(), 50);
|
||||
};
|
||||
|
||||
const updateImagesList = async () => {
|
||||
const folder = folderWidget ? (folderWidget.value || "") : "";
|
||||
let folder = folderWidget ? (folderWidget.value || "") : "";
|
||||
const currentRequest = Symbol();
|
||||
activeRequest = currentRequest;
|
||||
|
||||
@@ -470,6 +587,16 @@ app.registerExtension({
|
||||
debounceTimer = setTimeout(() => updateImagesList(), 350);
|
||||
};
|
||||
}
|
||||
|
||||
refreshBtn.onclick = () => {
|
||||
refreshBtn.style.transform = "rotate(180deg)";
|
||||
refreshBtn.style.transition = "transform 0.3s";
|
||||
updateImagesList();
|
||||
setTimeout(() => {
|
||||
refreshBtn.style.transform = "none";
|
||||
refreshBtn.style.transition = "none";
|
||||
}, 300);
|
||||
};
|
||||
|
||||
searchBar.addEventListener("input", () => {
|
||||
searchQuery = searchBar.value;
|
||||
@@ -481,7 +608,22 @@ app.registerExtension({
|
||||
node.onConfigure = function(config) {
|
||||
if (originalOnConfigure) originalOnConfigure.apply(this, arguments);
|
||||
|
||||
const curWidget = getHiddenWidget();
|
||||
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);
|
||||
|
||||
if (savedTileSize != null) {
|
||||
node.properties = node.properties || {};
|
||||
node.properties["tile_size"] = savedTileSize;
|
||||
if (zoomWidget) {
|
||||
zoomWidget.value = parseInt(savedTileSize);
|
||||
}
|
||||
if (viewContainer && viewContainer.style) {
|
||||
viewContainer.style.setProperty("--img-tile-size", `${savedTileSize}px`);
|
||||
}
|
||||
}
|
||||
|
||||
const curWidget = getHiddenWidget("_selected_image", "");
|
||||
const widgetIndex = node.widgets.indexOf(curWidget);
|
||||
const savedValue = (config.widgets_values && widgetIndex !== -1) ? config.widgets_values[widgetIndex] : curWidget.value;
|
||||
if (savedValue) {
|
||||
@@ -490,7 +632,18 @@ app.registerExtension({
|
||||
setTimeout(() => updateImagesList(), 100);
|
||||
};
|
||||
|
||||
setTimeout(() => updateImagesList(), 100);
|
||||
setTimeout(() => {
|
||||
const displayModeWidget = node.widgets ? node.widgets.find(w => w.name === "display_mode") : null;
|
||||
if (displayModeWidget) {
|
||||
const origCallback = displayModeWidget.callback;
|
||||
displayModeWidget.callback = function(val) {
|
||||
if (origCallback) origCallback.apply(this, arguments);
|
||||
applyDisplayMode(val || displayModeWidget.value);
|
||||
};
|
||||
applyDisplayMode(displayModeWidget.value || "Scrollable");
|
||||
}
|
||||
updateImagesList();
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { authenticatedFetch } from "./auth_helper.js";
|
||||
|
||||
// LeafFlow Local File Runner In-Memory Consent Gate Extension
|
||||
app.registerExtension({
|
||||
name: "LeafFlow.LocalRunner.Security",
|
||||
async nodeCreated(node) {
|
||||
if (node.comfyClass !== "RunLocalFileNode") return;
|
||||
|
||||
node._isAuthorized = false;
|
||||
node._authExpiresAt = 0;
|
||||
|
||||
const filePathWidget = node.widgets?.find(w => w.name === "file_path");
|
||||
|
||||
const updateVisualStatus = () => {
|
||||
const now = Date.now();
|
||||
const isArmed = Boolean(node._isAuthorized && node._authExpiresAt > now);
|
||||
|
||||
if (isArmed) {
|
||||
const remainingSec = Math.max(0, Math.round((node._authExpiresAt - now) / 1000));
|
||||
const remainingMin = Math.ceil(remainingSec / 60);
|
||||
node.title = `🍃 ⚡ Run Local File [ARMED (${remainingMin}m)]`;
|
||||
node.color = "#047857";
|
||||
node.bgcolor = "#064e3b";
|
||||
if (authButton) {
|
||||
authButton.name = `✅ Authorized (${remainingMin}m left)`;
|
||||
}
|
||||
} else {
|
||||
node._isAuthorized = false;
|
||||
node.title = "🍃 ⚡ Run Local File [DISARMED]";
|
||||
node.color = "#7f1d1d";
|
||||
node.bgcolor = "#18181b";
|
||||
if (authButton) {
|
||||
authButton.name = "⚡ Authorize Run (5 min)";
|
||||
}
|
||||
}
|
||||
node.setDirtyCanvas(true, true);
|
||||
};
|
||||
|
||||
// Add explicit interactive action button: "⚡ Authorize Run (5 min)"
|
||||
const authButton = node.addWidget(
|
||||
"button",
|
||||
"⚡ Authorize Run (5 min)",
|
||||
null,
|
||||
async () => {
|
||||
const scriptName = filePathWidget?.value?.trim() || "configured script";
|
||||
const confirmed = confirm(
|
||||
"🍃 LeafFlow Security Verification\n\n" +
|
||||
`Authorize a SINGLE run of local script:\n'${scriptName}'\n\n` +
|
||||
"⚠️ Notice: Scripts can execute shell commands on your system.\n" +
|
||||
"• The script must reside inside 'ComfyUI/scripts/'.\n" +
|
||||
"• Authorization is temporary (expires in 5 minutes) and single-use.\n" +
|
||||
"• External workflows can NEVER run scripts without your live authorization.\n\n" +
|
||||
"Authorize this run now?"
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const resp = await authenticatedFetch("/leafflow/local_runner/authorize", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
node_id: node.id,
|
||||
file_path: filePathWidget?.value || ""
|
||||
})
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
const errMsg = err.error || "Failed to authorize node run.";
|
||||
if (app.extensionManager?.toast?.add) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: "error",
|
||||
summary: "🍃 Authorization Failed",
|
||||
detail: errMsg,
|
||||
life: 6000
|
||||
});
|
||||
} else {
|
||||
alert("❌ " + errMsg);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
node._isAuthorized = true;
|
||||
node._authExpiresAt = Date.now() + (300 * 1000);
|
||||
updateVisualStatus();
|
||||
|
||||
if (app.extensionManager?.toast?.add) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: "success",
|
||||
summary: "⚡ Script Run Authorized",
|
||||
detail: `Node ${node.id} authorized for 5 minutes. Single-use: will be consumed upon execution.`,
|
||||
life: 5000
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[LeafFlow Security] Error authorizing script run:", e);
|
||||
alert("❌ Error communicating with authorization service: " + e.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Intercept workflow load / configuration: ALWAYS disarm on load!
|
||||
const origOnConfigure = node.onConfigure;
|
||||
node.onConfigure = function(info) {
|
||||
if (origOnConfigure) origOnConfigure.apply(this, arguments);
|
||||
// GUARANTEE: Never let imported workflow or image auto-run with consent pre-checked
|
||||
node._isAuthorized = false;
|
||||
node._authExpiresAt = 0;
|
||||
updateVisualStatus();
|
||||
};
|
||||
|
||||
// If file path changes, invalidate existing authorization
|
||||
if (filePathWidget) {
|
||||
const origCallback = filePathWidget.callback;
|
||||
filePathWidget.callback = function(val) {
|
||||
node._isAuthorized = false;
|
||||
node._authExpiresAt = 0;
|
||||
updateVisualStatus();
|
||||
if (origCallback) origCallback.apply(this, arguments);
|
||||
};
|
||||
}
|
||||
|
||||
// Draw security alert ribbon on canvas if disarmed
|
||||
const origOnDrawForeground = node.onDrawForeground;
|
||||
node.onDrawForeground = function(ctx) {
|
||||
if (origOnDrawForeground) origOnDrawForeground.apply(this, arguments);
|
||||
if (node.flags?.collapsed) return;
|
||||
|
||||
const now = Date.now();
|
||||
const isArmed = Boolean(node._isAuthorized && node._authExpiresAt > now);
|
||||
|
||||
ctx.save();
|
||||
ctx.textAlign = "center";
|
||||
ctx.font = "italic 9px sans-serif";
|
||||
ctx.fillStyle = "#9ca3af";
|
||||
ctx.fillText("📂 Script Location: ComfyUI/scripts/", node.size[0] / 2, node.size[1] - 19);
|
||||
|
||||
ctx.font = "bold 10px sans-serif";
|
||||
if (!isArmed) {
|
||||
ctx.fillStyle = "#ef4444";
|
||||
ctx.fillText("🛡️ DISARMED: Click 'Authorize Run' to execute", node.size[0] / 2, node.size[1] - 7);
|
||||
} else {
|
||||
const rem = Math.max(0, Math.ceil((node._authExpiresAt - now) / 60000));
|
||||
ctx.fillStyle = "#10b981";
|
||||
ctx.fillText(`⚡ ARMED (${rem}m left) - Consumed on run`, node.size[0] / 2, node.size[1] - 7);
|
||||
}
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
updateVisualStatus();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,694 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { api } from "/scripts/api.js";
|
||||
import { authenticatedFetch } from "./auth_helper.js";
|
||||
|
||||
const EXTENSION_NAME = "LeafFlow.PowerControlSidebar";
|
||||
const STYLE_ID = "leafflow-power-sidebar-styles";
|
||||
|
||||
let currentPowerState = {
|
||||
pending_action: null,
|
||||
armed_at: null,
|
||||
is_paused: false
|
||||
};
|
||||
|
||||
let activePopup = null;
|
||||
|
||||
function injectStyles() {
|
||||
if (document.getElementById(STYLE_ID)) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = STYLE_ID;
|
||||
style.textContent = `
|
||||
/* Power Sidebar Button Layout (Matching standard full-width sidebar button) */
|
||||
#leafflow-power-bottom-btn {
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
min-height: 48px !important;
|
||||
padding: 6px 4px !important;
|
||||
box-sizing: border-box !important;
|
||||
border-radius: 8px !important;
|
||||
align-self: stretch !important;
|
||||
}
|
||||
|
||||
#leafflow-power-bottom-btn .side-bar-button-content {
|
||||
width: 100% !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
align-items: center !important;
|
||||
gap: 4px !important;
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
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;
|
||||
}
|
||||
|
||||
.leafflow-power-btn-armed svg,
|
||||
.leafflow-power-btn-armed .side-bar-button-icon,
|
||||
.leafflow-power-btn-armed span {
|
||||
color: #ffffff !important;
|
||||
stroke: #ffffff !important;
|
||||
}
|
||||
|
||||
@keyframes leafflow-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 {
|
||||
position: fixed;
|
||||
z-index: 10005;
|
||||
min-width: 250px;
|
||||
max-width: 320px;
|
||||
background: var(--comfy-menu-bg, #18181b);
|
||||
border: 1px solid var(--border-color, #27272a);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.6);
|
||||
padding: 8px;
|
||||
color: var(--fg-color, #f4f4f5);
|
||||
font-family: inherit;
|
||||
animation: leafflow-popup-fade 0.15s ease-out;
|
||||
}
|
||||
|
||||
@keyframes leafflow-popup-fade {
|
||||
from { opacity: 0; transform: translateX(-6px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
.leafflow-power-popup .help-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--fg-color, #f4f4f5);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.leafflow-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 {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.leafflow-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 {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.leafflow-power-popup .help-menu-icon-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
color: var(--descrip-text, #a1a1aa);
|
||||
}
|
||||
|
||||
.leafflow-power-popup .help-menu-item:hover .help-menu-icon-container {
|
||||
color: var(--fg-color, #f4f4f5);
|
||||
}
|
||||
|
||||
.leafflow-power-popup .menu-label {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.leafflow-power-popup .menu-divider {
|
||||
height: 1px;
|
||||
background: var(--border-color, #27272a);
|
||||
margin: 4px 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Power Confirm Modal */
|
||||
.leafflow-power-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 18px;
|
||||
min-width: 330px;
|
||||
max-width: 440px;
|
||||
background: #18181b;
|
||||
color: #f4f4f5;
|
||||
border-radius: 12px;
|
||||
font-family: inherit;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function getPowerIconSvg(size = 18) {
|
||||
return `<svg class="size-4.5" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18.36 6.64a9 9 0 1 1-12.73 0"></path><line x1="12" y1="2" x2="12" y2="12"></line></svg>`;
|
||||
}
|
||||
|
||||
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)');
|
||||
btns.forEach(btn => {
|
||||
if (isArmed) {
|
||||
btn.classList.add("leafflow-power-btn-armed");
|
||||
const icon = btn.querySelector(".side-bar-button-icon, i, svg");
|
||||
if (icon) icon.classList.add("leafflow-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");
|
||||
const icon = btn.querySelector(".side-bar-button-icon, i, svg");
|
||||
if (icon) icon.classList.remove("leafflow-power-icon-armed");
|
||||
btn.setAttribute("title", "Power (Restart / Shutdown)");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchPowerStatus() {
|
||||
try {
|
||||
const resp = await api.fetchApi("/leafflow/power/status");
|
||||
if (resp.ok) {
|
||||
currentPowerState = await resp.json();
|
||||
updateSidebarButtonVisuals();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function openConfirmModal(title, message, onConfirm, isDanger = false) {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.style.position = "fixed";
|
||||
overlay.style.inset = "0";
|
||||
overlay.style.backgroundColor = "rgba(0,0,0,0.75)";
|
||||
overlay.style.display = "flex";
|
||||
overlay.style.alignItems = "center";
|
||||
overlay.style.justifyContent = "center";
|
||||
overlay.style.zIndex = "10010";
|
||||
overlay.style.backdropFilter = "blur(3px)";
|
||||
|
||||
const modal = document.createElement("div");
|
||||
modal.className = "leafflow-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)";
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.style.fontSize = "16px";
|
||||
header.style.fontWeight = "bold";
|
||||
header.style.display = "flex";
|
||||
header.style.alignItems = "center";
|
||||
header.style.gap = "8px";
|
||||
header.innerHTML = isDanger ? `⚠️ ${title}` : `ℹ️ ${title}`;
|
||||
|
||||
const desc = document.createElement("div");
|
||||
desc.style.fontSize = "13px";
|
||||
desc.style.lineHeight = "1.5";
|
||||
desc.style.color = "#d4d4d8";
|
||||
desc.textContent = message;
|
||||
|
||||
const btnRow = document.createElement("div");
|
||||
btnRow.style.display = "flex";
|
||||
btnRow.style.justifyContent = "flex-end";
|
||||
btnRow.style.gap = "8px";
|
||||
btnRow.style.marginTop = "8px";
|
||||
|
||||
const cancelBtn = document.createElement("button");
|
||||
cancelBtn.textContent = onConfirm ? "Cancel" : "Close";
|
||||
cancelBtn.style.padding = "8px 16px";
|
||||
cancelBtn.style.borderRadius = "6px";
|
||||
cancelBtn.style.border = "1px solid #52525b";
|
||||
cancelBtn.style.background = "#27272a";
|
||||
cancelBtn.style.color = "#f4f4f5";
|
||||
cancelBtn.style.cursor = "pointer";
|
||||
cancelBtn.onclick = () => overlay.remove();
|
||||
|
||||
if (onConfirm) {
|
||||
const confirmBtn = document.createElement("button");
|
||||
confirmBtn.textContent = "Confirm";
|
||||
confirmBtn.style.padding = "8px 16px";
|
||||
confirmBtn.style.borderRadius = "6px";
|
||||
confirmBtn.style.border = "none";
|
||||
confirmBtn.style.fontWeight = "600";
|
||||
confirmBtn.style.cursor = "pointer";
|
||||
if (isDanger) {
|
||||
confirmBtn.style.background = "#dc2626";
|
||||
confirmBtn.style.color = "#ffffff";
|
||||
} else {
|
||||
confirmBtn.style.background = "#2563eb";
|
||||
confirmBtn.style.color = "#ffffff";
|
||||
}
|
||||
|
||||
confirmBtn.onclick = async () => {
|
||||
overlay.remove();
|
||||
await onConfirm();
|
||||
};
|
||||
|
||||
btnRow.append(cancelBtn, confirmBtn);
|
||||
} else {
|
||||
btnRow.append(cancelBtn);
|
||||
}
|
||||
modal.append(header, desc, btnRow);
|
||||
overlay.appendChild(modal);
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
function checkProcessManagementEnabled() {
|
||||
if (currentPowerState.enabled === false) {
|
||||
if (app.extensionManager?.toast?.add) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: "warn",
|
||||
summary: "🍃 Process Management Disabled",
|
||||
detail: "Enable 'Allow Process Management' in LeafFlow settings to restart or shut down ComfyUI.",
|
||||
life: 7000
|
||||
});
|
||||
}
|
||||
openConfirmModal(
|
||||
"Process Management Disabled",
|
||||
"Server restart and shutdown controls are currently disabled for security.\n\nTo enable them, go to ComfyUI Settings ⚙️ -> LeafFlow -> Pause Controls -> Turn ON 'Allow Process Management (Restart / Shutdown)'.",
|
||||
null,
|
||||
false
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function closePowerPopup() {
|
||||
if (activePopup) {
|
||||
activePopup.remove();
|
||||
activePopup = null;
|
||||
}
|
||||
}
|
||||
|
||||
function showPowerSidePopup(anchorElement) {
|
||||
if (activePopup) {
|
||||
closePowerPopup();
|
||||
return;
|
||||
}
|
||||
|
||||
const popup = document.createElement("div");
|
||||
popup.className = "help-center-popup sidebar-left leafflow-power-popup";
|
||||
popup.setAttribute("data-testid", "power-popup");
|
||||
|
||||
const rect = anchorElement.getBoundingClientRect();
|
||||
popup.style.left = `${Math.round(rect.right + 8)}px`;
|
||||
popup.style.bottom = `${Math.max(12, Math.round(window.innerHeight - rect.bottom))}px`;
|
||||
|
||||
const menu = document.createElement("div");
|
||||
menu.className = "help-center-menu flex flex-col items-start gap-1";
|
||||
menu.setAttribute("role", "menu");
|
||||
menu.setAttribute("aria-label", "Power Menu");
|
||||
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "w-full";
|
||||
|
||||
const nav = document.createElement("nav");
|
||||
nav.className = "flex w-full flex-col gap-1";
|
||||
nav.setAttribute("role", "menubar");
|
||||
|
||||
// Cancel Armed Action Button (if armed)
|
||||
if (currentPowerState.pending_action) {
|
||||
const cancelBtn = document.createElement("button");
|
||||
cancelBtn.type = "button";
|
||||
cancelBtn.className = "help-menu-item armed-cancel-item";
|
||||
cancelBtn.setAttribute("role", "menuitem");
|
||||
cancelBtn.innerHTML = `
|
||||
<div class="help-menu-icon-container"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="15" y1="9" x2="9" y2="15"></line><line x1="9" y1="9" x2="15" y2="15"></line></svg></div>
|
||||
<span class="menu-label">Cancel ${currentPowerState.pending_action.toUpperCase()}</span>
|
||||
`;
|
||||
cancelBtn.onclick = async (e) => {
|
||||
e.stopPropagation();
|
||||
closePowerPopup();
|
||||
await authenticatedFetch("/leafflow/power/arm", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: null })
|
||||
});
|
||||
await fetchPowerStatus();
|
||||
if (app.extensionManager?.toast?.add) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: "info",
|
||||
summary: "🍃 LeafFlow Power",
|
||||
detail: "Scheduled power action has been cancelled.",
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
};
|
||||
nav.appendChild(cancelBtn);
|
||||
}
|
||||
|
||||
// 1. Restart Server (Immediate)
|
||||
const restartItem = document.createElement("button");
|
||||
restartItem.type = "button";
|
||||
restartItem.className = "help-menu-item";
|
||||
restartItem.setAttribute("role", "menuitem");
|
||||
restartItem.innerHTML = `
|
||||
<div class="help-menu-icon-container"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"></polyline><polyline points="1 20 1 14 7 14"></polyline><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path></svg></div>
|
||||
<span class="menu-label">Restart Server (Immediate)</span>
|
||||
`;
|
||||
restartItem.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
closePowerPopup();
|
||||
if (!checkProcessManagementEnabled()) return;
|
||||
openConfirmModal(
|
||||
"Restart ComfyUI Immediately?",
|
||||
"Are you sure you want to restart ComfyUI immediately? Any active prompt generation will be interrupted.",
|
||||
async () => {
|
||||
const tokenResp = await authenticatedFetch("/leafflow/power/request_token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "restart" })
|
||||
});
|
||||
if (!tokenResp.ok) {
|
||||
const err = await tokenResp.json().catch(() => ({}));
|
||||
if (app.extensionManager?.toast?.add) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: "error",
|
||||
summary: "🍃 Restart Denied",
|
||||
detail: err.error || "Failed to obtain power action ticket.",
|
||||
life: 6000
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
const tokenData = await tokenResp.json();
|
||||
const resp = await authenticatedFetch("/leafflow/power/confirm_action", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ticket: tokenData.ticket, action: "restart" })
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
if (app.extensionManager?.toast?.add) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: "error",
|
||||
summary: "🍃 Restart Failed",
|
||||
detail: err.error || "Failed to trigger restart.",
|
||||
life: 6000
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (app.extensionManager?.toast?.add) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: "warn",
|
||||
summary: "🔄 Restarting ComfyUI",
|
||||
detail: "The server process is restarting. The UI will reload shortly.",
|
||||
life: 8000
|
||||
});
|
||||
}
|
||||
setTimeout(() => window.location.reload(), 2500);
|
||||
},
|
||||
true
|
||||
);
|
||||
};
|
||||
nav.appendChild(restartItem);
|
||||
|
||||
// 2. Shutdown Server (Immediate)
|
||||
const shutdownItem = document.createElement("button");
|
||||
shutdownItem.type = "button";
|
||||
shutdownItem.className = "help-menu-item danger-item";
|
||||
shutdownItem.setAttribute("role", "menuitem");
|
||||
shutdownItem.innerHTML = `
|
||||
<div class="help-menu-icon-container"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18.36 6.64a9 9 0 1 1-12.73 0"></path><line x1="12" y1="2" x2="12" y2="12"></line></svg></div>
|
||||
<span class="menu-label">Shutdown Server (Immediate)</span>
|
||||
`;
|
||||
shutdownItem.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
closePowerPopup();
|
||||
if (!checkProcessManagementEnabled()) return;
|
||||
openConfirmModal(
|
||||
"Shutdown ComfyUI Server?",
|
||||
"Are you sure you want to SHUT DOWN the ComfyUI server immediately? The process will exit.",
|
||||
async () => {
|
||||
const tokenResp = await authenticatedFetch("/leafflow/power/request_token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "shutdown" })
|
||||
});
|
||||
if (!tokenResp.ok) {
|
||||
const err = await tokenResp.json().catch(() => ({}));
|
||||
if (app.extensionManager?.toast?.add) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: "error",
|
||||
summary: "🍃 Shutdown Denied",
|
||||
detail: err.error || "Failed to obtain power action ticket.",
|
||||
life: 6000
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
const tokenData = await tokenResp.json();
|
||||
const resp = await authenticatedFetch("/leafflow/power/confirm_action", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ticket: tokenData.ticket, action: "shutdown" })
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
if (app.extensionManager?.toast?.add) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: "error",
|
||||
summary: "🍃 Shutdown Failed",
|
||||
detail: err.error || "Failed to trigger shutdown.",
|
||||
life: 6000
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (app.extensionManager?.toast?.add) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: "error",
|
||||
summary: "🛑 ComfyUI Shutting Down",
|
||||
detail: "The server process is terminating.",
|
||||
life: 6000
|
||||
});
|
||||
}
|
||||
},
|
||||
true
|
||||
);
|
||||
};
|
||||
nav.appendChild(shutdownItem);
|
||||
|
||||
// Divider
|
||||
const divider = document.createElement("div");
|
||||
divider.className = "menu-divider";
|
||||
nav.appendChild(divider);
|
||||
|
||||
// 3. Restart After Queue Finish
|
||||
const restartQueueItem = document.createElement("button");
|
||||
restartQueueItem.type = "button";
|
||||
restartQueueItem.className = "help-menu-item";
|
||||
restartQueueItem.setAttribute("role", "menuitem");
|
||||
restartQueueItem.innerHTML = `
|
||||
<div class="help-menu-icon-container"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg></div>
|
||||
<span class="menu-label">Restart After Queue Finish</span>
|
||||
`;
|
||||
restartQueueItem.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
closePowerPopup();
|
||||
if (!checkProcessManagementEnabled()) return;
|
||||
openConfirmModal(
|
||||
"Schedule Restart After Queue Finish?",
|
||||
"ComfyUI will wait until all remaining prompts in the queue finish and the system returns to idle before restarting. (Note: If queue is paused, it will not restart).",
|
||||
async () => {
|
||||
const resp = await authenticatedFetch("/leafflow/power/arm", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "restart" })
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
if (app.extensionManager?.toast?.add) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: "error",
|
||||
summary: "🍃 Scheduling Failed",
|
||||
detail: err.error || "Failed to schedule restart.",
|
||||
life: 6000
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
await fetchPowerStatus();
|
||||
if (app.extensionManager?.toast?.add) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: "warn",
|
||||
summary: "⏳ Restart Scheduled",
|
||||
detail: "ComfyUI will restart automatically when the queue finishes.",
|
||||
life: 6000
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
nav.appendChild(restartQueueItem);
|
||||
|
||||
// 4. Shutdown After Queue Finish
|
||||
const shutdownQueueItem = document.createElement("button");
|
||||
shutdownQueueItem.type = "button";
|
||||
shutdownQueueItem.className = "help-menu-item danger-item";
|
||||
shutdownQueueItem.setAttribute("role", "menuitem");
|
||||
shutdownQueueItem.innerHTML = `
|
||||
<div class="help-menu-icon-container"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path></svg></div>
|
||||
<span class="menu-label">Shutdown After Queue Finish</span>
|
||||
`;
|
||||
shutdownQueueItem.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
closePowerPopup();
|
||||
if (!checkProcessManagementEnabled()) return;
|
||||
openConfirmModal(
|
||||
"Schedule Shutdown After Queue Finish?",
|
||||
"ComfyUI will wait until all queued prompts complete and the system returns to idle before shutting down completely.",
|
||||
async () => {
|
||||
const resp = await authenticatedFetch("/leafflow/power/arm", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "shutdown" })
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
if (app.extensionManager?.toast?.add) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: "error",
|
||||
summary: "🍃 Scheduling Failed",
|
||||
detail: err.error || "Failed to schedule shutdown.",
|
||||
life: 6000
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
await fetchPowerStatus();
|
||||
if (app.extensionManager?.toast?.add) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: "warn",
|
||||
summary: "🌙 Shutdown Scheduled",
|
||||
detail: "ComfyUI will shut down automatically when the queue finishes.",
|
||||
life: 6000
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
nav.appendChild(shutdownQueueItem);
|
||||
|
||||
wrap.appendChild(nav);
|
||||
menu.appendChild(wrap);
|
||||
popup.appendChild(menu);
|
||||
document.body.appendChild(popup);
|
||||
activePopup = popup;
|
||||
|
||||
// Close on click outside or escape
|
||||
const outsideListener = (e) => {
|
||||
if (popup && !popup.contains(e.target) && !anchorElement.contains(e.target)) {
|
||||
closePowerPopup();
|
||||
document.removeEventListener("pointerdown", outsideListener);
|
||||
document.removeEventListener("keydown", keyListener);
|
||||
}
|
||||
};
|
||||
const keyListener = (e) => {
|
||||
if (e.key === "Escape") {
|
||||
closePowerPopup();
|
||||
document.removeEventListener("pointerdown", outsideListener);
|
||||
document.removeEventListener("keydown", keyListener);
|
||||
}
|
||||
};
|
||||
setTimeout(() => {
|
||||
document.addEventListener("pointerdown", outsideListener);
|
||||
document.addEventListener("keydown", keyListener);
|
||||
}, 10);
|
||||
}
|
||||
|
||||
function attachBottomSidebarButton() {
|
||||
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.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");
|
||||
btn.setAttribute("data-testid", "power-button");
|
||||
btn.setAttribute("data-pd-tooltip", "true");
|
||||
btn.setAttribute("title", "Power (Restart / Shutdown)");
|
||||
|
||||
btn.innerHTML = `
|
||||
<div class="side-bar-button-content flex flex-col items-center gap-2">
|
||||
<div class="sidebar-icon-wrapper relative">
|
||||
${getPowerIconSvg(18)}
|
||||
</div>
|
||||
<span class="side-bar-button-label line-clamp-2 w-max max-w-[calc(var(--sidebar-width)-var(--sidebar-padding))] text-center text-2xs wrap-break-word whitespace-normal">Power</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
btn.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
showPowerSidePopup(btn);
|
||||
};
|
||||
|
||||
// Insert at the top of the bottom group (above Help Center)
|
||||
bottomGroup.insertBefore(btn, bottomGroup.firstChild);
|
||||
updateSidebarButtonVisuals();
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: EXTENSION_NAME,
|
||||
async setup() {
|
||||
injectStyles();
|
||||
await fetchPowerStatus();
|
||||
|
||||
// 1. Attach native button in bottom sidebar
|
||||
attachBottomSidebarButton();
|
||||
const observer = new MutationObserver(() => attachBottomSidebarButton());
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
// 2. Fallback listener on any matching button
|
||||
setTimeout(() => {
|
||||
const btns = document.querySelectorAll('.comfy-power-btn, [aria-label*="Power"], .side-bar-button:has(.pi-power)');
|
||||
btns.forEach(b => {
|
||||
b.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
showPowerSidePopup(b);
|
||||
}, true);
|
||||
});
|
||||
updateSidebarButtonVisuals();
|
||||
}, 1000);
|
||||
|
||||
try {
|
||||
api.addEventListener("leafflow_power_status", (e) => {
|
||||
currentPowerState = e.detail || currentPowerState;
|
||||
updateSidebarButtonVisuals();
|
||||
});
|
||||
} catch (_) {}
|
||||
|
||||
setInterval(fetchPowerStatus, 3000);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,314 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { api } from "/scripts/api.js";
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfyUI.LeafFlow.PreviewImageSizeAspectRatio",
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name !== "PreviewImageSizeAspectRatio" && nodeData.name !== "TextAspectRatioFinder") return;
|
||||
|
||||
if (nodeData.name === "PreviewImageSizeAspectRatio") {
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function() {
|
||||
if (onNodeCreated) onNodeCreated.apply(this, arguments);
|
||||
|
||||
// STRICTLY REMOVE ALL OUTPUT SLOTS (Once on node creation)
|
||||
this.outputs = [];
|
||||
if (this.outputs) this.outputs.length = 0;
|
||||
|
||||
this.aspect_ratio_data = {
|
||||
ratio: 1.0,
|
||||
display_text: "1 x 1"
|
||||
};
|
||||
|
||||
// Minimum size set to [230, 160] to accommodate 5-digit labels on left and right margins cleanly
|
||||
this.size = [240, 240];
|
||||
this.min_size = [230, 160];
|
||||
this.resizable = true;
|
||||
|
||||
// PERMANENTLY UNLOCK ASPECT RATIO RESIZING
|
||||
Object.defineProperty(this, "aspect_ratio", {
|
||||
get() { return false; },
|
||||
set(v) {},
|
||||
configurable: true
|
||||
});
|
||||
|
||||
// DOM Container for ComfyUI V2 Vue UI
|
||||
const viewContainer = document.createElement("div");
|
||||
viewContainer.className = "leafflow-ar-preview-container";
|
||||
viewContainer.style.width = "100%";
|
||||
viewContainer.style.height = "calc(100% - 110px)";
|
||||
viewContainer.style.minHeight = "80px";
|
||||
viewContainer.style.display = "flex";
|
||||
viewContainer.style.alignItems = "center";
|
||||
viewContainer.style.justifyContent = "center";
|
||||
// Equal 65px left and right padding for horizontal balance
|
||||
viewContainer.style.padding = "10px 65px 35px 65px";
|
||||
viewContainer.style.boxSizing = "border-box";
|
||||
viewContainer.style.pointerEvents = "none";
|
||||
viewContainer.style.position = "relative";
|
||||
viewContainer.style.overflow = "hidden";
|
||||
|
||||
viewContainer.innerHTML = `
|
||||
<div class="ar-box-wrapper" style="position: relative; display: flex; align-items: center; justify-content: center; width: 50px; height: 50px; transition: none !important;">
|
||||
<svg class="ar-box-svg" style="position: absolute; top: 0; left: 0; display: block; overflow: visible; pointer-events: none; transition: none !important;" width="50" height="50" viewBox="0 0 50 50">
|
||||
<path class="ar-crop-path" d="" stroke="#ffffff" stroke-width="2.8" stroke-linecap="round" stroke-linejoin="round" fill="none" style="transition: none !important;"/>
|
||||
</svg>
|
||||
<div class="ar-label-height" style="position: absolute; right: 100%; margin-right: 8px; top: 50%; transform: translateY(-50%); font-size: 13px; font-weight: 700; color: #ffffff; font-family: Inter, system-ui, sans-serif; white-space: nowrap;">1</div>
|
||||
<div class="ar-label-width" style="position: absolute; top: 100%; margin-top: 8px; left: 50%; transform: translateX(-50%); font-size: 13px; font-weight: 700; color: #ffffff; font-family: Inter, system-ui, sans-serif; white-space: nowrap;">1</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this._last_render_cache = "";
|
||||
|
||||
this.updateDomPreview = function() {
|
||||
if (!viewContainer) return;
|
||||
const data = this.aspect_ratio_data || { ratio: 1.0, display_text: "1 x 1" };
|
||||
const ratio = Math.max(0.05, Math.min(20.0, data.ratio || 1.0));
|
||||
|
||||
let partW = "1";
|
||||
let partH = "1";
|
||||
|
||||
if (data.display_text) {
|
||||
const str = String(data.display_text).trim();
|
||||
if (str.includes(" x ")) {
|
||||
const parts = str.split(" x ");
|
||||
partW = parts[0];
|
||||
partH = parts[1] || "";
|
||||
} else if (str.includes("x")) {
|
||||
const parts = str.split("x");
|
||||
partW = parts[0];
|
||||
partH = parts[1] || "";
|
||||
} else if (str.includes(":")) {
|
||||
const parts = str.split(":");
|
||||
partW = parts[0];
|
||||
partH = parts[1] || "";
|
||||
} else {
|
||||
partW = str;
|
||||
partH = "";
|
||||
}
|
||||
}
|
||||
|
||||
const widthEl = viewContainer.querySelector(".ar-label-width");
|
||||
const heightEl = viewContainer.querySelector(".ar-label-height");
|
||||
const boxWrapper = viewContainer.querySelector(".ar-box-wrapper");
|
||||
const svgEl = viewContainer.querySelector(".ar-box-svg");
|
||||
const pathEl = viewContainer.querySelector(".ar-crop-path");
|
||||
|
||||
if (widthEl) widthEl.innerText = partW;
|
||||
if (heightEl) heightEl.innerText = partH;
|
||||
|
||||
if (boxWrapper && svgEl && pathEl) {
|
||||
const availW = Math.max(20, (this.size[0] || 240) - 130);
|
||||
const availH = Math.max(20, (this.size[1] || 240) - 150);
|
||||
|
||||
let w, h;
|
||||
if (ratio >= availW / availH) {
|
||||
w = availW;
|
||||
h = availW / ratio;
|
||||
} else {
|
||||
h = availH;
|
||||
w = availH * ratio;
|
||||
}
|
||||
|
||||
w = Math.round(w);
|
||||
h = Math.round(h);
|
||||
|
||||
const cacheKey = `${w}_${h}_${partW}_${partH}`;
|
||||
if (this._last_render_cache === cacheKey) return;
|
||||
this._last_render_cache = cacheKey;
|
||||
|
||||
boxWrapper.style.width = `${w}px`;
|
||||
boxWrapper.style.height = `${h}px`;
|
||||
|
||||
svgEl.setAttribute("width", w);
|
||||
svgEl.setAttribute("height", h);
|
||||
svgEl.setAttribute("viewBox", `0 0 ${w} ${h}`);
|
||||
|
||||
const cX = w * 0.20;
|
||||
const cY = h * 0.20;
|
||||
const r = Math.min(12, Math.min(cX, cY) * 0.5);
|
||||
|
||||
const d = `M 0,${cY} L 0,${r} A ${r} ${r} 0 0 1 ${r},0 L ${cX},0 ` +
|
||||
`M ${w - cX},0 L ${w - r},0 A ${r} ${r} 0 0 1 ${w},${r} L ${w},${cY} ` +
|
||||
`M 0,${h - cY} L 0,${h - r} A ${r} ${r} 0 0 0 ${r},${h} L ${cX},${h} ` +
|
||||
`M ${w - cX},${h} L ${w - r},${h} A ${r} ${r} 0 0 0 ${w},${h - r} L ${w},${h - cY} ` +
|
||||
`M ${w * 0.40},0 L ${w * 0.60},0 ` +
|
||||
`M ${w * 0.40},${h} L ${w * 0.60},${h} ` +
|
||||
`M 0,${h * 0.40} L 0,${h * 0.60} ` +
|
||||
`M ${w},${h * 0.40} L ${w},${h * 0.60}`;
|
||||
|
||||
pathEl.setAttribute("d", d);
|
||||
}
|
||||
};
|
||||
|
||||
this.addDOMWidget("ar_preview_display", "HTML", viewContainer, {
|
||||
getValue() { return ""; },
|
||||
setValue(val) {},
|
||||
serialize: false,
|
||||
computeSize() { return [100, 80]; }
|
||||
});
|
||||
|
||||
// Canvas drawing fallback for LiteGraph (Classic V1)
|
||||
const origOnDrawForeground = this.onDrawForeground;
|
||||
this.onDrawForeground = function(ctx) {
|
||||
if (origOnDrawForeground) origOnDrawForeground.apply(this, arguments);
|
||||
if (this.flags?.collapsed) return;
|
||||
|
||||
// HIGH-PERFORMANCE OPTIMIZATION:
|
||||
// If HTML DOM widget is active in document (Vue UI / V2), skip duplicate canvas drawing!
|
||||
if (viewContainer && viewContainer.isConnected && viewContainer.offsetParent !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = this.aspect_ratio_data || { ratio: 1.0, display_text: "1 x 1" };
|
||||
const ratio = Math.max(0.05, Math.min(20.0, data.ratio || 1.0));
|
||||
|
||||
let partW = "1";
|
||||
let partH = "1";
|
||||
if (data.display_text) {
|
||||
const str = String(data.display_text).trim();
|
||||
if (str.includes(" x ")) {
|
||||
const parts = str.split(" x ");
|
||||
partW = parts[0];
|
||||
partH = parts[1] || "";
|
||||
} else if (str.includes("x")) {
|
||||
const parts = str.split("x");
|
||||
partW = parts[0];
|
||||
partH = parts[1] || "";
|
||||
} else if (str.includes(":")) {
|
||||
const parts = str.split(":");
|
||||
partW = parts[0];
|
||||
partH = parts[1] || "";
|
||||
} else {
|
||||
partW = str;
|
||||
partH = "";
|
||||
}
|
||||
}
|
||||
|
||||
ctx.save();
|
||||
|
||||
const startY = 30 + (this.inputs ? this.inputs.length * 20 : 80);
|
||||
|
||||
const marginLeft = 65;
|
||||
const marginRight = 65;
|
||||
const marginTop = startY + 10;
|
||||
const marginBottom = 35;
|
||||
|
||||
const availW = Math.max(20, this.size[0] - marginLeft - marginRight);
|
||||
const availH = Math.max(20, this.size[1] - marginTop - marginBottom);
|
||||
|
||||
let boxW, boxH;
|
||||
if (ratio >= availW / availH) {
|
||||
boxW = availW;
|
||||
boxH = availW / ratio;
|
||||
} else {
|
||||
boxH = availH;
|
||||
boxW = availH * ratio;
|
||||
}
|
||||
|
||||
const boxX = marginLeft + (availW - boxW) / 2;
|
||||
const boxY = marginTop + (availH - boxH) / 2;
|
||||
|
||||
const cX = boxW * 0.20;
|
||||
const cY = boxH * 0.20;
|
||||
const r = Math.min(12, Math.min(cX, cY) * 0.5);
|
||||
|
||||
ctx.strokeStyle = "#ffffff";
|
||||
ctx.lineWidth = 2.8;
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
ctx.beginPath();
|
||||
|
||||
// Top-Left Corner
|
||||
ctx.moveTo(boxX, boxY + cY);
|
||||
ctx.lineTo(boxX, boxY + r);
|
||||
ctx.arcTo(boxX, boxY, boxX + r, boxY, r);
|
||||
ctx.lineTo(boxX + cX, boxY);
|
||||
|
||||
// Top-Right Corner
|
||||
ctx.moveTo(boxX + boxW - cX, boxY);
|
||||
ctx.lineTo(boxX + boxW - r, boxY);
|
||||
ctx.arcTo(boxX + boxW, boxY, boxX + boxW, boxY + r, r);
|
||||
ctx.lineTo(boxX + boxW, boxY + cY);
|
||||
|
||||
// Bottom-Left Corner
|
||||
ctx.moveTo(boxX, boxY + boxH - cY);
|
||||
ctx.lineTo(boxX, boxY + boxH - r);
|
||||
ctx.arcTo(boxX, boxY + boxH, boxX + r, boxY + boxH, r);
|
||||
ctx.lineTo(boxX + cX, boxY + boxH);
|
||||
|
||||
// Bottom-Right Corner
|
||||
ctx.moveTo(boxX + boxW - cX, boxY + boxH);
|
||||
ctx.lineTo(boxX + boxW - r, boxY + boxH);
|
||||
ctx.arcTo(boxX + boxW, boxY + boxH, boxX + boxW, boxY + boxH - r, r);
|
||||
ctx.lineTo(boxX + boxW, boxY + boxH - cY);
|
||||
|
||||
// Top Center Handle
|
||||
ctx.moveTo(boxX + boxW * 0.40, boxY);
|
||||
ctx.lineTo(boxX + boxW * 0.60, boxY);
|
||||
|
||||
// Bottom Center Handle
|
||||
ctx.moveTo(boxX + boxW * 0.40, boxY + boxH);
|
||||
ctx.lineTo(boxX + boxW * 0.60, boxY + boxH);
|
||||
|
||||
// Left Center Handle
|
||||
ctx.moveTo(boxX, boxY + boxH * 0.40);
|
||||
ctx.lineTo(boxX, boxY + boxH * 0.60);
|
||||
|
||||
// Right Center Handle
|
||||
ctx.moveTo(boxX + boxW, boxY + boxH * 0.40);
|
||||
ctx.lineTo(boxX + boxW, boxY + boxH * 0.60);
|
||||
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.font = "bold 13px Inter, -apple-system, sans-serif";
|
||||
|
||||
if (partH) {
|
||||
ctx.textAlign = "right";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(partH, boxX - 8, boxY + boxH / 2);
|
||||
}
|
||||
|
||||
if (partW) {
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "top";
|
||||
ctx.fillText(partW, boxX + boxW / 2, boxY + boxH + 8);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
nodeType.prototype.computeSize = function() {
|
||||
return [230, 160];
|
||||
};
|
||||
|
||||
nodeType.prototype.onResize = function(size) {
|
||||
this.size[0] = Math.max(230, size[0]);
|
||||
this.size[1] = Math.max(160, size[1]);
|
||||
if (typeof this.updateDomPreview === "function") {
|
||||
this.updateDomPreview();
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
api.addEventListener("leafflow_update_preview_aspect_ratio", (event) => {
|
||||
const detail = event.detail;
|
||||
if (!detail || !detail.node_id) return;
|
||||
|
||||
const node = app.graph.getNodeById(detail.node_id);
|
||||
if (node && (node.type === "PreviewImageSizeAspectRatio" || node.type === "TextAspectRatioFinder")) {
|
||||
node.aspect_ratio_data = {
|
||||
ratio: detail.ratio,
|
||||
display_text: detail.display_text
|
||||
};
|
||||
if (typeof node.updateDomPreview === "function") {
|
||||
node.updateDomPreview();
|
||||
}
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
}
|
||||
});
|
||||
@@ -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.lastFlowControlSrc !== src) {
|
||||
imgEl.dataset.lastFlowControlSrc = src;
|
||||
if (src && src.startsWith("blob:") && imgEl.dataset.lastLeafFlowSrc !== src) {
|
||||
imgEl.dataset.lastLeafFlowSrc = 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;
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
|
||||
function countPromptsInText(text, separator, customRegex) {
|
||||
if (!text || !text.trim()) return 0;
|
||||
const cleanText = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
const sep = (separator || ">1 Empty Line").toLowerCase();
|
||||
|
||||
if (sep.includes("regex")) {
|
||||
const patStr = (customRegex || "").trim() || "\\n\\s*\\n+";
|
||||
try {
|
||||
const re = new RegExp(patStr, "g");
|
||||
const rawBlocks = cleanText.split(re);
|
||||
let count = 0;
|
||||
for (const b of rawBlocks) {
|
||||
const s = b.trim();
|
||||
if (s) {
|
||||
try {
|
||||
if (!new RegExp(`^(?:${patStr})$`).test(b)) {
|
||||
count++;
|
||||
}
|
||||
} catch (_) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return count;
|
||||
} catch (e) {
|
||||
// Fallback on invalid regex
|
||||
const rawBlocks = cleanText.split(/\n\s*\n+/);
|
||||
return rawBlocks.filter(b => b.trim()).length;
|
||||
}
|
||||
} else if (sep.includes("newline")) {
|
||||
return cleanText.split('\n').filter(line => line.trim()).length;
|
||||
} else if (sep.includes(">2") || sep.includes("2 empty")) {
|
||||
return cleanText.split(/(?:\n\s*){3,}/).filter(b => b.trim()).length;
|
||||
} else { // >1 Empty Line / >1 Emptyline
|
||||
return cleanText.split(/\n\s*\n+/).filter(b => b.trim()).length;
|
||||
}
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfyUI.LeafFlow.PromptCounter",
|
||||
async nodeCreated(node) {
|
||||
if (node.comfyClass === "PromptCounter") {
|
||||
const textWidget = node.widgets?.find(w => w.name === "text");
|
||||
const sepWidget = node.widgets?.find(w => w.name === "separator");
|
||||
const regexWidget = node.widgets?.find(w => w.name === "custom_regex");
|
||||
|
||||
// Add the live count display widget
|
||||
const countWidget = node.addWidget("text", "🍃 Prompts", "0 Prompts", () => {}, { serialize: false });
|
||||
countWidget.disabled = true;
|
||||
|
||||
// Custom drawing for counter widget
|
||||
countWidget.computeSize = function(width) {
|
||||
return [width, 28];
|
||||
};
|
||||
|
||||
countWidget.draw = function(ctx, n, widget_width, y, widget_height) {
|
||||
ctx.save();
|
||||
const count = n._promptCount ?? 0;
|
||||
const label = `${count} Prompt${count === 1 ? "" : "s"}`;
|
||||
|
||||
const margin = 8;
|
||||
const badgeX = margin;
|
||||
const badgeY = y + 2;
|
||||
const badgeW = widget_width - margin * 2;
|
||||
const badgeH = Math.max(24, widget_height - 4);
|
||||
const radius = 6;
|
||||
|
||||
// Subtle dark background pill
|
||||
ctx.fillStyle = count > 0 ? "rgba(34, 197, 94, 0.15)" : "rgba(255, 255, 255, 0.05)";
|
||||
ctx.strokeStyle = count > 0 ? "rgba(34, 197, 94, 0.5)" : "rgba(255, 255, 255, 0.15)";
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
ctx.beginPath();
|
||||
if (ctx.roundRect) {
|
||||
ctx.roundRect(badgeX, badgeY, badgeW, badgeH, radius);
|
||||
} else {
|
||||
ctx.rect(badgeX, badgeY, badgeW, badgeH);
|
||||
}
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
// Text
|
||||
ctx.font = "bold 13px Inter, system-ui, sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillStyle = count > 0 ? "#4ade80" : "#94a3b8";
|
||||
ctx.fillText(`🍃 ${label}`, badgeX + badgeW / 2, badgeY + badgeH / 2);
|
||||
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
function updateCount() {
|
||||
const text = (textWidget?.inputEl ? textWidget.inputEl.value : textWidget?.value) || "";
|
||||
const sep = sepWidget?.value || ">1 Empty Line";
|
||||
const regex = regexWidget?.value || "";
|
||||
const count = countPromptsInText(text, sep, regex);
|
||||
node._promptCount = count;
|
||||
const label = `${count} Prompt${count === 1 ? "" : "s"}`;
|
||||
countWidget.value = label;
|
||||
|
||||
// Manage custom_regex enable/disable
|
||||
if (regexWidget) {
|
||||
const isCustom = String(sep || "").toLowerCase().includes("regex");
|
||||
regexWidget.disabled = !isCustom;
|
||||
if (regexWidget.inputEl) {
|
||||
regexWidget.inputEl.disabled = !isCustom;
|
||||
regexWidget.inputEl.style.opacity = isCustom ? "1" : "0.4";
|
||||
regexWidget.inputEl.style.pointerEvents = isCustom ? "auto" : "none";
|
||||
}
|
||||
if (regexWidget.options) {
|
||||
regexWidget.options.disabled = !isCustom;
|
||||
}
|
||||
}
|
||||
|
||||
app.graph?.setDirtyCanvas(true, true);
|
||||
}
|
||||
|
||||
// Hook DOM input listener on text textarea
|
||||
function hookInputEl() {
|
||||
if (textWidget?.inputEl && !textWidget.inputEl._promptCounterHooked) {
|
||||
textWidget.inputEl._promptCounterHooked = true;
|
||||
textWidget.inputEl.addEventListener("input", () => {
|
||||
textWidget.value = textWidget.inputEl.value;
|
||||
updateCount();
|
||||
});
|
||||
textWidget.inputEl.addEventListener("paste", () => {
|
||||
setTimeout(() => {
|
||||
textWidget.value = textWidget.inputEl.value;
|
||||
updateCount();
|
||||
}, 20);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (textWidget) {
|
||||
const origTextCb = textWidget.callback;
|
||||
textWidget.callback = function(val) {
|
||||
if (origTextCb) origTextCb.apply(this, arguments);
|
||||
updateCount();
|
||||
};
|
||||
}
|
||||
|
||||
if (sepWidget) {
|
||||
const origSepCb = sepWidget.callback;
|
||||
sepWidget.callback = function(val) {
|
||||
if (origSepCb) origSepCb.apply(this, arguments);
|
||||
updateCount();
|
||||
};
|
||||
}
|
||||
|
||||
if (regexWidget) {
|
||||
const origRegexCb = regexWidget.callback;
|
||||
regexWidget.callback = function(val) {
|
||||
if (origRegexCb) origRegexCb.apply(this, arguments);
|
||||
updateCount();
|
||||
};
|
||||
|
||||
const origMouse = regexWidget.mouse;
|
||||
regexWidget.mouse = function(event, pos, n) {
|
||||
if (this.disabled) return false;
|
||||
if (origMouse) return origMouse.apply(this, arguments);
|
||||
};
|
||||
|
||||
const origDraw = regexWidget.draw;
|
||||
regexWidget.draw = function(ctx, n, widget_width, y, widget_height) {
|
||||
if (this.disabled) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.35;
|
||||
if (origDraw) origDraw.apply(this, arguments);
|
||||
ctx.restore();
|
||||
} else {
|
||||
if (origDraw) origDraw.apply(this, arguments);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Also draw title pill badge when node is collapsed
|
||||
const origOnDrawForeground = node.onDrawForeground;
|
||||
node.onDrawForeground = function(ctx) {
|
||||
hookInputEl();
|
||||
|
||||
// If textWidget value changed without callback
|
||||
const currentVal = (textWidget?.inputEl ? textWidget.inputEl.value : textWidget?.value) || "";
|
||||
if (currentVal !== node._lastPromptVal) {
|
||||
node._lastPromptVal = currentVal;
|
||||
updateCount();
|
||||
}
|
||||
|
||||
if (node.flags?.collapsed) {
|
||||
const count = node._promptCount ?? 0;
|
||||
const label = `${count} Prompt${count === 1 ? "" : "s"}`;
|
||||
ctx.save();
|
||||
ctx.font = "bold 11px Inter, system-ui, sans-serif";
|
||||
const badgeText = `🍃 ${label}`;
|
||||
const tw = ctx.measureText(badgeText).width;
|
||||
const bx = node.size[0] - tw - 16;
|
||||
const by = -LiteGraph.NODE_TITLE_HEIGHT + 3;
|
||||
|
||||
ctx.fillStyle = count > 0 ? "rgba(34, 197, 94, 0.2)" : "rgba(255, 255, 255, 0.08)";
|
||||
ctx.strokeStyle = count > 0 ? "rgba(34, 197, 94, 0.6)" : "rgba(255, 255, 255, 0.2)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
if (ctx.roundRect) {
|
||||
ctx.roundRect(bx, by, tw + 10, 18, 4);
|
||||
} else {
|
||||
ctx.rect(bx, by, tw + 10, 18);
|
||||
}
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = count > 0 ? "#4ade80" : "#cbd5e1";
|
||||
ctx.textAlign = "left";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(badgeText, bx + 5, by + 9);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
if (origOnDrawForeground) origOnDrawForeground.apply(this, arguments);
|
||||
};
|
||||
|
||||
const origOnConfigure = node.onConfigure;
|
||||
node.onConfigure = function() {
|
||||
if (origOnConfigure) origOnConfigure.apply(this, arguments);
|
||||
setTimeout(() => {
|
||||
hookInputEl();
|
||||
updateCount();
|
||||
}, 40);
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
hookInputEl();
|
||||
updateCount();
|
||||
}, 20);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { api } from "/scripts/api.js";
|
||||
import { authenticatedFetch } from "./auth_helper.js";
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfyUI.LeafFlow.PromptQueueIterator",
|
||||
async setup() {
|
||||
if (api && api.addEventListener) {
|
||||
api.addEventListener("leafflow_prompt_iterator_progress", (event) => {
|
||||
const data = event.detail;
|
||||
if (!data || !data.node_id) return;
|
||||
const node = app.graph?.getNodeById(data.node_id);
|
||||
if (node && node.widgets) {
|
||||
const statusWidget = node.widgets.find(w => w.name === "🍃 Progress");
|
||||
if (statusWidget) {
|
||||
statusWidget.value = `🍃 Run ${data.current_run} / ${data.total_runs}`;
|
||||
app.graph?.setDirtyCanvas(true, true);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
async nodeCreated(node) {
|
||||
if (node.comfyClass === "PromptQueueIterator") {
|
||||
// 1. Live Progress Status Widget
|
||||
const statusWidget = node.addWidget("text", "🍃 Progress", "🍃 Ready (-- / --)", () => {}, { serialize: false });
|
||||
statusWidget.disabled = true;
|
||||
|
||||
// 2. Reset Counter (0) Button
|
||||
const resetBtn = node.addWidget("button", "🔄 Reset Counter (0)", null, async () => {
|
||||
try {
|
||||
resetBtn.name = "⏳ Resetting...";
|
||||
app.graph?.setDirtyCanvas(true, true);
|
||||
const resp = await authenticatedFetch("/leafflow/prompt_iterator/reset_node", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ node_id: String(node.id) })
|
||||
});
|
||||
const res = await resp.json();
|
||||
if (res && res.status === "ok") {
|
||||
resetBtn.name = "✅ Reset to 0!";
|
||||
statusWidget.value = "🍃 Reset (0 / --)";
|
||||
} else {
|
||||
resetBtn.name = "⚠️ Error";
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[LeafFlow] 🍃 Error resetting counter:", e);
|
||||
resetBtn.name = "❌ Failed";
|
||||
}
|
||||
setTimeout(() => {
|
||||
resetBtn.name = "🔄 Reset Counter (0)";
|
||||
app.graph?.setDirtyCanvas(true, true);
|
||||
}, 1200);
|
||||
});
|
||||
resetBtn.serialize = false;
|
||||
|
||||
// 3. Open Queue File in Default OS Editor Button
|
||||
const openFileBtn = node.addWidget("button", "📂 Open Queue File", null, async () => {
|
||||
try {
|
||||
openFileBtn.name = "⏳ Opening...";
|
||||
app.graph?.setDirtyCanvas(true, true);
|
||||
const resp = await authenticatedFetch("/leafflow/prompt_iterator/open_file", { method: "POST" });
|
||||
const res = await resp.json();
|
||||
if (res && res.status === "ok") {
|
||||
openFileBtn.name = "✅ Opened in Editor!";
|
||||
} else {
|
||||
openFileBtn.name = "⚠️ Error";
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[LeafFlow] 🍃 Error opening state file:", e);
|
||||
openFileBtn.name = "❌ Failed";
|
||||
}
|
||||
setTimeout(() => {
|
||||
openFileBtn.name = "📂 Open Queue File";
|
||||
app.graph?.setDirtyCanvas(true, true);
|
||||
}, 1500);
|
||||
});
|
||||
// 4. Manage custom_regex enable/disable based on separator
|
||||
const sepWidget = node.widgets?.find(w => w.name === "separator");
|
||||
const regexWidget = node.widgets?.find(w => w.name === "custom_regex");
|
||||
|
||||
function updateRegexState() {
|
||||
if (!sepWidget || !regexWidget) return;
|
||||
const isCustom = String(sepWidget.value || "").toLowerCase().includes("regex");
|
||||
regexWidget.disabled = !isCustom;
|
||||
if (regexWidget.inputEl) {
|
||||
regexWidget.inputEl.disabled = !isCustom;
|
||||
regexWidget.inputEl.style.opacity = isCustom ? "1" : "0.4";
|
||||
regexWidget.inputEl.style.pointerEvents = isCustom ? "auto" : "none";
|
||||
}
|
||||
if (regexWidget.options) {
|
||||
regexWidget.options.disabled = !isCustom;
|
||||
}
|
||||
app.graph?.setDirtyCanvas(true, true);
|
||||
}
|
||||
|
||||
if (sepWidget && regexWidget) {
|
||||
const origSepCb = sepWidget.callback;
|
||||
sepWidget.callback = function(val) {
|
||||
if (origSepCb) origSepCb.apply(this, arguments);
|
||||
updateRegexState();
|
||||
};
|
||||
|
||||
const origMouse = regexWidget.mouse;
|
||||
regexWidget.mouse = function(event, pos, node) {
|
||||
if (this.disabled) return false;
|
||||
if (origMouse) return origMouse.apply(this, arguments);
|
||||
};
|
||||
|
||||
const origDraw = regexWidget.draw;
|
||||
regexWidget.draw = function(ctx, node, widget_width, y, widget_height) {
|
||||
if (this.disabled) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.35;
|
||||
if (origDraw) origDraw.apply(this, arguments);
|
||||
ctx.restore();
|
||||
} else {
|
||||
if (origDraw) origDraw.apply(this, arguments);
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(updateRegexState, 20);
|
||||
}
|
||||
|
||||
const origOnConfigure = node.onConfigure;
|
||||
node.onConfigure = function() {
|
||||
if (origOnConfigure) origOnConfigure.apply(this, arguments);
|
||||
setTimeout(updateRegexState, 40);
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
|
||||
// Vibrant Leaf Green palette matching the 🍃 emoji
|
||||
const LEAF_GREEN = { color: "#16a34a", bgcolor: "#14532d" };
|
||||
const LEAF_LIGHT = { color: "#22c55e", bgcolor: "#166534" };
|
||||
const LEAF_EMERALD = { color: "#059669", bgcolor: "#064e3b" };
|
||||
|
||||
const COLOR_MAP = {
|
||||
// Visual Loaders
|
||||
"VisualLoraLoader": LEAF_GREEN,
|
||||
"FolderLoraLoader": LEAF_GREEN,
|
||||
"FolderLoraLoaderPretty": LEAF_GREEN,
|
||||
"VisualImageLoader": LEAF_GREEN,
|
||||
"LoadRecentOutputs": LEAF_GREEN,
|
||||
|
||||
// Automation, Flow & Utilities
|
||||
"LoadImageFromFolder": LEAF_LIGHT,
|
||||
"TextAspectRatioFinder": LEAF_LIGHT,
|
||||
"PreviewImageSizeAspectRatio": LEAF_LIGHT,
|
||||
"TextLoraFinder": LEAF_LIGHT,
|
||||
"PromptQueueIterator": LEAF_LIGHT,
|
||||
"PromptCounter": LEAF_LIGHT,
|
||||
"MultiTextReplacer": LEAF_LIGHT,
|
||||
"LeafFlowTextSplit": LEAF_LIGHT,
|
||||
"LeafFlowDecision": LEAF_LIGHT,
|
||||
|
||||
// Queue & Previews
|
||||
"PreviewLatentLive": LEAF_EMERALD,
|
||||
"PauseQueueNode": LEAF_EMERALD,
|
||||
"PersistentQueueNode": LEAF_EMERALD
|
||||
};
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfyUI.LeafFlow.Colors",
|
||||
async nodeCreated(node) {
|
||||
const enabled = app.ui.settings.getSettingValue(
|
||||
"LeafFlow.1. 🖼️ Visual Loaders.00_EnableCustomColors",
|
||||
true
|
||||
);
|
||||
if (!enabled) return;
|
||||
|
||||
if (node && node.type && COLOR_MAP[node.type]) {
|
||||
const theme = COLOR_MAP[node.type];
|
||||
node.color = theme.color;
|
||||
node.bgcolor = theme.bgcolor;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,674 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { api } from "/scripts/api.js";
|
||||
import { authenticatedFetch } from "./js/auth_helper.js";
|
||||
|
||||
async function postLeafFlowSettings(bodyObj) {
|
||||
try {
|
||||
return await authenticatedFetch("/leafflow/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(bodyObj)
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn("[LeafFlow Settings] Failed to save setting:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Inject CSS to ensure settings buttons look distinct, styled with an emerald theme, and never get text cut off
|
||||
if (typeof document !== "undefined") {
|
||||
const styleId = "leafflow-settings-button-styles";
|
||||
if (!document.getElementById(styleId)) {
|
||||
const style = document.createElement("style");
|
||||
style.id = styleId;
|
||||
style.textContent = `
|
||||
/* ComfyUI V2 Vue & LiteGraph Settings Button Styling */
|
||||
button.leafflow-settings-btn,
|
||||
tr:has([id*="LeafFlow"]) button,
|
||||
tr:has([id*="leafflow"]) button,
|
||||
div:has(> [id*="LeafFlow"]) button,
|
||||
div:has(> [id*="leafflow"]) button,
|
||||
[data-setting-id*="LeafFlow"] button,
|
||||
div[class*="setting"]:has(span:contains("LeafFlow")) button {
|
||||
background: linear-gradient(135deg, #059669 0%, #047857 100%) !important;
|
||||
border: 1px solid #10b981 !important;
|
||||
color: #ffffff !important;
|
||||
font-weight: 600 !important;
|
||||
font-size: 12px !important;
|
||||
padding: 6px 14px !important;
|
||||
border-radius: 6px !important;
|
||||
width: auto !important;
|
||||
min-width: max-content !important;
|
||||
max-width: none !important;
|
||||
white-space: nowrap !important;
|
||||
overflow: visible !important;
|
||||
text-overflow: clip !important;
|
||||
cursor: pointer !important;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.15) !important;
|
||||
transition: all 0.2s ease !important;
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
}
|
||||
|
||||
button.leafflow-settings-btn:hover,
|
||||
tr:has([id*="LeafFlow"]) button:hover,
|
||||
div:has(> [id*="LeafFlow"]) button:hover,
|
||||
[data-setting-id*="LeafFlow"] button:hover {
|
||||
background: linear-gradient(135deg, #047857 0%, #059669 100%) !important;
|
||||
border-color: #34d399 !important;
|
||||
color: #ffffff !important;
|
||||
box-shadow: 0 0 10px rgba(16, 185, 129, 0.45) !important;
|
||||
transform: translateY(-1px) !important;
|
||||
}
|
||||
|
||||
button.leafflow-settings-btn:active,
|
||||
tr:has([id*="LeafFlow"]) button:active {
|
||||
transform: translateY(0) !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
}
|
||||
|
||||
// Global Non-Fatal Warning & Native Node Error Highlighter
|
||||
try {
|
||||
api.addEventListener("leafflow_node_error_state", (e) => {
|
||||
const { node_id, title, message, fallback } = e.detail || {};
|
||||
if (node_id && app.graph) {
|
||||
const targetNode = app.graph.getNodeById(Number(node_id)) || app.graph.getNodeById(String(node_id));
|
||||
if (targetNode) {
|
||||
targetNode.has_errors = true;
|
||||
targetNode.bgcolor = "#7f1d1d";
|
||||
targetNode.color = "#ef4444";
|
||||
targetNode.setDirtyCanvas(true, true);
|
||||
if (typeof targetNode.onExecutionError === "function") {
|
||||
targetNode.onExecutionError(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (app.extensionManager?.toast?.add) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: "warn",
|
||||
summary: `🍃 ${title || "LeafFlow"}`,
|
||||
detail: `${message} (Auto-resolved with ${fallback})`,
|
||||
life: 6000
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
api.addEventListener("leafflow_toast", (e) => {
|
||||
const data = e.detail || {};
|
||||
const title = data.title || "LeafFlow Notice";
|
||||
const message = data.message || "";
|
||||
const type = data.type || "warn";
|
||||
if (app.extensionManager?.toast?.add) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: type === "error" ? "error" : "warn",
|
||||
summary: `🍃 ${title}`,
|
||||
detail: message,
|
||||
life: 6000
|
||||
});
|
||||
} else if (app.ui?.dialog) {
|
||||
console.warn(`[LeafFlow] ${title}: ${message}`);
|
||||
}
|
||||
});
|
||||
} catch (_) {}
|
||||
|
||||
/**
|
||||
* Custom renderer for real, styled action buttons in ComfyUI Settings Modal.
|
||||
*/
|
||||
function renderSettingButton(label, workingText, successText, onClickHandler) {
|
||||
return (name, setter, value) => {
|
||||
const container = document.createElement("div");
|
||||
container.style.cssText = "display: flex; align-items: center; justify-content: flex-end; width: 100%; padding: 2px 0;";
|
||||
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.textContent = label;
|
||||
btn.className = "p-button leafflow-settings-btn";
|
||||
btn.style.cssText = `
|
||||
padding: 6px 14px !important;
|
||||
background: linear-gradient(135deg, #059669 0%, #047857 100%) !important;
|
||||
color: #ffffff !important;
|
||||
border: 1px solid #10b981 !important;
|
||||
border-radius: 6px !important;
|
||||
font-weight: 600 !important;
|
||||
font-size: 12px !important;
|
||||
cursor: pointer !important;
|
||||
transition: all 0.2s ease !important;
|
||||
outline: none !important;
|
||||
min-width: max-content !important;
|
||||
width: auto !important;
|
||||
white-space: nowrap !important;
|
||||
text-align: center !important;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.4) !important;
|
||||
`;
|
||||
|
||||
btn.onmouseover = () => {
|
||||
if (!btn.disabled) {
|
||||
btn.style.background = "#047857";
|
||||
btn.style.borderColor = "#34d399";
|
||||
btn.style.boxShadow = "0 0 10px rgba(16, 185, 129, 0.45)";
|
||||
}
|
||||
};
|
||||
btn.onmouseout = () => {
|
||||
if (!btn.disabled) {
|
||||
btn.style.background = "linear-gradient(135deg, #059669 0%, #047857 100%)";
|
||||
btn.style.borderColor = "#10b981";
|
||||
btn.style.boxShadow = "0 1px 3px rgba(0,0,0,0.4)";
|
||||
}
|
||||
};
|
||||
|
||||
btn.onclick = async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
btn.disabled = true;
|
||||
btn.style.opacity = "0.7";
|
||||
btn.textContent = workingText || "⏳ Working...";
|
||||
try {
|
||||
await onClickHandler();
|
||||
btn.textContent = successText || "✅ Done!";
|
||||
btn.style.background = "#1b5e20";
|
||||
btn.style.borderColor = "#2e7d32";
|
||||
btn.style.color = "#ffffff";
|
||||
} catch (err) {
|
||||
console.error("[LeafFlow] Button action failed:", err);
|
||||
btn.textContent = "❌ Error";
|
||||
btn.style.background = "#c62828";
|
||||
btn.style.borderColor = "#e53935";
|
||||
}
|
||||
setTimeout(() => {
|
||||
btn.textContent = label;
|
||||
btn.disabled = false;
|
||||
btn.style.opacity = "1";
|
||||
btn.style.background = "linear-gradient(135deg, #059669 0%, #047857 100%)";
|
||||
btn.style.borderColor = "#10b981";
|
||||
}, 1800);
|
||||
};
|
||||
|
||||
container.appendChild(btn);
|
||||
return container;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatic Settings Migration:
|
||||
* Seamlessly copies over existing user settings from older LeafFlow setting ID formats
|
||||
* so users never lose their saved API keys, tokens, or preferences.
|
||||
*/
|
||||
function migrateSavedSettings() {
|
||||
try {
|
||||
if (typeof localStorage === "undefined") return;
|
||||
const keysToMigrate = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const k = localStorage.key(i);
|
||||
if (k && (k.includes("LeafFlow.") || k.includes("leafflow."))) {
|
||||
keysToMigrate.push(k);
|
||||
}
|
||||
}
|
||||
for (const oldKey of keysToMigrate) {
|
||||
const val = localStorage.getItem(oldKey);
|
||||
if (val === null) continue;
|
||||
// Convert dot-numbered formats like "LeafFlow.1. 🖼️..." -> "LeafFlow.1 - 🖼️..."
|
||||
const newKey = oldKey.replace(/LeafFlow\.(\d+)\.\s+/g, "LeafFlow.$1 - ");
|
||||
if (newKey !== oldKey && localStorage.getItem(newKey) === null) {
|
||||
localStorage.setItem(newKey, val);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[LeafFlow] Settings migration skipped:", e);
|
||||
}
|
||||
}
|
||||
migrateSavedSettings();
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfyUI.LeafFlow.Settings",
|
||||
async setup() {
|
||||
migrateSavedSettings();
|
||||
|
||||
// =========================================================================
|
||||
// GRUPPE 1: 1 - 🖼️ Visual Loaders
|
||||
// =========================================================================
|
||||
|
||||
// 1.0 Custom Node Colors Toggle
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.1 - 🖼️ Visual Loaders.00_EnableCustomColors",
|
||||
name: "Enable Custom LeafFlow Node Colors",
|
||||
type: "boolean",
|
||||
defaultValue: true,
|
||||
tooltip: "Applies a fresh Leaf Green color theme to LeafFlow nodes on the canvas. When disabled, nodes use default ComfyUI colors.",
|
||||
});
|
||||
|
||||
// 1.1 Civitai API Key
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.1 - 🖼️ Visual Loaders.01_CivitaiApiKey",
|
||||
name: "Civitai API Key",
|
||||
type: "text",
|
||||
defaultValue: "",
|
||||
tooltip: "Optional. Civitai SHA256 search works publicly without a key for normal models. Only needed for NSFW/private models or higher rate limits. Whitespace is automatically stripped.",
|
||||
onChange(value) {
|
||||
const cleanKey = (value || "").trim();
|
||||
postLeafFlowSettings({ civitai_api_key: cleanKey });
|
||||
}
|
||||
});
|
||||
|
||||
// 1.2 Enable Civitai Auto-Scraping Toggle (default: true)
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.1 - 🖼️ Visual Loaders.02_EnableCivitaiScraping",
|
||||
name: "Enable Civitai Auto-Scraping",
|
||||
type: "boolean",
|
||||
defaultValue: true,
|
||||
tooltip: "Toggles automated downloading of preview thumbnails for new LoRAs from Civitai via SHA256 file hashes. Note: SHA256 hash searching will always work regardless of this setting when matching local models.",
|
||||
onChange(value) {
|
||||
postLeafFlowSettings({ enable_civitai_scraping: value ? "true" : "false" });
|
||||
}
|
||||
});
|
||||
|
||||
// 1.3 TMDB Access Token
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.1 - 🖼️ Visual Loaders.03_TMDBApiKey",
|
||||
name: "TMDB Access Token",
|
||||
type: "text",
|
||||
defaultValue: "",
|
||||
tooltip: "Optional. Accepts TMDB v3 API keys or TMDB v4 Read Access Tokens (eyJ...). Used for celebrity poster and preview image lookup. Whitespace is automatically stripped.",
|
||||
onChange(value) {
|
||||
const cleanKey = (value || "").trim();
|
||||
postLeafFlowSettings({ tmdb_api_key: cleanKey });
|
||||
}
|
||||
});
|
||||
|
||||
// 1.4 Enable TMDB Auto-Scraping Toggle (default: false)
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.1 - 🖼️ Visual Loaders.04_EnableTMDBScraping",
|
||||
name: "Enable TMDB Auto-Scraping",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
tooltip: "Toggles automated downloading of celebrity preview thumbnails from TMDB. Default is disabled.",
|
||||
onChange(value) {
|
||||
postLeafFlowSettings({ enable_tmdb_scraping: value ? "true" : "false" });
|
||||
}
|
||||
});
|
||||
|
||||
// 1.5 Enable LoRA Usage Tracking (default: true)
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.1 - 🖼️ Visual Loaders.05_EnableLoraUsage",
|
||||
name: "Enable LoRA Usage Tracking",
|
||||
type: "boolean",
|
||||
defaultValue: true,
|
||||
tooltip: "Toggles tracking and displaying LoRA usage counts & visual rank badges (🔥, Gold, Silver, Bronze) in the LoRA picker. Existing usage history is preserved when disabled.",
|
||||
onChange(value) {
|
||||
postLeafFlowSettings({ enable_lora_usage: value ? "true" : "false" });
|
||||
}
|
||||
});
|
||||
|
||||
// 1.6 Reset Scrapes Cache Button
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.1 - 🖼️ Visual Loaders.06_ResetScrapesCache",
|
||||
name: "Reset Failed Scrapes Cache",
|
||||
type: "button",
|
||||
defaultValue: "🗑️ Clear Scrapes Cache",
|
||||
tooltip: "Immediately clears failed_scrapes.json so Civitai and TMDB can retry downloading missing preview thumbnails on the next folder scan.",
|
||||
attrs: {
|
||||
className: "leafflow-settings-btn",
|
||||
class: "leafflow-settings-btn",
|
||||
onClick: async () => {
|
||||
try {
|
||||
const resp = await authenticatedFetch("/leafflow/scrapes/clear", { method: "POST" });
|
||||
const data = await resp.json();
|
||||
if (data && data.status === "ok") {
|
||||
alert("LeafFlow: Failed scrapes cache successfully reset!");
|
||||
} else {
|
||||
alert("LeafFlow: Failed to reset cache.");
|
||||
}
|
||||
} catch (err) {
|
||||
alert("LeafFlow: Error resetting cache: " + err);
|
||||
}
|
||||
}
|
||||
},
|
||||
render: renderSettingButton("🗑️ Clear Scrapes Cache", "⏳ Clearing...", "✅ Cache Reset!", async () => {
|
||||
const resp = await authenticatedFetch("/leafflow/scrapes/clear", { method: "POST" });
|
||||
const data = await resp.json();
|
||||
if (data.status !== "ok") {
|
||||
throw new Error(data.message || "Failed to reset scrapes cache");
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
// =========================================================================
|
||||
// GRUPPE 2: 2 - 🔄 Prompt Queue Iterator
|
||||
// =========================================================================
|
||||
|
||||
// 2.1 Clear Prompt Iterator State on Launch (default: false)
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.2 - 🔄 Prompt Iterator.01_ClearOnLaunch",
|
||||
name: "Clear State on Launch",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
tooltip: "Privacy setting. When enabled, prompt_iterator_state.json will be emptied automatically every time ComfyUI starts up.",
|
||||
onChange(value) {
|
||||
postLeafFlowSettings({ clear_prompt_iterator_on_launch: value ? "true" : "false" });
|
||||
}
|
||||
});
|
||||
|
||||
// 2.2 Reset Prompt Iterator Queues Now
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.2 - 🔄 Prompt Iterator.02_ResetActiveQueues",
|
||||
name: "Reset Active Queues State",
|
||||
type: "button",
|
||||
defaultValue: "🔄 Reset All Queues",
|
||||
tooltip: "Immediately empties all active prompt queues and resets iterator state across all workflows.",
|
||||
attrs: {
|
||||
className: "leafflow-settings-btn",
|
||||
class: "leafflow-settings-btn",
|
||||
onClick: async () => {
|
||||
try {
|
||||
const resp = await authenticatedFetch("/leafflow/prompt_iterator/clear", { method: "POST" });
|
||||
const data = await resp.json();
|
||||
if (data && data.status === "ok") {
|
||||
alert("LeafFlow: Prompt Iterator queues successfully reset!");
|
||||
} else {
|
||||
alert("LeafFlow: Failed to reset queues.");
|
||||
}
|
||||
} catch (err) {
|
||||
alert("LeafFlow: Error resetting queues: " + err);
|
||||
}
|
||||
}
|
||||
},
|
||||
render: renderSettingButton("🔄 Reset All Queues", "⏳ Resetting...", "✅ State Reset!", async () => {
|
||||
const resp = await authenticatedFetch("/leafflow/prompt_iterator/clear", { method: "POST" });
|
||||
const data = await resp.json();
|
||||
if (data.status !== "ok") {
|
||||
throw new Error(data.message || "Failed to clear prompt iterator state");
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
// =========================================================================
|
||||
// GRUPPE 3: 3 - 📋 Prompt Actions
|
||||
// =========================================================================
|
||||
|
||||
// 3.1 Show "Copy Prompt" Button on Image Overlays
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.3 - 📋 Prompt Actions.01_EnableAssetsCopyPromptButton",
|
||||
name: "Show \"Copy Prompt\" Button on Images",
|
||||
type: "boolean",
|
||||
defaultValue: true,
|
||||
tooltip: "Shows the 📋 'Copy Prompt' overlay action button when hovering over generated images in the Assets / History pane and preview nodes.",
|
||||
});
|
||||
|
||||
// 3.2 Show Right-Click "Copy Prompt" Menu Action
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.3 - 📋 Prompt Actions.02_EnableContextMenuCopyPrompt",
|
||||
name: "Show Right-Click \"Copy Prompt\" Menu Action",
|
||||
type: "boolean",
|
||||
defaultValue: true,
|
||||
tooltip: "Adds '📋 Copy Prompt' to node right-click context menus.",
|
||||
});
|
||||
|
||||
// 3.3 Show "Save to Prompt Bookmarks" Button on Images & Context Menu
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.3 - 📋 Prompt Actions.03_EnableSaveToPromptSaver",
|
||||
name: "Show \"Save to Prompt Saver\" Action",
|
||||
type: "boolean",
|
||||
defaultValue: true,
|
||||
tooltip: "Adds 🔖 'Save to Prompt Bookmarks' to image hover overlay bars and node context menus.",
|
||||
});
|
||||
|
||||
// 3.4 Show "Inspect Asset" (Zoom) Button on Image Overlays
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.3 - 📋 Prompt Actions.04_EnableInspectAssetButton",
|
||||
name: "Show \"Inspect Asset\" (Zoom) Button on Images",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
tooltip: "Restores the 🔍 'Inspect asset' (zoom in) button directly on image cards in the Assets pane next to Download, Copy Prompt, and Bookmarks (moved behind the 3-dots menu in newer ComfyUI versions).",
|
||||
});
|
||||
|
||||
|
||||
// =========================================================================
|
||||
// GRUPPE 4: 4 - ⏸️ Pause & Resume Controls
|
||||
// =========================================================================
|
||||
|
||||
// 4.1 Default Pause Queue State on Launch
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.4 - ⏸️ Pause Controls.01_DefaultStateOnLaunch",
|
||||
name: "Default State on Launch",
|
||||
type: "combo",
|
||||
options: ["Paused", "Running"],
|
||||
defaultValue: "Paused",
|
||||
tooltip: "Choose whether execution starts in Paused state or Running state on ComfyUI startup.",
|
||||
onChange(value) {
|
||||
postLeafFlowSettings({ default_pause_state: value });
|
||||
}
|
||||
});
|
||||
|
||||
// 4.2 Default Pause Queue Mode on Launch
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.4 - ⏸️ Pause Controls.02_DefaultPauseAction",
|
||||
name: "Default Pause Action",
|
||||
type: "combo",
|
||||
options: ["Finish Active Prompt", "Instant Resume Node"],
|
||||
defaultValue: "Finish Active Prompt",
|
||||
tooltip: "Choose default pause behavior when the pause button or hotkey is triggered.",
|
||||
onChange(value) {
|
||||
const modeKey = (value === "Instant Resume Node" || value === "Pause (Instant)") ? "instantly" : "after_finish";
|
||||
postLeafFlowSettings({ default_pause_mode: modeKey });
|
||||
}
|
||||
});
|
||||
|
||||
// 4.3 Enable Pause Queue Toolbar Button
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.4 - ⏸️ Pause Controls.03_EnableToolbarButton",
|
||||
name: "Enable Top Toolbar Button",
|
||||
type: "boolean",
|
||||
defaultValue: true,
|
||||
tooltip: "Displays the green/orange Pause & Continue button group in the top action bar.",
|
||||
onChange(value) {
|
||||
const group = document.querySelector(".pq-button-group");
|
||||
if (group) {
|
||||
group.style.display = value ? "inline-flex" : "none";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 4.4 Toolbar Button Unpaused Color
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.4 - ⏸️ Pause Controls.04_ToolbarButtonUnpausedColor",
|
||||
name: "Toolbar Button Unpaused Color",
|
||||
type: "text",
|
||||
defaultValue: "#16a34a",
|
||||
tooltip: "Hex color code for the Pause toolbar button when execution is unpaused/running (default: #16a34a).",
|
||||
onChange(value) {
|
||||
document.documentElement.style.setProperty("--pq-unpaused-color", value || "#16a34a");
|
||||
}
|
||||
});
|
||||
|
||||
// 4.5 Toolbar Button Paused Color
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.4 - ⏸️ Pause Controls.05_ToolbarButtonPausedColor",
|
||||
name: "Toolbar Button Paused Color",
|
||||
type: "text",
|
||||
defaultValue: "#ea580c",
|
||||
tooltip: "Hex color code for the Pause toolbar button when execution is paused (default: #ea580c).",
|
||||
onChange(value) {
|
||||
document.documentElement.style.setProperty("--pq-paused-color", value || "#ea580c");
|
||||
}
|
||||
});
|
||||
|
||||
// 4.6 Enable System Tray Icon
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.4 - ⏸️ Pause Controls.06_EnableTrayIcon",
|
||||
name: "Enable System Tray Icon",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
tooltip: "Displays an OS system tray icon with real-time queue status colors and outside-browser queue controls.",
|
||||
onChange(value) {
|
||||
postLeafFlowSettings({ enable_tray_icon: value ? "true" : "false" });
|
||||
}
|
||||
});
|
||||
|
||||
// 4.7 Allow Process Management (Restart / Shutdown)
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.4 - ⏸️ Pause Controls.07_AllowProcessManagement",
|
||||
name: "Allow Process Management (Restart / Shutdown)",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
tooltip: "Enables server restart and shutdown actions from the LeafFlow power controls. Disabled by default for security.",
|
||||
onChange(value) {
|
||||
postLeafFlowSettings({ enable_process_management: value ? "true" : "false" });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// =========================================================================
|
||||
// GRUPPE 5: 5 - 💾 Persistent Queue (Auto-Recovery)
|
||||
// =========================================================================
|
||||
|
||||
// 5.1 Enable Persistent Queue (Auto-Recovery)
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.5 - 💾 Persistent Queue.01_EnablePersistentQueue",
|
||||
name: "Enable Persistent Queue (Auto-Recovery)",
|
||||
type: "boolean",
|
||||
defaultValue: true,
|
||||
tooltip: "Automatically persists unfinished batch queue items to disk and restores them after server or browser crashes.",
|
||||
onChange(value) {
|
||||
postLeafFlowSettings({ enable_persistent_queue: value ? "true" : "false" });
|
||||
}
|
||||
});
|
||||
|
||||
// 5.2 Persistent Queue Restored Launch State
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.5 - 💾 Persistent Queue.02_RecoveryLaunchState",
|
||||
name: "Recovery Launch State",
|
||||
type: "combo",
|
||||
options: ["Match Default", "Force Paused", "Force Running"],
|
||||
defaultValue: "Match Default",
|
||||
tooltip: "Override launch state when unfinished queue items are recovered on startup.",
|
||||
onChange(value) {
|
||||
postLeafFlowSettings({ persistent_queue_restored_state: value });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// =========================================================================
|
||||
// GRUPPE 6: 6 - 🖼️ Assets & History Restore
|
||||
// =========================================================================
|
||||
|
||||
// 6.1 Enable Assets / History Restore on Launch
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.6 - 🖼️ Assets Restore.01_RestoreAssetsOnLaunch",
|
||||
name: "Restore Assets on Launch",
|
||||
type: "boolean",
|
||||
defaultValue: true,
|
||||
tooltip: "Automatically populates the Assets / History pane upon ComfyUI launch with your latest generated images.",
|
||||
onChange(value) {
|
||||
postLeafFlowSettings({ enable_assets_restore: value ? "true" : "false" });
|
||||
}
|
||||
});
|
||||
|
||||
// 6.2 Restored Assets Count
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.6 - 🖼️ Assets Restore.02_RestoredAssetsCount",
|
||||
name: "Restored Assets Count",
|
||||
type: "number",
|
||||
defaultValue: 64,
|
||||
tooltip: "Number of newest images from the output folder to restore into the Assets / History pane on launch (default: 64).",
|
||||
onChange(value) {
|
||||
const count = parseInt(value, 10) || 64;
|
||||
postLeafFlowSettings({ restore_assets_count: count });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// =========================================================================
|
||||
// GRUPPE 7: 7 - 🩺 Diagnostics & Debug
|
||||
// =========================================================================
|
||||
|
||||
// 7.1 Export Debug Profile Button
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.7 - 🩺 Diagnostics.01_ExportDebugProfile",
|
||||
name: "Export Debug Profile",
|
||||
type: "button",
|
||||
defaultValue: "📥 Export Debug Profile",
|
||||
tooltip: "Exports non-sensitive environment diagnostics (OS, Python, PyTorch, LeafFlow settings, local counts) as a JSON file to share when troubleshooting issues.",
|
||||
attrs: {
|
||||
className: "leafflow-settings-btn",
|
||||
class: "leafflow-settings-btn",
|
||||
onClick: async () => {
|
||||
const approved = confirm("🍃 ComfyUI-LeafFlow Diagnostics Export\n\nExport system diagnostics for troubleshooting?\n\nNOTE: Sensitive API keys, tokens, file paths, and private prompt texts are automatically stripped and NEVER exported.");
|
||||
if (!approved) return;
|
||||
|
||||
try {
|
||||
const resp = await api.fetchApi("/leafflow/debug/export");
|
||||
const data = await resp.json();
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `leafflow_debug_profile_${new Date().toISOString().slice(0, 10)}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
alert("✅ Diagnostics profile exported successfully! You can attach the downloaded JSON file to your bug report or GitHub issue.");
|
||||
} catch (e) {
|
||||
console.error("[LeafFlow] 🍃 Error exporting debug profile:", e);
|
||||
alert("❌ Failed to export debug profile: " + e.message);
|
||||
}
|
||||
}
|
||||
},
|
||||
render: renderSettingButton("📥 Export Debug Profile", "⏳ Exporting...", "✅ Exported!", async () => {
|
||||
const resp = await api.fetchApi("/leafflow/debug/export");
|
||||
const data = await resp.json();
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `leafflow_debug_profile_${new Date().toISOString().slice(0, 10)}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
})
|
||||
});
|
||||
// =========================================================================
|
||||
// GRUPPE 8: 8 - 🎨 Batch Queue Visuals
|
||||
// =========================================================================
|
||||
|
||||
// 8.1 Enable Batch Queue Grouping Lines
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.BatchQueue.Enabled",
|
||||
name: "Show Batch Queue 1D Lines",
|
||||
type: "boolean",
|
||||
defaultValue: true,
|
||||
tooltip: "Renders 1D git-graph style colored lines indicating batch groupings and contiguous segments on queued items."
|
||||
});
|
||||
|
||||
// 8.2 Batch Graph Snapshot Guard
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.BatchQueue.SnapshotGuard",
|
||||
name: "Batch Graph Snapshot Guard",
|
||||
type: "boolean",
|
||||
defaultValue: true,
|
||||
tooltip: "When queueing multi-item batches, snapshots prompt and node inputs (like text and LoRAs) in the background so mid-queue canvas edits do not corrupt queued items. Random seeds continue to randomize."
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// GRUPPE 9: 9 - 🛡️ Security & Script Execution
|
||||
// =========================================================================
|
||||
|
||||
// 9.1 Allow Local File Execution (Default Disabled)
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.9 - 🛡️ Security.01_AllowLocalFileExecution",
|
||||
name: "Allow Local File Execution",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
tooltip: "Controls whether the '🍃 ⚡ Run Local File' node is permitted to run executable files (.bat, .ps1, .exe, .sh). Disabled by default for operator security. When enabled, scripts are strictly confined to the 'ComfyUI/scripts/' directory. Execution is blocked if this setting is disabled or if the node is not interactively authorized.",
|
||||
onChange(value) {
|
||||
postLeafFlowSettings({ enable_local_file_execution: value ? "true" : "false" });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
+14
-1
@@ -1,4 +1,4 @@
|
||||
/* FlowControl PauseQueue CSS */
|
||||
/* LeafFlow PauseQueue CSS */
|
||||
|
||||
:root {
|
||||
--pq-unpaused-color: #059669;
|
||||
@@ -25,6 +25,19 @@
|
||||
animation: pq-pulse-orange 2s infinite;
|
||||
}
|
||||
|
||||
.pq-button-group.litegraph-mode {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
margin: 4px 0;
|
||||
height: 30px;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.pq-button-group.litegraph-mode .pq-main-btn,
|
||||
.pq-button-group.litegraph-mode .pq-dropdown-btn {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.pq-main-btn {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
|
||||
+45
-27
@@ -1,5 +1,6 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { api } from "/scripts/api.js";
|
||||
import { authenticatedFetch } from "./js/auth_helper.js";
|
||||
|
||||
const stylePath = new URL('./pause_queue.css', import.meta.url).href;
|
||||
if (!document.querySelector(`link[href="${stylePath}"]`)) {
|
||||
@@ -38,7 +39,7 @@ class PauseQueueUI {
|
||||
this.updateState(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[FlowControl] Error fetching status:", e);
|
||||
console.error("[LeafFlow] Error fetching status:", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +58,7 @@ class PauseQueueUI {
|
||||
|
||||
async togglePause(paused) {
|
||||
try {
|
||||
const res = await api.fetchApi("/pause_queue/toggle", {
|
||||
const res = await authenticatedFetch("/pause_queue/toggle", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ paused, mode: this.state.mode })
|
||||
@@ -67,13 +68,13 @@ class PauseQueueUI {
|
||||
this.updateState(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[FlowControl] Error toggling pause:", e);
|
||||
console.error("[LeafFlow] Error toggling pause:", e);
|
||||
}
|
||||
}
|
||||
|
||||
async setMode(mode) {
|
||||
try {
|
||||
const res = await api.fetchApi("/pause_queue/mode", {
|
||||
const res = await authenticatedFetch("/pause_queue/mode", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mode })
|
||||
@@ -83,56 +84,73 @@ class PauseQueueUI {
|
||||
this.updateState(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[FlowControl] Error setting mode:", e);
|
||||
console.error("[LeafFlow] Error setting mode:", e);
|
||||
}
|
||||
}
|
||||
|
||||
async continueQueue() {
|
||||
try {
|
||||
const res = await api.fetchApi("/pause_queue/continue", { method: "POST" });
|
||||
const res = await authenticatedFetch("/pause_queue/continue", { method: "POST" });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
this.updateState(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[FlowControl] Error continuing queue:", e);
|
||||
console.error("[LeafFlow] Error continuing queue:", e);
|
||||
}
|
||||
}
|
||||
|
||||
startObserver() {
|
||||
this.injectUI();
|
||||
setInterval(() => {
|
||||
if (!this.groupEl || !document.body.contains(this.groupEl)) {
|
||||
this.injectUI();
|
||||
}
|
||||
this.injectUI();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
injectUI() {
|
||||
// Vue V2 UI Detection
|
||||
const queueGroup = document.querySelector(".queue-button-group") ||
|
||||
document.querySelector('[data-testid="queue-button"]')?.closest(".queue-button-group");
|
||||
|
||||
if (!queueGroup) return;
|
||||
|
||||
const parentDiv = queueGroup.parentElement;
|
||||
if (!parentDiv) return;
|
||||
|
||||
if (queueGroup) {
|
||||
const parentDiv = queueGroup.parentElement;
|
||||
if (!parentDiv) return;
|
||||
|
||||
if (this.groupEl && parentDiv.contains(this.groupEl)) {
|
||||
// If it's already in the correct V2 container, do nothing
|
||||
if (this.groupEl && parentDiv.contains(this.groupEl)) return;
|
||||
|
||||
if (!this.groupEl) this.buildElement();
|
||||
|
||||
this.groupEl.classList.remove("litegraph-mode");
|
||||
|
||||
const dragHandle = parentDiv.querySelector(".drag-handle");
|
||||
if (dragHandle && dragHandle.nextSibling) {
|
||||
parentDiv.insertBefore(this.groupEl, dragHandle.nextSibling);
|
||||
} else {
|
||||
parentDiv.insertBefore(this.groupEl, queueGroup);
|
||||
}
|
||||
this.renderUI();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.groupEl) {
|
||||
this.buildElement();
|
||||
}
|
||||
// If we are definitely in V2, wait for V2 header to render
|
||||
const isV2 = !!document.querySelector('comfy-app') || !!document.querySelector('#app');
|
||||
if (isV2) return;
|
||||
|
||||
const dragHandle = parentDiv.querySelector(".drag-handle");
|
||||
if (dragHandle && dragHandle.nextSibling) {
|
||||
parentDiv.insertBefore(this.groupEl, dragHandle.nextSibling);
|
||||
} else {
|
||||
parentDiv.insertBefore(this.groupEl, queueGroup);
|
||||
}
|
||||
// LiteGraph V1 UI Detection
|
||||
if (app.ui && app.ui.menuContainer) {
|
||||
if (this.groupEl && app.ui.menuContainer.contains(this.groupEl)) return;
|
||||
if (!this.groupEl) this.buildElement();
|
||||
|
||||
this.renderUI();
|
||||
this.groupEl.classList.add("litegraph-mode");
|
||||
const queueBtn = app.ui.menuContainer.querySelector(".comfy-queue-btn");
|
||||
if (queueBtn) {
|
||||
app.ui.menuContainer.insertBefore(this.groupEl, queueBtn.nextSibling);
|
||||
} else {
|
||||
app.ui.menuContainer.appendChild(this.groupEl);
|
||||
}
|
||||
this.renderUI();
|
||||
}
|
||||
}
|
||||
|
||||
buildElement() {
|
||||
@@ -302,7 +320,7 @@ class PauseQueueUI {
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfyUI.FlowControl.PauseQueue",
|
||||
name: "ComfyUI.LeafFlow.PauseQueue",
|
||||
async setup() {
|
||||
new PauseQueueUI();
|
||||
}
|
||||
|
||||
+61
-5
@@ -1,30 +1,86 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { api } from "/scripts/api.js";
|
||||
import { authenticatedFetch } from "./js/auth_helper.js";
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfyUI.FlowControl.PersistentQueue",
|
||||
name: "ComfyUI.LeafFlow.PersistentQueue",
|
||||
async setup() {
|
||||
const patchRgthree = async () => {
|
||||
try {
|
||||
const rgthree = await import("/extensions/rgthree-comfy/common/prompt_service.js");
|
||||
if (rgthree && rgthree.SERVICE) {
|
||||
const queue = await api.getQueue();
|
||||
const running = queue.queue_running || queue.Running || [];
|
||||
const pending = queue.queue_pending || queue.Pending || [];
|
||||
const allItems = [...running, ...pending];
|
||||
let patched = false;
|
||||
for (const item of allItems) {
|
||||
const promptId = item[1];
|
||||
const promptData = item[2];
|
||||
if (promptId && promptData) {
|
||||
const promptExecution = rgthree.SERVICE.getOrMakePrompt(promptId);
|
||||
if (!promptExecution.totalNodes || !promptExecution.promptApi) {
|
||||
promptExecution.setPrompt({ output: promptData });
|
||||
if (rgthree.SERVICE.promptsMap) {
|
||||
rgthree.SERVICE.promptsMap.set(promptId, promptExecution);
|
||||
}
|
||||
patched = true;
|
||||
}
|
||||
if (running.some(r => r[1] === promptId)) {
|
||||
if (!rgthree.SERVICE.currentExecution || !rgthree.SERVICE.currentExecution.totalNodes) {
|
||||
rgthree.SERVICE.currentExecution = promptExecution;
|
||||
patched = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (patched) {
|
||||
rgthree.SERVICE.dispatchProgressUpdate();
|
||||
console.log("[LeafFlow] Synced recovered queue with rgthree-comfy progress bar.");
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// rgthree-comfy not installed or path changed, ignore
|
||||
}
|
||||
};
|
||||
|
||||
const claimQueueOwnership = async () => {
|
||||
if (api && api.clientId) {
|
||||
try {
|
||||
await api.fetchApi("/persistent_queue/claim", {
|
||||
await authenticatedFetch("/persistent_queue/claim", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ client_id: api.clientId })
|
||||
});
|
||||
|
||||
// Wait for server to sync queue state back to clients, then patch rgthree
|
||||
setTimeout(patchRgthree, 150);
|
||||
setTimeout(patchRgthree, 600);
|
||||
} catch (e) {
|
||||
console.error("[FlowControl] Error claiming queue ownership:", e);
|
||||
console.error("[LeafFlow] Error claiming queue ownership:", e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Delay execution until after app & API client ID initialization
|
||||
setTimeout(claimQueueOwnership, 1000);
|
||||
setTimeout(claimQueueOwnership, 500);
|
||||
setTimeout(patchRgthree, 800);
|
||||
|
||||
if (api && api.addEventListener) {
|
||||
api.addEventListener("status", () => {
|
||||
claimQueueOwnership();
|
||||
}, { once: true });
|
||||
setTimeout(patchRgthree, 200);
|
||||
});
|
||||
|
||||
api.addEventListener("execution_start", (e) => {
|
||||
setTimeout(patchRgthree, 50);
|
||||
});
|
||||
|
||||
api.addEventListener("executing", (e) => {
|
||||
if (e && e.detail) {
|
||||
patchRgthree();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
+255
-47
@@ -2,47 +2,137 @@ import { app } from "/scripts/app.js";
|
||||
import { api } from "/scripts/api.js";
|
||||
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";
|
||||
if (!document.getElementById(styleId)) {
|
||||
const style = document.createElement("style");
|
||||
style.id = styleId;
|
||||
style.textContent = `
|
||||
.leafflow-live-preview-container {
|
||||
position: absolute !important;
|
||||
inset: 0 !important;
|
||||
top: 70px !important;
|
||||
bottom: 40px !important;
|
||||
left: 8px !important;
|
||||
right: 8px !important;
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
min-width: 0 !important;
|
||||
min-height: 0 !important;
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
overflow: hidden !important;
|
||||
box-sizing: border-box !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
.leafflow-live-preview-img {
|
||||
position: absolute !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
max-width: 100% !important;
|
||||
max-height: 100% !important;
|
||||
min-width: 0 !important;
|
||||
min-height: 0 !important;
|
||||
object-fit: contain !important;
|
||||
object-position: center !important;
|
||||
display: none;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
[node-type="PreviewLatentLive"] {
|
||||
min-height: 0 !important;
|
||||
min-width: 0 !important;
|
||||
}
|
||||
[node-type="PreviewLatentLive"] .leafflow-live-preview-container {
|
||||
position: absolute !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
}
|
||||
|
||||
function isSamplerNode(type = "", title = "") {
|
||||
const lowerType = String(type).toLowerCase();
|
||||
const lowerTitle = String(title).toLowerCase();
|
||||
return lowerType.includes("sampler") || lowerType.includes("ksampler") || lowerType.includes("sample") || lowerType.includes("denoise") ||
|
||||
lowerTitle.includes("sampler") || lowerTitle.includes("ksampler") || lowerTitle.includes("sample") || lowerTitle.includes("denoise");
|
||||
}
|
||||
|
||||
function getKSamplerNodes() {
|
||||
if (!app || !app.graph) return [];
|
||||
|
||||
const samplers = [];
|
||||
const visitedNodes = new Set();
|
||||
|
||||
const traverseGraph = (graph) => {
|
||||
if (!graph || !graph._nodes) return;
|
||||
for (const node of graph._nodes) {
|
||||
if (!node || visitedNodes.has(node.id)) continue;
|
||||
visitedNodes.add(node.id);
|
||||
const checkNode = (node, prefix = "") => {
|
||||
if (!node) return;
|
||||
const idStr = String(node.id ?? "");
|
||||
if (!idStr || visitedNodes.has(idStr)) return;
|
||||
|
||||
const type = (node.type || node.comfyClass || "").toLowerCase();
|
||||
const title = (node.title || node.type || "").toLowerCase();
|
||||
const type = node.type || node.comfyClass || "";
|
||||
const title = node.title || node.type || `Node ${idStr}`;
|
||||
|
||||
if (type.includes("sampler") || type.includes("ksampler") || title.includes("sampler")) {
|
||||
samplers.push({
|
||||
id: node.id,
|
||||
title: node.title || node.type || `Node ${node.id}`
|
||||
});
|
||||
}
|
||||
if (isSamplerNode(type, title)) {
|
||||
visitedNodes.add(idStr);
|
||||
const displayTitle = prefix ? `${prefix} > ${title}` : title;
|
||||
samplers.push({
|
||||
id: idStr,
|
||||
title: displayTitle
|
||||
});
|
||||
}
|
||||
|
||||
if (node.subgraph) {
|
||||
traverseGraph(node.subgraph);
|
||||
}
|
||||
// Deep subgraph / group exploration
|
||||
const sub = node.subgraph || node.inner_graph || (typeof node.getInnerGraph === "function" ? node.getInnerGraph() : null) || node.sub_nodes || node.nodes;
|
||||
if (sub) {
|
||||
traverseGraph(sub, title);
|
||||
}
|
||||
};
|
||||
|
||||
traverseGraph(app.graph);
|
||||
|
||||
// Also check DOM nodes for ComfyUI V2 subgraphs
|
||||
document.querySelectorAll('[data-node-id], [data-widgets-grid-node-id], .lg-node').forEach(el => {
|
||||
const id = el.dataset.nodeId || el.dataset.widgetsGridNodeId || el.getAttribute('data-node-id');
|
||||
const text = (el.innerText || el.textContent || "").split('\n')[0];
|
||||
if (id && text.toLowerCase().includes("sampler") && !samplers.some(s => String(s.id) === String(id))) {
|
||||
const titleMatch = text.match(/KSampler[^\n]*/i) || text.match(/Sampler[^\n]*/i);
|
||||
let title = titleMatch ? titleMatch[0].trim() : `Sampler (${id})`;
|
||||
if (title.length > 45) title = title.substring(0, 42) + "...";
|
||||
samplers.push({ id, title });
|
||||
const traverseGraph = (graph, prefix = "") => {
|
||||
if (!graph) return;
|
||||
let nodesList = [];
|
||||
if (Array.isArray(graph._nodes)) {
|
||||
nodesList = graph._nodes;
|
||||
} else if (Array.isArray(graph.nodes)) {
|
||||
nodesList = graph.nodes;
|
||||
} else if (graph._nodes_by_id) {
|
||||
nodesList = Object.values(graph._nodes_by_id);
|
||||
} else if (typeof graph.values === "function") {
|
||||
nodesList = Array.from(graph.values());
|
||||
}
|
||||
});
|
||||
|
||||
for (const node of nodesList) {
|
||||
checkNode(node, prefix);
|
||||
}
|
||||
};
|
||||
|
||||
if (app) {
|
||||
if (app.graph) traverseGraph(app.graph);
|
||||
if (app.rootGraph && app.rootGraph !== app.graph) traverseGraph(app.rootGraph);
|
||||
if (app.canvas?.graph && app.canvas.graph !== app.graph) traverseGraph(app.canvas.graph);
|
||||
}
|
||||
|
||||
// Comprehensive DOM search for any rendered nodes in canvas (Vue Node 2.0 and LiteGraph)
|
||||
if (typeof document !== "undefined") {
|
||||
document.querySelectorAll('[data-node-id], [data-widgets-grid-node-id], .lg-node, [node-id]').forEach(el => {
|
||||
const id = el.dataset.nodeId || el.dataset.widgetsGridNodeId || el.getAttribute('data-node-id') || el.getAttribute('node-id');
|
||||
if (!id || visitedNodes.has(String(id))) return;
|
||||
|
||||
const headerEl = el.querySelector('[data-testid^="node-header-"], [data-testid="node-title"], .lg-node-header, .node-title');
|
||||
const text = (headerEl ? headerEl.innerText : el.innerText) || "";
|
||||
|
||||
if (isSamplerNode("", text)) {
|
||||
visitedNodes.add(String(id));
|
||||
const titleMatch = text.match(/KSampler[^\n]*/i) || text.match(/Sampler[^\n]*/i) || text.match(/[^\n]+/);
|
||||
let title = titleMatch ? titleMatch[0].trim() : `Sampler (${id})`;
|
||||
if (title.length > 45) title = title.substring(0, 42) + "...";
|
||||
samplers.push({ id: String(id), title });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return samplers;
|
||||
}
|
||||
@@ -50,32 +140,66 @@ function getKSamplerNodes() {
|
||||
function updateAllPreviewNodeDropdowns() {
|
||||
if (!app || !app.graph) return;
|
||||
|
||||
const previewNodes = app.graph._nodes ? app.graph._nodes.filter(n => n && n.type === "PreviewLatentLive") : [];
|
||||
const previewNodes = [];
|
||||
const collectPreviewNodes = (g) => {
|
||||
if (!g) return;
|
||||
const list = g._nodes || g.nodes || (g._nodes_by_id ? Object.values(g._nodes_by_id) : []);
|
||||
if (Array.isArray(list)) {
|
||||
for (const n of list) {
|
||||
if (n && n.type === "PreviewLatentLive") previewNodes.push(n);
|
||||
if (n && n.subgraph) collectPreviewNodes(n.subgraph);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
collectPreviewNodes(app.graph);
|
||||
if (app.rootGraph && app.rootGraph !== app.graph) collectPreviewNodes(app.rootGraph);
|
||||
|
||||
const currentNodes = getKSamplerNodes();
|
||||
const currentValues = ["Auto", ...currentNodes.map(n => `${n.title} (${n.id})`)];
|
||||
|
||||
for (const node of previewNodes) {
|
||||
const widget = node.widgets?.find(w => w.name === "Source");
|
||||
if (widget) {
|
||||
widget.options.values = currentValues;
|
||||
if (!currentValues.includes(widget.value)) {
|
||||
widget.value = "Auto";
|
||||
const currentVal = widget.value;
|
||||
widget.options = widget.options || {};
|
||||
widget.options.values = [...currentValues];
|
||||
|
||||
if (currentVal && currentVal !== "Auto" && !currentValues.includes(currentVal)) {
|
||||
widget.options.values.push(currentVal);
|
||||
}
|
||||
if (typeof node.setDirtyCanvas === "function") {
|
||||
node.setDirtyCanvas(true, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfyUI.FlowControl.PreviewLatentLive",
|
||||
name: "ComfyUI.LeafFlow.PreviewLatentLive",
|
||||
async setup() {
|
||||
PreviewManager.init(app, api);
|
||||
|
||||
api.addEventListener("status", () => {
|
||||
updateAllPreviewNodeDropdowns();
|
||||
});
|
||||
|
||||
if (typeof document !== "undefined") {
|
||||
document.addEventListener("pointerdown", (e) => {
|
||||
if (e.target && (e.target.closest?.('[data-testid="widget-select-default-trigger"]') || e.target.closest?.('.lg-node') || e.target.closest?.('[data-node-id]'))) {
|
||||
updateAllPreviewNodeDropdowns();
|
||||
}
|
||||
}, { passive: true });
|
||||
}
|
||||
},
|
||||
async loadedGraph() {
|
||||
updateAllPreviewNodeDropdowns();
|
||||
setTimeout(updateAllPreviewNodeDropdowns, 200);
|
||||
setTimeout(updateAllPreviewNodeDropdowns, 1000);
|
||||
},
|
||||
async nodeCreated(node) {
|
||||
if (node && node.type === "PreviewLatentLive") {
|
||||
setTimeout(updateAllPreviewNodeDropdowns, 100);
|
||||
}
|
||||
},
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name === "PreviewLatentLive") {
|
||||
@@ -94,48 +218,119 @@ app.registerExtension({
|
||||
updateAllPreviewNodeDropdowns();
|
||||
};
|
||||
|
||||
this.size = [300, 300];
|
||||
this.size = [200, 200];
|
||||
this.min_size = [80, 80];
|
||||
this.resizable = true;
|
||||
|
||||
// Disallow automatic image aspect ratio resizing
|
||||
this.setSizeForImage = function() {};
|
||||
Object.defineProperty(this, "aspect_ratio", {
|
||||
get() { return false; },
|
||||
set(v) {},
|
||||
configurable: true
|
||||
});
|
||||
|
||||
this.imgs = [];
|
||||
this.imageIndex = 0;
|
||||
|
||||
// Create robust DOM container for V2 overlay
|
||||
const viewContainer = document.createElement("div");
|
||||
viewContainer.style.width = "100%";
|
||||
viewContainer.style.height = "100%";
|
||||
viewContainer.className = "leafflow-live-preview-container";
|
||||
viewContainer.style.position = "absolute";
|
||||
viewContainer.style.inset = "0";
|
||||
viewContainer.style.top = "70px";
|
||||
viewContainer.style.bottom = "40px";
|
||||
viewContainer.style.left = "8px";
|
||||
viewContainer.style.right = "8px";
|
||||
viewContainer.style.display = "flex";
|
||||
viewContainer.style.alignItems = "center";
|
||||
viewContainer.style.justifyContent = "center";
|
||||
viewContainer.style.backgroundColor = "transparent";
|
||||
viewContainer.style.color = "#888";
|
||||
viewContainer.style.fontFamily = "Inter, sans-serif";
|
||||
viewContainer.style.fontSize = "14px";
|
||||
viewContainer.style.fontSize = "13px";
|
||||
viewContainer.style.overflow = "hidden";
|
||||
viewContainer.style.boxSizing = "border-box";
|
||||
viewContainer.style.pointerEvents = "none";
|
||||
|
||||
const fallbackText = document.createElement("div");
|
||||
fallbackText.className = "leafflow-live-preview-fallback";
|
||||
fallbackText.innerText = "Waiting for live preview...";
|
||||
viewContainer.appendChild(fallbackText);
|
||||
|
||||
const imgElement = document.createElement("img");
|
||||
imgElement.className = "leafflow-live-preview-img";
|
||||
imgElement.style.position = "absolute";
|
||||
imgElement.style.top = "0";
|
||||
imgElement.style.left = "0";
|
||||
imgElement.style.width = "100%";
|
||||
imgElement.style.height = "100%";
|
||||
imgElement.style.maxWidth = "100%";
|
||||
imgElement.style.maxHeight = "100%";
|
||||
imgElement.style.minWidth = "0";
|
||||
imgElement.style.minHeight = "0";
|
||||
imgElement.style.objectFit = "contain";
|
||||
imgElement.style.objectPosition = "center";
|
||||
imgElement.style.display = "none";
|
||||
imgElement.style.pointerEvents = "none";
|
||||
viewContainer.appendChild(imgElement);
|
||||
|
||||
this.previewImgElement = imgElement;
|
||||
this.previewFallbackText = fallbackText;
|
||||
|
||||
// Mount DOM widget
|
||||
this.addDOMWidget("preview_display", "HTML", viewContainer, {
|
||||
getValue() { return ""; },
|
||||
setValue(val) {},
|
||||
serialize: false
|
||||
serialize: false,
|
||||
computeSize() { return [0, 0]; }
|
||||
});
|
||||
|
||||
PreviewManager.registerNode(this);
|
||||
setTimeout(updateAllPreviewNodeDropdowns, 100);
|
||||
};
|
||||
|
||||
nodeType.prototype.computeSize = function() {
|
||||
return [80, 80];
|
||||
};
|
||||
|
||||
nodeType.prototype.setSizeForImage = function() {};
|
||||
|
||||
nodeType.prototype.onResize = function(size) {
|
||||
if (size) {
|
||||
this.size[0] = Math.max(80, size[0]);
|
||||
this.size[1] = Math.max(80, size[1]);
|
||||
}
|
||||
};
|
||||
|
||||
nodeType.prototype.onDrawBackground = function(ctx) {
|
||||
if (this.flags.collapsed) return;
|
||||
if (this.imgs && this.imgs.length) {
|
||||
const img = this.imgs[this.imageIndex || 0];
|
||||
if (!img || !img.complete || (img.naturalWidth === 0 && img.width === 0)) return;
|
||||
|
||||
const margin = 8;
|
||||
const topY = 50;
|
||||
const maxW = Math.max(10, this.size[0] - margin * 2);
|
||||
const maxH = Math.max(10, this.size[1] - topY - margin);
|
||||
if (maxW <= 0 || maxH <= 0) return;
|
||||
|
||||
const w = img.naturalWidth || img.width;
|
||||
const h = img.naturalHeight || img.height;
|
||||
const imgAspect = w / h;
|
||||
|
||||
let drawW = maxW;
|
||||
let drawH = maxW / imgAspect;
|
||||
if (drawH > maxH) {
|
||||
drawH = maxH;
|
||||
drawW = maxH * imgAspect;
|
||||
}
|
||||
|
||||
const drawX = margin + (maxW - drawW) / 2;
|
||||
const drawY = topY + (maxH - drawH) / 2;
|
||||
|
||||
ctx.drawImage(img, drawX, drawY, drawW, drawH);
|
||||
}
|
||||
};
|
||||
|
||||
const onRemoved = nodeType.prototype.onRemoved;
|
||||
nodeType.prototype.onRemoved = function() {
|
||||
if (onRemoved) {
|
||||
@@ -150,10 +345,10 @@ app.registerExtension({
|
||||
const selectedSource = sourceWidget ? sourceWidget.value : "Auto";
|
||||
|
||||
if (selectedSource !== "Auto") {
|
||||
const match = selectedSource.match(/\((\d+)\)$/);
|
||||
const match = selectedSource.match(/\(([^)]+)\)$/);
|
||||
if (match) {
|
||||
const targetId = match[1];
|
||||
if (samplerId && String(samplerId) !== targetId) {
|
||||
if (samplerId && String(samplerId) !== String(targetId)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -162,14 +357,27 @@ app.registerExtension({
|
||||
this.imgs = [img];
|
||||
this.imageIndex = 0;
|
||||
|
||||
// Update DOM elements natively for V2 visibility
|
||||
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`);
|
||||
if (domImgs.length > 0) {
|
||||
domImgs.forEach(imgEl => {
|
||||
imgEl.src = img.src;
|
||||
imgEl.style.display = "block";
|
||||
const fallback = imgEl.parentElement?.querySelector(".leafflow-live-preview-fallback");
|
||||
if (fallback) fallback.style.display = "none";
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (this.previewImgElement && this.previewFallbackText && img && img.src) {
|
||||
this.previewFallbackText.style.display = "none";
|
||||
this.previewImgElement.src = img.src;
|
||||
this.previewImgElement.style.display = "block";
|
||||
}
|
||||
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
if (app.graph && typeof app.graph.setDirtyCanvas === "function") {
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user