Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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](file:///d:/GenAI/ComfyUI/ComfyUI/custom_nodes/ComfyUI-LeafFlow/AGENTS.md) |
|
||||
| **`CONTRIBUTING.md`** | Community contribution guidelines, strict branch rules, PR submission workflow. | [CONTRIBUTING.md](file:///d:/GenAI/ComfyUI/ComfyUI/custom_nodes/ComfyUI-LeafFlow/CONTRIBUTING.md) |
|
||||
| **`README.md`** | Main project homepage, installation guide, complete node catalog with parameter tables and UI settings guide. | [README.md](file:///d:/GenAI/ComfyUI/ComfyUI/custom_nodes/ComfyUI-LeafFlow/README.md) |
|
||||
| **`WALKTHROUGH.md`** | Step-by-step user tutorials, example workflows, visual load guides, and batch prompting patterns. | [WALKTHROUGH.md](file:///d:/GenAI/ComfyUI/ComfyUI/custom_nodes/ComfyUI-LeafFlow/WALKTHROUGH.md) |
|
||||
| **`CHANGELOG.md`** | Strict Keep-A-Changelog semantic version release history across all updates. | [CHANGELOG.md](file:///d:/GenAI/ComfyUI/ComfyUI/custom_nodes/ComfyUI-LeafFlow/CHANGELOG.md) |
|
||||
| **`SECURITY.md`** | Security policies, supported versions, and private vulnerability disclosure instructions. | [SECURITY.md](file:///d:/GenAI/ComfyUI/ComfyUI/custom_nodes/ComfyUI-LeafFlow/SECURITY.md) |
|
||||
| **`CLAUDE.md`** | AI agent instruction pointer. | [CLAUDE.md](file:///d:/GenAI/ComfyUI/ComfyUI/custom_nodes/ComfyUI-LeafFlow/CLAUDE.md) |
|
||||
| **`GEMINI.md`** | AI agent instruction pointer. | [GEMINI.md](file:///d:/GenAI/ComfyUI/ComfyUI/custom_nodes/ComfyUI-LeafFlow/GEMINI.md) |
|
||||
| **`AGENTS.md`** | AI agent instruction pointer. | [AGENTS.md](file:///d:/GenAI/ComfyUI/ComfyUI/custom_nodes/ComfyUI-LeafFlow/AGENTS.md) |
|
||||
| **`bug_report.md`** | GitHub issue template for reporting reproducible defects. | [bug_report.md](file:///d:/GenAI/ComfyUI/ComfyUI/custom_nodes/ComfyUI-LeafFlow/.github/ISSUE_TEMPLATE/bug_report.md) |
|
||||
| **`feature_request.md`** | GitHub issue template for proposing new nodes or UI features. | [feature_request.md](file:///d:/GenAI/ComfyUI/ComfyUI/custom_nodes/ComfyUI-LeafFlow/.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`](file:///d:/GenAI/ComfyUI/ComfyUI/custom_nodes/ComfyUI-LeafFlow/__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`](file:///d:/GenAI/ComfyUI/ComfyUI/custom_nodes/ComfyUI-LeafFlow/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`](file:///d:/GenAI/ComfyUI/ComfyUI/custom_nodes/ComfyUI-LeafFlow/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`](file:///d:/GenAI/ComfyUI/ComfyUI/custom_nodes/ComfyUI-LeafFlow/WALKTHROUGH.md).
|
||||
|
||||
### Step 2.5: Changelog
|
||||
- [ ] Add entry under `### Added` in [`CHANGELOG.md`](file:///d:/GenAI/ComfyUI/ComfyUI/custom_nodes/ComfyUI-LeafFlow/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`](file:///d:/GenAI/ComfyUI/ComfyUI/custom_nodes/ComfyUI-LeafFlow/.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`](file:///d:/GenAI/ComfyUI/ComfyUI/custom_nodes/ComfyUI-LeafFlow/README.md).
|
||||
- [ ] Update `## 4. Settings & Configuration` in [`WALKTHROUGH.md`](file:///d:/GenAI/ComfyUI/ComfyUI/custom_nodes/ComfyUI-LeafFlow/WALKTHROUGH.md).
|
||||
|
||||
---
|
||||
|
||||
## 4. Checklist: Updating Dependencies
|
||||
|
||||
If a feature requires a new Python package:
|
||||
- [ ] Add package to `dependencies` in [`pyproject.toml`](file:///d:/GenAI/ComfyUI/ComfyUI/custom_nodes/ComfyUI-LeafFlow/pyproject.toml).
|
||||
- [ ] Add package to `dependencies` list in [`install.py`](file:///d:/GenAI/ComfyUI/ComfyUI/custom_nodes/ComfyUI-LeafFlow/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)
|
||||
+36
-1
@@ -1,10 +1,45 @@
|
||||
# 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.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,96 +1,331 @@
|
||||
# 🍃 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 (🔥), and multi-selection support.
|
||||
|
||||
#### Inputs & Widgets
|
||||
- **`model`** (`MODEL`): Input model.
|
||||
- **`clip`** (`CLIP`): Input CLIP.
|
||||
- **`folder`** (`STRING`): Subfolder filter.
|
||||
- **`strength_model`** (`FLOAT`): Model strength.
|
||||
- **`strength_clip`** (`FLOAT`): CLIP strength.
|
||||
- **`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 and EXIF positive prompt metadata extraction.
|
||||
|
||||
#### Inputs & Widgets
|
||||
- **`folder`** (`STRING`): Folder path to load images from.
|
||||
- **`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.
|
||||
- **`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
|
||||
Parses multiline prompts or batch text blocks separated by double newlines (`\n\n`), automatically popping the top prompt per queue iteration and keeping remaining prompts in memory.
|
||||
|
||||
#### Inputs & Widgets
|
||||
- **`prompts`** (`STRING`, Multiline): Queue of prompt blocks.
|
||||
- **`mode`** (`COMBO`): `Double Newline (\n\n)` vs `Single Line (\n)`.
|
||||
- **`delete_after_queue`** (`BOOLEAN`): Pop executed block from widget text.
|
||||
|
||||
#### Outputs
|
||||
- **`active_prompt`** (`STRING`): The current prompt block for this run.
|
||||
- **`remaining_prompts`** (`STRING`): The queue of remaining prompts.
|
||||
- **`remaining_count`** (`INT`): Count of remaining items in queue.
|
||||
</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>
|
||||
|
||||
---
|
||||
|
||||
### 🎨 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.
|
||||
</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.
|
||||
</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>
|
||||
|
||||
---
|
||||
|
||||
@@ -107,17 +342,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).
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# 🍃 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 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`**.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
### 🍃 ⏱️ 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.
|
||||
|
||||
---
|
||||
|
||||
## 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 often 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).
|
||||
- **Absolute Paths**: You can paste full absolute paths from your OS, e.g., `C:\Users\Name\Pictures\Renders` or `/home/user/images`.
|
||||
- **Relative Paths**: If you use a relative path like `input/watch`, it resolves relative to your root ComfyUI directory.
|
||||
- **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 perfectly 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!
|
||||
+228
-28
@@ -1,65 +1,111 @@
|
||||
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
|
||||
def ensure_dependencies():
|
||||
dep_map = {
|
||||
"pystray": "pystray",
|
||||
"piexif": "piexif",
|
||||
"PIL": "Pillow",
|
||||
"numpy": "numpy"
|
||||
}
|
||||
for mod_name, pip_name in dep_map.items():
|
||||
try:
|
||||
__import__(mod_name)
|
||||
except ImportError:
|
||||
try:
|
||||
print(f"[ComfyUI-LeafFlow] Auto-installing missing dependency: {pip_name}")
|
||||
subprocess.check_call([sys.executable, "-m", "pip", "install", pip_name])
|
||||
except Exception as e:
|
||||
print(f"[ComfyUI-LeafFlow] Warning: Failed to install {pip_name}: {e}")
|
||||
|
||||
ensure_dependencies()
|
||||
|
||||
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.text_replacer import MultiTextReplacer
|
||||
from .nodes.text_split import LeafFlowTextSplit
|
||||
|
||||
|
||||
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,
|
||||
"MultiTextReplacer": MultiTextReplacer,
|
||||
"LeafFlowTextSplit": LeafFlowTextSplit,
|
||||
}
|
||||
|
||||
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",
|
||||
"MultiTextReplacer": "🍃 🔤 Multi Text Replacer",
|
||||
"LeafFlowTextSplit": "🍃 ✂️ Text Split"
|
||||
}
|
||||
|
||||
WEB_DIRECTORY = "./web"
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
|
||||
|
||||
from .nodes.utils import get_leafflow_user_dir, get_env_setting
|
||||
|
||||
server = PromptServer.instance
|
||||
setup_queue_control_routes(server)
|
||||
|
||||
try:
|
||||
from .prompt_bookmarks.api import register_routes as register_prompt_bookmarks_routes
|
||||
register_prompt_bookmarks_routes()
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
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.")
|
||||
print("[ComfyUI-LeafFlow] 🍃 Loaded 13 nodes & visual endpoints successfully.")
|
||||
|
||||
@routes.get("/leafflow/settings")
|
||||
@routes.get("/flow_control/settings")
|
||||
async def get_settings(request):
|
||||
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"))
|
||||
if os.path.exists(ENV_FILE):
|
||||
try:
|
||||
with open(ENV_FILE, "r", encoding="utf-8") as f:
|
||||
@@ -69,13 +115,26 @@ 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
|
||||
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
|
||||
})
|
||||
|
||||
@routes.post("/leafflow/settings")
|
||||
@routes.post("/flow_control/settings")
|
||||
async def save_settings(request):
|
||||
try:
|
||||
@@ -87,6 +146,11 @@ async def save_settings(request):
|
||||
default_pause_mode = data.get("default_pause_mode")
|
||||
enable_civitai = data.get("enable_civitai_scraping")
|
||||
enable_tmdb = data.get("enable_tmdb_scraping")
|
||||
enable_lora_usage = data.get("enable_lora_usage")
|
||||
enable_tray_icon = data.get("enable_tray_icon")
|
||||
enable_assets_restore = data.get("enable_assets_restore")
|
||||
restore_assets_count = data.get("restore_assets_count")
|
||||
clear_prompt_iterator_on_launch = data.get("clear_prompt_iterator_on_launch")
|
||||
restored_state = data.get("persistent_queue_restored_state")
|
||||
|
||||
lines = []
|
||||
@@ -105,6 +169,11 @@ 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
|
||||
|
||||
for line in lines:
|
||||
@@ -129,6 +198,21 @@ 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
|
||||
@@ -149,6 +233,16 @@ 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")
|
||||
|
||||
@@ -159,8 +253,114 @@ 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 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):
|
||||
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": "2.1.0",
|
||||
"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"),
|
||||
"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)
|
||||
|
||||
|
||||
|
||||
+9
-6
@@ -3,16 +3,19 @@ import sys
|
||||
import subprocess
|
||||
|
||||
def main():
|
||||
print("[ComfyUI-FlowControl] Checking dependencies...")
|
||||
dependencies = ["pillow", "piexif", "numpy"]
|
||||
print("[ComfyUI-LeafFlow] Checking dependencies...")
|
||||
dependencies = ["pillow", "piexif", "numpy", "pystray"]
|
||||
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.")
|
||||
print(f"[ComfyUI-LeafFlow] Installing missing dependency: {dep}")
|
||||
try:
|
||||
subprocess.check_call([sys.executable, "-m", "pip", "install", dep])
|
||||
except Exception as e:
|
||||
print(f"[ComfyUI-LeafFlow] Warning: Failed to install {dep}: {e}")
|
||||
|
||||
print("[ComfyUI-LeafFlow] Dependency check complete! Ready for ComfyUI.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# Package marker for LeafFlow nodes
|
||||
@@ -0,0 +1,230 @@
|
||||
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": ([
|
||||
"1:1", "2:3", "3:2", "3:4", "4:3", "9:16", "16:9", "21:9"
|
||||
], {
|
||||
"default": "1:1"
|
||||
}),
|
||||
"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}),
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
):
|
||||
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)
|
||||
|
||||
if not valid_ratios:
|
||||
valid_ratios = ["1:1", "2:3", "3:2", "3:4", "4:3", "9:16", "16:9", "21:9"]
|
||||
|
||||
found_ratio = None
|
||||
matches = []
|
||||
|
||||
if text_str:
|
||||
# 1. Match configured target aspect 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))
|
||||
|
||||
# 2. General ratio pattern fallback to catch any valid ratio format (e.g. 16:9., 21:9, 1.77:1) regardless of trailing punctuation
|
||||
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
|
||||
|
||||
try:
|
||||
w_part, h_part = map(float, str(found_ratio).split(":"))
|
||||
if w_part <= 0 or h_part <= 0:
|
||||
raise ValueError("Dimensions must be positive")
|
||||
except Exception:
|
||||
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,236 @@
|
||||
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:
|
||||
from app.assets.services.ingest import register_file_in_place
|
||||
res = register_file_in_place(abs_path=filepath, name=filename, tags=["output"])
|
||||
if res and hasattr(res, "ref") and res.ref and hasattr(res.ref, "id"):
|
||||
try:
|
||||
from app.database.db import create_session, init_db
|
||||
from app.assets.database.queries import set_reference_metadata
|
||||
init_db()
|
||||
with create_session() as session:
|
||||
set_reference_metadata(session, reference_id=res.ref.id, metadata={
|
||||
"jobId": prompt_id,
|
||||
"nodeId": output_node_id,
|
||||
"filename": filename,
|
||||
"subfolder": subfolder
|
||||
})
|
||||
session.commit()
|
||||
except Exception:
|
||||
pass
|
||||
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()
|
||||
+70
-34
@@ -2,25 +2,30 @@ 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:
|
||||
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. Default is False."}),
|
||||
}
|
||||
}
|
||||
|
||||
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 an optional toggle to delete the image after loading."
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(s, **kwargs):
|
||||
@@ -31,61 +36,92 @@ class AutoWatcherNode:
|
||||
# Returns a 1x1 0-tensor for no-image state
|
||||
return torch.zeros((1, 64, 64, 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):
|
||||
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 watch(self, folder, wait_if_folder_is_empty, rescan_interval, sort_by, regex_filter, delete_image=False, **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 = files[0] # Pick the first one based on sorting
|
||||
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}")
|
||||
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}")
|
||||
|
||||
time.sleep(interval_sec)
|
||||
time.sleep(rescan_interval)
|
||||
else:
|
||||
# Non-blocking check: returns immediately if no image is present
|
||||
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 = files[0]
|
||||
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}")
|
||||
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
|
||||
return (self.create_dummy_image(), False)
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import subprocess
|
||||
from server import PromptServer
|
||||
from aiohttp import web
|
||||
import comfy.model_management
|
||||
|
||||
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)
|
||||
|
||||
print(f"[LeafFlow] Node {unique_id} is waiting for user decision...")
|
||||
|
||||
if timeout > 0:
|
||||
waited = event.wait(timeout)
|
||||
if not waited:
|
||||
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:
|
||||
event.wait()
|
||||
|
||||
# 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):
|
||||
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)
|
||||
+83
-40
@@ -8,10 +8,13 @@ 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
|
||||
|
||||
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 parse_positive_from_parameters(parameters):
|
||||
if not parameters:
|
||||
@@ -48,7 +51,7 @@ def extract_metadata_from_image(filepath):
|
||||
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,7 +68,7 @@ def save_prompts_cache(cache):
|
||||
with open(CACHE_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(cache, f, indent=4, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
print(f"[FlowControl] Failed to save prompts cache: {e}")
|
||||
print(f"[LeafFlow] Failed to save prompts cache: {e}")
|
||||
|
||||
server = PromptServer.instance
|
||||
routes = server.routes
|
||||
@@ -73,10 +76,32 @@ routes = server.routes
|
||||
@routes.get("/image_loader/get_images")
|
||||
async def get_images_endpoint(request):
|
||||
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_folder_path(folder)
|
||||
|
||||
if not 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))
|
||||
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 +109,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)
|
||||
@@ -124,9 +152,13 @@ async def get_images_endpoint(request):
|
||||
@routes.get("/image_loader/get_thumbnail")
|
||||
async def get_thumbnail_endpoint(request):
|
||||
folder = request.query.get("folder", "")
|
||||
if not folder:
|
||||
folder = request.query.get("folder_path", "")
|
||||
resolved_folder = sanitize_folder_path(folder)
|
||||
|
||||
image_rel = request.query.get("image", "")
|
||||
|
||||
full_path = os.path.normpath(os.path.join(folder, image_rel))
|
||||
full_path = os.path.normpath(os.path.join(resolved_folder, image_rel))
|
||||
if not os.path.exists(full_path):
|
||||
return web.Response(status=404)
|
||||
|
||||
@@ -142,20 +174,25 @@ async def get_thumbnail_endpoint(request):
|
||||
@routes.get("/image_loader/get_full_image")
|
||||
async def get_full_image_endpoint(request):
|
||||
folder = request.query.get("folder", "")
|
||||
if not folder:
|
||||
folder = request.query.get("folder_path", "")
|
||||
resolved_folder = sanitize_folder_path(folder)
|
||||
|
||||
image_rel = request.query.get("image", "")
|
||||
|
||||
full_path = os.path.normpath(os.path.join(folder, image_rel))
|
||||
full_path = os.path.normpath(os.path.join(resolved_folder, image_rel))
|
||||
if not os.path.exists(full_path):
|
||||
return web.Response(status=404)
|
||||
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,12 +209,15 @@ 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:
|
||||
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_folder_path(input_dir)
|
||||
|
||||
if not resolved_folder or not _selected_image:
|
||||
empty_image = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
|
||||
return (empty_image, "", 0, 0)
|
||||
|
||||
full_path = os.path.normpath(os.path.join(folder_path, _selected_image))
|
||||
full_path = os.path.normpath(os.path.join(resolved_folder, _selected_image))
|
||||
if not os.path.exists(full_path):
|
||||
empty_image = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
|
||||
return (empty_image, "", 0, 0)
|
||||
@@ -194,3 +234,6 @@ class ImageLoaderVisualPrettyV2:
|
||||
image = torch.from_numpy(image)[None,]
|
||||
|
||||
return (image, positive_prompt, width, height)
|
||||
|
||||
# Alias for backward compatibility
|
||||
ImageLoaderVisualPrettyV2 = VisualImageLoader
|
||||
|
||||
+25
-14
@@ -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,
|
||||
@@ -25,10 +27,14 @@ class LoadRecentOutputs:
|
||||
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.\n- For example, if amount=64, setting index=0 to 63 in a batch will load each of the 64 newest images one by one.\n- If index exceeds amount, it loops back around (e.g. index 64 = index 0)."
|
||||
|
||||
def load_single_image(self, amount, index):
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
def load_single_image(self, output_folder, amount, index):
|
||||
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)
|
||||
@@ -36,15 +42,20 @@ class LoadRecentOutputs:
|
||||
|
||||
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, 64, 64, 3), dtype=torch.float32)
|
||||
return (dummy,)
|
||||
|
||||
if not file_list:
|
||||
dummy = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
|
||||
@@ -64,7 +75,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}")
|
||||
print(f"[LeafFlow] Error loading recent output image {target_file}: {e}")
|
||||
image = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
|
||||
|
||||
return (image,)
|
||||
|
||||
@@ -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": "Parsed Name", "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="Parsed Name",
|
||||
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
|
||||
+236
-143
@@ -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,54 @@ 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
|
||||
)
|
||||
|
||||
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 +76,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 +96,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)
|
||||
@@ -133,7 +152,7 @@ def scrape_missing_images_sync():
|
||||
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 +182,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)
|
||||
@@ -184,31 +202,50 @@ def scrape_missing_images_sync():
|
||||
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 Civitai preview for {pretty_name}")
|
||||
print(f"[LeafFlow] Scraped Civitai preview for {pretty_name}")
|
||||
success = True
|
||||
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}"
|
||||
dest_path = os.path.splitext(lora_path)[0] + ".jpg"
|
||||
img_req = urllib.request.Request(img_url, headers=tmdb_headers)
|
||||
with urllib.request.urlopen(img_req, timeout=15) as img_resp:
|
||||
with open(dest_path, "wb") as f:
|
||||
f.write(img_resp.read())
|
||||
print(f"[LeafFlow] Scraped TMDB preview for {pretty_name} (via '{term}')")
|
||||
success = True
|
||||
break
|
||||
except Exception as tmdb_err:
|
||||
pass
|
||||
|
||||
if not success:
|
||||
@@ -222,7 +259,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 +272,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 +287,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,7 +311,6 @@ 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
|
||||
|
||||
@@ -285,30 +324,45 @@ async def get_loras_endpoint(request):
|
||||
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):
|
||||
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 system_path and system_path != "[ NONE ]":
|
||||
img_path = find_preview_image(system_path)
|
||||
if img_path and os.path.exists(img_path):
|
||||
return web.FileResponse(img_path)
|
||||
|
||||
@@ -325,13 +379,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 +397,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": "Parsed Name", "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="Parsed Name", 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 +474,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 +488,26 @@ 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 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):
|
||||
class VisualLoraLoader(FolderLoraLoaderPretty):
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
@@ -428,59 +517,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": "Parsed Name", "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="Parsed Name", 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 +559,35 @@ class FolderLoraLoaderVisualPrettyV2(FolderLoraLoaderVisualPretty):
|
||||
else:
|
||||
loras_to_load = [active_lora]
|
||||
|
||||
if _selection_mode == "Random" and len(loras_to_load) > 0:
|
||||
import random
|
||||
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
|
||||
loras_to_load = [random.choice(valid_choices)]
|
||||
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}"
|
||||
idx = state.get(state_key, 0)
|
||||
selected_item = valid_choices[idx % len(valid_choices)]
|
||||
state[state_key] = idx + 1
|
||||
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}"
|
||||
pool = state.get(state_key, [])
|
||||
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)
|
||||
state[state_key] = pool
|
||||
save_lora_cycle_state(state)
|
||||
loras_to_load = [selected_item]
|
||||
else:
|
||||
loras_to_load = []
|
||||
|
||||
@@ -519,12 +608,12 @@ 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
|
||||
|
||||
@@ -541,8 +630,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,282 @@
|
||||
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
|
||||
|
||||
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):
|
||||
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):
|
||||
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):
|
||||
ensure_user_dir()
|
||||
if not os.path.exists(STATE_FILE):
|
||||
save_state({})
|
||||
try:
|
||||
sys_name = platform.system()
|
||||
if sys_name == "Windows":
|
||||
os.startfile(STATE_FILE)
|
||||
elif sys_name == "Darwin":
|
||||
subprocess.Popen(["open", STATE_FILE])
|
||||
else:
|
||||
subprocess.Popen(["xdg-open", STATE_FILE])
|
||||
return web.json_response({"status": "ok", "path": STATE_FILE})
|
||||
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):
|
||||
if not text_str or not text_str.strip():
|
||||
return []
|
||||
|
||||
clean_text = text_str.replace('\r\n', '\n').replace('\r', '\n')
|
||||
|
||||
if separator == "Newline":
|
||||
return [line.strip() for line in clean_text.split('\n') if line.strip()]
|
||||
elif separator == ">2 Empty Lines":
|
||||
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"
|
||||
raw_blocks = re.split(r'\n\s*\n+', clean_text)
|
||||
return [b.strip() for b in raw_blocks if b.strip()]
|
||||
|
||||
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"
|
||||
], {"default": ">1 Empty Line"}),
|
||||
"text": ("STRING", {"default": "", "multiline": True}),
|
||||
},
|
||||
"optional": {
|
||||
"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="",
|
||||
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)
|
||||
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}"
|
||||
|
||||
# Load state fresh from disk (no stale memory cache)
|
||||
state = load_state()
|
||||
node_state = state.get(state_key)
|
||||
|
||||
current_index = 0
|
||||
if isinstance(node_state, dict):
|
||||
current_index = int(node_state.get("index", 0))
|
||||
elif isinstance(node_state, int):
|
||||
current_index = node_state
|
||||
|
||||
# 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
|
||||
|
||||
# Calculate remaining items
|
||||
remaining_count = max(0, total_count - display_run)
|
||||
join_delim = "\n" if separator == "Newline" else ("\n\n\n" if separator == ">2 Empty Lines" else "\n\n")
|
||||
remaining_text = join_delim.join(original_blocks[display_run:]) if display_run < total_count else ""
|
||||
|
||||
# Update and save state to disk
|
||||
state[state_key] = {
|
||||
"index": next_index,
|
||||
"total": total_count,
|
||||
"last_run": display_run,
|
||||
"last_updated": int(time.time()),
|
||||
"preview": selected_prompt[:60].replace('\n', ' ')
|
||||
}
|
||||
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)
|
||||
+67
-49
@@ -5,12 +5,16 @@ 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
|
||||
|
||||
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):
|
||||
@@ -169,12 +173,19 @@ 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.is_restoring = False
|
||||
self._patched = False
|
||||
self.has_claimed_once = False
|
||||
self.load_from_file()
|
||||
|
||||
def load_from_file(self):
|
||||
@@ -239,46 +250,7 @@ class PersistentQueueManager:
|
||||
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:
|
||||
@@ -336,13 +308,18 @@ class PersistentQueueManager:
|
||||
self._patched = True
|
||||
print("[PersistentQueue] Server queue hooks patched successfully.")
|
||||
|
||||
def restore_queue(self):
|
||||
def restore_queue(self, active_client_id=None):
|
||||
if self.has_claimed_once:
|
||||
return
|
||||
|
||||
if not is_persistent_queue_enabled():
|
||||
print("[PersistentQueue] Auto-recovery is disabled in settings.")
|
||||
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,28 +328,69 @@ 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
|
||||
try:
|
||||
for entry in list(self.persistent_items):
|
||||
try:
|
||||
item_tuple = tuple(entry["item"])
|
||||
server.prompt_queue.put(item_tuple)
|
||||
item_list = list(entry["item"])
|
||||
if active_client_id and len(item_list) > 3 and isinstance(item_list[3], dict):
|
||||
item_list[3]["client_id"] = active_client_id
|
||||
|
||||
server.prompt_queue.put(tuple(item_list))
|
||||
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
|
||||
|
||||
# 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()
|
||||
|
||||
# Force a UI sync so the client sees the newly injected items
|
||||
try:
|
||||
if hasattr(server, "get_queue_status"):
|
||||
server.send_sync("status", {"status": server.get_queue_status()})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
pause_manager = PauseQueueManager()
|
||||
persistent_manager = PersistentQueueManager()
|
||||
tray_manager = TrayIconManager(pause_manager)
|
||||
|
||||
def setup_queue_control_routes(server):
|
||||
pause_manager.patch_all()
|
||||
persistent_manager.patch_server()
|
||||
persistent_manager.restore_queue()
|
||||
|
||||
if is_tray_icon_enabled():
|
||||
tray_manager.start()
|
||||
|
||||
if is_assets_restore_enabled():
|
||||
assets_restore_manager.restore_on_launch(server)
|
||||
|
||||
routes = server.routes
|
||||
|
||||
@routes.post("/leafflow/assets/restore")
|
||||
async def restore_assets_endpoint(request):
|
||||
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):
|
||||
return web.json_response(assets_restore_manager.last_debug_report)
|
||||
|
||||
@routes.get("/pause_queue/status")
|
||||
async def get_status(request):
|
||||
pause_manager.patch_all()
|
||||
@@ -425,8 +443,8 @@ def setup_queue_control_routes(server):
|
||||
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,272 @@
|
||||
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()
|
||||
return val in ["true", "1", "yes"]
|
||||
|
||||
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:
|
||||
try:
|
||||
import subprocess
|
||||
print("[LeafFlow] pystray is not installed. Auto-installing pystray...")
|
||||
subprocess.check_call([sys.executable, "-m", "pip", "install", "pystray"])
|
||||
import pystray
|
||||
print("[LeafFlow] Successfully auto-installed pystray.")
|
||||
except Exception as e:
|
||||
print(f"[LeafFlow] Failed to auto-install pystray: {e}. System tray icon cannot start.")
|
||||
return
|
||||
|
||||
_, 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,)
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
import os
|
||||
import re
|
||||
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
|
||||
|
||||
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()
|
||||
+7
-6
@@ -1,14 +1,15 @@
|
||||
[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.1.0"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
dependencies = ["torch", "pillow", "numpy", "piexif"]
|
||||
dependencies = ["torch", "pillow", "numpy", "piexif", "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,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,10 @@
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
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,80 @@
|
||||
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_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,73 @@
|
||||
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, 64, 64, 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, 64, 64, 3))
|
||||
|
||||
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,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,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, 64, 64, 3))
|
||||
|
||||
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,180 @@
|
||||
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_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_empty_text_edge_case(self):
|
||||
prompt, remaining_text, remaining_count = self.node.process_queue(
|
||||
text="",
|
||||
unique_id="empty_node"
|
||||
)
|
||||
self.assertEqual(prompt, "")
|
||||
self.assertEqual(remaining_text, "")
|
||||
self.assertEqual(remaining_count, 0)
|
||||
|
||||
def test_none_text_edge_case(self):
|
||||
prompt, remaining_text, remaining_count = self.node.process_queue(
|
||||
text=None,
|
||||
unique_id="none_node"
|
||||
)
|
||||
self.assertEqual(prompt, "")
|
||||
self.assertEqual(remaining_text, "")
|
||||
self.assertEqual(remaining_count, 0)
|
||||
|
||||
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,55 @@
|
||||
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
|
||||
)
|
||||
|
||||
class TestUtils(unittest.TestCase):
|
||||
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_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()
|
||||
@@ -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,174 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to fetch image metadata and extract the positive prompt text.
|
||||
*/
|
||||
async function copyImagePrompt(imgSrc) {
|
||||
if (!imgSrc) return false;
|
||||
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) return false;
|
||||
|
||||
// Try extracting via LeafFlow /view_image_prompt endpoint if available or direct PNG extraction
|
||||
const promptUrl = `/leafflow/view_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) {
|
||||
return await copyToClipboard(data.prompt);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[LeafFlow] Direct prompt fetch failed:", e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 1. Hook into Node Context Menu (Right Click on Image Nodes)
|
||||
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);
|
||||
|
||||
const enabled = app.ui.settings.getSettingValue(
|
||||
"LeafFlow.3. 📋 Prompt Actions.02_EnableContextMenuCopyPrompt",
|
||||
true
|
||||
);
|
||||
if (!enabled) return;
|
||||
|
||||
if (this.imgs && this.imgs.length > 0) {
|
||||
options.push({
|
||||
content: "📋 Copy Prompt",
|
||||
callback: async () => {
|
||||
const img = this.imgs[this.imageIndex || 0];
|
||||
if (!img || !img.src) return;
|
||||
const success = await copyImagePrompt(img.src);
|
||||
if (success) {
|
||||
alert("Prompt copied to clipboard!");
|
||||
} else {
|
||||
alert("LeafFlow: Could not extract positive prompt from this image.");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// 2. Hook into Standard Top Hover Action Bar over Image Cards in Assets Pane & Canvas Previews
|
||||
function injectHoverCopyAction(overlayBar) {
|
||||
if (!overlayBar || overlayBar.querySelector(".leafflow-hover-copy")) return;
|
||||
|
||||
const enabled = app.ui.settings.getSettingValue(
|
||||
"LeafFlow.3. 📋 Prompt Actions.01_EnableAssetsCopyPromptButton",
|
||||
true
|
||||
);
|
||||
if (!enabled) return;
|
||||
|
||||
// Locate the white icon group container (div.flex.shrink-0) inside the overlay
|
||||
const iconGroup =
|
||||
overlayBar.querySelector(".flex.shrink-0") ||
|
||||
overlayBar.querySelector('button[aria-label="Zoom in"]')?.parentElement ||
|
||||
overlayBar;
|
||||
|
||||
// Locate parent image card or asset item container
|
||||
const parentCard = overlayBar.closest(
|
||||
"div[data-virtual-grid-item], .asset-card, [data-node-id], .lg-node, div.relative"
|
||||
);
|
||||
const img = parentCard ? parentCard.querySelector("img") : overlayBar.parentElement?.querySelector("img");
|
||||
if (!img || !img.src) return;
|
||||
|
||||
// Find the 'More options' button (3-dots ellipsis) to insert before it
|
||||
const moreBtn =
|
||||
iconGroup.querySelector('button[aria-label="More options"]') ||
|
||||
iconGroup.querySelector('button[aria-label="More"]') ||
|
||||
iconGroup.lastElementChild;
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
if (moreBtn && moreBtn.parentElement === iconGroup) {
|
||||
iconGroup.insertBefore(copyBtn, moreBtn);
|
||||
} else {
|
||||
iconGroup.appendChild(copyBtn);
|
||||
}
|
||||
}
|
||||
|
||||
// Observe DOM mutations to attach the copy button whenever a hover overlay bar appears
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
if (mutation.type === "childList") {
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const selectors =
|
||||
'.absolute.top-2, .absolute.top-1, .asset-card-overlay, [data-testid="asset-card-actions"]';
|
||||
if (node.matches?.(selectors)) {
|
||||
injectHoverCopyAction(node);
|
||||
} else if (node.querySelectorAll) {
|
||||
const overlays = node.querySelectorAll(selectors);
|
||||
overlays.forEach((overlay) => injectHoverCopyAction(overlay));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
@@ -0,0 +1,141 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { api } from "/scripts/api.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 api.fetchApi("/leafflow/decision", {
|
||||
method: "POST",
|
||||
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);
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
+419
-89
@@ -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;
|
||||
@@ -266,6 +297,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 +382,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,22 +481,24 @@ 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");
|
||||
@@ -413,6 +513,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];
|
||||
@@ -568,22 +684,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);
|
||||
}
|
||||
|
||||
@@ -643,11 +767,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 +785,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 +805,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,14 +825,17 @@ 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();
|
||||
@@ -708,12 +845,69 @@ app.registerExtension({
|
||||
const initialZoom = localStorage.getItem("comfy_lora_picker_zoom") || "80";
|
||||
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 +919,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 +1007,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,6 +1019,22 @@ 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",
|
||||
@@ -917,11 +1136,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 +1176,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 +1191,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 +1320,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 +1340,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 +1369,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 +1404,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 +1437,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,6 +1456,17 @@ 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);
|
||||
@@ -1181,7 +1499,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);
|
||||
}
|
||||
}
|
||||
});
|
||||
+155
-23
@@ -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,88 @@ app.registerExtension({
|
||||
gridContainer.className = "img-grid-container";
|
||||
viewContainer.appendChild(gridContainer);
|
||||
|
||||
node.computeSize = function() {
|
||||
const displayModeWidget = node.widgets ? node.widgets.find(w => w.name === "display_mode") : null;
|
||||
let userSavedHeight = 480;
|
||||
|
||||
const applyDisplayMode = (mode) => {
|
||||
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);
|
||||
};
|
||||
|
||||
const initialZoom = localStorage.getItem("comfy_img_picker_zoom") || "80";
|
||||
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];
|
||||
};
|
||||
@@ -302,8 +411,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 +513,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 +536,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 +556,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 +581,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 +602,7 @@ app.registerExtension({
|
||||
node.onConfigure = function(config) {
|
||||
if (originalOnConfigure) originalOnConfigure.apply(this, arguments);
|
||||
|
||||
const curWidget = getHiddenWidget();
|
||||
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 +611,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,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,80 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { api } from "/scripts/api.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 api.fetchApi("/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 api.fetchApi("/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);
|
||||
});
|
||||
openFileBtn.serialize = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
|
||||
// Vibrant Leaf Green palette matching the 🍃 emoji
|
||||
const LEAF_GREEN = { color: "#16a34a", bgcolor: "#14532d" };
|
||||
const LEAF_LIGHT = { color: "#22c55e", bgcolor: "#166534" };
|
||||
const LEAF_EMERALD = { color: "#059669", bgcolor: "#064e3b" };
|
||||
|
||||
const COLOR_MAP = {
|
||||
// Visual Loaders
|
||||
"VisualLoraLoader": LEAF_GREEN,
|
||||
"FolderLoraLoader": LEAF_GREEN,
|
||||
"FolderLoraLoaderPretty": LEAF_GREEN,
|
||||
"VisualImageLoader": LEAF_GREEN,
|
||||
"LoadRecentOutputs": LEAF_GREEN,
|
||||
|
||||
// Automation, Flow & Utilities
|
||||
"LoadImageFromFolder": LEAF_LIGHT,
|
||||
"TextAspectRatioFinder": LEAF_LIGHT,
|
||||
"PreviewImageSizeAspectRatio": LEAF_LIGHT,
|
||||
"TextLoraFinder": LEAF_LIGHT,
|
||||
"PromptQueueIterator": LEAF_LIGHT,
|
||||
"MultiTextReplacer": LEAF_LIGHT,
|
||||
"LeafFlowTextSplit": LEAF_LIGHT,
|
||||
"LeafFlowDecision": LEAF_LIGHT,
|
||||
|
||||
// Queue & Previews
|
||||
"PreviewLatentLive": LEAF_EMERALD,
|
||||
"PauseQueueNode": LEAF_EMERALD,
|
||||
"PersistentQueueNode": LEAF_EMERALD
|
||||
};
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfyUI.LeafFlow.Colors",
|
||||
async nodeCreated(node) {
|
||||
const enabled = app.ui.settings.getSettingValue(
|
||||
"LeafFlow.1. 🖼️ Visual Loaders.00_EnableCustomColors",
|
||||
true
|
||||
);
|
||||
if (!enabled) return;
|
||||
|
||||
if (node && node.type && COLOR_MAP[node.type]) {
|
||||
const theme = COLOR_MAP[node.type];
|
||||
node.color = theme.color;
|
||||
node.bgcolor = theme.bgcolor;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,569 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { api } from "/scripts/api.js";
|
||||
|
||||
// Inject CSS to ensure settings buttons look distinct, styled with an emerald theme, and never get text cut off
|
||||
if (typeof document !== "undefined") {
|
||||
const styleId = "leafflow-settings-button-styles";
|
||||
if (!document.getElementById(styleId)) {
|
||||
const style = document.createElement("style");
|
||||
style.id = styleId;
|
||||
style.textContent = `
|
||||
/* ComfyUI V2 Vue & LiteGraph Settings Button Styling */
|
||||
button.leafflow-settings-btn,
|
||||
tr:has([id*="LeafFlow"]) button,
|
||||
tr:has([id*="leafflow"]) button,
|
||||
div:has(> [id*="LeafFlow"]) button,
|
||||
div:has(> [id*="leafflow"]) button,
|
||||
[data-setting-id*="LeafFlow"] button,
|
||||
div[class*="setting"]:has(span:contains("LeafFlow")) button {
|
||||
background: linear-gradient(135deg, #059669 0%, #047857 100%) !important;
|
||||
border: 1px solid #10b981 !important;
|
||||
color: #ffffff !important;
|
||||
font-weight: 600 !important;
|
||||
font-size: 12px !important;
|
||||
padding: 6px 14px !important;
|
||||
border-radius: 6px !important;
|
||||
width: auto !important;
|
||||
min-width: max-content !important;
|
||||
max-width: none !important;
|
||||
white-space: nowrap !important;
|
||||
overflow: visible !important;
|
||||
text-overflow: clip !important;
|
||||
cursor: pointer !important;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.15) !important;
|
||||
transition: all 0.2s ease !important;
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
}
|
||||
|
||||
button.leafflow-settings-btn:hover,
|
||||
tr:has([id*="LeafFlow"]) button:hover,
|
||||
div:has(> [id*="LeafFlow"]) button:hover,
|
||||
[data-setting-id*="LeafFlow"] button:hover {
|
||||
background: linear-gradient(135deg, #047857 0%, #059669 100%) !important;
|
||||
border-color: #34d399 !important;
|
||||
color: #ffffff !important;
|
||||
box-shadow: 0 0 10px rgba(16, 185, 129, 0.45) !important;
|
||||
transform: translateY(-1px) !important;
|
||||
}
|
||||
|
||||
button.leafflow-settings-btn:active,
|
||||
tr:has([id*="LeafFlow"]) button:active {
|
||||
transform: translateY(0) !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfyUI.LeafFlow.Settings",
|
||||
async setup() {
|
||||
// =========================================================================
|
||||
// GRUPPE 1: 1. 🖼️ Visual Loaders
|
||||
// =========================================================================
|
||||
|
||||
// 1.0 Custom Node Colors Toggle
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.1. 🖼️ Visual Loaders.00_EnableCustomColors",
|
||||
name: "Enable Custom LeafFlow Node Colors",
|
||||
type: "boolean",
|
||||
defaultValue: true,
|
||||
tooltip: "Applies a fresh Leaf Green color theme to LeafFlow nodes on the canvas. When disabled, nodes use default ComfyUI colors.",
|
||||
});
|
||||
|
||||
// 1.1 Civitai API Key
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.1. 🖼️ Visual Loaders.01_CivitaiApiKey",
|
||||
name: "Civitai API Key",
|
||||
type: "text",
|
||||
defaultValue: "",
|
||||
tooltip: "Optional. Civitai SHA256 search works publicly without a key for normal models. Only needed for NSFW/private models or higher rate limits. Whitespace is automatically stripped.",
|
||||
onChange(value) {
|
||||
const cleanKey = (value || "").trim();
|
||||
api.fetchApi("/leafflow/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ civitai_api_key: cleanKey })
|
||||
}).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
// 1.2 Enable Civitai Auto-Scraping Toggle (default: true)
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.1. 🖼️ Visual Loaders.02_EnableCivitaiScraping",
|
||||
name: "Enable Civitai Auto-Scraping",
|
||||
type: "boolean",
|
||||
defaultValue: true,
|
||||
tooltip: "Toggles automated downloading of preview thumbnails for new LoRAs from Civitai via SHA256 file hashes. Note: SHA256 hash searching will always work regardless of this setting when matching local models.",
|
||||
onChange(value) {
|
||||
api.fetchApi("/leafflow/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enable_civitai_scraping: value ? "true" : "false" })
|
||||
}).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
// 1.3 TMDB Access Token
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.1. 🖼️ Visual Loaders.03_TMDBApiKey",
|
||||
name: "TMDB Access Token",
|
||||
type: "text",
|
||||
defaultValue: "",
|
||||
tooltip: "Optional. Accepts TMDB v3 API keys or TMDB v4 Read Access Tokens (eyJ...). Used for celebrity poster and preview image lookup. Whitespace is automatically stripped.",
|
||||
onChange(value) {
|
||||
const cleanKey = (value || "").trim();
|
||||
api.fetchApi("/leafflow/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ tmdb_api_key: cleanKey })
|
||||
}).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
// 1.4 Enable TMDB Auto-Scraping Toggle (default: false)
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.1. 🖼️ Visual Loaders.04_EnableTMDBScraping",
|
||||
name: "Enable TMDB Auto-Scraping",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
tooltip: "Toggles automated downloading of celebrity preview thumbnails from TMDB. Default is disabled.",
|
||||
onChange(value) {
|
||||
api.fetchApi("/leafflow/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enable_tmdb_scraping: value ? "true" : "false" })
|
||||
}).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
// 1.5 Enable LoRA Usage Tracking (default: true)
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.1. 🖼️ Visual Loaders.05_EnableLoraUsage",
|
||||
name: "Enable LoRA Usage Tracking",
|
||||
type: "boolean",
|
||||
defaultValue: true,
|
||||
tooltip: "Toggles tracking and displaying LoRA usage counts & visual rank badges (🔥, Gold, Silver, Bronze) in the LoRA picker. Existing usage history is preserved when disabled.",
|
||||
onChange(value) {
|
||||
api.fetchApi("/leafflow/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enable_lora_usage: value ? "true" : "false" })
|
||||
}).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
// 1.6 Reset Scrapes Cache Button
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.1. 🖼️ Visual Loaders.06_ResetScrapesCache",
|
||||
name: "Reset Failed Scrapes Cache",
|
||||
type: "button",
|
||||
defaultValue: "🗑️ Clear Scrapes Cache",
|
||||
tooltip: "Immediately clears failed_scrapes.json so Civitai and TMDB can retry downloading missing preview thumbnails on the next folder scan.",
|
||||
attrs: {
|
||||
className: "leafflow-settings-btn",
|
||||
class: "leafflow-settings-btn",
|
||||
onClick: async () => {
|
||||
try {
|
||||
const resp = await api.fetchApi("/leafflow/scrapes/clear", { method: "POST" });
|
||||
const data = await resp.json();
|
||||
if (data && data.status === "ok") {
|
||||
alert("LeafFlow: Failed scrapes cache successfully reset!");
|
||||
} else {
|
||||
alert("LeafFlow: Failed to reset cache.");
|
||||
}
|
||||
} catch (err) {
|
||||
alert("LeafFlow: Error resetting cache: " + err);
|
||||
}
|
||||
}
|
||||
},
|
||||
render: renderSettingButton("🗑️ Clear Scrapes Cache", "⏳ Clearing...", "✅ Cache Reset!", async () => {
|
||||
const resp = await api.fetchApi("/leafflow/scrapes/clear", { method: "POST" });
|
||||
const data = await resp.json();
|
||||
if (data.status !== "ok") {
|
||||
throw new Error(data.message || "Failed to reset scrapes cache");
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
// =========================================================================
|
||||
// GRUPPE 2: 2. 🔄 Prompt Queue Iterator
|
||||
// =========================================================================
|
||||
|
||||
// 2.1 Clear Prompt Iterator State on Launch (default: false)
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.2. 🔄 Prompt Iterator.01_ClearOnLaunch",
|
||||
name: "Clear State on Launch",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
tooltip: "Privacy setting. When enabled, prompt_iterator_state.json will be emptied automatically every time ComfyUI starts up.",
|
||||
onChange(value) {
|
||||
api.fetchApi("/leafflow/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ clear_prompt_iterator_on_launch: value ? "true" : "false" })
|
||||
}).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
// 2.2 Reset Prompt Iterator Queues Now
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.2. 🔄 Prompt Iterator.02_ResetActiveQueues",
|
||||
name: "Reset Active Queues State",
|
||||
type: "button",
|
||||
defaultValue: "🔄 Reset All Queues",
|
||||
tooltip: "Immediately empties all active prompt queues and resets iterator state across all workflows.",
|
||||
attrs: {
|
||||
className: "leafflow-settings-btn",
|
||||
class: "leafflow-settings-btn",
|
||||
onClick: async () => {
|
||||
try {
|
||||
const resp = await api.fetchApi("/leafflow/prompt_iterator/clear", { method: "POST" });
|
||||
const data = await resp.json();
|
||||
if (data && data.status === "ok") {
|
||||
alert("LeafFlow: Prompt Iterator queues successfully reset!");
|
||||
} else {
|
||||
alert("LeafFlow: Failed to reset queues.");
|
||||
}
|
||||
} catch (err) {
|
||||
alert("LeafFlow: Error resetting queues: " + err);
|
||||
}
|
||||
}
|
||||
},
|
||||
render: renderSettingButton("🔄 Reset All Queues", "⏳ Resetting...", "✅ State Reset!", async () => {
|
||||
const resp = await api.fetchApi("/leafflow/prompt_iterator/clear", { method: "POST" });
|
||||
const data = await resp.json();
|
||||
if (data.status !== "ok") {
|
||||
throw new Error(data.message || "Failed to clear prompt iterator state");
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
// =========================================================================
|
||||
// GRUPPE 3: 3. 📋 Prompt Actions
|
||||
// =========================================================================
|
||||
|
||||
// 3.1 Show "Copy Prompt" Button on Image Overlays
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.3. 📋 Prompt Actions.01_EnableAssetsCopyPromptButton",
|
||||
name: "Show \"Copy Prompt\" Button on Images",
|
||||
type: "boolean",
|
||||
defaultValue: true,
|
||||
tooltip: "Shows the 📋 'Copy Prompt' overlay action button when hovering over generated images in the Assets / History pane and preview nodes.",
|
||||
});
|
||||
|
||||
// 3.2 Show Right-Click "Copy Prompt" Menu Action
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.3. 📋 Prompt Actions.02_EnableContextMenuCopyPrompt",
|
||||
name: "Show Right-Click \"Copy Prompt\" Menu Action",
|
||||
type: "boolean",
|
||||
defaultValue: true,
|
||||
tooltip: "Adds '📋 Copy Prompt' to node right-click context menus.",
|
||||
});
|
||||
|
||||
|
||||
// =========================================================================
|
||||
// GRUPPE 4: 4. ⏸️ Pause & Resume Controls
|
||||
// =========================================================================
|
||||
|
||||
// 4.1 Default Pause Queue State on Launch
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.4. ⏸️ Pause Controls.01_DefaultStateOnLaunch",
|
||||
name: "Default State on Launch",
|
||||
type: "combo",
|
||||
options: ["Paused", "Running"],
|
||||
defaultValue: "Paused",
|
||||
tooltip: "Choose whether execution starts in Paused state or Running state on ComfyUI startup.",
|
||||
onChange(value) {
|
||||
api.fetchApi("/leafflow/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ default_pause_state: value })
|
||||
}).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
// 4.2 Default Pause Queue Mode on Launch
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.4. ⏸️ Pause Controls.02_DefaultPauseAction",
|
||||
name: "Default Pause Action",
|
||||
type: "combo",
|
||||
options: ["Finish Active Prompt", "Instant Resume Node"],
|
||||
defaultValue: "Finish Active Prompt",
|
||||
tooltip: "Choose default pause behavior when the pause button or hotkey is triggered.",
|
||||
onChange(value) {
|
||||
const modeKey = (value === "Instant Resume Node" || value === "Pause (Instant)") ? "instantly" : "after_finish";
|
||||
api.fetchApi("/leafflow/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ default_pause_mode: modeKey })
|
||||
}).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
// 4.3 Enable Pause Queue Toolbar Button
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.4. ⏸️ Pause Controls.03_EnableToolbarButton",
|
||||
name: "Enable Top Toolbar Button",
|
||||
type: "boolean",
|
||||
defaultValue: true,
|
||||
tooltip: "Displays the green/orange Pause & Continue button group in the top action bar.",
|
||||
onChange(value) {
|
||||
const group = document.querySelector(".pq-button-group");
|
||||
if (group) {
|
||||
group.style.display = value ? "inline-flex" : "none";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 4.4 Toolbar Button Unpaused Color
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.4. ⏸️ Pause Controls.04_ToolbarButtonUnpausedColor",
|
||||
name: "Toolbar Button Unpaused Color",
|
||||
type: "text",
|
||||
defaultValue: "#16a34a",
|
||||
tooltip: "Hex color code for the Pause toolbar button when execution is unpaused/running (default: #16a34a).",
|
||||
onChange(value) {
|
||||
document.documentElement.style.setProperty("--pq-unpaused-color", value || "#16a34a");
|
||||
}
|
||||
});
|
||||
|
||||
// 4.5 Toolbar Button Paused Color
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.4. ⏸️ Pause Controls.05_ToolbarButtonPausedColor",
|
||||
name: "Toolbar Button Paused Color",
|
||||
type: "text",
|
||||
defaultValue: "#ea580c",
|
||||
tooltip: "Hex color code for the Pause toolbar button when execution is paused (default: #ea580c).",
|
||||
onChange(value) {
|
||||
document.documentElement.style.setProperty("--pq-paused-color", value || "#ea580c");
|
||||
}
|
||||
});
|
||||
|
||||
// 4.6 Enable System Tray Icon
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.4. ⏸️ Pause Controls.06_EnableTrayIcon",
|
||||
name: "Enable System Tray Icon",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
tooltip: "Displays an OS system tray icon with real-time queue status colors and outside-browser queue controls.",
|
||||
onChange(value) {
|
||||
api.fetchApi("/leafflow/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enable_tray_icon: value ? "true" : "false" })
|
||||
}).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// =========================================================================
|
||||
// GRUPPE 5: 5. 💾 Persistent Queue (Auto-Recovery)
|
||||
// =========================================================================
|
||||
|
||||
// 5.1 Enable Persistent Queue (Auto-Recovery)
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.5. 💾 Persistent Queue.01_EnablePersistentQueue",
|
||||
name: "Enable Persistent Queue (Auto-Recovery)",
|
||||
type: "boolean",
|
||||
defaultValue: true,
|
||||
tooltip: "Automatically persists unfinished batch queue items to disk and restores them after server or browser crashes.",
|
||||
onChange(value) {
|
||||
api.fetchApi("/leafflow/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enable_persistent_queue: value ? "true" : "false" })
|
||||
}).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
// 5.2 Persistent Queue Restored Launch State
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.5. 💾 Persistent Queue.02_RecoveryLaunchState",
|
||||
name: "Recovery Launch State",
|
||||
type: "combo",
|
||||
options: ["Match Default", "Force Paused", "Force Running"],
|
||||
defaultValue: "Match Default",
|
||||
tooltip: "Override launch state when unfinished queue items are recovered on startup.",
|
||||
onChange(value) {
|
||||
api.fetchApi("/leafflow/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ persistent_queue_restored_state: value })
|
||||
}).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// =========================================================================
|
||||
// GRUPPE 6: 6. 🖼️ Assets & History Restore
|
||||
// =========================================================================
|
||||
|
||||
// 6.1 Enable Assets / History Restore on Launch
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.6. 🖼️ Assets Restore.01_RestoreAssetsOnLaunch",
|
||||
name: "Restore Assets on Launch",
|
||||
type: "boolean",
|
||||
defaultValue: true,
|
||||
tooltip: "Automatically populates the Assets / History pane upon ComfyUI launch with your latest generated images.",
|
||||
onChange(value) {
|
||||
api.fetchApi("/leafflow/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enable_assets_restore: value ? "true" : "false" })
|
||||
}).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
// 6.2 Restored Assets Count
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.6. 🖼️ Assets Restore.02_RestoredAssetsCount",
|
||||
name: "Restored Assets Count",
|
||||
type: "number",
|
||||
defaultValue: 64,
|
||||
tooltip: "Number of newest images from the output folder to restore into the Assets / History pane on launch (default: 64).",
|
||||
onChange(value) {
|
||||
const count = parseInt(value, 10) || 64;
|
||||
api.fetchApi("/leafflow/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ restore_assets_count: count })
|
||||
}).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// =========================================================================
|
||||
// GRUPPE 7: 7. 🩺 Diagnostics & Debug
|
||||
// =========================================================================
|
||||
|
||||
// 7.1 Export Debug Profile Button
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.7. 🩺 Diagnostics.01_ExportDebugProfile",
|
||||
name: "Export Debug Profile",
|
||||
type: "button",
|
||||
defaultValue: "📥 Export Debug Profile",
|
||||
tooltip: "Exports non-sensitive environment diagnostics (OS, Python, PyTorch, LeafFlow settings, local counts) as a JSON file to share when troubleshooting issues.",
|
||||
attrs: {
|
||||
className: "leafflow-settings-btn",
|
||||
class: "leafflow-settings-btn",
|
||||
onClick: async () => {
|
||||
const approved = confirm("🍃 ComfyUI-LeafFlow Diagnostics Export\n\nExport system diagnostics for troubleshooting?\n\nNOTE: Sensitive API keys, tokens, file paths, and private prompt texts are automatically stripped and NEVER exported.");
|
||||
if (!approved) return;
|
||||
|
||||
try {
|
||||
const resp = await api.fetchApi("/leafflow/debug/export");
|
||||
const data = await resp.json();
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `leafflow_debug_profile_${new Date().toISOString().slice(0, 10)}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
alert("✅ Diagnostics profile exported successfully! You can attach the downloaded JSON file to your bug report or GitHub issue.");
|
||||
} catch (e) {
|
||||
console.error("[LeafFlow] 🍃 Error exporting debug profile:", e);
|
||||
alert("❌ Failed to export debug profile: " + e.message);
|
||||
}
|
||||
}
|
||||
},
|
||||
render: renderSettingButton("📥 Export Debug Profile", "⏳ Exporting...", "✅ Exported!", async () => {
|
||||
const resp = await api.fetchApi("/leafflow/debug/export");
|
||||
const data = await resp.json();
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `leafflow_debug_profile_${new Date().toISOString().slice(0, 10)}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
})
|
||||
});
|
||||
}
|
||||
});
|
||||
+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;
|
||||
|
||||
+41
-24
@@ -38,7 +38,7 @@ class PauseQueueUI {
|
||||
this.updateState(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[FlowControl] Error fetching status:", e);
|
||||
console.error("[LeafFlow] Error fetching status:", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ class PauseQueueUI {
|
||||
this.updateState(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[FlowControl] Error toggling pause:", e);
|
||||
console.error("[LeafFlow] Error toggling pause:", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ class PauseQueueUI {
|
||||
this.updateState(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[FlowControl] Error setting mode:", e);
|
||||
console.error("[LeafFlow] Error setting mode:", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,44 +95,61 @@ class PauseQueueUI {
|
||||
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 +319,7 @@ class PauseQueueUI {
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfyUI.FlowControl.PauseQueue",
|
||||
name: "ComfyUI.LeafFlow.PauseQueue",
|
||||
async setup() {
|
||||
new PauseQueueUI();
|
||||
}
|
||||
|
||||
+38
-2
@@ -2,8 +2,36 @@ import { app } from "/scripts/app.js";
|
||||
import { api } from "/scripts/api.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 allItems = [...(queue.Running || []), ...(queue.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.setPrompt({ output: promptData });
|
||||
patched = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (patched) {
|
||||
rgthree.SERVICE.dispatchProgressUpdate();
|
||||
console.log("[LeafFlow] Synced recovered queue with rgthree-comfy.");
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// rgthree-comfy not installed or path changed, ignore
|
||||
}
|
||||
};
|
||||
|
||||
const claimQueueOwnership = async () => {
|
||||
if (api && api.clientId) {
|
||||
try {
|
||||
@@ -12,8 +40,11 @@ app.registerExtension({
|
||||
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, 500);
|
||||
} catch (e) {
|
||||
console.error("[FlowControl] Error claiming queue ownership:", e);
|
||||
console.error("[LeafFlow] Error claiming queue ownership:", e);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -25,6 +56,11 @@ app.registerExtension({
|
||||
api.addEventListener("status", () => {
|
||||
claimQueueOwnership();
|
||||
}, { once: true });
|
||||
|
||||
// Also run patch anytime an execution starts just in case it was triggered externally
|
||||
api.addEventListener("execution_start", () => {
|
||||
setTimeout(patchRgthree, 100);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
+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