Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c9f9568b5 | ||
|
|
29d9790c8a | ||
|
|
15c6f13ac3 | ||
|
|
b62511a5b3 | ||
|
|
3c7c1ca267 | ||
|
|
0e456783e3 | ||
|
|
b93d7ffe18 | ||
|
|
026107923b | ||
|
|
3bb42c3d2f | ||
|
|
5577efa485 | ||
|
|
121802da9e | ||
|
|
a40f09c7d9 | ||
|
|
95421b410f | ||
|
|
a3a1bb2446 | ||
|
|
3e9b4e1d6e | ||
|
|
dcadd6d605 | ||
|
|
720021b940 | ||
|
|
65635e3e95 | ||
|
|
024cb92f1f | ||
|
|
b2cfa6b8d9 | ||
|
|
babfecfaf1 | ||
|
|
66ab2f9606 | ||
|
|
3af42597d0 | ||
|
|
f0942b25b3 | ||
|
|
b82da68c8a | ||
|
|
ac2c097bfa | ||
|
|
c61537509f | ||
|
|
402c26db7f | ||
|
|
995d508979 |
@@ -11,17 +11,17 @@ For Git workflow, branch protection, and Pull Request guidelines, see **[`CONTRI
|
||||
|
||||
| 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) |
|
||||
| **`AGENTS.md`** | Authoritative developer & agent standard operating procedure, engineering checklists, architecture rules. | [AGENTS.md](./AGENTS.md) |
|
||||
| **`CONTRIBUTING.md`** | Community contribution guidelines, strict branch rules, PR submission workflow. | [CONTRIBUTING.md](./CONTRIBUTING.md) |
|
||||
| **`README.md`** | Main project homepage, installation guide, complete node catalog with parameter tables and UI settings guide. | [README.md](./README.md) |
|
||||
| **`WALKTHROUGH.md`** | Step-by-step user tutorials, example workflows, visual load guides, and batch prompting patterns. | [WALKTHROUGH.md](./WALKTHROUGH.md) |
|
||||
| **`CHANGELOG.md`** | Strict Keep-A-Changelog semantic version release history across all updates. | [CHANGELOG.md](./CHANGELOG.md) |
|
||||
| **`SECURITY.md`** | Security policies, supported versions, and private vulnerability disclosure instructions. | [SECURITY.md](./SECURITY.md) |
|
||||
| **`CLAUDE.md`** | AI agent instruction pointer. | [CLAUDE.md](./CLAUDE.md) |
|
||||
| **`GEMINI.md`** | AI agent instruction pointer. | [GEMINI.md](./GEMINI.md) |
|
||||
| **`AGENTS.md`** | AI agent instruction pointer. | [AGENTS.md](./AGENTS.md) |
|
||||
| **`bug_report.md`** | GitHub issue template for reporting reproducible defects. | [bug_report.md](./.github/ISSUE_TEMPLATE/bug_report.md) |
|
||||
| **`feature_request.md`** | GitHub issue template for proposing new nodes or UI features. | [feature_request.md](./.github/ISSUE_TEMPLATE/feature_request.md) |
|
||||
|
||||
---
|
||||
|
||||
@@ -99,7 +99,7 @@ When implementing a new custom node, you **must complete all 5 steps**:
|
||||
- [ ] 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).
|
||||
- [ ] Import the node class into [`__init__.py`](./__init__.py).
|
||||
- [ ] Register in `NODE_CLASS_MAPPINGS`:
|
||||
```python
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
@@ -123,21 +123,21 @@ When implementing a new custom node, you **must complete all 5 steps**:
|
||||
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):
|
||||
- [ ] Register node theme colors in [`web/leafflow_colors.js`](./web/leafflow_colors.js):
|
||||
- Loaders: Emerald Green (`color: "#059669"`, `bgcolor: "#047857"`)
|
||||
- Automation & Utils: Amber (`color: "#d97706"`, `bgcolor: "#b45309"`)
|
||||
- Previews & Decisions: Violet (`color: "#7c3aed"`, `bgcolor: "#6d28d9"`)
|
||||
|
||||
### Step 2.4: Documentation (`README.md` & `WALKTHROUGH.md`)
|
||||
- [ ] Add a collapsible `<details>` section in [`README.md`](file:///d:/GenAI/ComfyUI/ComfyUI/custom_nodes/ComfyUI-LeafFlow/README.md) under `## 📦 Individual Nodes Reference`:
|
||||
- [ ] Add a collapsible `<details>` section in [`README.md`](./README.md) under `## 📦 Individual Nodes Reference`:
|
||||
- Node summary tag: `<summary><b>🍃 🏷️ Display Name</b> (<code>ClassName</code>)</summary>`
|
||||
- `#### Overview`
|
||||
- `#### Inputs & Widgets`
|
||||
- `#### Outputs`
|
||||
- [ ] If the node introduces a new workflow pattern, add a section in [`WALKTHROUGH.md`](file:///d:/GenAI/ComfyUI/ComfyUI/custom_nodes/ComfyUI-LeafFlow/WALKTHROUGH.md).
|
||||
- [ ] If the node introduces a new workflow pattern, add a section in [`WALKTHROUGH.md`](./WALKTHROUGH.md).
|
||||
|
||||
### Step 2.5: Changelog
|
||||
- [ ] Add entry under `### Added` in [`CHANGELOG.md`](file:///d:/GenAI/ComfyUI/ComfyUI/custom_nodes/ComfyUI-LeafFlow/CHANGELOG.md).
|
||||
- [ ] Add entry under `### Added` in [`CHANGELOG.md`](./CHANGELOG.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -146,7 +146,7 @@ When implementing a new custom node, you **must complete all 5 steps**:
|
||||
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):
|
||||
- [ ] Add the variable with default value and explanation in [`.env.example`](./.env.example):
|
||||
```ini
|
||||
# Brief description of what this setting controls
|
||||
YOUR_NEW_SETTING=true
|
||||
@@ -176,16 +176,16 @@ When introducing a configurable option or feature toggle:
|
||||
```
|
||||
|
||||
### 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).
|
||||
- [ ] Update `## ⚙️ ComfyUI Settings Menu Reference` in [`README.md`](./README.md).
|
||||
- [ ] Update `## 4. Settings & Configuration` in [`WALKTHROUGH.md`](./WALKTHROUGH.md).
|
||||
|
||||
---
|
||||
|
||||
## 4. Checklist: Updating Dependencies
|
||||
|
||||
If a feature requires a new Python package:
|
||||
- [ ] Add package to `dependencies` in [`pyproject.toml`](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).
|
||||
- [ ] Add package to `dependencies` in [`pyproject.toml`](./pyproject.toml).
|
||||
- [ ] Add package to `dependencies` list in [`install.py`](./install.py).
|
||||
- [ ] Check imports gracefully handle missing optional libraries (e.g. `try... except ImportError`).
|
||||
|
||||
---
|
||||
|
||||
@@ -5,6 +5,53 @@ 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.2.0] - 2026-09-08
|
||||
|
||||
> ⚠️ **Important Frontend Compatibility Notice**:
|
||||
> The previous release (**v2.1.0**) does **not** support the new ComfyUI frontend package (`@comfyorg/comfyui-frontend` / modern Vue virtual grid).
|
||||
> **v2.2.0 introduces complete support for the new frontend package**, restoring action buttons (Copy Prompt, Save to Prompt Bookmarks, Inspect Asset zoom) directly on modern virtual grid cards, alongside the 1D Git-Graph batch queue visualizer and per-node zoom settings!
|
||||
|
||||
### Added
|
||||
- **🌐 Full Modern ComfyUI Frontend Package Support**:
|
||||
- Full compatibility with `@comfyorg/comfyui-frontend` modern virtual grid and card DOM structures.
|
||||
- Seamless overlay action bar button groups for modern asset cards with dynamic rounded pill styling (`rounded-l-lg`, `rounded-r-lg`, `rounded-none`, `border-r`).
|
||||
- **🔍 Toggleable "Inspect Asset" Button (`04_EnableInspectAssetButton`)**:
|
||||
- Restores the direct "Inspect asset" (zoom in) button directly onto image cards in the Assets pane hover action bar, placed seamlessly alongside Download, Copy Prompt, and Bookmarks (restoring access since newer ComfyUI versions tucked it away behind the 3-dots submenu). Default is `false` (opt-in).
|
||||
- **🔖 Robust Inline SVG Icons for "Save to Prompt Bookmarks"**:
|
||||
- Replaced runtime Tailwind icon class references with crisp inline SVG ribbons, fixing an issue in modern `comfyui_frontend_package` where the bookmark icon rendered with 0 dimensions (empty space).
|
||||
- **🎨 1D Git-Graph Batch Queue Visualizer (`ComfyUI.LeafFlow.BatchQueue`)**:
|
||||
- Automatically identifies workflows queued together as batches in both ComfyUI Frontend V2 (`JobAssetsList`) and Classic V1 queue list.
|
||||
- Purely graphical 1D colored line along the left edge of queue items without any text or number clutter.
|
||||
- **36 Curated Cycling Colors**: Sequential assignment ensures adjacent batches have distinct, high-contrast colors.
|
||||
- **Start / Middle / End / Single Segments**:
|
||||
- First item in batch segment curves inward at the top (`┌`) and continues straight down.
|
||||
- Middle items connect with a continuous straight line (`│`).
|
||||
- Last item in batch segment comes straight from the top and curves inward at the bottom (`└`).
|
||||
- Single-item batches curve inward at both top and bottom (`(`).
|
||||
- **In-between Infiltration Handling**: When another prompt or batch is queued in-between items of an existing batch, the interrupted batch retains its open straight ends (no false start/end curves), while the inserted item is cleanly enclosed in its own bracket.
|
||||
- **PersistentQueue Interoperability**: Operates client-side via `localStorage` when standalone, and synchronizes with `PersistentQueueManager` so batch relationships restore automatically upon server restart.
|
||||
- **Settings Toggle**: Added `LeafFlow.BatchQueue.Enabled` setting in ComfyUI Settings menu.
|
||||
|
||||
### Fixed
|
||||
- **📐 Independent Per-Node `tile_size` in Visual Loaders**:
|
||||
- Decoupled `tile_size` zoom levels from global browser `localStorage` in `VisualLoraLoader` (V1 & V2) and `VisualImageLoader`.
|
||||
- Tile zoom values are now persisted directly in `node.properties["tile_size"]` within the workflow JSON, allowing multiple loader nodes on the canvas to maintain independent, custom tile sizes across browser reloads.
|
||||
- **🛡️ Queue Container Conflict Prevention**:
|
||||
- Hardened image card button injection guards to avoid injecting into queue item elements or small icon previews in modern virtual grids.
|
||||
|
||||
### Security & Registry Compliance
|
||||
- **Local-Only Route Enforcement**: All server control endpoints (`/leafflow/power/*`), settings updates (`/leafflow/settings`), and queue sync routes now strictly verify loopback origin (`127.0.0.1` / `::1`), rejecting remote calls with `403 Forbidden`.
|
||||
- **Directory Traversal Protection**: Image loading (`VisualImageLoader`) and thumbnail endpoints are strictly confined to ComfyUI `input`, `output`, and `temp` directories.
|
||||
- **Packaging & Installation Standards**: Removed runtime `install.py` in favor of standard `requirements.txt` (`Pillow`, `piexif`, `numpy`, `pystray`), and removed `"torch"` from `pyproject.toml` to prevent host CUDA environment corruption.
|
||||
- **Opt-In Preview Scraping**: Civitai and TMDB network scraping defaulted to disabled (`ENABLE_CIVITAI_SCRAPING=false`, `ENABLE_TMDB_SCRAPING=false`).
|
||||
|
||||
### Changed
|
||||
- **Text Aspect Ratio Finder Whitelist / Open Mode**:
|
||||
- When `aspect_ratios` is populated (e.g. `16:9, 9:16, 1:1`), strictly matches only those configured ratios in prompt text, falling back to default if prompt contains non-whitelisted ratios.
|
||||
- When `aspect_ratios` is left empty (`""`), enters open mode and accepts any valid aspect ratio found in text.
|
||||
|
||||
---
|
||||
|
||||
## [2.1.0] - 2026-08-24
|
||||
|
||||
### Added
|
||||
|
||||
@@ -56,7 +56,7 @@ Loads a LoRA using formatted pretty names (e.g. `Ana De Armas V1` instead of `kr
|
||||
<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.
|
||||
Visual thumbnail browser for LoRAs with Civitai SHA256 search & TMDB auto-scraping, popularity rank badges (🔥), multi-selection support, and independent per-node thumbnail zoom sizing.
|
||||
|
||||
#### Inputs & Widgets
|
||||
- **`model`** (`MODEL`): Input model.
|
||||
@@ -64,6 +64,7 @@ Visual thumbnail browser for LoRAs with Civitai SHA256 search & TMDB auto-scrapi
|
||||
- **`folder`** (`STRING`): Subfolder filter.
|
||||
- **`strength_model`** (`FLOAT`): Model strength.
|
||||
- **`strength_clip`** (`FLOAT`): CLIP strength.
|
||||
- **`tile_size`** (`SLIDER`, UI): Adjust thumbnail card size (persisted per node in workflow JSON).
|
||||
- **`display_mode`** (`COMBO`, *Advanced*): `Scrollable` vs `Show All`.
|
||||
- **`sort_loras_by`** (`COMBO`, *Advanced*): Sort by `Name (A-Z)`, `Name (Z-A)`, `Date Modified (Newest First)`, `Date Modified (Oldest First)`, or `Usage (Most Used First)`.
|
||||
|
||||
@@ -77,10 +78,11 @@ Visual thumbnail browser for LoRAs with Civitai SHA256 search & TMDB auto-scrapi
|
||||
<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.
|
||||
Visual thumbnail browser for image folders with instant preview selection, independent per-node thumbnail zoom sizing, and EXIF positive prompt metadata extraction. Path access is strictly confined to ComfyUI `input`, `output`, and `temp` directories for safe operation.
|
||||
|
||||
#### Inputs & Widgets
|
||||
- **`folder`** (`STRING`): Folder path to load images from.
|
||||
- **`folder`** (`STRING`): Folder path to load images from (confined to ComfyUI input/output/temp).
|
||||
- **`tile_size`** (`SLIDER`, UI): Adjust thumbnail card size (persisted per node in workflow JSON).
|
||||
- **`display_mode`** (`COMBO`, *Advanced*): `Scrollable` vs `Show All`.
|
||||
- **`sort_images_by`** (`COMBO`, *Advanced*): `Name (A-Z)`, `Name (Z-A)`, `Date Modified (Newest First)`, `Date Modified (Oldest First)`.
|
||||
|
||||
@@ -158,7 +160,7 @@ Pauses workflow execution at a specific step and displays an inline UI popup wit
|
||||
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.
|
||||
- **`aspect_ratios`** (`STRING`): Allowed ratio list (e.g. `16:9, 9:16, 1:1`). When populated, strictly enforces matching only the configured ratios and ignores any other numbers/ratios in text. When left completely empty (`""`), acts in open mode and accepts any valid ratio found in text.
|
||||
- **`search_mode`** (`COMBO`): `First match (Front)` vs `Last match (Back)`.
|
||||
- **`target_mp`** (`FLOAT`): Target megapixels (e.g. `1.0 MP`).
|
||||
- **`default_aspect_ratio`** (`COMBO`): Fallback ratio if none found.
|
||||
@@ -294,6 +296,8 @@ Configure options directly under ComfyUI Settings (⚙ gear icon):
|
||||
|
||||
- **`Show "Copy Prompt" Button on Images`** (`boolean`, *Default: true*): Shows the 📋 "Copy Prompt" overlay action button when hovering over generated images in the Assets / History pane and preview nodes.
|
||||
- **`Show Right-Click "Copy Prompt" Menu Action`** (`boolean`, *Default: true*): Adds "📋 Copy Prompt" to node right-click context menus.
|
||||
- **`Show "Save to Prompt Saver" Action`** (`boolean`, *Default: true*): Adds 🔖 "Save to Prompt Bookmarks" to image hover overlay bars and node context menus.
|
||||
- **`Show "Inspect Asset" (Zoom) Button on Images`** (`boolean`, *Default: false*): Restores the 🔍 "Inspect asset" (zoom in) button directly onto image cards in the Assets pane next to Download, Copy Prompt, and Bookmarks (restoring one-click access moved behind the 3-dots menu in newer ComfyUI versions).
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
@@ -327,13 +331,26 @@ Configure options directly under ComfyUI Settings (⚙ gear icon):
|
||||
- **`Export Debug Profile`** (*Button: `📥 Export Debug Profile`*): Exports non-sensitive system environment details (OS, Python, PyTorch, LeafFlow settings, local cache counts) to a JSON file to share when reporting bugs or requesting assistance. Sensitive API keys and tokens are never exported.
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<summary><b>8. 🎨 Batch Queue Visuals</b></summary>
|
||||
|
||||
- **`Show Batch Queue 1D Lines`** (`boolean`, *Default: true*): Renders a 1D git-graph style colored line on the left side of queued items in the queue list.
|
||||
- **36 Cycling Colors:** Every queued batch is assigned a vibrant, high-contrast color from a 36-color sequence.
|
||||
- **Clean 1D Line:** The first item starts with a top curve (`┌`), middle items are straight lines (`│`), and the last item ends with a bottom curve (`└`). Standalone 1-item batches are rounded at both ends (`(`).
|
||||
- **In-between Infiltration Detection:** If a different prompt is queued in-between items of a batch, the interrupted batch maintains its open line ends without false curves, while the inserted item is highlighted with its own bracket.
|
||||
- **Works Standalone or with PersistentQueue:** Operates client-side via `localStorage` when standalone, and syncs with `PersistentQueue` to retain batch relationships after server restarts.
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security & Privacy
|
||||
|
||||
- All API keys and settings are stored locally in `.env` (excluded from git commits via `.gitignore`).
|
||||
- No hardcoded personal paths, keys, or credentials.
|
||||
- Atomic file writes (`.tmp` + `os.replace`) prevent JSON corruption during sudden crashes.
|
||||
- **Local-Only Enforced Endpoints:** Power actions (`/leafflow/power/*`), settings updates (`/leafflow/settings`), and queue sync routes are strictly protected with loopback verification (`127.0.0.1` / `::1`), rejecting external network requests with `403 Forbidden`.
|
||||
- **Directory Confinement:** Image loading and thumbnail routes are strictly confined to ComfyUI `input`, `output`, and `temp` directories with traversal guards.
|
||||
- **Opt-In Scraping:** Civitai and TMDB network scraping are opt-in and disabled by default.
|
||||
- **Zero Runtime Package Installs:** No `install.py` or dynamic `pip` execution; all standard dependencies (`Pillow`, `piexif`, `numpy`, `pystray`) are cleanly declared in `requirements.txt`.
|
||||
- **Local Environment:** All API keys and settings are stored locally in `.env` (excluded from git commits via `.gitignore`).
|
||||
- **Atomic File Writes:** Safe atomic writes (`.tmp` + `os.replace`) prevent JSON corruption during sudden crashes.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+8
-3
@@ -10,9 +10,14 @@ Instead, please report security vulnerabilities directly to the maintainer via G
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 2.x | :white_check_mark: |
|
||||
| 1.x | :white_check_mark: |
|
||||
|
||||
## Security Best Practices
|
||||
## Security Architecture & Best Practices
|
||||
|
||||
- All API keys (Civitai / TMDB) are stored locally in `.env` and are strictly excluded from git tracking via `.gitignore`.
|
||||
- API keys are masked in log outputs (e.g. `Bearer civ_*****`).
|
||||
- **Loopback Enforcement for Server Control**: All administrative endpoints (power controls `/leafflow/power/*`, settings modification `/leafflow/settings`, and internal state synchronization `/leafflow/batch_queue/*`) strictly verify loopback origin (`127.0.0.1` / `::1`), rejecting remote network requests with `403 Forbidden`.
|
||||
- **Filesystem Path Confinement**: Image loading (`VisualImageLoader`) and thumbnail file streaming endpoints are strictly confined to standard ComfyUI `input`, `output`, and `temp` directories with path canonicalization and directory traversal guards.
|
||||
- **Opt-In Network Requests**: External metadata and preview scraping (Civitai / TMDB) are completely opt-in and disabled by default.
|
||||
- **No Dynamic Package Installation**: Runtime `pip` execution (`install.py` / `subprocess`) is eliminated; all dependencies are transparently listed in `requirements.txt`.
|
||||
- **Local Environment**: All API keys (Civitai / TMDB) are stored locally in `.env` and are strictly excluded from git tracking via `.gitignore`. Sensitive keys are masked in server log outputs.
|
||||
- **Atomic File Writes**: Atomic file operations (`.tmp` + `os.replace`) prevent JSON file corruption on sudden system shutdown or crash.
|
||||
|
||||
+24
-4
@@ -27,6 +27,18 @@ Want to check your queue status or pause/resume generations while working in oth
|
||||
3. Left-click the tray icon to quickly toggle between **Pause** and **Continue**.
|
||||
4. Right-click the tray icon to choose explicit actions: **Pause (Finish)**, **Pause (Instant)**, **Continue / Resume**, switch default pause modes, or quickly **Open ComfyUI in Browser**.
|
||||
|
||||
### The 1D Git-Graph Batch Queue Visualizer
|
||||
LeafFlow automatically tracks which workflows are submitted together as a batch (whether through "Batch count" or sequential submission) and renders a clean, purely graphical 1D git-graph line directly to the left of your queued items in the queue list:
|
||||
- **36 High-Contrast Cycling Colors**: Every new batch gets a distinct color from an accessible 36-color sequence so adjacent batches are visually distinguishable at a glance.
|
||||
- **Purely Graphical (No Clutter)**: No numbers or text labels.
|
||||
- **Batch Start (`┌`)**: The top of the line curves inward towards the card and runs straight down.
|
||||
- **Batch Middle (`│`)**: Seamless, continuous straight vertical line.
|
||||
- **Batch End (`└`)**: Runs straight from above and curves inward towards the card at the bottom.
|
||||
- **Single Item (`(`)**: A standalone 1-item batch curves inward at both top and bottom like a bracket.
|
||||
- **In-between Infiltration**: If you use "Queue Front" or insert another prompt between items of an active batch, the original batch maintains its open ends (no false start/end curves), while the inserted prompt is enclosed in its own bracket.
|
||||
- **Works Standalone or with PersistentQueue**: Runs locally via `localStorage`, and synchronizes with `PersistentQueue` so batch groupings survive server restarts.
|
||||
- **V1 & V2 Frontend Compatible**: Works in the modern V2 queue sidebar (`JobAssetsList`) and classic V1 queue dialog.
|
||||
|
||||
### The Persistent Queue (Crash Recovery)
|
||||
You don't need to interact with this—it runs silently in the background!
|
||||
Every time you queue a prompt, LeafFlow saves the entire queue state to your hard drive in real time. If your computer crashes, power goes out, or you accidentally close the terminal, ComfyUI will automatically restore your unfinished queue upon next boot (in a paused state, so it doesn't overwhelm your GPU before you're ready).
|
||||
@@ -38,6 +50,13 @@ In standard ComfyUI, restarting your server wipes the in-memory execution histor
|
||||
- Features like **Copy Prompt** and **Copy Workflow** immediately work with restored images as if they were generated in your current session!
|
||||
- You can adjust the restored image count or toggle this feature in **ComfyUI Settings (⚙️)** under **`🍃 LeafFlow: Restore Assets on Launch`** and **`🍃 LeafFlow: Restored Assets Count`**.
|
||||
|
||||
### Image Overlay Actions & Prompt Bookmarks
|
||||
Hovering over any image in the ComfyUI Assets / History pane or preview nodes provides powerful quick actions directly on the card:
|
||||
- **📋 Copy Prompt**: Instantly copies the positive prompt text/graph to your clipboard.
|
||||
- **🔖 Save to Prompt Bookmarks**: One-click saves prompt text straight into your Prompt Bookmarks list with robust SVG bookmark iconography.
|
||||
- **🔍 Inspect Asset (Zoom)**: When enabled in settings (`04_EnableInspectAssetButton`), restores the direct full-size zoom inspect button right on the card hover pill bar alongside Download and Copy Prompt (bringing back quick one-click access that newer ComfyUI frontend versions moved into the 3-dots submenu).
|
||||
- **Dynamic Button Pill Styling**: The action button bar dynamically calculates borders and corner rounding (`rounded-l-lg`, `rounded-r-lg`) depending on which actions are enabled in settings, maintaining a seamless native appearance.
|
||||
|
||||
---
|
||||
|
||||
## 2. Advanced Nodes
|
||||
@@ -61,6 +80,7 @@ Tired of guessing what a LoRA does based on its filename?
|
||||
1. Use the **`🍃 🖼️ Visual LoRA Loader`**. It natively extracts preview images if you place an identically named `.png`/`.jpg` next to your `.safetensors` file.
|
||||
2. If you enable the **Civitai Auto-Scraper** in your ComfyUI Settings, the node will calculate the SHA256 hash of your LoRA and automatically download the official preview thumbnail directly from Civitai!
|
||||
3. **Pretty Name String Output**: The node outputs the parsed, human-readable name of the loaded LoRA (e.g. "Addison Rae V1") by default, allowing you to route it into a text prompt or watermark node. You can easily switch this via the `output_name` toggle on the node to output the raw filename without extension instead.
|
||||
4. **Independent Per-Node Zoom**: Use the `tile_size` slider to adjust preview card sizes to your preference. Zoom levels are saved per node directly inside your workflow JSON, so different visual loaders maintain independent tile sizes across browser reloads.
|
||||
|
||||
### 🍃 ⏱️ Recent Outputs
|
||||
Want to quickly reference your recent generations without digging through Windows Explorer? The **`🍃 ⏱️ Recent Outputs`** node cycles chronologically through your `output/` directory, pulling your freshest creations right back into the workflow canvas.
|
||||
@@ -70,12 +90,12 @@ Want to quickly reference your recent generations without digging through Window
|
||||
## 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.
|
||||
- **Empty String**: Leaving a field completely empty (or spaces) will default to the sensible native directory for that node (e.g., leaving it empty on *Recent Outputs* will automatically target ComfyUI's root `output/` directory).
|
||||
- **Safe Directory Containment**: For security, image loading and visual thumbnail nodes are confined to ComfyUI `input`, `output`, and `temp` directories with path traversal protection.
|
||||
- **Subfolder Paths**: You can specify subdirectories like `batch_01` or `renders/portraits` within the allowed ComfyUI directories.
|
||||
- **Wildcard Filters (`*`)**:
|
||||
- **For the LoRA Loader**: Ending a path with `*` acts as a wildcard directory filter. For example, if you input `style/*`, the loader will recursively display all LoRAs located inside any subfolder that begins with `style/`.
|
||||
- **For File Loaders**: If you accidentally copy a path from Windows Explorer that ends in `\*` or `*`, these nodes are smart enough to automatically strip the trailing asterisk so the folder path still resolves perfectly without throwing an OS Error.
|
||||
- **For File Loaders**: If you accidentally copy a path from Windows Explorer that ends in `\*` or `*`, these nodes are smart enough to automatically strip the trailing asterisk so the folder path still resolves cleanly without throwing an OS Error.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+71
-35
@@ -4,25 +4,6 @@ import subprocess
|
||||
from aiohttp import web
|
||||
from server import PromptServer
|
||||
|
||||
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,
|
||||
@@ -40,7 +21,12 @@ 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
|
||||
|
||||
from .nodes.utils import (
|
||||
get_leafflow_user_dir,
|
||||
get_env_setting,
|
||||
is_local_request,
|
||||
is_safe_path
|
||||
)
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"FolderLoraLoader": FolderLoraLoader,
|
||||
@@ -79,8 +65,6 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
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)
|
||||
|
||||
@@ -98,9 +82,54 @@ routes = server.routes
|
||||
|
||||
print("[ComfyUI-LeafFlow] 🍃 Loaded 13 nodes & visual endpoints successfully.")
|
||||
|
||||
@routes.get("/leafflow/get_image_prompt")
|
||||
@routes.get("/leafflow/view_image_prompt")
|
||||
async def get_image_prompt_endpoint(request):
|
||||
if not is_local_request(request):
|
||||
return web.json_response({"success": False, "error": "Forbidden: Local access only"}, status=403)
|
||||
try:
|
||||
import folder_paths
|
||||
filename = request.query.get("filename")
|
||||
subfolder = request.query.get("subfolder", "")
|
||||
folder_type = request.query.get("type", "output")
|
||||
|
||||
if not filename:
|
||||
return web.json_response({"success": False, "error": "Missing filename"}, status=400)
|
||||
|
||||
if folder_type == "input":
|
||||
base_dir = folder_paths.get_input_directory()
|
||||
elif folder_type == "temp":
|
||||
base_dir = folder_paths.get_temp_directory()
|
||||
else:
|
||||
base_dir = folder_paths.get_output_directory()
|
||||
|
||||
if subfolder:
|
||||
subfolder_clean = str(subfolder).strip().lstrip("/\\")
|
||||
filepath = os.path.join(base_dir, subfolder_clean, filename)
|
||||
else:
|
||||
filepath = os.path.join(base_dir, filename)
|
||||
|
||||
if not is_safe_path(filepath) or not os.path.exists(filepath) or not os.path.isfile(filepath):
|
||||
return web.json_response({"success": False, "error": "File not found or forbidden"}, status=404)
|
||||
|
||||
from .nodes.image_loader import extract_metadata_from_image
|
||||
prompt, width, height = extract_metadata_from_image(filepath)
|
||||
|
||||
return web.json_response({
|
||||
"success": True,
|
||||
"prompt": prompt,
|
||||
"filename": filename,
|
||||
"width": width,
|
||||
"height": height
|
||||
})
|
||||
except Exception as e:
|
||||
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||
|
||||
@routes.get("/leafflow/settings")
|
||||
@routes.get("/flow_control/settings")
|
||||
async def get_settings(request):
|
||||
if not is_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local access only"}, status=403)
|
||||
civitai_key = os.getenv("CIVITAI_API_KEY", "")
|
||||
tmdb_key = os.getenv("TMDB_API_KEY", "")
|
||||
enable_tray = os.getenv("ENABLE_TRAY_ICON", "false").lower() in ["true", "1", "yes"]
|
||||
@@ -134,24 +163,31 @@ async def get_settings(request):
|
||||
"restore_assets_count": restore_assets_count
|
||||
})
|
||||
|
||||
def _clean_env_val(v):
|
||||
if v is None:
|
||||
return None
|
||||
return str(v).replace("\n", "").replace("\r", "").strip()
|
||||
|
||||
@routes.post("/leafflow/settings")
|
||||
@routes.post("/flow_control/settings")
|
||||
async def save_settings(request):
|
||||
if not is_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local access only"}, status=403)
|
||||
try:
|
||||
data = await request.json()
|
||||
civitai_key = data.get("civitai_api_key")
|
||||
tmdb_key = data.get("tmdb_api_key")
|
||||
enable_persistent_queue = data.get("enable_persistent_queue")
|
||||
default_pause_state = data.get("default_pause_state")
|
||||
default_pause_mode = data.get("default_pause_mode")
|
||||
enable_civitai = data.get("enable_civitai_scraping")
|
||||
enable_tmdb = data.get("enable_tmdb_scraping")
|
||||
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")
|
||||
civitai_key = _clean_env_val(data.get("civitai_api_key"))
|
||||
tmdb_key = _clean_env_val(data.get("tmdb_api_key"))
|
||||
enable_persistent_queue = _clean_env_val(data.get("enable_persistent_queue"))
|
||||
default_pause_state = _clean_env_val(data.get("default_pause_state"))
|
||||
default_pause_mode = _clean_env_val(data.get("default_pause_mode"))
|
||||
enable_civitai = _clean_env_val(data.get("enable_civitai_scraping"))
|
||||
enable_tmdb = _clean_env_val(data.get("enable_tmdb_scraping"))
|
||||
enable_lora_usage = _clean_env_val(data.get("enable_lora_usage"))
|
||||
enable_tray_icon = _clean_env_val(data.get("enable_tray_icon"))
|
||||
enable_assets_restore = _clean_env_val(data.get("enable_assets_restore"))
|
||||
restore_assets_count = _clean_env_val(data.get("restore_assets_count"))
|
||||
clear_prompt_iterator_on_launch = _clean_env_val(data.get("clear_prompt_iterator_on_launch"))
|
||||
restored_state = _clean_env_val(data.get("persistent_queue_restored_state"))
|
||||
|
||||
lines = []
|
||||
if os.path.exists(ENV_FILE):
|
||||
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
|
||||
def main():
|
||||
print("[ComfyUI-LeafFlow] Checking dependencies...")
|
||||
dependencies = ["pillow", "piexif", "numpy", "pystray"]
|
||||
for dep in dependencies:
|
||||
try:
|
||||
__import__(dep)
|
||||
except ImportError:
|
||||
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()
|
||||
+58
-33
@@ -20,10 +20,9 @@ class TextAspectRatioFinder:
|
||||
"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"
|
||||
"default_aspect_ratio": ("STRING", {
|
||||
"default": "1:1",
|
||||
"multiline": False
|
||||
}),
|
||||
"multiple_of": ("INT", {
|
||||
"default": 8,
|
||||
@@ -51,6 +50,9 @@ class TextAspectRatioFinder:
|
||||
},
|
||||
"optional": {
|
||||
"text": ("STRING", {"forceInput": True}),
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +71,9 @@ class TextAspectRatioFinder:
|
||||
multiple_of=8,
|
||||
min_mp=0.1,
|
||||
max_mp=16.0,
|
||||
text=None
|
||||
text=None,
|
||||
unique_id=None,
|
||||
**kwargs
|
||||
):
|
||||
text_str = str(text) if text is not None else ""
|
||||
|
||||
@@ -92,34 +96,32 @@ class TextAspectRatioFinder:
|
||||
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 valid_ratios:
|
||||
# 1. When allowed aspect_ratios are configured: ONLY match these exact ratios!
|
||||
for ratio in valid_ratios:
|
||||
w_p, h_p = ratio.split(":")
|
||||
pattern = r'(?<![\d.])' + re.escape(w_p) + r'\s*[:xX/]\s*' + re.escape(h_p) + r'(?!\d|\.\d)'
|
||||
for m in re.finditer(pattern, text_str):
|
||||
matches.append((m.start(), ratio))
|
||||
else:
|
||||
# 2. When aspect_ratios input is EMPTY: accept ANY valid aspect ratio found in the text
|
||||
gen_pattern = r'(?<![\d.])(\d+(?:\.\d+)?)\s*[:xX/]\s*(\d+(?:\.\d+)?)(?!\d|\.\d)'
|
||||
for m in re.finditer(gen_pattern, text_str):
|
||||
try:
|
||||
w_val = float(m.group(1))
|
||||
h_val = float(m.group(2))
|
||||
if w_val > 0 and h_val > 0:
|
||||
w_str = str(int(w_val)) if w_val.is_integer() else str(w_val)
|
||||
h_str = str(int(h_val)) if h_val.is_integer() else str(h_val)
|
||||
ratio_fmt = f"{w_str}:{h_str}"
|
||||
if not any(m_pos == m.start() for m_pos, _ in matches):
|
||||
matches.append((m.start(), ratio_fmt))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if matches:
|
||||
matches.sort(key=lambda x: x[0])
|
||||
@@ -130,13 +132,36 @@ class TextAspectRatioFinder:
|
||||
else:
|
||||
found_ratio = default_aspect_ratio
|
||||
|
||||
# Parse found_ratio and validate. If corrupted/invalid, fall back to "1:1"
|
||||
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:
|
||||
m_ratio = re.match(r'^(\d+(?:\.\d+)?)\s*[:xX/]\s*(\d+(?:\.\d+)?)$', str(found_ratio or "").strip())
|
||||
if m_ratio:
|
||||
w_part = float(m_ratio.group(1))
|
||||
h_part = float(m_ratio.group(2))
|
||||
if w_part <= 0 or h_part <= 0:
|
||||
raise ValueError("Dimensions must be positive")
|
||||
w_str = str(int(w_part)) if w_part.is_integer() else str(w_part)
|
||||
h_str = str(int(h_part)) if h_part.is_integer() else str(h_part)
|
||||
found_ratio = f"{w_str}:{h_str}"
|
||||
else:
|
||||
raise ValueError(f"Cannot parse aspect ratio: {found_ratio}")
|
||||
except Exception as e:
|
||||
err_msg = f"Aspect ratio '{found_ratio}' is invalid. Falling back to '1:1'."
|
||||
print(f"[LeafFlow] Warning: {err_msg} ({e})")
|
||||
try:
|
||||
from server import PromptServer
|
||||
if hasattr(PromptServer, "instance") and PromptServer.instance:
|
||||
PromptServer.instance.send_sync("leafflow_node_error_state", {
|
||||
"node_id": str(unique_id or ""),
|
||||
"title": "Aspect Ratio Warning",
|
||||
"message": err_msg,
|
||||
"fallback": "1:1"
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
w_part, h_part = 1.0, 1.0
|
||||
found_ratio = "1:1"
|
||||
|
||||
r = w_part / h_part
|
||||
|
||||
total_pixels = effective_mp * 1024.0 * 1024.0
|
||||
|
||||
+80
-19
@@ -7,6 +7,30 @@ from PIL import Image, ImageOps
|
||||
import comfy.model_management
|
||||
from .utils import sanitize_folder_path
|
||||
|
||||
def get_current_prompt_id():
|
||||
try:
|
||||
from server import PromptServer
|
||||
server = PromptServer.instance
|
||||
if hasattr(server, "prompt_queue") and server.prompt_queue.currently_running:
|
||||
for item in server.prompt_queue.currently_running.values():
|
||||
if isinstance(item, (tuple, list)) and len(item) > 1:
|
||||
return str(item[1])
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def is_file_ready(filepath):
|
||||
try:
|
||||
if not os.path.exists(filepath) or os.path.getsize(filepath) == 0:
|
||||
return False
|
||||
with open(filepath, "rb") as f:
|
||||
f.seek(0, os.SEEK_END)
|
||||
return True
|
||||
except (OSError, IOError, PermissionError):
|
||||
return False
|
||||
|
||||
_WATCHER_STATE = {}
|
||||
|
||||
class LoadImageFromFolder:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
@@ -17,7 +41,10 @@ class LoadImageFromFolder:
|
||||
"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."}),
|
||||
"delete_image": ("BOOLEAN", {"default": False, "tooltip": "If enabled, deletes the image file from disk after loading it. If disabled, cycles through all images in the folder sequentially."}),
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +52,7 @@ class LoadImageFromFolder:
|
||||
RETURN_NAMES = ("image", "has_image")
|
||||
FUNCTION = "watch"
|
||||
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."
|
||||
DESCRIPTION = "Loads an image from a folder, optionally waiting if the folder is empty, with automatic sequential cycling when delete is False."
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(s, **kwargs):
|
||||
@@ -33,8 +60,8 @@ class LoadImageFromFolder:
|
||||
return float("NaN")
|
||||
|
||||
def create_dummy_image(self):
|
||||
# Returns a 1x1 0-tensor for no-image state
|
||||
return torch.zeros((1, 64, 64, 3), dtype=torch.float32)
|
||||
# Returns a 512x512 standard 0-tensor for safe no-image state
|
||||
return torch.zeros((1, 512, 512, 3), dtype=torch.float32)
|
||||
|
||||
def load_and_remove_image(self, filepath, delete_image=False):
|
||||
with Image.open(filepath) as img:
|
||||
@@ -62,7 +89,7 @@ class LoadImageFromFolder:
|
||||
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):
|
||||
if regex.search(f) and is_file_ready(filepath):
|
||||
files.append(filepath)
|
||||
|
||||
if not files:
|
||||
@@ -88,7 +115,39 @@ class LoadImageFromFolder:
|
||||
|
||||
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):
|
||||
def _select_file(self, files, delete_image, unique_id, folder):
|
||||
if not files:
|
||||
return None
|
||||
|
||||
if delete_image:
|
||||
# When deleting on each run, always consume the first available file
|
||||
return files[0]
|
||||
|
||||
# When keeping images (delete_image=False), cycle sequentially through all files!
|
||||
state_key = f"{unique_id}_{folder}"
|
||||
current_pid = get_current_prompt_id()
|
||||
|
||||
node_state = _WATCHER_STATE.get(state_key, {"index": 0, "assigned": {}})
|
||||
assigned = node_state.get("assigned", {})
|
||||
current_idx = int(node_state.get("index", 0))
|
||||
|
||||
if current_pid and current_pid in assigned:
|
||||
# Re-use exact index on persistent queue crash recovery
|
||||
idx = assigned[current_pid] % len(files)
|
||||
else:
|
||||
idx = current_idx % len(files)
|
||||
if current_pid:
|
||||
assigned[current_pid] = idx
|
||||
if len(assigned) > 50:
|
||||
for k in list(assigned.keys())[:-50]:
|
||||
del assigned[k]
|
||||
node_state["index"] = (current_idx + 1) % len(files)
|
||||
node_state["assigned"] = assigned
|
||||
_WATCHER_STATE[state_key] = node_state
|
||||
|
||||
return files[idx]
|
||||
|
||||
def watch(self, folder, wait_if_folder_is_empty, rescan_interval, sort_by, regex_filter, delete_image=False, unique_id="default", **kwargs):
|
||||
folder = sanitize_folder_path(folder, default_dir="input/watch")
|
||||
|
||||
if wait_if_folder_is_empty:
|
||||
@@ -99,12 +158,13 @@ class LoadImageFromFolder:
|
||||
try:
|
||||
files = self.get_filtered_files(folder, sort_by, regex_filter)
|
||||
if files:
|
||||
filepath = files[0] # Pick the first one based on sorting
|
||||
try:
|
||||
img_tensor = self.load_and_remove_image(filepath, delete_image=delete_image)
|
||||
return (img_tensor, True)
|
||||
except Exception as e:
|
||||
print(f"[LeafFlow] Error processing {filepath}: {e}")
|
||||
filepath = self._select_file(files, delete_image, unique_id, folder)
|
||||
if filepath:
|
||||
try:
|
||||
img_tensor = self.load_and_remove_image(filepath, delete_image=delete_image)
|
||||
return (img_tensor, True)
|
||||
except Exception as e:
|
||||
print(f"[LeafFlow] Error processing {filepath}: {e}")
|
||||
except Exception as e:
|
||||
print(f"[LeafFlow] Error accessing directory {folder}: {e}")
|
||||
|
||||
@@ -114,14 +174,15 @@ class LoadImageFromFolder:
|
||||
try:
|
||||
files = self.get_filtered_files(folder, sort_by, regex_filter)
|
||||
if files:
|
||||
filepath = files[0]
|
||||
try:
|
||||
img_tensor = self.load_and_remove_image(filepath, delete_image=delete_image)
|
||||
return (img_tensor, True)
|
||||
except Exception as e:
|
||||
print(f"[LeafFlow] Error processing {filepath}: {e}")
|
||||
filepath = self._select_file(files, delete_image, unique_id, folder)
|
||||
if filepath:
|
||||
try:
|
||||
img_tensor = self.load_and_remove_image(filepath, delete_image=delete_image)
|
||||
return (img_tensor, True)
|
||||
except Exception as e:
|
||||
print(f"[LeafFlow] Error processing {filepath}: {e}")
|
||||
except Exception as e:
|
||||
print(f"[LeafFlow] Error accessing directory {folder}: {e}")
|
||||
|
||||
# No image present or folder missing -> return dummy tensor & False immediately
|
||||
# No image present or folder missing -> return standard dummy tensor & False immediately
|
||||
return (self.create_dummy_image(), False)
|
||||
|
||||
+24
-12
@@ -5,6 +5,7 @@ import subprocess
|
||||
from server import PromptServer
|
||||
from aiohttp import web
|
||||
import comfy.model_management
|
||||
from .utils import is_local_request
|
||||
|
||||
class DecisionManager:
|
||||
_waiting_events = {}
|
||||
@@ -115,18 +116,27 @@ $toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
|
||||
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()
|
||||
import time
|
||||
try:
|
||||
if timeout > 0:
|
||||
end_time = time.time() + timeout
|
||||
while time.time() < end_time and not event.is_set():
|
||||
comfy.model_management.throw_exception_if_processing_interrupted()
|
||||
event.wait(0.5)
|
||||
if not event.is_set():
|
||||
print(f"[LeafFlow] Node {unique_id} timed out. Auto-continuing...")
|
||||
DecisionManager.unregister_wait(unique_id)
|
||||
# Notify frontend to update UI back to normal
|
||||
PromptServer.instance.send_sync("leafflow_decision_resolved", {"node_id": unique_id})
|
||||
return (False,)
|
||||
else:
|
||||
while not event.is_set():
|
||||
comfy.model_management.throw_exception_if_processing_interrupted()
|
||||
event.wait(0.5)
|
||||
except Exception:
|
||||
DecisionManager.unregister_wait(unique_id)
|
||||
PromptServer.instance.send_sync("leafflow_decision_resolved", {"node_id": unique_id})
|
||||
raise
|
||||
|
||||
# Retrieve action
|
||||
action = DecisionManager.get_action(unique_id)
|
||||
@@ -161,6 +171,8 @@ $toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
|
||||
# Register API Route
|
||||
@PromptServer.instance.routes.post("/leafflow/decision")
|
||||
async def handle_decision(request):
|
||||
if not is_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local access only"}, status=403)
|
||||
try:
|
||||
data = await request.json()
|
||||
node_id = data.get("node_id")
|
||||
|
||||
+146
-26
@@ -9,17 +9,35 @@ import piexif.helper
|
||||
from aiohttp import web
|
||||
from server import PromptServer
|
||||
import folder_paths
|
||||
from .utils import sanitize_folder_path, get_leafflow_user_dir
|
||||
from .utils import (
|
||||
sanitize_folder_path,
|
||||
get_leafflow_user_dir,
|
||||
is_local_request,
|
||||
is_safe_path,
|
||||
sanitize_image_loader_folder
|
||||
)
|
||||
|
||||
IMAGE_CATEGORY = "🍃 LeafFlow/Loaders"
|
||||
CURRENT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
USER_DIR = get_leafflow_user_dir()
|
||||
CACHE_FILE = os.path.join(USER_DIR, "image_prompts_cache.json")
|
||||
|
||||
def decode_bytes_safely(raw_bytes):
|
||||
if not isinstance(raw_bytes, bytes):
|
||||
return str(raw_bytes or "")
|
||||
for enc in ["utf-8", "utf-16le", "utf-16", "shift_jis", "gbk", "latin-1"]:
|
||||
try:
|
||||
return raw_bytes.decode(enc)
|
||||
except (UnicodeDecodeError, LookupError):
|
||||
continue
|
||||
return raw_bytes.decode("utf-8", errors="ignore")
|
||||
|
||||
def parse_positive_from_parameters(parameters):
|
||||
if not parameters:
|
||||
return ""
|
||||
lines = parameters.split("\n")
|
||||
if isinstance(parameters, bytes):
|
||||
parameters = decode_bytes_safely(parameters)
|
||||
lines = str(parameters).split("\n")
|
||||
pos_lines = []
|
||||
for line in lines:
|
||||
if line.strip().startswith("Negative prompt:") or line.strip().startswith("Steps:"):
|
||||
@@ -27,29 +45,108 @@ def parse_positive_from_parameters(parameters):
|
||||
pos_lines.append(line)
|
||||
return "\n".join(pos_lines).strip()
|
||||
|
||||
def trace_positive_prompt_from_graph(prompt_data):
|
||||
if not isinstance(prompt_data, dict):
|
||||
return ""
|
||||
|
||||
start_nodes = []
|
||||
for nid, ndata in prompt_data.items():
|
||||
if not isinstance(ndata, dict):
|
||||
continue
|
||||
ctype = ndata.get("class_type", "")
|
||||
inputs = ndata.get("inputs", {})
|
||||
if any(kw in ctype for kw in ["Sampler", "KSampler", "Guider", "CFG"]):
|
||||
for link_key in ["positive", "conditioning", "guider"]:
|
||||
link = inputs.get(link_key)
|
||||
if isinstance(link, list) and len(link) > 0:
|
||||
start_nodes.append(str(link[0]))
|
||||
|
||||
visited = set()
|
||||
found_texts = []
|
||||
|
||||
def walk(nid):
|
||||
if not nid or nid in visited:
|
||||
return
|
||||
visited.add(nid)
|
||||
ndata = prompt_data.get(nid)
|
||||
if not isinstance(ndata, dict):
|
||||
return
|
||||
inputs = ndata.get("inputs", {})
|
||||
|
||||
for tkey in ["text", "prompt", "text_g", "text_l", "text_positive", "positive_prompt", "value", "string"]:
|
||||
val = inputs.get(tkey)
|
||||
if isinstance(val, str) and val.strip():
|
||||
s = val.strip()
|
||||
if s not in found_texts:
|
||||
found_texts.append(s)
|
||||
elif isinstance(val, list) and len(val) > 0:
|
||||
walk(str(val[0]))
|
||||
|
||||
for in_name, in_val in inputs.items():
|
||||
if isinstance(in_val, list) and len(in_val) > 0:
|
||||
low = in_name.lower()
|
||||
if "negative" in low:
|
||||
continue
|
||||
if any(k in low for k in ["conditioning", "positive", "cond", "text", "prompt", "guider"]):
|
||||
walk(str(in_val[0]))
|
||||
|
||||
for sn in start_nodes:
|
||||
walk(sn)
|
||||
|
||||
if found_texts:
|
||||
return "\n".join(found_texts)
|
||||
|
||||
for nid, ndata in prompt_data.items():
|
||||
if not isinstance(ndata, dict):
|
||||
continue
|
||||
ctype = ndata.get("class_type", "")
|
||||
inputs = ndata.get("inputs", {})
|
||||
if any(k in ctype for k in ["CLIPTextEncode", "Text", "Prompt"]):
|
||||
for tkey in ["text", "prompt", "text_g", "text_l"]:
|
||||
val = inputs.get(tkey)
|
||||
if isinstance(val, str) and val.strip() and len(val.strip()) > 1:
|
||||
s = val.strip()
|
||||
if s not in found_texts:
|
||||
found_texts.append(s)
|
||||
return "\n".join(found_texts) if found_texts else ""
|
||||
|
||||
def extract_metadata_from_image(filepath):
|
||||
positive_prompt = ""
|
||||
width, height = 0, 0
|
||||
try:
|
||||
with Image.open(filepath) as img:
|
||||
width, height = img.size
|
||||
if img.format == 'PNG':
|
||||
parameters = img.info.get("parameters", "")
|
||||
info = img.info or {}
|
||||
|
||||
# 1. ComfyUI native format (JSON string in img.info["prompt"])
|
||||
prompt_str = info.get("prompt")
|
||||
if prompt_str:
|
||||
try:
|
||||
prompt_data = json.loads(prompt_str) if isinstance(prompt_str, str) else prompt_str
|
||||
positive_prompt = trace_positive_prompt_from_graph(prompt_data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2. A1111 / WebUI parameters
|
||||
if not positive_prompt and "parameters" in info:
|
||||
parameters = info.get("parameters", "")
|
||||
if parameters:
|
||||
positive_prompt = parse_positive_from_parameters(parameters)
|
||||
else:
|
||||
exif_data = img.info.get("exif")
|
||||
|
||||
# 3. EXIF UserComment
|
||||
if not positive_prompt and "exif" in info:
|
||||
exif_data = info.get("exif")
|
||||
if exif_data:
|
||||
exif_dict = piexif.load(exif_data)
|
||||
user_comment = exif_dict.get("Exif", {}).get(piexif.ExifIFD.UserComment)
|
||||
if user_comment:
|
||||
try:
|
||||
try:
|
||||
exif_dict = piexif.load(exif_data)
|
||||
user_comment = exif_dict.get("Exif", {}).get(piexif.ExifIFD.UserComment)
|
||||
if user_comment:
|
||||
parameters = piexif.helper.UserComment.load(user_comment)
|
||||
if isinstance(parameters, bytes):
|
||||
parameters = parameters.decode('utf-8', errors='ignore')
|
||||
positive_prompt = parse_positive_from_parameters(parameters)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f"[LeafFlow] Error extracting metadata: {e}")
|
||||
return positive_prompt, width, height
|
||||
@@ -70,18 +167,20 @@ def save_prompts_cache(cache):
|
||||
except Exception as e:
|
||||
print(f"[LeafFlow] Failed to save prompts cache: {e}")
|
||||
|
||||
server = PromptServer.instance
|
||||
routes = server.routes
|
||||
routes = PromptServer.instance.routes if hasattr(PromptServer, "instance") and PromptServer.instance is not None else web.RouteTableDef()
|
||||
|
||||
@routes.get("/image_loader/get_images")
|
||||
async def get_images_endpoint(request):
|
||||
if not is_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local access only"}, status=403)
|
||||
|
||||
folder = request.query.get("folder", "")
|
||||
if not folder:
|
||||
folder = request.query.get("folder_path", "")
|
||||
|
||||
resolved_folder = sanitize_folder_path(folder)
|
||||
resolved_folder = sanitize_image_loader_folder(folder)
|
||||
|
||||
if not resolved_folder or not os.path.exists(resolved_folder):
|
||||
if not resolved_folder or not is_safe_path(resolved_folder) or not os.path.exists(resolved_folder):
|
||||
return web.json_response({"names": [], "mapping": {}, "prompts": {}})
|
||||
|
||||
image_extensions = {".png", ".jpg", ".jpeg", ".webp", ".PNG", ".JPG", ".JPEG", ".WEBP"}
|
||||
@@ -92,6 +191,8 @@ async def get_images_endpoint(request):
|
||||
ext = os.path.splitext(file)[1]
|
||||
if ext in image_extensions:
|
||||
full_path = os.path.normpath(os.path.join(root, file))
|
||||
if not is_safe_path(full_path):
|
||||
continue
|
||||
try:
|
||||
mtime = os.path.getmtime(full_path)
|
||||
except Exception:
|
||||
@@ -151,16 +252,25 @@ async def get_images_endpoint(request):
|
||||
|
||||
@routes.get("/image_loader/get_thumbnail")
|
||||
async def get_thumbnail_endpoint(request):
|
||||
if not is_local_request(request):
|
||||
return web.Response(status=403)
|
||||
|
||||
folder = request.query.get("folder", "")
|
||||
if not folder:
|
||||
folder = request.query.get("folder_path", "")
|
||||
resolved_folder = sanitize_folder_path(folder)
|
||||
resolved_folder = sanitize_image_loader_folder(folder)
|
||||
|
||||
image_rel = request.query.get("image", "")
|
||||
|
||||
if not image_rel:
|
||||
return web.Response(status=400)
|
||||
|
||||
full_path = os.path.normpath(os.path.join(resolved_folder, image_rel))
|
||||
if not os.path.exists(full_path):
|
||||
if not is_safe_path(full_path) or not os.path.exists(full_path) or not os.path.isfile(full_path):
|
||||
return web.Response(status=404)
|
||||
|
||||
image_extensions = {".png", ".jpg", ".jpeg", ".webp", ".bmp"}
|
||||
if os.path.splitext(full_path)[1].lower() not in image_extensions:
|
||||
return web.Response(status=403)
|
||||
|
||||
try:
|
||||
with Image.open(full_path) as img:
|
||||
@@ -173,16 +283,26 @@ async def get_thumbnail_endpoint(request):
|
||||
|
||||
@routes.get("/image_loader/get_full_image")
|
||||
async def get_full_image_endpoint(request):
|
||||
if not is_local_request(request):
|
||||
return web.Response(status=403)
|
||||
|
||||
folder = request.query.get("folder", "")
|
||||
if not folder:
|
||||
folder = request.query.get("folder_path", "")
|
||||
resolved_folder = sanitize_folder_path(folder)
|
||||
resolved_folder = sanitize_image_loader_folder(folder)
|
||||
|
||||
image_rel = request.query.get("image", "")
|
||||
|
||||
if not image_rel:
|
||||
return web.Response(status=400)
|
||||
|
||||
full_path = os.path.normpath(os.path.join(resolved_folder, image_rel))
|
||||
if not os.path.exists(full_path):
|
||||
if not is_safe_path(full_path) or not os.path.exists(full_path) or not os.path.isfile(full_path):
|
||||
return web.Response(status=404)
|
||||
|
||||
image_extensions = {".png", ".jpg", ".jpeg", ".webp", ".bmp"}
|
||||
if os.path.splitext(full_path)[1].lower() not in image_extensions:
|
||||
return web.Response(status=403)
|
||||
|
||||
return web.FileResponse(full_path)
|
||||
|
||||
class VisualImageLoader:
|
||||
@@ -211,15 +331,15 @@ class VisualImageLoader:
|
||||
|
||||
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)
|
||||
resolved_folder = sanitize_image_loader_folder(input_dir)
|
||||
|
||||
if not resolved_folder or not _selected_image:
|
||||
empty_image = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
|
||||
empty_image = torch.zeros((1, 512, 512, 3), dtype=torch.float32)
|
||||
return (empty_image, "", 0, 0)
|
||||
|
||||
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)
|
||||
if not is_safe_path(full_path) or not os.path.exists(full_path):
|
||||
empty_image = torch.zeros((1, 512, 512, 3), dtype=torch.float32)
|
||||
return (empty_image, "", 0, 0)
|
||||
|
||||
i = Image.open(full_path)
|
||||
|
||||
+12
-7
@@ -21,15 +21,19 @@ class LoadRecentOutputs:
|
||||
"step": 1,
|
||||
"control_after_generate": "increment"
|
||||
}),
|
||||
"order": (["Newest First (0 = latest)", "Chronological (Oldest First)"], {
|
||||
"default": "Newest First (0 = latest)",
|
||||
"advanced": True
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "load_single_image"
|
||||
CATEGORY = LOADER_CATEGORY
|
||||
DESCRIPTION = "Cycles through recently generated output images.\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)."
|
||||
DESCRIPTION = "Cycles through recently generated output images.\n\nExplanation of Index:\n- 'amount' grabs the X newest images from the folder.\n- 'index' selects which of those X images to output (0 = newest image).\n- If index exceeds amount, it loops back around."
|
||||
|
||||
def load_single_image(self, output_folder, amount, index):
|
||||
def load_single_image(self, output_folder, amount, index, order="Newest First (0 = latest)", **kwargs):
|
||||
clean_input = (output_folder or "").strip()
|
||||
if clean_input == "output" or not clean_input:
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
@@ -37,7 +41,7 @@ class LoadRecentOutputs:
|
||||
output_dir = sanitize_folder_path(clean_input, default_dir=folder_paths.get_output_directory())
|
||||
|
||||
if not os.path.exists(output_dir):
|
||||
dummy = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
|
||||
dummy = torch.zeros((1, 512, 512, 3), dtype=torch.float32)
|
||||
return (dummy,)
|
||||
|
||||
valid_extensions = ('.png', '.jpg', '.jpeg', '.webp', '.bmp')
|
||||
@@ -54,16 +58,17 @@ class LoadRecentOutputs:
|
||||
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)
|
||||
dummy = torch.zeros((1, 512, 512, 3), dtype=torch.float32)
|
||||
return (dummy,)
|
||||
|
||||
if not file_list:
|
||||
dummy = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
|
||||
dummy = torch.zeros((1, 512, 512, 3), dtype=torch.float32)
|
||||
return (dummy,)
|
||||
|
||||
file_list.sort(key=lambda x: x[1], reverse=True)
|
||||
recent_files = file_list[:amount]
|
||||
recent_files.reverse()
|
||||
if "oldest" in str(order).lower() or "chronological" in str(order).lower():
|
||||
recent_files.reverse()
|
||||
|
||||
actual_index = index % len(recent_files)
|
||||
target_file, _ = recent_files[actual_index]
|
||||
@@ -76,6 +81,6 @@ class LoadRecentOutputs:
|
||||
image = torch.from_numpy(image).unsqueeze(0)
|
||||
except Exception as e:
|
||||
print(f"[LeafFlow] Error loading recent output image {target_file}: {e}")
|
||||
image = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
|
||||
image = torch.zeros((1, 512, 512, 3), dtype=torch.float32)
|
||||
|
||||
return (image,)
|
||||
|
||||
@@ -30,7 +30,7 @@ class TextLoraFinder:
|
||||
"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}),
|
||||
"output_format": (LORA_OUTPUT_FORMAT_CHOICES, {"default": "Filename", "advanced": True}),
|
||||
},
|
||||
"optional": {
|
||||
"text": ("STRING", {"forceInput": True}),
|
||||
@@ -54,7 +54,7 @@ class TextLoraFinder:
|
||||
find_amount=1,
|
||||
strength_model=1.0,
|
||||
strength_clip=1.0,
|
||||
output_format="Parsed Name",
|
||||
output_format="Filename",
|
||||
text=None,
|
||||
start_from=None,
|
||||
prompt=None,
|
||||
|
||||
+97
-23
@@ -18,7 +18,9 @@ from .utils import (
|
||||
parse_pretty_name_with_version,
|
||||
format_lora_output_name,
|
||||
sanitize_folder_path,
|
||||
get_leafflow_user_dir
|
||||
get_leafflow_user_dir,
|
||||
is_local_request,
|
||||
is_safe_path
|
||||
)
|
||||
|
||||
LORA_CATEGORY = "🍃 LeafFlow/Loaders"
|
||||
@@ -146,8 +148,8 @@ def get_env_setting(key, default_val):
|
||||
|
||||
def scrape_missing_images_sync():
|
||||
try:
|
||||
civitai_enabled = get_env_setting("ENABLE_CIVITAI_SCRAPING", "true").lower() in ["true", "1", "yes"]
|
||||
tmdb_enabled = get_env_setting("ENABLE_TMDB_SCRAPING", "true").lower() in ["true", "1", "yes"]
|
||||
civitai_enabled = get_env_setting("ENABLE_CIVITAI_SCRAPING", "false").lower() in ["true", "1", "yes"]
|
||||
tmdb_enabled = get_env_setting("ENABLE_TMDB_SCRAPING", "false").lower() in ["true", "1", "yes"]
|
||||
if not civitai_enabled and not tmdb_enabled:
|
||||
return
|
||||
|
||||
@@ -316,9 +318,11 @@ routes = server.routes
|
||||
|
||||
@routes.get("/folder_lora_loader/get_loras")
|
||||
async def get_loras_endpoint(request):
|
||||
if not is_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local access only"}, status=403)
|
||||
folder = request.query.get("folder", "")
|
||||
pretty = request.query.get("pretty", "false").lower() == "true"
|
||||
scrape_on_new = request.query.get("scrape_on_new", "true").lower() == "true"
|
||||
scrape_on_new = request.query.get("scrape_on_new", "false").lower() == "true"
|
||||
|
||||
mapping = get_filtered_loras_mapping(folder, pretty=pretty)
|
||||
if scrape_on_new:
|
||||
@@ -352,6 +356,8 @@ async def get_loras_endpoint(request):
|
||||
|
||||
@routes.get("/folder_lora_loader/get_preview")
|
||||
async def get_preview_endpoint(request):
|
||||
if not is_local_request(request):
|
||||
return web.Response(status=403)
|
||||
system_path = request.query.get("system_path", "")
|
||||
lora_name = request.query.get("lora", "")
|
||||
folder = request.query.get("folder", "")
|
||||
@@ -363,7 +369,7 @@ async def get_preview_endpoint(request):
|
||||
|
||||
if system_path and system_path != "[ NONE ]":
|
||||
img_path = find_preview_image(system_path)
|
||||
if img_path and os.path.exists(img_path):
|
||||
if img_path and os.path.exists(img_path) and os.path.isfile(img_path):
|
||||
return web.FileResponse(img_path)
|
||||
|
||||
return web.Response(status=404)
|
||||
@@ -443,7 +449,7 @@ class FolderLoraLoaderPretty(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": "Parsed Name", "advanced": True}),
|
||||
"output_format": (LORA_OUTPUT_FORMAT_CHOICES, {"default": "Filename", "advanced": True}),
|
||||
"custom_regex": ("STRING", {"default": "", "advanced": True}),
|
||||
},
|
||||
"hidden": {
|
||||
@@ -455,7 +461,7 @@ class FolderLoraLoaderPretty(FolderLoraLoader):
|
||||
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, output_format="Parsed Name", custom_regex="", output_name=None, _selected_lora="[ NONE ]"):
|
||||
def load_lora(self, model, clip, folder, lora_name, strength_model, strength_clip, output_format="Filename", custom_regex="", output_name=None, _selected_lora="[ NONE ]"):
|
||||
active_format = output_name if output_name is not None else output_format
|
||||
active_lora = lora_name if lora_name != "[ NONE ]" else _selected_lora
|
||||
if active_lora == "[ NONE ]" or not active_lora:
|
||||
@@ -497,6 +503,10 @@ class FolderLoraLoaderPretty(FolderLoraLoader):
|
||||
return (model, clip, out_name)
|
||||
|
||||
lora_path = folder_paths.get_full_path("loras", resolved_path)
|
||||
if strength_model == 0 and strength_clip == 0:
|
||||
increment_lora_usage(resolved_path)
|
||||
return (model, clip, out_name)
|
||||
|
||||
if not lora_path or not os.path.exists(lora_path):
|
||||
return (model, clip, out_name)
|
||||
|
||||
@@ -507,6 +517,18 @@ class FolderLoraLoaderPretty(FolderLoraLoader):
|
||||
increment_lora_usage(resolved_path)
|
||||
return (model_lora, clip_lora, out_name)
|
||||
|
||||
def get_current_prompt_id():
|
||||
try:
|
||||
from server import PromptServer
|
||||
server = PromptServer.instance
|
||||
if hasattr(server, "prompt_queue") and server.prompt_queue.currently_running:
|
||||
for item in server.prompt_queue.currently_running.values():
|
||||
if isinstance(item, (tuple, list)) and len(item) > 1:
|
||||
return str(item[1])
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
class VisualLoraLoader(FolderLoraLoaderPretty):
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
@@ -522,7 +544,7 @@ class VisualLoraLoader(FolderLoraLoaderPretty):
|
||||
"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}),
|
||||
"output_format": (LORA_OUTPUT_FORMAT_CHOICES, {"default": "Filename", "advanced": True}),
|
||||
"custom_regex": ("STRING", {"default": "", "advanced": True}),
|
||||
},
|
||||
"hidden": {
|
||||
@@ -544,7 +566,7 @@ class VisualLoraLoader(FolderLoraLoaderPretty):
|
||||
return time.time()
|
||||
return ""
|
||||
|
||||
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):
|
||||
def load_lora(self, model, clip, folder, strength_model, strength_clip, display_mode="Scrollable", sort_loras_by="Name (A-Z)", sort_folders_by="Name (A-Z)", folder_position="Folders First", content_alignment="Left Aligned", output_format="Filename", custom_regex="", output_name=None, _selected_lora="[]", _selection_mode="All", _scrape_on_new="true", unique_id="default", **kwargs):
|
||||
active_format = output_name if output_name is not None else output_format
|
||||
active_lora = _selected_lora if _selected_lora else "[]"
|
||||
if active_lora == "[ NONE ]" or active_lora == "[]" or not active_lora:
|
||||
@@ -559,34 +581,78 @@ class VisualLoraLoader(FolderLoraLoaderPretty):
|
||||
else:
|
||||
loras_to_load = [active_lora]
|
||||
|
||||
current_pid = get_current_prompt_id()
|
||||
if _selection_mode in ["Random", "Sequential", "Random (No Replace)"] and len(loras_to_load) > 0:
|
||||
valid_choices = [item for item in loras_to_load if item != "[ NONE ]" and item]
|
||||
if valid_choices:
|
||||
if _selection_mode == "Random":
|
||||
import random
|
||||
loras_to_load = [random.choice(valid_choices)]
|
||||
state = load_lora_cycle_state()
|
||||
text_hash = hashlib.sha256(json.dumps(sorted(valid_choices)).encode('utf-8')).hexdigest()[:16]
|
||||
state_key = f"rand_{unique_id}_{text_hash}"
|
||||
node_state = state.get(state_key, {})
|
||||
assigned = node_state.get("assigned", {}) if isinstance(node_state, dict) else {}
|
||||
if current_pid and current_pid in assigned:
|
||||
selected_item = assigned[current_pid]
|
||||
else:
|
||||
selected_item = random.choice(valid_choices)
|
||||
if current_pid:
|
||||
assigned[current_pid] = selected_item
|
||||
state[state_key] = {"assigned": assigned}
|
||||
save_lora_cycle_state(state)
|
||||
loras_to_load = [selected_item]
|
||||
elif _selection_mode == "Sequential":
|
||||
state = load_lora_cycle_state()
|
||||
text_hash = hashlib.sha256(json.dumps(sorted(valid_choices)).encode('utf-8')).hexdigest()[:16]
|
||||
state_key = f"seq_{unique_id}_{text_hash}"
|
||||
idx = state.get(state_key, 0)
|
||||
selected_item = valid_choices[idx % len(valid_choices)]
|
||||
state[state_key] = idx + 1
|
||||
save_lora_cycle_state(state)
|
||||
node_state = state.get(state_key, {})
|
||||
if isinstance(node_state, dict):
|
||||
idx = int(node_state.get("index", 0))
|
||||
assigned = node_state.get("assigned", {})
|
||||
else:
|
||||
idx = int(node_state) if isinstance(node_state, int) else 0
|
||||
assigned = {}
|
||||
|
||||
if current_pid and current_pid in assigned:
|
||||
selected_item = assigned[current_pid]
|
||||
else:
|
||||
selected_item = valid_choices[idx % len(valid_choices)]
|
||||
if current_pid:
|
||||
assigned[current_pid] = selected_item
|
||||
state[state_key] = {
|
||||
"index": idx + 1,
|
||||
"assigned": assigned
|
||||
}
|
||||
save_lora_cycle_state(state)
|
||||
loras_to_load = [selected_item]
|
||||
elif _selection_mode == "Random (No Replace)":
|
||||
state = load_lora_cycle_state()
|
||||
text_hash = hashlib.sha256(json.dumps(sorted(valid_choices)).encode('utf-8')).hexdigest()[:16]
|
||||
state_key = f"noreplace_{unique_id}_{text_hash}"
|
||||
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)
|
||||
node_state = state.get(state_key, {})
|
||||
if isinstance(node_state, dict):
|
||||
pool = node_state.get("pool", [])
|
||||
assigned = node_state.get("assigned", {})
|
||||
else:
|
||||
pool = node_state if isinstance(node_state, list) else []
|
||||
assigned = {}
|
||||
|
||||
if current_pid and current_pid in assigned:
|
||||
selected_item = assigned[current_pid]
|
||||
else:
|
||||
pool = [x for x in pool if x in valid_choices]
|
||||
if not pool:
|
||||
import random
|
||||
pool = list(valid_choices)
|
||||
random.shuffle(pool)
|
||||
selected_item = pool.pop(0)
|
||||
if current_pid:
|
||||
assigned[current_pid] = selected_item
|
||||
state[state_key] = {
|
||||
"pool": pool,
|
||||
"assigned": assigned
|
||||
}
|
||||
save_lora_cycle_state(state)
|
||||
loras_to_load = [selected_item]
|
||||
else:
|
||||
loras_to_load = []
|
||||
@@ -620,6 +686,14 @@ class VisualLoraLoader(FolderLoraLoaderPretty):
|
||||
if not resolved_path:
|
||||
continue
|
||||
|
||||
# Fast-path: If strength is 0 for both model and clip, don't load gigabytes into RAM/VRAM
|
||||
if strength_model == 0 and strength_clip == 0:
|
||||
increment_lora_usage(resolved_path)
|
||||
out_name = format_lora_output_name(resolved_path, display_name, output_format=active_format, custom_regex=custom_regex)
|
||||
if out_name:
|
||||
loaded_names.append(out_name)
|
||||
continue
|
||||
|
||||
lora_path = folder_paths.get_full_path("loras", resolved_path)
|
||||
if not lora_path or not os.path.exists(lora_path):
|
||||
continue
|
||||
|
||||
+63
-23
@@ -154,6 +154,17 @@ def parse_prompt_blocks(text_str, separator):
|
||||
raw_blocks = re.split(r'\n\s*\n+', clean_text)
|
||||
return [b.strip() for b in raw_blocks if b.strip()]
|
||||
|
||||
def get_current_prompt_id():
|
||||
try:
|
||||
server = PromptServer.instance
|
||||
if hasattr(server, "prompt_queue") and server.prompt_queue.currently_running:
|
||||
for item in server.prompt_queue.currently_running.values():
|
||||
if isinstance(item, (tuple, list)) and len(item) > 1:
|
||||
return str(item[1])
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
class PromptQueueIterator:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
@@ -215,57 +226,86 @@ class PromptQueueIterator:
|
||||
text_hash = hashlib.sha256(text_str.strip().encode('utf-8')).hexdigest()[:16]
|
||||
state_key = f"node_{unique_id}_{text_hash}"
|
||||
|
||||
current_prompt_id = get_current_prompt_id()
|
||||
|
||||
# Load state fresh from disk (no stale memory cache)
|
||||
state = load_state()
|
||||
node_state = state.get(state_key)
|
||||
|
||||
current_index = 0
|
||||
assigned_prompts = {}
|
||||
if isinstance(node_state, dict):
|
||||
current_index = int(node_state.get("index", 0))
|
||||
raw_assigned = node_state.get("assigned_prompts", {})
|
||||
if isinstance(raw_assigned, dict):
|
||||
assigned_prompts = dict(raw_assigned)
|
||||
elif isinstance(node_state, int):
|
||||
current_index = node_state
|
||||
|
||||
# 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
|
||||
# Check if this prompt_id was already assigned an index (e.g. recovered from persistent queue / retry)
|
||||
if current_prompt_id and current_prompt_id in assigned_prompts:
|
||||
idx = assigned_prompts[current_prompt_id] % total_count
|
||||
selected_prompt = original_blocks[idx]
|
||||
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
|
||||
next_index = current_index # Keep current index without double-advancing
|
||||
else:
|
||||
# Select prompt based on mode and calculate next index
|
||||
if pop_mode in ["Sequential (Loop on End)", "Cycle / Loop", "Pop Top & Delete"]:
|
||||
idx = current_index % total_count
|
||||
selected_prompt = original_blocks[idx]
|
||||
next_index = total_count
|
||||
display_run = total_count
|
||||
else:
|
||||
idx = current_index
|
||||
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
|
||||
elif pop_mode in ["Random (Keep)", "Random (Delete)"]:
|
||||
idx = random.randint(0, total_count - 1)
|
||||
selected_prompt = original_blocks[idx]
|
||||
next_index = current_index + 1
|
||||
display_run = (current_index % total_count) + 1
|
||||
else: # Default fallback
|
||||
idx = current_index % total_count
|
||||
selected_prompt = original_blocks[idx]
|
||||
next_index = (current_index + 1) % total_count
|
||||
display_run = idx + 1
|
||||
|
||||
if current_prompt_id:
|
||||
assigned_prompts[current_prompt_id] = idx
|
||||
if len(assigned_prompts) > 50:
|
||||
for old_k in list(assigned_prompts.keys())[:-50]:
|
||||
del assigned_prompts[old_k]
|
||||
|
||||
# Calculate remaining items
|
||||
remaining_count = max(0, total_count - display_run)
|
||||
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
|
||||
# Update state dictionary
|
||||
state[state_key] = {
|
||||
"index": next_index,
|
||||
"total": total_count,
|
||||
"last_run": display_run,
|
||||
"last_updated": int(time.time()),
|
||||
"preview": selected_prompt[:60].replace('\n', ' ')
|
||||
"preview": selected_prompt[:60].replace('\n', ' '),
|
||||
"assigned_prompts": assigned_prompts
|
||||
}
|
||||
|
||||
# Keep at most 3 most recent text hashes per node_id
|
||||
node_prefix = f"node_{unique_id}_"
|
||||
node_keys = [k for k in state.keys() if k.startswith(node_prefix)]
|
||||
if len(node_keys) > 3:
|
||||
node_keys.sort(key=lambda k: state[k].get("last_updated", 0) if isinstance(state[k], dict) else 0)
|
||||
for old_k in node_keys[:-3]:
|
||||
del state[old_k]
|
||||
|
||||
save_state(state)
|
||||
|
||||
# Send live progress update to UI
|
||||
|
||||
+267
-25
@@ -7,7 +7,7 @@ from server import PromptServer
|
||||
import nodes
|
||||
from .tray_icon import TrayIconManager, is_tray_icon_enabled
|
||||
from .assets_restore import assets_restore_manager, is_assets_restore_enabled, get_assets_restore_count
|
||||
from .utils import get_leafflow_user_dir
|
||||
from .utils import get_leafflow_user_dir, is_local_request
|
||||
|
||||
QUEUE_CATEGORY = "🍃 LeafFlow/Queue"
|
||||
|
||||
@@ -183,6 +183,7 @@ class PersistentQueueManager:
|
||||
def __init__(self):
|
||||
self.lock = threading.RLock()
|
||||
self.persistent_items = []
|
||||
self.batch_meta = {}
|
||||
self.is_restoring = False
|
||||
self._patched = False
|
||||
self.has_claimed_once = False
|
||||
@@ -193,14 +194,37 @@ class PersistentQueueManager:
|
||||
if os.path.exists(PERSISTENT_FILE):
|
||||
try:
|
||||
with open(PERSISTENT_FILE, "r", encoding="utf-8") as f:
|
||||
self.persistent_items = json.load(f)
|
||||
print(f"[PersistentQueue] Loaded {len(self.persistent_items)} saved queue item(s).")
|
||||
data = json.load(f)
|
||||
if isinstance(data, list):
|
||||
self.persistent_items = [
|
||||
x for x in data
|
||||
if isinstance(x, dict) and "prompt_id" in x and isinstance(x.get("item"), (list, tuple))
|
||||
]
|
||||
for x in self.persistent_items:
|
||||
if "batch_info" in x and "prompt_id" in x:
|
||||
self.batch_meta[str(x["prompt_id"])] = x["batch_info"]
|
||||
print(f"[PersistentQueue] Loaded {len(self.persistent_items)} saved queue item(s).")
|
||||
else:
|
||||
self.persistent_items = []
|
||||
except Exception as e:
|
||||
print(f"[PersistentQueue] Error loading persistent queue file: {e}")
|
||||
self.persistent_items = []
|
||||
else:
|
||||
self.persistent_items = []
|
||||
|
||||
def set_batch_info(self, prompt_id, batch_info):
|
||||
if not prompt_id:
|
||||
return
|
||||
pid_str = str(prompt_id)
|
||||
with self.lock:
|
||||
self.batch_meta[pid_str] = batch_info
|
||||
for item in self.persistent_items:
|
||||
if str(item.get("prompt_id")) == pid_str:
|
||||
item["batch_info"] = batch_info
|
||||
break
|
||||
if is_persistent_queue_enabled():
|
||||
self.save_to_file()
|
||||
|
||||
def save_to_file(self):
|
||||
if not is_persistent_queue_enabled():
|
||||
return
|
||||
@@ -208,7 +232,7 @@ class PersistentQueueManager:
|
||||
try:
|
||||
temp_file = PERSISTENT_FILE + ".tmp"
|
||||
with open(temp_file, "w", encoding="utf-8") as f:
|
||||
json.dump(self.persistent_items, f, indent=2)
|
||||
json.dump(self.persistent_items, f, indent=2, default=str)
|
||||
os.replace(temp_file, PERSISTENT_FILE)
|
||||
except Exception as e:
|
||||
print(f"[PersistentQueue] Error saving persistent queue file: {e}")
|
||||
@@ -222,36 +246,42 @@ class PersistentQueueManager:
|
||||
prompt_id = item_list[1] if len(item_list) > 1 else None
|
||||
if not prompt_id:
|
||||
return
|
||||
pid_str = str(prompt_id)
|
||||
entry = {
|
||||
"prompt_id": prompt_id,
|
||||
"prompt_id": pid_str,
|
||||
"item": item_list
|
||||
}
|
||||
if pid_str in self.batch_meta:
|
||||
entry["batch_info"] = self.batch_meta[pid_str]
|
||||
with self.lock:
|
||||
self.persistent_items = [x for x in self.persistent_items if x.get("prompt_id") != prompt_id]
|
||||
self.persistent_items = [x for x in self.persistent_items if str(x.get("prompt_id")) != pid_str]
|
||||
self.persistent_items.append(entry)
|
||||
self.save_to_file()
|
||||
print(f"[PersistentQueue] Persisted prompt {prompt_id} to disk.")
|
||||
print(f"[PersistentQueue] Persisted prompt {pid_str} to disk.")
|
||||
except Exception as e:
|
||||
print(f"[PersistentQueue] Error formatting queue item for persistence: {e}")
|
||||
|
||||
def remove_item(self, prompt_id):
|
||||
if not prompt_id:
|
||||
return
|
||||
pid_str = str(prompt_id)
|
||||
with self.lock:
|
||||
initial_count = len(self.persistent_items)
|
||||
self.persistent_items = [x for x in self.persistent_items if x.get("prompt_id") != prompt_id]
|
||||
self.persistent_items = [x for x in self.persistent_items if str(x.get("prompt_id")) != pid_str]
|
||||
self.batch_meta.pop(pid_str, None)
|
||||
changed = len(self.persistent_items) != initial_count
|
||||
if changed and is_persistent_queue_enabled():
|
||||
self.save_to_file()
|
||||
print(f"[PersistentQueue] Removed prompt {prompt_id} from disk.")
|
||||
print(f"[PersistentQueue] Removed prompt {pid_str} from disk.")
|
||||
|
||||
def wipe_all(self):
|
||||
with self.lock:
|
||||
self.persistent_items = []
|
||||
self.batch_meta = {}
|
||||
if is_persistent_queue_enabled():
|
||||
self.save_to_file()
|
||||
print("[PersistentQueue] Cleared all saved queue items.")
|
||||
|
||||
|
||||
|
||||
def patch_server(self):
|
||||
if self._patched:
|
||||
return
|
||||
@@ -261,6 +291,7 @@ class PersistentQueueManager:
|
||||
|
||||
queue = server.prompt_queue
|
||||
|
||||
# 1. Patch queue.put
|
||||
original_put = queue.put
|
||||
def patched_put(item, *args, **kwargs):
|
||||
res = original_put(item, *args, **kwargs)
|
||||
@@ -268,12 +299,34 @@ class PersistentQueueManager:
|
||||
return res
|
||||
queue.put = patched_put
|
||||
|
||||
# 2. Patch queue.task_done (Guaranteed cleanup on task completion / error / interrupt)
|
||||
if hasattr(queue, "task_done"):
|
||||
original_task_done = queue.task_done
|
||||
def patched_task_done(item_id, history_result, status, process_item=None):
|
||||
prompt_id = None
|
||||
try:
|
||||
with queue.mutex:
|
||||
if item_id in queue.currently_running:
|
||||
running_item = queue.currently_running[item_id]
|
||||
if isinstance(running_item, (tuple, list)) and len(running_item) > 1:
|
||||
prompt_id = running_item[1]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
res = original_task_done(item_id, history_result, status, process_item)
|
||||
|
||||
if prompt_id:
|
||||
self.remove_item(prompt_id)
|
||||
return res
|
||||
queue.task_done = patched_task_done
|
||||
|
||||
# 3. Patch queue.delete_queue_item
|
||||
if hasattr(queue, "delete_queue_item"):
|
||||
original_delete = queue.delete_queue_item
|
||||
def patched_delete(fn, *args, **kwargs):
|
||||
try:
|
||||
with queue.mutex:
|
||||
to_delete = [item[1] for item in queue.queue if fn(item)]
|
||||
to_delete = [item[1] for item in queue.queue if fn(item) and len(item) > 1]
|
||||
for pid in to_delete:
|
||||
self.remove_item(pid)
|
||||
except Exception:
|
||||
@@ -281,6 +334,7 @@ class PersistentQueueManager:
|
||||
return original_delete(fn, *args, **kwargs)
|
||||
queue.delete_queue_item = patched_delete
|
||||
|
||||
# 4. Patch queue.wipe_queue
|
||||
if hasattr(queue, "wipe_queue"):
|
||||
original_wipe = queue.wipe_queue
|
||||
def patched_wipe(*args, **kwargs):
|
||||
@@ -288,18 +342,17 @@ class PersistentQueueManager:
|
||||
return original_wipe(*args, **kwargs)
|
||||
queue.wipe_queue = patched_wipe
|
||||
|
||||
# 5. Redundant websocket event hooks
|
||||
original_send_sync = server.send_sync
|
||||
def patched_send_sync(event, data, sid=None):
|
||||
try:
|
||||
if event == "executing" and isinstance(data, dict):
|
||||
node = data.get("node")
|
||||
prompt_id = data.get("prompt_id")
|
||||
if node is None and prompt_id:
|
||||
self.remove_item(prompt_id)
|
||||
elif event == "execution_interrupted" and isinstance(data, dict):
|
||||
if isinstance(data, dict):
|
||||
prompt_id = data.get("prompt_id")
|
||||
if prompt_id:
|
||||
self.remove_item(prompt_id)
|
||||
if event == "executing" and data.get("node") is None:
|
||||
self.remove_item(prompt_id)
|
||||
elif event in ("execution_interrupted", "execution_error"):
|
||||
self.remove_item(prompt_id)
|
||||
except Exception:
|
||||
pass
|
||||
return original_send_sync(event, data, sid)
|
||||
@@ -311,7 +364,7 @@ class PersistentQueueManager:
|
||||
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
|
||||
@@ -330,20 +383,43 @@ class PersistentQueueManager:
|
||||
|
||||
print(f"[PersistentQueue] Restoring {len(self.persistent_items)} saved queue item(s) to client '{active_client_id}'...")
|
||||
self.is_restoring = True
|
||||
max_number = 0
|
||||
restored_count = 0
|
||||
try:
|
||||
history = server.prompt_queue.get_history() if hasattr(server.prompt_queue, "get_history") else {}
|
||||
for entry in list(self.persistent_items):
|
||||
try:
|
||||
item_list = list(entry["item"])
|
||||
pid = entry.get("prompt_id")
|
||||
# If this prompt is already in history, it finished previously - drop it
|
||||
if pid and pid in history:
|
||||
self.remove_item(pid)
|
||||
continue
|
||||
|
||||
item_list = list(entry.get("item", []))
|
||||
if len(item_list) < 3 or not item_list[1] or not isinstance(item_list[2], dict):
|
||||
# Malformed entry, drop from disk
|
||||
if pid:
|
||||
self.remove_item(pid)
|
||||
continue
|
||||
|
||||
if active_client_id and len(item_list) > 3 and isinstance(item_list[3], dict):
|
||||
item_list[3]["client_id"] = active_client_id
|
||||
|
||||
|
||||
if isinstance(item_list[0], (int, float)):
|
||||
max_number = max(max_number, int(item_list[0]))
|
||||
|
||||
server.prompt_queue.put(tuple(item_list))
|
||||
restored_count += 1
|
||||
except Exception as e:
|
||||
print(f"[PersistentQueue] Error restoring prompt {entry.get('prompt_id')}: {e}")
|
||||
finally:
|
||||
self.is_restoring = False
|
||||
self.has_claimed_once = True
|
||||
|
||||
|
||||
# Keep server sequence counter above restored items to avoid priority collisions
|
||||
if hasattr(server, "number") and isinstance(server.number, (int, float)):
|
||||
server.number = max(server.number, max_number + 1)
|
||||
|
||||
# Sync the updated client_id to the persistent file
|
||||
if active_client_id:
|
||||
with self.lock:
|
||||
@@ -352,21 +428,130 @@ class PersistentQueueManager:
|
||||
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
|
||||
|
||||
# Broadcast updated queue state to client UI
|
||||
try:
|
||||
if hasattr(server, "queue_updated"):
|
||||
server.queue_updated()
|
||||
if hasattr(server, "get_queue_status"):
|
||||
server.send_sync("status", {"status": server.get_queue_status()})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
class PowerControlManager:
|
||||
def __init__(self, pause_manager):
|
||||
self.pause_manager = pause_manager
|
||||
self.pending_action = None # None | "restart" | "shutdown"
|
||||
self.armed_at = None
|
||||
self.lock = threading.Lock()
|
||||
self._executing = False
|
||||
self._loop_thread = None
|
||||
|
||||
def start_watcher(self):
|
||||
if self._loop_thread is None:
|
||||
self._loop_thread = threading.Thread(target=self._watch_loop, daemon=True)
|
||||
self._loop_thread.start()
|
||||
|
||||
def _watch_loop(self):
|
||||
import time
|
||||
while True:
|
||||
time.sleep(1.0)
|
||||
if self.pending_action:
|
||||
self.check_and_execute()
|
||||
|
||||
def arm(self, action):
|
||||
with self.lock:
|
||||
if action in ["restart", "shutdown"]:
|
||||
self.pending_action = action
|
||||
import time
|
||||
self.armed_at = time.time()
|
||||
print(f"[LeafFlow Power] Armed action '{action}' after queue completion.")
|
||||
else:
|
||||
self.pending_action = None
|
||||
self.armed_at = None
|
||||
print("[LeafFlow Power] Cancelled armed queue power action.")
|
||||
self.notify_clients()
|
||||
|
||||
def get_status(self):
|
||||
with self.lock:
|
||||
return {
|
||||
"pending_action": self.pending_action,
|
||||
"armed_at": self.armed_at,
|
||||
"is_paused": getattr(self.pause_manager, "paused", False) or getattr(self.pause_manager, "is_waiting", False)
|
||||
}
|
||||
|
||||
def notify_clients(self):
|
||||
try:
|
||||
PromptServer.instance.send_sync("leafflow_power_status", self.get_status())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def check_and_execute(self):
|
||||
with self.lock:
|
||||
if not self.pending_action or self._executing:
|
||||
return
|
||||
|
||||
# CRITICAL: If pause mode is active or waiting, DO NOT execute!
|
||||
is_paused = getattr(self.pause_manager, "paused", False) or getattr(self.pause_manager, "is_waiting", False)
|
||||
if is_paused:
|
||||
return
|
||||
|
||||
server = PromptServer.instance
|
||||
if not hasattr(server, "prompt_queue"):
|
||||
return
|
||||
|
||||
queue = server.prompt_queue
|
||||
tasks_remaining = queue.get_tasks_remaining()
|
||||
currently_running = len(getattr(queue, "currently_running", {}))
|
||||
|
||||
if tasks_remaining == 0 and currently_running == 0:
|
||||
action = self.pending_action
|
||||
self._executing = True
|
||||
self.pending_action = None
|
||||
print(f"[LeafFlow Power] Queue is empty and idle. Executing '{action}' now...")
|
||||
|
||||
if action == "restart":
|
||||
self.execute_restart()
|
||||
elif action == "shutdown":
|
||||
self.execute_shutdown()
|
||||
|
||||
def execute_restart(self):
|
||||
def _run():
|
||||
import subprocess, sys, os, time
|
||||
print("[LeafFlow Power] Restarting ComfyUI server process...")
|
||||
time.sleep(0.6)
|
||||
cmd = [sys.executable]
|
||||
if getattr(sys.flags, "no_user_site", 0):
|
||||
cmd.append("-s")
|
||||
cmd.extend(sys.argv)
|
||||
cwd = os.getcwd()
|
||||
env = os.environ.copy()
|
||||
if sys.platform.startswith("win"):
|
||||
creationflags = subprocess.CREATE_NEW_CONSOLE if hasattr(subprocess, "CREATE_NEW_CONSOLE") else 0
|
||||
subprocess.Popen(cmd, cwd=cwd, env=env, creationflags=creationflags)
|
||||
else:
|
||||
subprocess.Popen(cmd, cwd=cwd, env=env, start_new_session=True)
|
||||
time.sleep(0.4)
|
||||
os._exit(0)
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
|
||||
def execute_shutdown(self):
|
||||
def _run():
|
||||
import os, time
|
||||
print("[LeafFlow Power] Shutting down ComfyUI server...")
|
||||
time.sleep(0.6)
|
||||
os._exit(0)
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
|
||||
pause_manager = PauseQueueManager()
|
||||
persistent_manager = PersistentQueueManager()
|
||||
tray_manager = TrayIconManager(pause_manager)
|
||||
power_manager = PowerControlManager(pause_manager)
|
||||
|
||||
def setup_queue_control_routes(server):
|
||||
pause_manager.patch_all()
|
||||
persistent_manager.patch_server()
|
||||
power_manager.start_watcher()
|
||||
|
||||
if is_tray_icon_enabled():
|
||||
tray_manager.start()
|
||||
@@ -376,8 +561,42 @@ def setup_queue_control_routes(server):
|
||||
|
||||
routes = server.routes
|
||||
|
||||
@routes.get("/leafflow/power/status")
|
||||
async def get_power_status(request):
|
||||
if not is_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local access only"}, status=403)
|
||||
return web.json_response(power_manager.get_status())
|
||||
|
||||
@routes.post("/leafflow/power/arm")
|
||||
async def arm_power_action(request):
|
||||
if not is_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local access only"}, status=403)
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
data = {}
|
||||
action = data.get("action")
|
||||
power_manager.arm(action)
|
||||
return web.json_response(power_manager.get_status())
|
||||
|
||||
@routes.post("/leafflow/power/restart")
|
||||
async def trigger_restart(request):
|
||||
if not is_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local access only"}, status=403)
|
||||
power_manager.execute_restart()
|
||||
return web.json_response({"status": "restarting"})
|
||||
|
||||
@routes.post("/leafflow/power/shutdown")
|
||||
async def trigger_shutdown(request):
|
||||
if not is_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local access only"}, status=403)
|
||||
power_manager.execute_shutdown()
|
||||
return web.json_response({"status": "shutting_down"})
|
||||
|
||||
@routes.post("/leafflow/assets/restore")
|
||||
async def restore_assets_endpoint(request):
|
||||
if not is_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local access only"}, status=403)
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
@@ -389,8 +608,31 @@ def setup_queue_control_routes(server):
|
||||
|
||||
@routes.get("/leafflow/assets/debug")
|
||||
async def get_assets_debug(request):
|
||||
if not is_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local access only"}, status=403)
|
||||
return web.json_response(assets_restore_manager.last_debug_report)
|
||||
|
||||
@routes.get("/leafflow/batch_queue/data")
|
||||
async def get_batch_queue_data(request):
|
||||
if not is_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local access only"}, status=403)
|
||||
return web.json_response(persistent_manager.batch_meta)
|
||||
|
||||
@routes.post("/leafflow/batch_queue/sync")
|
||||
async def sync_batch_queue_data(request):
|
||||
if not is_local_request(request):
|
||||
return web.json_response({"error": "Forbidden: Local access only"}, status=403)
|
||||
try:
|
||||
data = await request.json()
|
||||
pid = data.get("prompt_id")
|
||||
info = data.get("batch_info")
|
||||
if pid and info:
|
||||
persistent_manager.set_batch_info(pid, info)
|
||||
return web.json_response({"success": True})
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
return web.json_response({"success": False}, status=400)
|
||||
|
||||
@routes.get("/pause_queue/status")
|
||||
async def get_status(request):
|
||||
pause_manager.patch_all()
|
||||
|
||||
+106
@@ -30,6 +30,112 @@ def get_leafflow_user_dir():
|
||||
os.makedirs(leafflow_dir, exist_ok=True)
|
||||
return leafflow_dir
|
||||
|
||||
def is_local_request(request):
|
||||
"""
|
||||
Returns True if the HTTP request originates strictly from loopback (localhost / 127.0.0.1 / ::1).
|
||||
Used to protect sensitive endpoints (power control, system settings, file browsing).
|
||||
"""
|
||||
if request is None:
|
||||
return True
|
||||
remote = getattr(request, "remote", None)
|
||||
if not remote:
|
||||
transport = getattr(request, "transport", None)
|
||||
if transport:
|
||||
peername = transport.get_extra_info("peername")
|
||||
if peername and isinstance(peername, tuple):
|
||||
remote = peername[0]
|
||||
if not remote:
|
||||
return False
|
||||
local_hosts = {"127.0.0.1", "::1", "localhost", "0.0.0.0", "testclient"}
|
||||
if remote in local_hosts:
|
||||
return True
|
||||
try:
|
||||
import ipaddress
|
||||
ip = ipaddress.ip_address(remote)
|
||||
return ip.is_loopback
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def get_allowed_image_directories():
|
||||
"""
|
||||
Returns approved base directories for image loader operations: strictly ComfyUI input, output, and temp dirs.
|
||||
"""
|
||||
allowed = []
|
||||
try:
|
||||
inp = folder_paths.get_input_directory()
|
||||
if inp:
|
||||
allowed.append(os.path.abspath(os.path.realpath(inp)))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
out = folder_paths.get_output_directory()
|
||||
if out:
|
||||
allowed.append(os.path.abspath(os.path.realpath(out)))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
tmp = folder_paths.get_temp_directory()
|
||||
if tmp:
|
||||
allowed.append(os.path.abspath(os.path.realpath(tmp)))
|
||||
except Exception:
|
||||
pass
|
||||
return allowed
|
||||
|
||||
def is_safe_path(target_path, allowed_bases=None):
|
||||
"""
|
||||
Confines file access strictly within approved base directories.
|
||||
Prevents path traversal, directory escape, and arbitrary system file reads.
|
||||
"""
|
||||
if not target_path:
|
||||
return False
|
||||
if allowed_bases is None:
|
||||
allowed_bases = get_allowed_image_directories()
|
||||
try:
|
||||
abs_target = os.path.abspath(os.path.realpath(target_path))
|
||||
for base in allowed_bases:
|
||||
abs_base = os.path.abspath(os.path.realpath(base))
|
||||
if os.path.commonpath([abs_base, abs_target]) == abs_base:
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
|
||||
def sanitize_image_loader_folder(folder_input, default_to_output=True):
|
||||
"""
|
||||
Resolves folder input strictly within ComfyUI input/output/temp directories.
|
||||
If folder_input attempts to escape or points outside allowed bases, falls back safely.
|
||||
"""
|
||||
allowed_bases = get_allowed_image_directories()
|
||||
input_dir = folder_paths.get_input_directory()
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
default_dir = output_dir if default_to_output else input_dir
|
||||
|
||||
if not folder_input or not str(folder_input).strip():
|
||||
return default_dir
|
||||
|
||||
clean = str(folder_input).strip().rstrip("\\/")
|
||||
|
||||
if clean.lower() == "input" or clean.lower().startswith("input/") or clean.lower().startswith("input\\"):
|
||||
sub = clean[5:].lstrip("\\/")
|
||||
resolved = os.path.join(input_dir, sub) if sub else input_dir
|
||||
elif clean.lower() == "output" or clean.lower().startswith("output/") or clean.lower().startswith("output\\"):
|
||||
sub = clean[6:].lstrip("\\/")
|
||||
resolved = os.path.join(output_dir, sub) if sub else output_dir
|
||||
elif os.path.isabs(clean):
|
||||
resolved = clean
|
||||
else:
|
||||
cand_out = os.path.join(output_dir, clean)
|
||||
cand_inp = os.path.join(input_dir, clean)
|
||||
if os.path.exists(cand_inp) and not os.path.exists(cand_out):
|
||||
resolved = cand_inp
|
||||
else:
|
||||
resolved = cand_out
|
||||
|
||||
if is_safe_path(resolved, allowed_bases):
|
||||
return os.path.normpath(resolved)
|
||||
|
||||
return default_dir
|
||||
|
||||
def get_env_setting(key, default_val):
|
||||
"""
|
||||
Reads a key setting from ComfyUI/user/default/LeafFlow/.env safely.
|
||||
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
[project]
|
||||
name = "comfyui-leafflow"
|
||||
description = "Essential workflow controls, mid-generation pausing, real-time queue persistence, folder watcher, and visual model loaders for ComfyUI."
|
||||
version = "2.1.0"
|
||||
version = "2.2.0"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
dependencies = ["torch", "pillow", "numpy", "piexif", "pystray"]
|
||||
dependencies = ["pillow", "numpy", "piexif", "pystray"]
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/KOFiblto/ComfyUI-LeafFlow"
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
Pillow
|
||||
piexif
|
||||
numpy
|
||||
pystray
|
||||
@@ -66,6 +66,62 @@ class TestAspectRatioNodes(unittest.TestCase):
|
||||
w2, h2, r2 = self.finder.find_aspect_ratio(text=None, default_aspect_ratio="1:1")
|
||||
self.assertEqual(r2, "1:1")
|
||||
|
||||
def test_custom_default_aspect_ratio(self):
|
||||
w, h, ratio = self.finder.find_aspect_ratio(
|
||||
text="No ratio mentioned",
|
||||
default_aspect_ratio="5:4",
|
||||
target_mp=1.0
|
||||
)
|
||||
self.assertEqual(ratio, "5:4")
|
||||
|
||||
w2, h2, ratio2 = self.finder.find_aspect_ratio(
|
||||
text="No ratio mentioned",
|
||||
default_aspect_ratio="30:1",
|
||||
target_mp=1.0
|
||||
)
|
||||
self.assertEqual(ratio2, "30:1")
|
||||
|
||||
def test_corrupt_default_aspect_ratio_falls_back_to_1_1(self):
|
||||
w, h, ratio = self.finder.find_aspect_ratio(
|
||||
text="No ratio mentioned",
|
||||
default_aspect_ratio="corrupted:invalid_ratio",
|
||||
target_mp=1.0
|
||||
)
|
||||
self.assertEqual(ratio, "1:1")
|
||||
|
||||
def test_custom_allowed_ratios_whitelist(self):
|
||||
# When aspect_ratios is configured with "16:9, 9:16", any other ratio in text like "4:3" or "5:7" must be ignored!
|
||||
w, h, ratio = self.finder.find_aspect_ratio(
|
||||
text="First 4:3 then 16:9 and also 5:7",
|
||||
aspect_ratios="16:9, 9:16",
|
||||
default_aspect_ratio="1:1"
|
||||
)
|
||||
self.assertEqual(ratio, "16:9")
|
||||
|
||||
# When prompt only contains unapproved ratios, fall back to default
|
||||
w2, h2, ratio2 = self.finder.find_aspect_ratio(
|
||||
text="A photo in 5:7 format with 12:34 crop",
|
||||
aspect_ratios="16:9, 9:16, 1:1",
|
||||
default_aspect_ratio="1:1"
|
||||
)
|
||||
self.assertEqual(ratio2, "1:1")
|
||||
|
||||
def test_empty_aspect_ratios_accepts_any_ratio(self):
|
||||
# When aspect_ratios is empty, accept any valid aspect ratio found in text
|
||||
w, h, ratio = self.finder.find_aspect_ratio(
|
||||
text="A photo in custom 5:7 format",
|
||||
aspect_ratios="",
|
||||
default_aspect_ratio="1:1"
|
||||
)
|
||||
self.assertEqual(ratio, "5:7")
|
||||
|
||||
w2, h2, ratio2 = self.finder.find_aspect_ratio(
|
||||
text="Anamorphic 2.39:1 movie frame",
|
||||
aspect_ratios=" ",
|
||||
default_aspect_ratio="1:1"
|
||||
)
|
||||
self.assertEqual(ratio2, "2.39:1")
|
||||
|
||||
def test_preview_process(self):
|
||||
with patch("nodes.aspect_ratio.PromptServer.instance.send_sync") as mock_send:
|
||||
res = self.preview_node.process_preview(
|
||||
|
||||
@@ -20,7 +20,7 @@ class TestAutoWatcher(unittest.TestCase):
|
||||
|
||||
def test_create_dummy_image(self):
|
||||
dummy = self.node.create_dummy_image()
|
||||
self.assertEqual(dummy.shape, (1, 64, 64, 3))
|
||||
self.assertEqual(dummy.shape, (1, 512, 512, 3))
|
||||
|
||||
def test_get_filtered_files_by_regex(self):
|
||||
img = Image.new("RGB", (64, 64), color="blue")
|
||||
@@ -52,7 +52,26 @@ class TestAutoWatcher(unittest.TestCase):
|
||||
regex_filter=".*"
|
||||
)
|
||||
self.assertFalse(has_image)
|
||||
self.assertEqual(img_tensor.shape, (1, 64, 64, 3))
|
||||
self.assertEqual(img_tensor.shape, (1, 512, 512, 3))
|
||||
|
||||
def test_sequential_cycle_when_delete_false(self):
|
||||
"""Verify that when delete_image=False, consecutive runs cycle through files instead of loading the same file forever."""
|
||||
f1 = os.path.join(self.temp_dir, "img_a.png")
|
||||
f2 = os.path.join(self.temp_dir, "img_b.png")
|
||||
Image.new("RGB", (64, 64), color="red").save(f1)
|
||||
Image.new("RGB", (64, 64), color="green").save(f2)
|
||||
|
||||
# Run 1 -> loads img_a
|
||||
t1, has1 = self.node.watch(folder=self.temp_dir, wait_if_folder_is_empty=False, rescan_interval=1, sort_by="name", regex_filter=".*", delete_image=False, unique_id="cycle_test")
|
||||
self.assertTrue(has1)
|
||||
|
||||
# Run 2 -> loads img_b (cycled!)
|
||||
t2, has2 = self.node.watch(folder=self.temp_dir, wait_if_folder_is_empty=False, rescan_interval=1, sort_by="name", regex_filter=".*", delete_image=False, unique_id="cycle_test")
|
||||
self.assertTrue(has2)
|
||||
|
||||
# Run 3 -> loops back to img_a
|
||||
t3, has3 = self.node.watch(folder=self.temp_dir, wait_if_folder_is_empty=False, rescan_interval=1, sort_by="name", regex_filter=".*", delete_image=False, unique_id="cycle_test")
|
||||
self.assertTrue(has3)
|
||||
|
||||
def test_load_and_remove_image_with_delete_toggle(self):
|
||||
img = Image.new("RGB", (64, 64), color="red")
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
import json
|
||||
import tempfile
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import test_helper
|
||||
|
||||
from nodes.queue_control import PersistentQueueManager
|
||||
|
||||
# 36 Colors matching web/batch_queue.js
|
||||
BATCH_COLORS = [
|
||||
"#10b981", "#3b82f6", "#8b5cf6", "#ec4899", "#f59e0b", "#06b6d4",
|
||||
"#ef4444", "#14b8a6", "#6366f1", "#f97316", "#84cc16", "#d946ef",
|
||||
"#0ea5e9", "#a855f7", "#e11d48", "#22c55e", "#eab308", "#64748b",
|
||||
"#2dd4bf", "#fb7185", "#38bdf8", "#c084fc", "#f43f5e", "#4ade80",
|
||||
"#fbbf24", "#818cf8", "#fb923c", "#a3e635", "#f472b6", "#22d3ee",
|
||||
"#e879f9", "#a7f3d0", "#fde047", "#fda4af", "#93c5fd", "#c4b5fd"
|
||||
]
|
||||
|
||||
def calculate_segment_types(ordered_pids, registry):
|
||||
"""
|
||||
Python mirror of the segment calculation logic in web/batch_queue.js
|
||||
"""
|
||||
segments = {}
|
||||
for pid in ordered_pids:
|
||||
info = registry.get(pid)
|
||||
if not info or not info.get("batchId"):
|
||||
segments[pid] = None
|
||||
continue
|
||||
|
||||
batch_id = info["batchId"]
|
||||
occurrences = [p for p in ordered_pids if registry.get(p, {}).get("batchId") == batch_id]
|
||||
|
||||
if len(occurrences) <= 1:
|
||||
batch_count = info.get("batchCount", 1)
|
||||
if batch_count == 1:
|
||||
segments[pid] = "single"
|
||||
else:
|
||||
item_index = info.get("itemIndex", 0)
|
||||
if item_index == 0:
|
||||
segments[pid] = "first"
|
||||
elif item_index == (batch_count - 1):
|
||||
segments[pid] = "last"
|
||||
else:
|
||||
segments[pid] = "middle"
|
||||
continue
|
||||
|
||||
first_pid = occurrences[0]
|
||||
last_pid = occurrences[-1]
|
||||
|
||||
if pid == first_pid:
|
||||
segments[pid] = "first"
|
||||
elif pid == last_pid:
|
||||
segments[pid] = "last"
|
||||
else:
|
||||
segments[pid] = "middle"
|
||||
|
||||
return segments
|
||||
|
||||
|
||||
class TestBatchQueueLogic(unittest.TestCase):
|
||||
def test_36_color_cycling(self):
|
||||
self.assertEqual(len(BATCH_COLORS), 36)
|
||||
|
||||
# Batch 1 -> color index 0
|
||||
self.assertEqual((1 - 1) % 36, 0)
|
||||
self.assertEqual(BATCH_COLORS[(1 - 1) % 36], "#10b981")
|
||||
|
||||
# Batch 36 -> color index 35
|
||||
self.assertEqual((36 - 1) % 36, 35)
|
||||
self.assertEqual(BATCH_COLORS[(36 - 1) % 36], "#c4b5fd")
|
||||
|
||||
# Batch 37 -> cycles back to color index 0
|
||||
self.assertEqual((37 - 1) % 36, 0)
|
||||
self.assertEqual(BATCH_COLORS[(37 - 1) % 36], "#10b981")
|
||||
|
||||
# Batch 73 -> cycles back to color index 0
|
||||
self.assertEqual((73 - 1) % 36, 0)
|
||||
|
||||
def test_single_item_batch(self):
|
||||
registry = {
|
||||
"p1": {"batchId": "b1", "batchCount": 1, "itemIndex": 0}
|
||||
}
|
||||
segments = calculate_segment_types(["p1"], registry)
|
||||
self.assertEqual(segments["p1"], "single")
|
||||
|
||||
def test_intact_contiguous_batch(self):
|
||||
registry = {
|
||||
"p1": {"batchId": "b1", "batchCount": 3, "itemIndex": 0},
|
||||
"p2": {"batchId": "b1", "batchCount": 3, "itemIndex": 1},
|
||||
"p3": {"batchId": "b1", "batchCount": 3, "itemIndex": 2},
|
||||
}
|
||||
segments = calculate_segment_types(["p1", "p2", "p3"], registry)
|
||||
self.assertEqual(segments["p1"], "first")
|
||||
self.assertEqual(segments["p2"], "middle")
|
||||
self.assertEqual(segments["p3"], "last")
|
||||
|
||||
def test_interrupted_batch_inserted_in_between(self):
|
||||
# Batch A was [pA1, pA2], but prompt B was inserted in-between: [pA1, pB, pA2]
|
||||
registry = {
|
||||
"pA1": {"batchId": "bA", "batchCount": 2, "itemIndex": 0},
|
||||
"pB": {"batchId": "bB", "batchCount": 1, "itemIndex": 0},
|
||||
"pA2": {"batchId": "bA", "batchCount": 2, "itemIndex": 1},
|
||||
}
|
||||
segments = calculate_segment_types(["pA1", "pB", "pA2"], registry)
|
||||
# pA1 is the start of Batch A (curves top, open bottom)
|
||||
self.assertEqual(segments["pA1"], "first")
|
||||
# pB is a standalone single item (curves top and bottom)
|
||||
self.assertEqual(segments["pB"], "single")
|
||||
# pA2 is the end of Batch A (open top, curves bottom)
|
||||
self.assertEqual(segments["pA2"], "last")
|
||||
|
||||
def test_multiple_interruptions(self):
|
||||
# [pA1, pB, pA2, pC, pA3]
|
||||
registry = {
|
||||
"pA1": {"batchId": "bA", "batchCount": 3, "itemIndex": 0},
|
||||
"pB": {"batchId": "bB", "batchCount": 1, "itemIndex": 0},
|
||||
"pA2": {"batchId": "bA", "batchCount": 3, "itemIndex": 1},
|
||||
"pC": {"batchId": "bC", "batchCount": 1, "itemIndex": 0},
|
||||
"pA3": {"batchId": "bA", "batchCount": 3, "itemIndex": 2},
|
||||
}
|
||||
segments = calculate_segment_types(["pA1", "pB", "pA2", "pC", "pA3"], registry)
|
||||
self.assertEqual(segments["pA1"], "first")
|
||||
self.assertEqual(segments["pB"], "single")
|
||||
self.assertEqual(segments["pA2"], "middle")
|
||||
self.assertEqual(segments["pC"], "single")
|
||||
self.assertEqual(segments["pA3"], "last")
|
||||
|
||||
|
||||
class TestPersistentQueueBatchMetadata(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.temp_file = os.path.join(self.temp_dir.name, "test_persistent_queue.json")
|
||||
self.patcher = patch("nodes.queue_control.PERSISTENT_FILE", self.temp_file)
|
||||
self.patcher.start()
|
||||
self.enabled_patcher = patch("nodes.queue_control.is_persistent_queue_enabled", return_value=True)
|
||||
self.enabled_patcher.start()
|
||||
|
||||
def tearDown(self):
|
||||
self.enabled_patcher.stop()
|
||||
self.patcher.stop()
|
||||
self.temp_dir.cleanup()
|
||||
|
||||
def test_save_and_restore_batch_info(self):
|
||||
mgr = PersistentQueueManager()
|
||||
|
||||
# Set batch info for a prompt
|
||||
batch_data = {
|
||||
"batchId": "b_12345",
|
||||
"batchNumber": 5,
|
||||
"batchCount": 3,
|
||||
"itemIndex": 0,
|
||||
"color": "#10b981",
|
||||
"colorIndex": 4
|
||||
}
|
||||
mgr.set_batch_info("prompt_99", batch_data)
|
||||
self.assertIn("prompt_99", mgr.batch_meta)
|
||||
|
||||
# Add item to persistent queue
|
||||
item_tuple = (0, "prompt_99", {"prompt": {}}, {}, {})
|
||||
mgr.add_item(item_tuple)
|
||||
|
||||
# Verify batch_info is attached inside persistent_items
|
||||
saved_entry = [x for x in mgr.persistent_items if x["prompt_id"] == "prompt_99"][0]
|
||||
self.assertEqual(saved_entry.get("batch_info"), batch_data)
|
||||
|
||||
# Simulate reload from disk
|
||||
new_mgr = PersistentQueueManager()
|
||||
self.assertIn("prompt_99", new_mgr.batch_meta)
|
||||
self.assertEqual(new_mgr.batch_meta["prompt_99"]["batchId"], "b_12345")
|
||||
|
||||
def test_remove_item_cleans_batch_meta(self):
|
||||
mgr = PersistentQueueManager()
|
||||
mgr.set_batch_info("p1", {"batchId": "b1"})
|
||||
mgr.add_item((0, "p1", {}, {}, {}))
|
||||
self.assertIn("p1", mgr.batch_meta)
|
||||
|
||||
mgr.remove_item("p1")
|
||||
self.assertNotIn("p1", mgr.batch_meta)
|
||||
self.assertEqual(len(mgr.persistent_items), 0)
|
||||
|
||||
def test_wipe_all_cleans_batch_meta(self):
|
||||
mgr = PersistentQueueManager()
|
||||
mgr.set_batch_info("p1", {"batchId": "b1"})
|
||||
mgr.set_batch_info("p2", {"batchId": "b2"})
|
||||
mgr.wipe_all()
|
||||
self.assertEqual(len(mgr.batch_meta), 0)
|
||||
self.assertEqual(len(mgr.persistent_items), 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,162 @@
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import test_helper
|
||||
|
||||
from nodes.image_loader import trace_positive_prompt_from_graph, parse_positive_from_parameters
|
||||
|
||||
class TestPromptExtraction(unittest.TestCase):
|
||||
def test_standard_ksampler_graph(self):
|
||||
graph = {
|
||||
"3": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"positive": ["6", 0],
|
||||
"negative": ["7", 0],
|
||||
"model": ["4", 0]
|
||||
}
|
||||
},
|
||||
"6": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"text": "a majestic lion in the savanna at golden hour"
|
||||
}
|
||||
},
|
||||
"7": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"text": "ugly, blurry, low quality"
|
||||
}
|
||||
}
|
||||
}
|
||||
res = trace_positive_prompt_from_graph(graph)
|
||||
self.assertEqual(res, "a majestic lion in the savanna at golden hour")
|
||||
|
||||
def test_flux_guidance_graph(self):
|
||||
graph = {
|
||||
"1": {
|
||||
"class_type": "KSamplerSelect",
|
||||
"inputs": {}
|
||||
},
|
||||
"2": {
|
||||
"class_type": "SamplerCustomAdvanced",
|
||||
"inputs": {
|
||||
"guider": ["3", 0]
|
||||
}
|
||||
},
|
||||
"3": {
|
||||
"class_type": "BasicGuider",
|
||||
"inputs": {
|
||||
"conditioning": ["4", 0]
|
||||
}
|
||||
},
|
||||
"4": {
|
||||
"class_type": "FluxGuidance",
|
||||
"inputs": {
|
||||
"guidance": 3.5,
|
||||
"conditioning": ["5", 0]
|
||||
}
|
||||
},
|
||||
"5": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"text": "a cyberpunk futuristic street in Tokyo, neon reflections"
|
||||
}
|
||||
}
|
||||
}
|
||||
res = trace_positive_prompt_from_graph(graph)
|
||||
self.assertEqual(res, "a cyberpunk futuristic street in Tokyo, neon reflections")
|
||||
|
||||
def test_sdxl_text_g_and_text_l(self):
|
||||
graph = {
|
||||
"10": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"positive": ["20", 0],
|
||||
"negative": ["21", 0]
|
||||
}
|
||||
},
|
||||
"20": {
|
||||
"class_type": "CLIPTextEncodeSDXL",
|
||||
"inputs": {
|
||||
"text_g": "an astronaut riding a horse",
|
||||
"text_l": "photorealistic, 8k resolution"
|
||||
}
|
||||
},
|
||||
"21": {
|
||||
"class_type": "CLIPTextEncodeSDXL",
|
||||
"inputs": {
|
||||
"text_g": "blurry",
|
||||
"text_l": "cartoon"
|
||||
}
|
||||
}
|
||||
}
|
||||
res = trace_positive_prompt_from_graph(graph)
|
||||
self.assertIn("an astronaut riding a horse", res)
|
||||
self.assertIn("photorealistic, 8k resolution", res)
|
||||
self.assertNotIn("blurry", res)
|
||||
|
||||
def test_conditioning_combine(self):
|
||||
graph = {
|
||||
"1": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"positive": ["2", 0],
|
||||
"negative": ["9", 0]
|
||||
}
|
||||
},
|
||||
"2": {
|
||||
"class_type": "ConditioningCombine",
|
||||
"inputs": {
|
||||
"conditioning_1": ["3", 0],
|
||||
"conditioning_2": ["4", 0]
|
||||
}
|
||||
},
|
||||
"3": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": "part 1 of positive prompt"}
|
||||
},
|
||||
"4": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": "part 2 of positive prompt"}
|
||||
}
|
||||
}
|
||||
res = trace_positive_prompt_from_graph(graph)
|
||||
self.assertIn("part 1 of positive prompt", res)
|
||||
self.assertIn("part 2 of positive prompt", res)
|
||||
|
||||
def test_primitive_string_link(self):
|
||||
graph = {
|
||||
"1": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"positive": ["2", 0]
|
||||
}
|
||||
},
|
||||
"2": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"text": ["3", 0]
|
||||
}
|
||||
},
|
||||
"3": {
|
||||
"class_type": "PrimitiveString",
|
||||
"inputs": {
|
||||
"value": "string from primitive node"
|
||||
}
|
||||
}
|
||||
}
|
||||
res = trace_positive_prompt_from_graph(graph)
|
||||
self.assertEqual(res, "string from primitive node")
|
||||
|
||||
def test_a1111_parameters_parsing(self):
|
||||
param_str = """A portrait of an ancient wizard with a glowing crystal staff
|
||||
Negative prompt: ugly, deformed, bad anatomy
|
||||
Steps: 30, Sampler: DPM++ 2M Karras, CFG scale: 7, Seed: 12345, Size: 512x768"""
|
||||
res = parse_positive_from_parameters(param_str)
|
||||
self.assertEqual(res, "A portrait of an ancient wizard with a glowing crystal staff")
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -35,7 +35,7 @@ class TestLoadRecent(unittest.TestCase):
|
||||
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))
|
||||
self.assertEqual(out_tensor.shape, (1, 512, 512, 3))
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from nodes.queue_control import PowerControlManager, PauseQueueManager
|
||||
|
||||
class TestPowerControlManager(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.pause_manager = PauseQueueManager()
|
||||
self.pause_manager.paused = False
|
||||
self.pause_manager.is_waiting = False
|
||||
self.power_manager = PowerControlManager(self.pause_manager)
|
||||
|
||||
def test_arm_and_disarm(self):
|
||||
self.power_manager.arm("restart")
|
||||
self.assertEqual(self.power_manager.pending_action, "restart")
|
||||
self.assertIsNotNone(self.power_manager.armed_at)
|
||||
|
||||
status = self.power_manager.get_status()
|
||||
self.assertEqual(status["pending_action"], "restart")
|
||||
|
||||
self.power_manager.arm("shutdown")
|
||||
self.assertEqual(self.power_manager.pending_action, "shutdown")
|
||||
|
||||
self.power_manager.arm(None)
|
||||
self.assertIsNone(self.power_manager.pending_action)
|
||||
self.assertIsNone(self.power_manager.armed_at)
|
||||
|
||||
def test_does_not_execute_when_paused(self):
|
||||
self.power_manager.arm("restart")
|
||||
self.pause_manager.paused = True
|
||||
|
||||
with patch.object(self.power_manager, "execute_restart") as mock_restart:
|
||||
self.power_manager.check_and_execute()
|
||||
mock_restart.assert_not_called()
|
||||
self.assertEqual(self.power_manager.pending_action, "restart")
|
||||
|
||||
def test_does_not_execute_when_tasks_remaining(self):
|
||||
self.power_manager.arm("restart")
|
||||
mock_queue = MagicMock()
|
||||
mock_queue.get_tasks_remaining.return_value = 2
|
||||
mock_queue.currently_running = {"1": True}
|
||||
|
||||
with patch("server.PromptServer.instance") as mock_server:
|
||||
mock_server.prompt_queue = mock_queue
|
||||
with patch.object(self.power_manager, "execute_restart") as mock_restart:
|
||||
self.power_manager.check_and_execute()
|
||||
mock_restart.assert_not_called()
|
||||
self.assertEqual(self.power_manager.pending_action, "restart")
|
||||
|
||||
def test_executes_when_queue_empty_and_idle(self):
|
||||
self.power_manager.arm("restart")
|
||||
mock_queue = MagicMock()
|
||||
mock_queue.get_tasks_remaining.return_value = 0
|
||||
mock_queue.currently_running = {}
|
||||
|
||||
with patch("server.PromptServer.instance") as mock_server:
|
||||
mock_server.prompt_queue = mock_queue
|
||||
with patch.object(self.power_manager, "execute_restart") as mock_restart:
|
||||
self.power_manager.check_and_execute()
|
||||
mock_restart.assert_called_once()
|
||||
self.assertIsNone(self.power_manager.pending_action)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -158,23 +158,66 @@ class TestPromptQueueIterator(unittest.TestCase):
|
||||
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_crash_recovery_assigned_prompt_id_retains_index(self):
|
||||
"""
|
||||
Verify that if a prompt is interrupted/restored with the same prompt_id,
|
||||
it re-executes the exact same prompt index instead of skipping ahead.
|
||||
"""
|
||||
pack = "Prompt 0\n\nPrompt 1\n\nPrompt 2"
|
||||
state_db = {}
|
||||
|
||||
def 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)
|
||||
def mock_load():
|
||||
return dict(state_db)
|
||||
|
||||
def mock_save(new_state):
|
||||
nonlocal state_db
|
||||
state_db = dict(new_state)
|
||||
|
||||
with patch("nodes.prompt_iterator.load_state", side_effect=mock_load), \
|
||||
patch("nodes.prompt_iterator.save_state", side_effect=mock_save), \
|
||||
patch("nodes.prompt_iterator.PromptServer.instance.send_sync"):
|
||||
|
||||
# 1. First execution with prompt_id = "job-uuid-1" -> gets Prompt 0
|
||||
with patch("nodes.prompt_iterator.get_current_prompt_id", return_value="job-uuid-1"):
|
||||
p0, _, _ = self.node.process_queue(text=pack, unique_id="recover_node")
|
||||
self.assertEqual(p0, "Prompt 0")
|
||||
|
||||
# 2. Workflow is interrupted mid-way and restored by persistent queue (same prompt_id "job-uuid-1")
|
||||
with patch("nodes.prompt_iterator.get_current_prompt_id", return_value="job-uuid-1"):
|
||||
p0_retry, _, _ = self.node.process_queue(text=pack, unique_id="recover_node")
|
||||
# Must STILL be Prompt 0, NOT Prompt 1!
|
||||
self.assertEqual(p0_retry, "Prompt 0")
|
||||
|
||||
# 3. Next workflow run with new prompt_id = "job-uuid-2" -> gets Prompt 1!
|
||||
with patch("nodes.prompt_iterator.get_current_prompt_id", return_value="job-uuid-2"):
|
||||
p1, _, _ = self.node.process_queue(text=pack, unique_id="recover_node")
|
||||
self.assertEqual(p1, "Prompt 1")
|
||||
|
||||
def test_per_node_hash_pruning(self):
|
||||
"""
|
||||
Verify that editing text in a node cleans up old abandoned text hashes beyond 3 entries.
|
||||
"""
|
||||
state_db = {}
|
||||
|
||||
def mock_load():
|
||||
return dict(state_db)
|
||||
|
||||
def mock_save(new_state):
|
||||
nonlocal state_db
|
||||
state_db = dict(new_state)
|
||||
|
||||
with patch("nodes.prompt_iterator.load_state", side_effect=mock_load), \
|
||||
patch("nodes.prompt_iterator.save_state", side_effect=mock_save), \
|
||||
patch("nodes.prompt_iterator.PromptServer.instance.send_sync"):
|
||||
|
||||
# Create 6 text variants on the same node
|
||||
for i in range(6):
|
||||
text_variant = f"Variant {i} text"
|
||||
self.node.process_queue(text=text_variant, unique_id="prune_node")
|
||||
|
||||
# Only at most 3 entries for 'prune_node' should remain in state
|
||||
matching_keys = [k for k in state_db.keys() if k.startswith("node_prune_node_")]
|
||||
self.assertLessEqual(len(matching_keys), 3)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
+25
-1
@@ -9,10 +9,34 @@ from nodes.utils import (
|
||||
parse_pretty_name,
|
||||
parse_pretty_name_with_version,
|
||||
sanitize_folder_path,
|
||||
format_lora_output_name
|
||||
format_lora_output_name,
|
||||
is_local_request,
|
||||
is_safe_path,
|
||||
sanitize_image_loader_folder
|
||||
)
|
||||
|
||||
class TestUtils(unittest.TestCase):
|
||||
def test_is_local_request(self):
|
||||
class DummyReq:
|
||||
def __init__(self, ip):
|
||||
self.remote = ip
|
||||
self.assertTrue(is_local_request(DummyReq("127.0.0.1")))
|
||||
self.assertTrue(is_local_request(DummyReq("::1")))
|
||||
self.assertTrue(is_local_request(DummyReq("localhost")))
|
||||
self.assertFalse(is_local_request(DummyReq("192.168.1.50")))
|
||||
self.assertFalse(is_local_request(DummyReq("10.0.0.1")))
|
||||
self.assertFalse(is_local_request(DummyReq("8.8.8.8")))
|
||||
|
||||
def test_is_safe_path(self):
|
||||
import tempfile
|
||||
base1 = tempfile.mkdtemp()
|
||||
sub = os.path.join(base1, "images", "test.png")
|
||||
escape = os.path.join(base1, "..", "passwords.txt")
|
||||
self.assertTrue(is_safe_path(sub, allowed_bases=[base1]))
|
||||
self.assertFalse(is_safe_path(escape, allowed_bases=[base1]))
|
||||
import shutil
|
||||
shutil.rmtree(base1, ignore_errors=True)
|
||||
|
||||
def test_parse_pretty_name(self):
|
||||
name = parse_pretty_name("krea2_ana-de-armas_v1.safetensors")
|
||||
self.assertEqual(name, "Ana De Armas")
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { api } from "/scripts/api.js";
|
||||
|
||||
// 36 Curated Accessible, High-Contrast Cycling Colors for Batch Visual Grouping
|
||||
const BATCH_COLORS = [
|
||||
"#10b981", // 1. Emerald
|
||||
"#3b82f6", // 2. Blue
|
||||
"#8b5cf6", // 3. Purple
|
||||
"#ec4899", // 4. Pink
|
||||
"#f59e0b", // 5. Amber
|
||||
"#06b6d4", // 6. Cyan
|
||||
"#ef4444", // 7. Red
|
||||
"#14b8a6", // 8. Teal
|
||||
"#6366f1", // 9. Indigo
|
||||
"#f97316", // 10. Orange
|
||||
"#84cc16", // 11. Lime
|
||||
"#d946ef", // 12. Fuchsia
|
||||
"#0ea5e9", // 13. Sky
|
||||
"#a855f7", // 14. Violet
|
||||
"#e11d48", // 15. Rose
|
||||
"#22c55e", // 16. Green
|
||||
"#eab308", // 17. Yellow
|
||||
"#64748b", // 18. Slate
|
||||
"#2dd4bf", // 19. Turquoise
|
||||
"#fb7185", // 20. Coral
|
||||
"#38bdf8", // 21. Light Blue
|
||||
"#c084fc", // 22. Lavender
|
||||
"#f43f5e", // 23. Ruby
|
||||
"#4ade80", // 24. Mint
|
||||
"#fbbf24", // 25. Gold
|
||||
"#818cf8", // 26. Periwinkle
|
||||
"#fb923c", // 27. Tangerine
|
||||
"#a3e635", // 28. Chartreuse
|
||||
"#f472b6", // 29. Hot Pink
|
||||
"#22d3ee", // 30. Aqua
|
||||
"#e879f9", // 31. Orchid
|
||||
"#a7f3d0", // 32. Seafoam
|
||||
"#fde047", // 33. Lemon
|
||||
"#fda4af", // 34. Blush
|
||||
"#93c5fd", // 35. Cornflower
|
||||
"#c4b5fd" // 36. Lilac
|
||||
];
|
||||
|
||||
const STORAGE_KEY = "leafflow_batch_queue_meta";
|
||||
const BATCH_COUNTER_KEY = "leafflow_batch_counter";
|
||||
const SETTING_ID = "LeafFlow.BatchQueue.Enabled";
|
||||
|
||||
// In-memory registry mapping prompt_id -> batch metadata
|
||||
let batchRegistry = new Map();
|
||||
let activeBatchContext = null;
|
||||
let batchCounter = 0;
|
||||
|
||||
function loadStorage() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) {
|
||||
for (const item of parsed) {
|
||||
if (item && item.prompt_id) {
|
||||
batchRegistry.set(String(item.prompt_id), item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const savedCounter = localStorage.getItem(BATCH_COUNTER_KEY);
|
||||
if (savedCounter) {
|
||||
batchCounter = parseInt(savedCounter, 10) || 0;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[LeafFlow BatchQueue] Failed to load batch storage:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function saveStorage() {
|
||||
try {
|
||||
// Prune entries to last 250 items to keep localStorage small
|
||||
const allEntries = Array.from(batchRegistry.values());
|
||||
const pruned = allEntries.slice(-250);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(pruned));
|
||||
localStorage.setItem(BATCH_COUNTER_KEY, String(batchCounter));
|
||||
} catch (e) {
|
||||
// Storage full or private mode
|
||||
}
|
||||
}
|
||||
|
||||
function getNextBatch(batchCount = 1) {
|
||||
batchCounter++;
|
||||
const colorIndex = (batchCounter - 1) % BATCH_COLORS.length;
|
||||
const color = BATCH_COLORS[colorIndex];
|
||||
const batchId = `batch_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`;
|
||||
|
||||
const batch = {
|
||||
batchId,
|
||||
batchNumber: batchCounter,
|
||||
batchCount: Math.max(1, batchCount),
|
||||
color,
|
||||
colorIndex,
|
||||
queuedAt: Date.now(),
|
||||
prompts: []
|
||||
};
|
||||
return batch;
|
||||
}
|
||||
|
||||
// Inject CSS for the 1D git-graph style line
|
||||
function injectBatchStyles() {
|
||||
if (typeof document === "undefined") return;
|
||||
const styleId = "leafflow-batch-queue-styles";
|
||||
if (document.getElementById(styleId)) return;
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.id = styleId;
|
||||
style.textContent = `
|
||||
/* LeafFlow 1D Git-Graph Batch Line Container */
|
||||
.leafflow-batch-line-wrap {
|
||||
position: absolute;
|
||||
left: -9px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 9px;
|
||||
pointer-events: none;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.leafflow-batch-line-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Ensure parent card has relative positioning and allows line in left margin */
|
||||
[data-testid="job-assets-list"] [data-job-id] {
|
||||
position: relative !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
/* Support for classic frontend v1 queue table rows */
|
||||
.comfy-table tr[data-job-id],
|
||||
.comfy-queue-item {
|
||||
position: relative !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
// Build 1D SVG line path according to segment type:
|
||||
// 'single': bracket (rounded top & bottom curving right to card)
|
||||
// 'first': rounded top curving right, straight all the way down
|
||||
// 'middle': straight line top to bottom
|
||||
// 'last': straight from top, rounded bottom curving right to card
|
||||
function buildSvgPath(segmentType) {
|
||||
// Coordinate system: width=10, height=48. Curve bends toward the card (x=9)
|
||||
switch (segmentType) {
|
||||
case "single":
|
||||
return "M 9,6 Q 2,6 2,16 L 2,32 Q 2,42 9,42";
|
||||
case "first":
|
||||
return "M 9,6 Q 2,6 2,16 L 2,48";
|
||||
case "middle":
|
||||
return "M 2,0 L 2,48";
|
||||
case "last":
|
||||
return "M 2,0 L 2,32 Q 2,42 9,42";
|
||||
default:
|
||||
return "M 9,6 Q 2,6 2,16 L 2,32 Q 2,42 9,42";
|
||||
}
|
||||
}
|
||||
|
||||
function renderBatchLineOnElement(element, segmentType, color) {
|
||||
if (!element) return;
|
||||
|
||||
let wrap = element.querySelector(".leafflow-batch-line-wrap");
|
||||
if (!wrap) {
|
||||
wrap = document.createElement("div");
|
||||
wrap.className = "leafflow-batch-line-wrap";
|
||||
element.appendChild(wrap);
|
||||
}
|
||||
|
||||
const dPath = buildSvgPath(segmentType);
|
||||
wrap.innerHTML = `
|
||||
<svg class="leafflow-batch-line-svg" viewBox="0 0 10 48" preserveAspectRatio="none">
|
||||
<path d="${dPath}" stroke="${color}" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" fill="none" />
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
function removeBatchLineFromElement(element) {
|
||||
if (!element) return;
|
||||
const wrap = element.querySelector(".leafflow-batch-line-wrap");
|
||||
if (wrap) {
|
||||
wrap.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Check if LeafFlow Batch Queue is enabled in settings
|
||||
function isBatchQueueEnabled() {
|
||||
try {
|
||||
if (app.extensionManager?.setting?.get) {
|
||||
return app.extensionManager.setting.get(SETTING_ID) !== false;
|
||||
}
|
||||
} catch (e) {}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Sync with server if PersistentQueue is available
|
||||
async function syncBatchToServer(promptId, batchInfo) {
|
||||
try {
|
||||
if (!api || !api.fetchApi) return;
|
||||
await api.fetchApi("/leafflow/batch_queue/sync", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
prompt_id: promptId,
|
||||
batch_info: batchInfo
|
||||
})
|
||||
}).catch(() => null);
|
||||
} catch (e) {
|
||||
// Silently ignore if server endpoint is not present
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBatchFromServer() {
|
||||
try {
|
||||
if (!api || !api.fetchApi) return;
|
||||
const res = await api.fetchApi("/leafflow/batch_queue/data", { cache: "no-store" }).catch(() => null);
|
||||
if (res && res.ok) {
|
||||
const data = await res.json();
|
||||
if (data && typeof data === "object") {
|
||||
for (const [pid, info] of Object.entries(data)) {
|
||||
if (pid && info) {
|
||||
batchRegistry.set(String(pid), info);
|
||||
}
|
||||
}
|
||||
saveStorage();
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// Refresh visual lines in DOM for both v1 and v2
|
||||
async function updateQueueBatchVisuals() {
|
||||
if (!isBatchQueueEnabled()) {
|
||||
document.querySelectorAll(".leafflow-batch-line-wrap").forEach(el => el.remove());
|
||||
return;
|
||||
}
|
||||
|
||||
// Find all visible queue item elements in DOM (supports v2 job cards and v1 rows)
|
||||
const elements = Array.from(document.querySelectorAll('[data-testid="job-assets-list"] [data-job-id], .comfy-table tr[data-job-id]'));
|
||||
if (!elements.length) return;
|
||||
|
||||
// Elements are strictly in visual screen order from top to bottom
|
||||
const visiblePromptIds = elements.map(el => el.getAttribute("data-job-id")).filter(Boolean);
|
||||
|
||||
// Build map of promptId -> element for visible items
|
||||
const elMap = new Map();
|
||||
for (const el of elements) {
|
||||
const pid = el.getAttribute("data-job-id");
|
||||
if (pid) elMap.set(pid, el);
|
||||
}
|
||||
|
||||
// For every prompt on screen, calculate segment type based on visual screen order:
|
||||
// - Top-most card on screen for Batch A curves at the top (┌)
|
||||
// - Bottom-most card on screen for Batch A curves at the bottom (└)
|
||||
// - 1-item Batch curves at both top and bottom (()
|
||||
// - Anything in between connects with straight lines (│)
|
||||
for (const pid of visiblePromptIds) {
|
||||
const el = elMap.get(pid);
|
||||
if (!el) continue;
|
||||
|
||||
const info = batchRegistry.get(pid);
|
||||
if (!info || !info.batchId) {
|
||||
removeBatchLineFromElement(el);
|
||||
continue;
|
||||
}
|
||||
|
||||
const batchId = info.batchId;
|
||||
const color = info.color || BATCH_COLORS[0];
|
||||
|
||||
// Find all visible items of this batch in top-to-bottom visual order
|
||||
const occurrencesOnScreen = visiblePromptIds.filter(p => {
|
||||
const b = batchRegistry.get(p);
|
||||
return b && b.batchId === batchId;
|
||||
});
|
||||
|
||||
if (occurrencesOnScreen.length <= 1) {
|
||||
// Only 1 item of this batch currently visible on screen
|
||||
if ((info.batchCount || 1) === 1) {
|
||||
renderBatchLineOnElement(el, "single", color);
|
||||
} else {
|
||||
// Batch originally had multiple items, but only 1 is currently visible/unexecuted
|
||||
if (info.itemIndex === 0) {
|
||||
renderBatchLineOnElement(el, "first", color);
|
||||
} else if (info.itemIndex === (info.batchCount - 1)) {
|
||||
renderBatchLineOnElement(el, "last", color);
|
||||
} else {
|
||||
renderBatchLineOnElement(el, "middle", color);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const topPidOnScreen = occurrencesOnScreen[0];
|
||||
const bottomPidOnScreen = occurrencesOnScreen[occurrencesOnScreen.length - 1];
|
||||
|
||||
if (pid === topPidOnScreen) {
|
||||
// Top card on screen -> curves at top (┌)
|
||||
renderBatchLineOnElement(el, "first", color);
|
||||
} else if (pid === bottomPidOnScreen) {
|
||||
// Bottom card on screen -> curves at bottom (└)
|
||||
renderBatchLineOnElement(el, "last", color);
|
||||
} else {
|
||||
// Middle cards on screen -> straight line (│)
|
||||
renderBatchLineOnElement(el, "middle", color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hook queuePrompt to track batch execution
|
||||
function setupQueueHooks() {
|
||||
loadStorage();
|
||||
|
||||
// 1. Hook app.queuePrompt to know when a batch is initiated
|
||||
if (app && typeof app.queuePrompt === "function") {
|
||||
const origQueuePrompt = app.queuePrompt.bind(app);
|
||||
app.queuePrompt = async function(number, batchCount = 1, queueNodeIds) {
|
||||
const count = Math.max(1, parseInt(batchCount, 10) || 1);
|
||||
activeBatchContext = getNextBatch(count);
|
||||
saveStorage();
|
||||
return origQueuePrompt(number, batchCount, queueNodeIds);
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Hook api.queuePrompt to record every generated prompt_id
|
||||
if (api && typeof api.queuePrompt === "function") {
|
||||
const origApiQueuePrompt = api.queuePrompt.bind(api);
|
||||
api.queuePrompt = async function(number, prompt, targets) {
|
||||
const res = await origApiQueuePrompt(number, prompt, targets);
|
||||
if (res && res.prompt_id) {
|
||||
const pid = String(res.prompt_id);
|
||||
if (!activeBatchContext) {
|
||||
activeBatchContext = getNextBatch(1);
|
||||
}
|
||||
|
||||
const itemIndex = activeBatchContext.prompts.length;
|
||||
const batchInfo = {
|
||||
prompt_id: pid,
|
||||
batchId: activeBatchContext.batchId,
|
||||
batchNumber: activeBatchContext.batchNumber,
|
||||
batchCount: activeBatchContext.batchCount,
|
||||
itemIndex: itemIndex,
|
||||
color: activeBatchContext.color,
|
||||
colorIndex: activeBatchContext.colorIndex,
|
||||
isQueueFront: number < 0,
|
||||
queuedAt: Date.now()
|
||||
};
|
||||
|
||||
activeBatchContext.prompts.push(pid);
|
||||
batchRegistry.set(pid, batchInfo);
|
||||
saveStorage();
|
||||
syncBatchToServer(pid, batchInfo);
|
||||
|
||||
// If this batch completed its expected prompt count, clear context
|
||||
if (activeBatchContext.prompts.length >= activeBatchContext.batchCount) {
|
||||
activeBatchContext = null;
|
||||
}
|
||||
|
||||
setTimeout(updateQueueBatchVisuals, 100);
|
||||
}
|
||||
return res;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfyUI.LeafFlow.BatchQueue",
|
||||
async setup() {
|
||||
injectBatchStyles();
|
||||
setupQueueHooks();
|
||||
loadBatchFromServer();
|
||||
|
||||
// Listen for queue changes
|
||||
if (api && api.addEventListener) {
|
||||
api.addEventListener("status", () => {
|
||||
setTimeout(updateQueueBatchVisuals, 100);
|
||||
});
|
||||
api.addEventListener("execution_start", () => {
|
||||
setTimeout(updateQueueBatchVisuals, 80);
|
||||
});
|
||||
api.addEventListener("executing", () => {
|
||||
setTimeout(updateQueueBatchVisuals, 150);
|
||||
});
|
||||
}
|
||||
|
||||
// Set up MutationObserver on body to detect queue sidebar rendering
|
||||
let debounceTimer = null;
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
let shouldUpdate = false;
|
||||
for (const m of mutations) {
|
||||
if (m.target && m.target.nodeType === 1) {
|
||||
if (m.target.closest?.('[data-testid="job-assets-list"], .comfy-table') ||
|
||||
m.target.matches?.('[data-testid="job-assets-list"], [data-job-id]')) {
|
||||
shouldUpdate = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (shouldUpdate) {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(updateQueueBatchVisuals, 60);
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
// Initial check after load
|
||||
setTimeout(updateQueueBatchVisuals, 800);
|
||||
setTimeout(updateQueueBatchVisuals, 2000);
|
||||
}
|
||||
});
|
||||
|
||||
export { BATCH_COLORS, buildSvgPath };
|
||||
+707
-53
@@ -33,35 +33,311 @@ async function copyToClipboard(text) {
|
||||
}
|
||||
}
|
||||
|
||||
function isCopyEnabled(settingKey, defaultVal = true) {
|
||||
try {
|
||||
let val;
|
||||
if (app.extensionManager?.setting?.get) {
|
||||
val = app.extensionManager.setting.get(settingKey);
|
||||
}
|
||||
if ((val === undefined || val === null || val === "") && app.ui?.settings?.getSettingValue) {
|
||||
val = app.ui.settings.getSettingValue(settingKey);
|
||||
}
|
||||
if (val === undefined || val === null || val === "") {
|
||||
return defaultVal;
|
||||
}
|
||||
return val === true || val === "true" || val === 1 || val === "1";
|
||||
} catch (_) {
|
||||
return defaultVal;
|
||||
}
|
||||
}
|
||||
|
||||
const BOOKMARK_SVG = `<svg class="size-4 pointer-events-none" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z"/></svg>`;
|
||||
const BOOKMARK_SUCCESS_SVG = `<svg class="size-4 text-emerald-600 pointer-events-none" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`;
|
||||
const BOOKMARK_FAIL_SVG = `<svg class="size-4 text-rose-600 pointer-events-none" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
|
||||
const INSPECT_SVG = `<svg class="size-4 pointer-events-none" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>`;
|
||||
|
||||
function updateButtonGroupBorders(container) {
|
||||
if (!container) return;
|
||||
const buttons = Array.from(container.children).filter(el => el.tagName === "BUTTON");
|
||||
if (buttons.length === 0) return;
|
||||
if (buttons.length === 1) {
|
||||
buttons[0].classList.remove("rounded-l-lg", "rounded-r-lg", "rounded-none", "rounded-l-none", "rounded-r-none", "border-r", "border-modal-card-badge-border");
|
||||
buttons[0].classList.add("rounded-lg");
|
||||
return;
|
||||
}
|
||||
buttons.forEach((btn, index) => {
|
||||
btn.classList.remove("rounded-lg", "rounded-l-lg", "rounded-r-lg", "rounded-none", "rounded-l-none", "rounded-r-none", "border-r", "border-modal-card-badge-border");
|
||||
if (index === 0) {
|
||||
btn.classList.add("rounded-l-lg", "rounded-r-none", "border-r", "border-modal-card-badge-border");
|
||||
} else if (index === buttons.length - 1) {
|
||||
btn.classList.add("rounded-r-lg", "rounded-l-none");
|
||||
} else {
|
||||
btn.classList.add("rounded-none", "border-r", "border-modal-card-badge-border");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side binary PNG chunk parser to extract prompt/parameters directly from ArrayBuffer.
|
||||
*/
|
||||
async function extractPromptFromImageUrl(imgSrc) {
|
||||
try {
|
||||
const resp = await fetch(imgSrc);
|
||||
if (!resp.ok) return null;
|
||||
const arrayBuffer = await resp.arrayBuffer();
|
||||
const dataView = new DataView(arrayBuffer);
|
||||
|
||||
// Verify PNG magic header: 0x89504E47 0x0D0A1A0A
|
||||
if (dataView.byteLength < 16) return null;
|
||||
if (dataView.getUint32(0) !== 0x89504E47 || dataView.getUint32(4) !== 0x0D0A1A0A) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let offset = 8;
|
||||
const textDecoder = new TextDecoder("utf-8");
|
||||
const metadata = {};
|
||||
|
||||
while (offset < dataView.byteLength - 8) {
|
||||
const length = dataView.getUint32(offset);
|
||||
const type = String.fromCharCode(
|
||||
dataView.getUint8(offset + 4),
|
||||
dataView.getUint8(offset + 5),
|
||||
dataView.getUint8(offset + 6),
|
||||
dataView.getUint8(offset + 7)
|
||||
);
|
||||
|
||||
if (type === "tEXt" || type === "iTXt") {
|
||||
const chunkData = new Uint8Array(arrayBuffer, offset + 8, length);
|
||||
let nullIndex = 0;
|
||||
while (nullIndex < chunkData.length && chunkData[nullIndex] !== 0) nullIndex++;
|
||||
const keyword = textDecoder.decode(chunkData.subarray(0, nullIndex));
|
||||
|
||||
let textValue = "";
|
||||
if (type === "tEXt") {
|
||||
textValue = textDecoder.decode(chunkData.subarray(nullIndex + 1));
|
||||
} else if (type === "iTXt") {
|
||||
const compFlag = chunkData[nullIndex + 1];
|
||||
let textStart = nullIndex + 3;
|
||||
while (textStart < chunkData.length && chunkData[textStart] !== 0) textStart++;
|
||||
textStart++;
|
||||
while (textStart < chunkData.length && chunkData[textStart] !== 0) textStart++;
|
||||
textStart++;
|
||||
if (compFlag === 0) {
|
||||
textValue = textDecoder.decode(chunkData.subarray(textStart));
|
||||
}
|
||||
}
|
||||
if (keyword && textValue) {
|
||||
metadata[keyword.toLowerCase()] = textValue;
|
||||
}
|
||||
}
|
||||
offset += 12 + length;
|
||||
}
|
||||
|
||||
// 1. Check A1111 / WebUI / Forge / Civitai parameters
|
||||
if (metadata["parameters"]) {
|
||||
const lines = metadata["parameters"].split("\n");
|
||||
const pos = [];
|
||||
for (const line of lines) {
|
||||
if (line.trim().startsWith("Negative prompt:") || line.trim().startsWith("Steps:")) break;
|
||||
pos.push(line);
|
||||
}
|
||||
const res = pos.join("\n").trim();
|
||||
if (res) return res;
|
||||
}
|
||||
|
||||
// 2. Check native ComfyUI prompt graph JSON
|
||||
if (metadata["prompt"]) {
|
||||
try {
|
||||
const promptData = JSON.parse(metadata["prompt"]);
|
||||
if (typeof promptData === "object" && promptData !== null) {
|
||||
const startNodes = [];
|
||||
for (const [nid, ndata] of Object.entries(promptData)) {
|
||||
const ctype = ndata?.class_type || "";
|
||||
const inputs = ndata?.inputs || {};
|
||||
if (ctype.includes("Sampler") || ctype.includes("KSampler") || ctype.includes("Guider") || ctype.includes("CFG")) {
|
||||
for (const linkKey of ["positive", "conditioning", "guider"]) {
|
||||
const link = inputs[linkKey];
|
||||
if (Array.isArray(link) && link.length > 0) {
|
||||
startNodes.push(String(link[0]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const visited = new Set();
|
||||
const texts = [];
|
||||
|
||||
function walk(nid) {
|
||||
if (!nid || visited.has(nid)) return;
|
||||
visited.add(nid);
|
||||
const ndata = promptData[nid];
|
||||
if (!ndata || typeof ndata !== "object") return;
|
||||
const inputs = ndata.inputs || {};
|
||||
|
||||
for (const tkey of ["text", "prompt", "text_g", "text_l", "text_positive", "positive_prompt", "value", "string"]) {
|
||||
const val = inputs[tkey];
|
||||
if (typeof val === "string" && val.trim()) {
|
||||
const s = val.trim();
|
||||
if (!texts.includes(s)) texts.push(s);
|
||||
} else if (Array.isArray(val) && val.length > 0) {
|
||||
walk(String(val[0]));
|
||||
}
|
||||
}
|
||||
|
||||
for (const [inName, inVal] of Object.entries(inputs)) {
|
||||
if (Array.isArray(inVal) && inVal.length > 0) {
|
||||
const low = inName.toLowerCase();
|
||||
if (low.includes("negative")) continue;
|
||||
if (["conditioning", "positive", "cond", "text", "prompt", "guider"].some(k => low.includes(k))) {
|
||||
walk(String(inVal[0]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const sn of startNodes) {
|
||||
walk(sn);
|
||||
}
|
||||
|
||||
if (texts.length) return texts.join("\n");
|
||||
|
||||
// Fallback: search all text / prompt nodes
|
||||
for (const [nid, ndata] of Object.entries(promptData)) {
|
||||
const ctype = ndata?.class_type || "";
|
||||
const inputs = ndata?.inputs || {};
|
||||
if (ctype.includes("CLIPTextEncode") || ctype.includes("Text") || ctype.includes("Prompt")) {
|
||||
for (const tkey of ["text", "prompt", "text_g", "text_l"]) {
|
||||
const t = inputs[tkey];
|
||||
if (typeof t === "string" && t.trim().length > 1) {
|
||||
const s = t.trim();
|
||||
if (!texts.includes(s)) texts.push(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (texts.length) return texts.join("\n");
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[LeafFlow] Direct PNG metadata extraction failed:", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to fetch image metadata and extract the positive prompt text.
|
||||
*/
|
||||
|
||||
async function getImagePromptAndMeta(imgSrc) {
|
||||
if (!imgSrc) return null;
|
||||
let url = new URL(imgSrc, window.location.origin);
|
||||
let filename = url.searchParams.get("filename");
|
||||
let type = url.searchParams.get("type") || "output";
|
||||
let subfolder = url.searchParams.get("subfolder") || "";
|
||||
|
||||
if (!filename) {
|
||||
const parts = url.pathname.split("/");
|
||||
filename = decodeURIComponent(parts[parts.length - 1]);
|
||||
}
|
||||
|
||||
let promptText = null;
|
||||
try {
|
||||
promptText = await extractPromptFromImageUrl(imgSrc);
|
||||
} catch (_) {}
|
||||
|
||||
if (!promptText && filename) {
|
||||
try {
|
||||
const promptUrl = `/leafflow/get_image_prompt?filename=${encodeURIComponent(filename)}&type=${encodeURIComponent(type)}&subfolder=${encodeURIComponent(subfolder)}`;
|
||||
const response = await api.fetchApi(promptUrl);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data && data.prompt) promptText = data.prompt;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
return {
|
||||
promptText: promptText || "",
|
||||
filename: filename || "bookmark",
|
||||
subfolder: subfolder || "",
|
||||
type: type || "output"
|
||||
};
|
||||
}
|
||||
|
||||
async function saveImageToPromptBookmarks(imgSrc) {
|
||||
const meta = await getImagePromptAndMeta(imgSrc);
|
||||
if (!meta) return false;
|
||||
|
||||
const event = new CustomEvent("prompt-bookmarks-create", {
|
||||
detail: {
|
||||
name: meta.filename.replace(/\.[^/.]+$/, ""),
|
||||
text: meta.promptText,
|
||||
media: [{
|
||||
filename: meta.filename,
|
||||
subfolder: meta.subfolder,
|
||||
type: meta.type,
|
||||
media_type: "image"
|
||||
}]
|
||||
}
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function copyImagePrompt(imgSrc) {
|
||||
if (!imgSrc) return false;
|
||||
let promptText = null;
|
||||
|
||||
// 1. Try client-side direct binary extraction first
|
||||
try {
|
||||
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") || "";
|
||||
promptText = await extractPromptFromImageUrl(imgSrc);
|
||||
} catch (_) {}
|
||||
|
||||
if (!filename) return false;
|
||||
// 2. Fallback to server endpoint
|
||||
if (!promptText) {
|
||||
try {
|
||||
let url = new URL(imgSrc, window.location.origin);
|
||||
let filename = url.searchParams.get("filename");
|
||||
let type = url.searchParams.get("type") || "output";
|
||||
let subfolder = url.searchParams.get("subfolder") || "";
|
||||
|
||||
// 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);
|
||||
if (!filename) {
|
||||
const parts = url.pathname.split("/");
|
||||
filename = decodeURIComponent(parts[parts.length - 1]);
|
||||
}
|
||||
|
||||
if (filename) {
|
||||
const promptUrl = `/leafflow/get_image_prompt?filename=${encodeURIComponent(filename)}&type=${encodeURIComponent(type)}&subfolder=${encodeURIComponent(subfolder)}`;
|
||||
const response = await api.fetchApi(promptUrl);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data && data.prompt) {
|
||||
promptText = data.prompt;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[LeafFlow] Direct prompt fetch failed:", e);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[LeafFlow] Direct prompt fetch failed:", e);
|
||||
}
|
||||
|
||||
if (promptText) {
|
||||
const copied = await copyToClipboard(promptText);
|
||||
if (copied && app.extensionManager?.toast?.add) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: "success",
|
||||
summary: "📋 Prompt Copied",
|
||||
detail: promptText.length > 80 ? promptText.slice(0, 80) + "..." : promptText,
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
return copied;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 1. Hook into Node Context Menu (Right Click on Image Nodes)
|
||||
// 1. Hook into Node Context Menu (Right Click)
|
||||
app.registerExtension({
|
||||
name: "ComfyUI.LeafFlow.CopyPrompt",
|
||||
|
||||
@@ -70,59 +346,377 @@ app.registerExtension({
|
||||
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 (!isCopyEnabled("LeafFlow.3 - 📋 Prompt Actions.02_EnableContextMenuCopyPrompt")) return;
|
||||
|
||||
if (this.imgs && this.imgs.length > 0) {
|
||||
const imgs = this.imgs || this.images || (this.widgets && this.widgets.filter(w => w.type === "image" || w.name === "image"));
|
||||
if (imgs && imgs.length > 0) {
|
||||
options.push({
|
||||
content: "📋 Copy Prompt",
|
||||
callback: async () => {
|
||||
const img = 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.");
|
||||
const img = imgs[this.imageIndex || 0];
|
||||
const src = typeof img === "string" ? img : (img?.src || img?.value);
|
||||
if (!src) return;
|
||||
const success = await copyImagePrompt(src);
|
||||
if (!success) {
|
||||
if (app.extensionManager?.toast?.add) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: "warn",
|
||||
summary: "LeafFlow Copy Prompt",
|
||||
detail: "Could not extract positive prompt from this image.",
|
||||
life: 3000
|
||||
});
|
||||
} else {
|
||||
alert("LeafFlow: Could not extract positive prompt from this image.");
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (isCopyEnabled("LeafFlow.3 - 📋 Prompt Actions.03_EnableSaveToPromptSaver")) {
|
||||
options.push({
|
||||
content: "🔖 Bookmark in Prompt Saver",
|
||||
callback: async () => {
|
||||
const img = imgs[this.imageIndex || 0];
|
||||
const src = typeof img === "string" ? img : (img?.src || img?.value);
|
||||
if (!src) return;
|
||||
await saveImageToPromptBookmarks(src);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Hook into Standard Top Hover Action Bar over Image Cards in Assets Pane & Canvas Previews
|
||||
// 2. Active Interaction Tracking & Image URL Resolution
|
||||
let lastInteractedAssetCard = null;
|
||||
|
||||
function isQueueItemElement(el) {
|
||||
if (!el || typeof el.closest !== "function") return false;
|
||||
// An asset card is explicitly an asset card in the gallery/history, never a queue job row
|
||||
if (el.closest("div[data-virtual-grid-item], [data-asset-id], .asset-card, [data-testid='asset-card']")) {
|
||||
return !!el.closest("[data-job-id]");
|
||||
}
|
||||
return !!el.closest("[data-job-id], .comfy-queue-item, .queue-item, .queue-list-item, .queue-entry");
|
||||
}
|
||||
|
||||
function registerAssetInteraction(target) {
|
||||
if (!target) return;
|
||||
const card = target.closest?.(
|
||||
"div[data-virtual-grid-item], [data-asset-id], .asset-card, [data-testid='asset-card'], [data-node-id], .lg-node, .comfy-image-preview, .group"
|
||||
);
|
||||
if (card && !isQueueItemElement(card)) {
|
||||
lastInteractedAssetCard = card;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("pointerdown", (e) => registerAssetInteraction(e.target), true);
|
||||
document.addEventListener("click", (e) => registerAssetInteraction(e.target), true);
|
||||
document.addEventListener("contextmenu", (e) => registerAssetInteraction(e.target), true);
|
||||
|
||||
function getActiveImageSrc() {
|
||||
// 1. From last clicked / right-clicked / interacted asset card
|
||||
if (lastInteractedAssetCard) {
|
||||
const img = lastInteractedAssetCard.querySelector("img");
|
||||
if (img && img.src) return img.src;
|
||||
}
|
||||
// 2. From currently selected asset card in DOM
|
||||
const selected = document.querySelector(
|
||||
'div[data-virtual-grid-item] [data-selected="true"], [data-asset-id][data-selected="true"], .group[data-selected="true"]'
|
||||
);
|
||||
if (selected) {
|
||||
const img = selected.querySelector("img");
|
||||
if (img && img.src) return img.src;
|
||||
}
|
||||
// 3. Any active hover card
|
||||
const hovered = document.querySelector("div[data-virtual-grid-item]:hover, .asset-card:hover, .group:hover");
|
||||
if (hovered) {
|
||||
const img = hovered.querySelector("img");
|
||||
if (img && img.src) return img.src;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3. Inject Copy Prompt, Bookmark & Inspect Directly Next to Download Button on Asset Cards
|
||||
function injectCopyPromptNextToDownload(downloadBtn) {
|
||||
if (!downloadBtn || !downloadBtn.parentElement) return;
|
||||
|
||||
if (isQueueItemElement(downloadBtn)) return;
|
||||
|
||||
const card = downloadBtn.closest(
|
||||
"div[data-virtual-grid-item], [data-asset-id], .asset-card, [data-testid='asset-card'], [data-node-id], .lg-node, .comfy-image-preview, .group"
|
||||
);
|
||||
if (!card) return;
|
||||
if (isQueueItemElement(card)) return;
|
||||
|
||||
const img = card.querySelector("img");
|
||||
if (!img || !img.src) return;
|
||||
if (img.classList.contains("size-8") || img.closest(".size-8, .size-10, .h-12")) return;
|
||||
|
||||
const parent = downloadBtn.parentElement;
|
||||
const enableCopy = isCopyEnabled("LeafFlow.3 - 📋 Prompt Actions.01_EnableAssetsCopyPromptButton");
|
||||
const enableBookmark = isCopyEnabled("LeafFlow.3 - 📋 Prompt Actions.03_EnableSaveToPromptSaver");
|
||||
const enableInspect = isCopyEnabled("LeafFlow.3 - 📋 Prompt Actions.04_EnableInspectAssetButton", false);
|
||||
|
||||
if (!enableCopy && !enableBookmark && !enableInspect) return;
|
||||
|
||||
let baseClasses = downloadBtn.className
|
||||
.replace(/\brounded-[a-z0-9-]+\b/g, "")
|
||||
.replace(/\brounded\b/g, "")
|
||||
.replace(/\bborder-r\b/g, "")
|
||||
.replace(/\bborder-modal-card-badge-border\b/g, "")
|
||||
.trim();
|
||||
|
||||
if (!baseClasses || baseClasses.length < 5) {
|
||||
baseClasses = "inline-flex items-center justify-center font-medium font-inter transition-colors focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50 border border-transparent shadow-xs cursor-pointer bg-modal-card-badge-background text-modal-card-badge-foreground hover:bg-modal-card-badge-background-hover size-8 p-0";
|
||||
}
|
||||
|
||||
const hasModernIcons = !!(
|
||||
downloadBtn.querySelector("[class*='icon-']") ||
|
||||
document.querySelector("[class*='icon-[lucide']")
|
||||
);
|
||||
|
||||
let lastBtn = downloadBtn;
|
||||
|
||||
// 1. Copy Prompt Button
|
||||
let copyBtn = parent.querySelector(".leafflow-hover-copy");
|
||||
if (enableCopy) {
|
||||
if (!copyBtn) {
|
||||
copyBtn = document.createElement("button");
|
||||
copyBtn.type = "button";
|
||||
copyBtn.title = "Copy Prompt";
|
||||
copyBtn.setAttribute("aria-label", "Copy Prompt");
|
||||
copyBtn.className = `leafflow-hover-btn leafflow-hover-copy ${baseClasses} shrink-0`;
|
||||
|
||||
if (hasModernIcons) {
|
||||
copyBtn.innerHTML = `<i class="icon-[lucide--copy] size-4 pointer-events-none"></i>`;
|
||||
} else {
|
||||
copyBtn.innerHTML = `<svg class="size-4 pointer-events-none" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`;
|
||||
}
|
||||
|
||||
copyBtn.onclick = async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const activeImg = card.querySelector("img") || img;
|
||||
const success = await copyImagePrompt(activeImg.src);
|
||||
if (hasModernIcons) {
|
||||
copyBtn.innerHTML = success
|
||||
? `<i class="icon-[lucide--check] size-4 text-emerald-600 pointer-events-none"></i>`
|
||||
: `<i class="icon-[lucide--x] size-4 text-rose-600 pointer-events-none"></i>`;
|
||||
setTimeout(() => {
|
||||
copyBtn.innerHTML = `<i class="icon-[lucide--copy] size-4 pointer-events-none"></i>`;
|
||||
}, 2000);
|
||||
} else {
|
||||
copyBtn.innerHTML = success
|
||||
? `<svg class="size-4 text-emerald-600 pointer-events-none" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>`
|
||||
: `<svg class="size-4 text-rose-600 pointer-events-none" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
|
||||
setTimeout(() => {
|
||||
copyBtn.innerHTML = `<svg class="size-4 pointer-events-none" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`;
|
||||
}, 2000);
|
||||
}
|
||||
};
|
||||
parent.insertBefore(copyBtn, lastBtn.nextSibling);
|
||||
}
|
||||
lastBtn = copyBtn;
|
||||
} else if (copyBtn) {
|
||||
copyBtn.remove();
|
||||
}
|
||||
|
||||
// 2. Bookmark Button
|
||||
let bookmarkBtn = parent.querySelector(".leafflow-hover-bookmark");
|
||||
if (enableBookmark) {
|
||||
if (!bookmarkBtn) {
|
||||
bookmarkBtn = document.createElement("button");
|
||||
bookmarkBtn.type = "button";
|
||||
bookmarkBtn.title = "Save to Prompt Bookmarks";
|
||||
bookmarkBtn.setAttribute("aria-label", "Save to Prompt Bookmarks");
|
||||
bookmarkBtn.className = `leafflow-hover-btn leafflow-hover-bookmark ${baseClasses} shrink-0`;
|
||||
bookmarkBtn.innerHTML = BOOKMARK_SVG;
|
||||
|
||||
bookmarkBtn.onclick = async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const activeImg = card.querySelector("img") || img;
|
||||
const success = await saveImageToPromptBookmarks(activeImg.src);
|
||||
bookmarkBtn.innerHTML = success ? BOOKMARK_SUCCESS_SVG : BOOKMARK_FAIL_SVG;
|
||||
setTimeout(() => {
|
||||
bookmarkBtn.innerHTML = BOOKMARK_SVG;
|
||||
}, 2000);
|
||||
};
|
||||
parent.insertBefore(bookmarkBtn, lastBtn.nextSibling);
|
||||
}
|
||||
lastBtn = bookmarkBtn;
|
||||
} else if (bookmarkBtn) {
|
||||
bookmarkBtn.remove();
|
||||
}
|
||||
|
||||
// 3. Inspect Asset (Zoom) Button
|
||||
let inspectBtn = parent.querySelector(".leafflow-hover-inspect");
|
||||
if (enableInspect) {
|
||||
if (!inspectBtn) {
|
||||
inspectBtn = document.createElement("button");
|
||||
inspectBtn.type = "button";
|
||||
inspectBtn.title = "Inspect asset";
|
||||
inspectBtn.setAttribute("aria-label", "Inspect asset");
|
||||
inspectBtn.className = `leafflow-hover-btn leafflow-hover-inspect ${baseClasses} shrink-0`;
|
||||
inspectBtn.innerHTML = INSPECT_SVG;
|
||||
|
||||
inspectBtn.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const target = card.querySelector(".aspect-square, [class*='aspect-square'], img") || img;
|
||||
if (target) {
|
||||
target.dispatchEvent(new MouseEvent("dblclick", { bubbles: true, cancelable: true, view: window }));
|
||||
}
|
||||
};
|
||||
parent.insertBefore(inspectBtn, lastBtn.nextSibling);
|
||||
}
|
||||
lastBtn = inspectBtn;
|
||||
} else if (inspectBtn) {
|
||||
inspectBtn.remove();
|
||||
}
|
||||
|
||||
updateButtonGroupBorders(parent);
|
||||
}
|
||||
|
||||
// 4. Inject Copy Prompt into PrimeVue Context Menu Next to Download Item
|
||||
function injectContextMenuCopy(contextMenu) {
|
||||
if (!contextMenu) return;
|
||||
if (contextMenu.querySelector(".leafflow-contextmenu-copy")) return;
|
||||
if (!isCopyEnabled("LeafFlow.3 - 📋 Prompt Actions.02_EnableContextMenuCopyPrompt")) return;
|
||||
|
||||
let downloadLi = contextMenu.querySelector(
|
||||
'li[aria-label="Download"], li[aria-label*="ownload" i], [data-pc-section="item"][aria-label*="ownload" i]'
|
||||
);
|
||||
if (!downloadLi) {
|
||||
const items = contextMenu.querySelectorAll('li, [data-pc-section="item"]');
|
||||
for (const item of items) {
|
||||
const text = item.textContent?.trim().toLowerCase() || "";
|
||||
if (text.includes("download") || item.querySelector('.icon-[lucide--download], [class*="download" i]')) {
|
||||
downloadLi = item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!downloadLi || !downloadLi.parentElement) return;
|
||||
|
||||
const refBtn = downloadLi.querySelector("button");
|
||||
const btnClass = refBtn
|
||||
? refBtn.className
|
||||
: "relative inline-flex items-center gap-2 cursor-pointer touch-manipulation whitespace-nowrap appearance-none border-none font-medium font-inter transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 text-secondary-foreground bg-secondary-background hover:bg-secondary-background-hover h-8 rounded-lg p-2 text-xs w-full justify-start p-contextmenu-item-link";
|
||||
|
||||
const hasModernIcons = !!(
|
||||
downloadLi.querySelector("[class*='icon-']") ||
|
||||
document.querySelector("[class*='icon-[lucide']")
|
||||
);
|
||||
|
||||
const copyLi = document.createElement("li");
|
||||
copyLi.className = "p-contextmenu-item leafflow-contextmenu-copy";
|
||||
copyLi.setAttribute("role", "menuitem");
|
||||
copyLi.setAttribute("aria-label", "Copy prompt");
|
||||
copyLi.setAttribute("data-pc-section", "item");
|
||||
copyLi.setAttribute("data-p-active", "false");
|
||||
copyLi.setAttribute("data-p-focused", "false");
|
||||
|
||||
const copyIconHtml = hasModernIcons
|
||||
? `<i class="icon-[lucide--copy] size-4"></i>`
|
||||
: `<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`;
|
||||
|
||||
copyLi.innerHTML = `
|
||||
<div class="p-contextmenu-item-content" data-pc-section="itemcontent">
|
||||
<button class="${btnClass}" tabindex="-1" data-pc-section="itemlink">
|
||||
${copyIconHtml}
|
||||
<span>Copy prompt</span>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
copyLi.addEventListener("click", async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const span = copyLi.querySelector("span");
|
||||
const src = getActiveImageSrc();
|
||||
if (src) {
|
||||
const success = await copyImagePrompt(src);
|
||||
if (span) span.textContent = success ? "Copied! ✅" : "Failed to copy ❌";
|
||||
} else {
|
||||
if (span) span.textContent = "No image found ❌";
|
||||
}
|
||||
setTimeout(() => {
|
||||
contextMenu.style.display = "none";
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||
}, 500);
|
||||
});
|
||||
|
||||
downloadLi.parentElement.insertBefore(copyLi, downloadLi.nextSibling);
|
||||
|
||||
if (isCopyEnabled("LeafFlow.3 - 📋 Prompt Actions.03_EnableSaveToPromptSaver") && !contextMenu.querySelector(".leafflow-contextmenu-bookmark")) {
|
||||
const bookmarkLi = document.createElement("li");
|
||||
bookmarkLi.className = "p-contextmenu-item leafflow-contextmenu-bookmark";
|
||||
bookmarkLi.setAttribute("role", "menuitem");
|
||||
bookmarkLi.setAttribute("aria-label", "Save to prompt bookmarks");
|
||||
bookmarkLi.setAttribute("data-pc-section", "item");
|
||||
bookmarkLi.setAttribute("data-p-active", "false");
|
||||
bookmarkLi.setAttribute("data-p-focused", "false");
|
||||
|
||||
const bIconHtml = BOOKMARK_SVG;
|
||||
|
||||
bookmarkLi.innerHTML = `
|
||||
<div class="p-contextmenu-item-content" data-pc-section="itemcontent">
|
||||
<button class="${btnClass}" tabindex="-1" data-pc-section="itemlink">
|
||||
${bIconHtml}
|
||||
<span>Save to prompt bookmarks</span>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
bookmarkLi.addEventListener("click", async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const span = bookmarkLi.querySelector("span");
|
||||
const src = getActiveImageSrc();
|
||||
if (src) {
|
||||
const success = await saveImageToPromptBookmarks(src);
|
||||
if (span) span.textContent = success ? "Bookmarked! ✅" : "Failed ❌";
|
||||
}
|
||||
setTimeout(() => {
|
||||
contextMenu.style.display = "none";
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||
}, 500);
|
||||
});
|
||||
|
||||
downloadLi.parentElement.insertBefore(bookmarkLi, copyLi.nextSibling);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Fallback Hook for Older Frontend Action Bars
|
||||
function injectHoverCopyAction(overlayBar) {
|
||||
if (!overlayBar || overlayBar.querySelector(".leafflow-hover-copy")) return;
|
||||
if (!isCopyEnabled("LeafFlow.3 - 📋 Prompt Actions.01_EnableAssetsCopyPromptButton")) return;
|
||||
|
||||
const enabled = app.ui.settings.getSettingValue(
|
||||
"LeafFlow.3. 📋 Prompt Actions.01_EnableAssetsCopyPromptButton",
|
||||
true
|
||||
);
|
||||
if (!enabled) return;
|
||||
if (isQueueItemElement(overlayBar)) return;
|
||||
|
||||
const downloadBtn = overlayBar.querySelector('button[aria-label="Download"], button[aria-label*="ownload" i]');
|
||||
if (downloadBtn) {
|
||||
injectCopyPromptNextToDownload(downloadBtn);
|
||||
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.querySelector('button[aria-label*="zoom" i]')?.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"
|
||||
"div[data-virtual-grid-item], .asset-card, [data-testid='asset-card'], [data-node-id], .lg-node, .comfy-image-preview"
|
||||
);
|
||||
const img = parentCard ? parentCard.querySelector("img") : overlayBar.parentElement?.querySelector("img");
|
||||
if (!img || !img.src) return;
|
||||
if (!parentCard) return;
|
||||
if (isQueueItemElement(parentCard)) 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 img = parentCard.querySelector("img");
|
||||
if (!img || !img.src) return;
|
||||
if (img.classList.contains("size-8") || img.closest(".size-8, .size-10, .h-12")) return;
|
||||
|
||||
const copyBtn = document.createElement("button");
|
||||
copyBtn.className =
|
||||
@@ -145,6 +739,11 @@ function injectHoverCopyAction(overlayBar) {
|
||||
}
|
||||
};
|
||||
|
||||
const moreBtn =
|
||||
iconGroup.querySelector('button[aria-label="More options"]') ||
|
||||
iconGroup.querySelector('button[aria-label="More"]') ||
|
||||
iconGroup.lastElementChild;
|
||||
|
||||
if (moreBtn && moreBtn.parentElement === iconGroup) {
|
||||
iconGroup.insertBefore(copyBtn, moreBtn);
|
||||
} else {
|
||||
@@ -152,23 +751,78 @@ function injectHoverCopyAction(overlayBar) {
|
||||
}
|
||||
}
|
||||
|
||||
// Observe DOM mutations to attach the copy button whenever a hover overlay bar appears
|
||||
// 6. Active Scanner for Dynamic Virtual Grid & Context Menus
|
||||
function scanAndInject() {
|
||||
const downloadBtns = document.querySelectorAll(
|
||||
'button[aria-label="Download"], button[aria-label*="ownload" i]'
|
||||
);
|
||||
downloadBtns.forEach(injectCopyPromptNextToDownload);
|
||||
|
||||
const menus = document.querySelectorAll('.p-contextmenu, .p-menu, .p-tieredmenu, [data-pc-name="contextmenu"], [data-pc-name="menu"], [data-pc-name="tieredmenu"]');
|
||||
menus.forEach(injectContextMenuCopy);
|
||||
|
||||
const olderOverlays = document.querySelectorAll(
|
||||
'[data-testid="asset-card-actions"], .asset-card-overlay, .asset-item-overlay'
|
||||
);
|
||||
olderOverlays.forEach(injectHoverCopyAction);
|
||||
}
|
||||
|
||||
// Delegated hover listener for instant injection during virtual scrolling
|
||||
document.addEventListener("pointerover", (e) => {
|
||||
const btn = e.target.closest?.('button[aria-label="Download"], button[aria-label*="ownload" i]');
|
||||
if (btn) {
|
||||
injectCopyPromptNextToDownload(btn);
|
||||
return;
|
||||
}
|
||||
const card = e.target.closest?.('div[data-virtual-grid-item], [data-asset-id], .asset-card, .group');
|
||||
if (card) {
|
||||
registerAssetInteraction(card);
|
||||
const cardDl = card.querySelector('button[aria-label="Download"], button[aria-label*="ownload" i]');
|
||||
if (cardDl) injectCopyPromptNextToDownload(cardDl);
|
||||
}
|
||||
const menu = e.target.closest?.('.p-contextmenu, .p-menu, .p-tieredmenu, [data-pc-name="contextmenu"], [data-pc-name="menu"], [data-pc-name="tieredmenu"]');
|
||||
if (menu) {
|
||||
injectContextMenuCopy(menu);
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
// MutationObserver for DOM changes
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
if (mutation.type === "childList") {
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
for (const node of mutation.addedNodes) {
|
||||
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)) {
|
||||
if (isQueueItemElement(node)) continue;
|
||||
|
||||
if (node.matches?.('button[aria-label="Download"], button[aria-label*="ownload" i]')) {
|
||||
injectCopyPromptNextToDownload(node);
|
||||
} else if (node.matches?.('.p-contextmenu, .p-menu, .p-tieredmenu, [data-pc-name="contextmenu"], [data-pc-name="menu"], [data-pc-name="tieredmenu"]')) {
|
||||
injectContextMenuCopy(node);
|
||||
} else if (node.matches?.('[data-testid="asset-card-actions"], .asset-card-overlay, .asset-item-overlay')) {
|
||||
injectHoverCopyAction(node);
|
||||
} else if (node.querySelectorAll) {
|
||||
const overlays = node.querySelectorAll(selectors);
|
||||
overlays.forEach((overlay) => injectHoverCopyAction(overlay));
|
||||
const dlBtns = node.querySelectorAll('button[aria-label="Download"], button[aria-label*="ownload" i]');
|
||||
dlBtns.forEach(injectCopyPromptNextToDownload);
|
||||
|
||||
const ctxMenus = node.querySelectorAll('.p-contextmenu, .p-menu, .p-tieredmenu, [data-pc-name="contextmenu"], [data-pc-name="menu"], [data-pc-name="tieredmenu"]');
|
||||
ctxMenus.forEach(injectContextMenuCopy);
|
||||
|
||||
const overlays = node.querySelectorAll('[data-testid="asset-card-actions"], .asset-card-overlay, .asset-item-overlay');
|
||||
overlays.forEach(injectHoverCopyAction);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
// Run scanner immediately, on DOM ready, and periodic sweep
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", scanAndInject);
|
||||
} else {
|
||||
scanAndInject();
|
||||
}
|
||||
setInterval(scanAndInject, 500);
|
||||
|
||||
|
||||
|
||||
@@ -191,6 +191,23 @@ visualStyles.textContent = `
|
||||
gap: 8px;
|
||||
padding: 4px 2px;
|
||||
}
|
||||
.right-aligned-mode .lora-folder-grid {
|
||||
direction: rtl;
|
||||
}
|
||||
.right-aligned-mode .lora-tile {
|
||||
direction: ltr;
|
||||
}
|
||||
.right-aligned-mode .lora-folder-header {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
.right-aligned-mode .lora-folder-toggle {
|
||||
margin-right: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
.right-aligned-mode .lora-folder-count {
|
||||
margin-right: 0;
|
||||
margin-left: 10px;
|
||||
}
|
||||
.lora-folder-container.collapsed .lora-folder-grid {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -503,7 +520,11 @@ app.registerExtension({
|
||||
// Create Visual HTML DOM Element
|
||||
const viewContainer = document.createElement("div");
|
||||
viewContainer.className = "lora-visual-container grid-layout";
|
||||
const initialZoom = localStorage.getItem("comfy_lora_picker_zoom") || "80";
|
||||
node.properties = node.properties || {};
|
||||
const initialZoom = node.properties["tile_size"] != null
|
||||
? node.properties["tile_size"]
|
||||
: (localStorage.getItem("comfy_lora_picker_zoom") || "80");
|
||||
node.properties["tile_size"] = parseInt(initialZoom);
|
||||
viewContainer.style.setProperty("--lora-tile-size", `${initialZoom}px`);
|
||||
|
||||
// Embed DOM Widget inside the node container
|
||||
@@ -540,8 +561,10 @@ app.registerExtension({
|
||||
"tile_size",
|
||||
parseInt(initialZoom),
|
||||
(val) => {
|
||||
localStorage.setItem("comfy_lora_picker_zoom", val);
|
||||
node.properties = node.properties || {};
|
||||
node.properties["tile_size"] = val;
|
||||
viewContainer.style.setProperty("--lora-tile-size", `${val}px`);
|
||||
if (app.graph) app.graph.setDirtyCanvas(true, true);
|
||||
},
|
||||
{ min: 50, max: 180, step: 1 }
|
||||
);
|
||||
@@ -752,6 +775,21 @@ app.registerExtension({
|
||||
const originalOnConfigure = node.onConfigure;
|
||||
node.onConfigure = function(config) {
|
||||
if (originalOnConfigure) originalOnConfigure.apply(this, arguments);
|
||||
|
||||
const savedTileSize = (config && config.properties && config.properties["tile_size"] != null)
|
||||
? config.properties["tile_size"]
|
||||
: (node.properties && node.properties["tile_size"] != null ? node.properties["tile_size"] : null);
|
||||
|
||||
if (savedTileSize != null) {
|
||||
node.properties = node.properties || {};
|
||||
node.properties["tile_size"] = savedTileSize;
|
||||
if (zoomWidget) {
|
||||
zoomWidget.value = parseInt(savedTileSize);
|
||||
}
|
||||
if (viewContainer && viewContainer.style) {
|
||||
viewContainer.style.setProperty("--lora-tile-size", `${savedTileSize}px`);
|
||||
}
|
||||
}
|
||||
|
||||
const curWidget = getHiddenWidget();
|
||||
const widgetIndex = node.widgets.indexOf(curWidget);
|
||||
@@ -842,7 +880,11 @@ app.registerExtension({
|
||||
|
||||
const viewContainer = document.createElement("div");
|
||||
viewContainer.className = "lora-visual-container flex-layout";
|
||||
const initialZoom = localStorage.getItem("comfy_lora_picker_zoom") || "80";
|
||||
node.properties = node.properties || {};
|
||||
const initialZoom = node.properties["tile_size"] != null
|
||||
? node.properties["tile_size"]
|
||||
: (localStorage.getItem("comfy_lora_picker_zoom") || "80");
|
||||
node.properties["tile_size"] = parseInt(initialZoom);
|
||||
viewContainer.style.setProperty("--lora-tile-size", `${initialZoom}px`);
|
||||
|
||||
const gridContainer = document.createElement("div");
|
||||
@@ -1040,8 +1082,10 @@ app.registerExtension({
|
||||
"tile_size",
|
||||
parseInt(initialZoom),
|
||||
(val) => {
|
||||
localStorage.setItem("comfy_lora_picker_zoom", val);
|
||||
node.properties = node.properties || {};
|
||||
node.properties["tile_size"] = val;
|
||||
viewContainer.style.setProperty("--lora-tile-size", `${val}px`);
|
||||
if (app.graph) app.graph.setDirtyCanvas(true, true);
|
||||
},
|
||||
{ min: 50, max: 180, step: 1 }
|
||||
);
|
||||
@@ -1471,6 +1515,21 @@ app.registerExtension({
|
||||
node.onConfigure = function(config) {
|
||||
if (originalOnConfigure) originalOnConfigure.apply(this, arguments);
|
||||
|
||||
const savedTileSize = (config && config.properties && config.properties["tile_size"] != null)
|
||||
? config.properties["tile_size"]
|
||||
: (node.properties && node.properties["tile_size"] != null ? node.properties["tile_size"] : null);
|
||||
|
||||
if (savedTileSize != null) {
|
||||
node.properties = node.properties || {};
|
||||
node.properties["tile_size"] = savedTileSize;
|
||||
if (zoomWidget) {
|
||||
zoomWidget.value = parseInt(savedTileSize);
|
||||
}
|
||||
if (viewContainer && viewContainer.style) {
|
||||
viewContainer.style.setProperty("--lora-tile-size", `${savedTileSize}px`);
|
||||
}
|
||||
}
|
||||
|
||||
const curWidget = getHiddenWidget();
|
||||
const widgetIndex = node.widgets.indexOf(curWidget);
|
||||
const savedValue = (config.widgets_values && widgetIndex !== -1) ? config.widgets_values[widgetIndex] : curWidget.value;
|
||||
|
||||
+23
-2
@@ -344,7 +344,11 @@ app.registerExtension({
|
||||
applyDisplayMode(mode);
|
||||
};
|
||||
|
||||
const initialZoom = localStorage.getItem("comfy_img_picker_zoom") || "80";
|
||||
node.properties = node.properties || {};
|
||||
const initialZoom = node.properties["tile_size"] != null
|
||||
? node.properties["tile_size"]
|
||||
: (localStorage.getItem("comfy_img_picker_zoom") || "80");
|
||||
node.properties["tile_size"] = parseInt(initialZoom);
|
||||
viewContainer.style.setProperty("--img-tile-size", `${initialZoom}px`);
|
||||
|
||||
// Embed DOM Widget inside the node container
|
||||
@@ -380,8 +384,10 @@ app.registerExtension({
|
||||
"tile_size",
|
||||
parseInt(initialZoom),
|
||||
(val) => {
|
||||
localStorage.setItem("comfy_img_picker_zoom", val);
|
||||
node.properties = node.properties || {};
|
||||
node.properties["tile_size"] = val;
|
||||
viewContainer.style.setProperty("--img-tile-size", `${val}px`);
|
||||
if (app.graph) app.graph.setDirtyCanvas(true, true);
|
||||
},
|
||||
{ min: 50, max: 200, step: 1 }
|
||||
);
|
||||
@@ -602,6 +608,21 @@ app.registerExtension({
|
||||
node.onConfigure = function(config) {
|
||||
if (originalOnConfigure) originalOnConfigure.apply(this, arguments);
|
||||
|
||||
const savedTileSize = (config && config.properties && config.properties["tile_size"] != null)
|
||||
? config.properties["tile_size"]
|
||||
: (node.properties && node.properties["tile_size"] != null ? node.properties["tile_size"] : null);
|
||||
|
||||
if (savedTileSize != null) {
|
||||
node.properties = node.properties || {};
|
||||
node.properties["tile_size"] = savedTileSize;
|
||||
if (zoomWidget) {
|
||||
zoomWidget.value = parseInt(savedTileSize);
|
||||
}
|
||||
if (viewContainer && viewContainer.style) {
|
||||
viewContainer.style.setProperty("--img-tile-size", `${savedTileSize}px`);
|
||||
}
|
||||
}
|
||||
|
||||
const curWidget = getHiddenWidget("_selected_image", "");
|
||||
const widgetIndex = node.widgets.indexOf(curWidget);
|
||||
const savedValue = (config.widgets_values && widgetIndex !== -1) ? config.widgets_values[widgetIndex] : curWidget.value;
|
||||
|
||||
@@ -0,0 +1,572 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { api } from "/scripts/api.js";
|
||||
|
||||
const EXTENSION_NAME = "LeafFlow.PowerControlSidebar";
|
||||
const STYLE_ID = "leafflow-power-sidebar-styles";
|
||||
|
||||
let currentPowerState = {
|
||||
pending_action: null,
|
||||
armed_at: null,
|
||||
is_paused: false
|
||||
};
|
||||
|
||||
let activePopup = null;
|
||||
|
||||
function injectStyles() {
|
||||
if (document.getElementById(STYLE_ID)) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = STYLE_ID;
|
||||
style.textContent = `
|
||||
/* Power Sidebar Button Layout (Matching standard full-width sidebar button) */
|
||||
#leafflow-power-bottom-btn {
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
min-height: 48px !important;
|
||||
padding: 6px 4px !important;
|
||||
box-sizing: border-box !important;
|
||||
border-radius: 8px !important;
|
||||
align-self: stretch !important;
|
||||
}
|
||||
|
||||
#leafflow-power-bottom-btn .side-bar-button-content {
|
||||
width: 100% !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
align-items: center !important;
|
||||
gap: 4px !important;
|
||||
}
|
||||
|
||||
/* Power Sidebar Button Armed Red Warning State */
|
||||
#leafflow-power-bottom-btn.leafflow-power-btn-armed,
|
||||
.comfy-power-btn.leafflow-power-btn-armed,
|
||||
.side-bar-button.leafflow-power-btn-armed,
|
||||
.side-bar-button:has(.leafflow-power-icon-armed),
|
||||
button[aria-label*="Power"].leafflow-power-btn-armed {
|
||||
background: linear-gradient(135deg, #dc2626 0%, #991b1b 100%) !important;
|
||||
color: #ffffff !important;
|
||||
border: 1px solid #f87171 !important;
|
||||
box-shadow: 0 0 14px rgba(239, 68, 68, 0.8) !important;
|
||||
animation: leafflow-power-pulse 1.8s infinite ease-in-out !important;
|
||||
}
|
||||
|
||||
.leafflow-power-btn-armed svg,
|
||||
.leafflow-power-btn-armed .side-bar-button-icon,
|
||||
.leafflow-power-btn-armed span {
|
||||
color: #ffffff !important;
|
||||
stroke: #ffffff !important;
|
||||
}
|
||||
|
||||
@keyframes leafflow-power-pulse {
|
||||
0% { box-shadow: 0 0 6px rgba(239, 68, 68, 0.5); transform: scale(1); }
|
||||
50% { box-shadow: 0 0 16px rgba(239, 68, 68, 0.95); transform: scale(1.03); }
|
||||
100% { box-shadow: 0 0 6px rgba(239, 68, 68, 0.5); transform: scale(1); }
|
||||
}
|
||||
|
||||
/* Power Side Popup Menu (like Help Center popup) */
|
||||
.leafflow-power-popup {
|
||||
position: fixed;
|
||||
z-index: 10005;
|
||||
min-width: 250px;
|
||||
max-width: 320px;
|
||||
background: var(--comfy-menu-bg, #18181b);
|
||||
border: 1px solid var(--border-color, #27272a);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.6);
|
||||
padding: 8px;
|
||||
color: var(--fg-color, #f4f4f5);
|
||||
font-family: inherit;
|
||||
animation: leafflow-popup-fade 0.15s ease-out;
|
||||
}
|
||||
|
||||
@keyframes leafflow-popup-fade {
|
||||
from { opacity: 0; transform: translateX(-6px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
.leafflow-power-popup .help-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--fg-color, #f4f4f5);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.leafflow-power-popup .help-menu-item:hover {
|
||||
background: var(--comfy-menu-secondary-bg, rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
|
||||
.leafflow-power-popup .help-menu-item.danger-item:hover {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.leafflow-power-popup .help-menu-item.armed-cancel-item {
|
||||
background: rgba(239, 68, 68, 0.85);
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.leafflow-power-popup .help-menu-item.armed-cancel-item:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.leafflow-power-popup .help-menu-icon-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
color: var(--descrip-text, #a1a1aa);
|
||||
}
|
||||
|
||||
.leafflow-power-popup .help-menu-item:hover .help-menu-icon-container {
|
||||
color: var(--fg-color, #f4f4f5);
|
||||
}
|
||||
|
||||
.leafflow-power-popup .menu-label {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.leafflow-power-popup .menu-divider {
|
||||
height: 1px;
|
||||
background: var(--border-color, #27272a);
|
||||
margin: 4px 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Power Confirm Modal */
|
||||
.leafflow-power-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 18px;
|
||||
min-width: 330px;
|
||||
max-width: 440px;
|
||||
background: #18181b;
|
||||
color: #f4f4f5;
|
||||
border-radius: 12px;
|
||||
font-family: inherit;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function getPowerIconSvg(size = 18) {
|
||||
return `<svg class="size-4.5" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18.36 6.64a9 9 0 1 1-12.73 0"></path><line x1="12" y1="2" x2="12" y2="12"></line></svg>`;
|
||||
}
|
||||
|
||||
function updateSidebarButtonVisuals() {
|
||||
const isArmed = Boolean(currentPowerState.pending_action);
|
||||
const btns = document.querySelectorAll('#leafflow-power-bottom-btn, .comfy-power-btn, [aria-label*="Power"], .side-bar-button:has(.pi-power)');
|
||||
btns.forEach(btn => {
|
||||
if (isArmed) {
|
||||
btn.classList.add("leafflow-power-btn-armed");
|
||||
const icon = btn.querySelector(".side-bar-button-icon, i, svg");
|
||||
if (icon) icon.classList.add("leafflow-power-icon-armed");
|
||||
btn.setAttribute("title", `⚠️ ComfyUI Power: Armed to ${currentPowerState.pending_action.toUpperCase()} after queue finish! Click to cancel.`);
|
||||
} else {
|
||||
btn.classList.remove("leafflow-power-btn-armed");
|
||||
const icon = btn.querySelector(".side-bar-button-icon, i, svg");
|
||||
if (icon) icon.classList.remove("leafflow-power-icon-armed");
|
||||
btn.setAttribute("title", "Power (Restart / Shutdown)");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchPowerStatus() {
|
||||
try {
|
||||
const resp = await api.fetchApi("/leafflow/power/status");
|
||||
if (resp.ok) {
|
||||
currentPowerState = await resp.json();
|
||||
updateSidebarButtonVisuals();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function openConfirmModal(title, message, onConfirm, isDanger = false) {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.style.position = "fixed";
|
||||
overlay.style.inset = "0";
|
||||
overlay.style.backgroundColor = "rgba(0,0,0,0.75)";
|
||||
overlay.style.display = "flex";
|
||||
overlay.style.alignItems = "center";
|
||||
overlay.style.justifyContent = "center";
|
||||
overlay.style.zIndex = "10010";
|
||||
overlay.style.backdropFilter = "blur(3px)";
|
||||
|
||||
const modal = document.createElement("div");
|
||||
modal.className = "leafflow-power-modal";
|
||||
modal.style.border = isDanger ? "1px solid rgba(239, 68, 68, 0.5)" : "1px solid rgba(59, 130, 246, 0.5)";
|
||||
modal.style.boxShadow = isDanger ? "0 0 25px rgba(239, 68, 68, 0.35)" : "0 0 25px rgba(59, 130, 246, 0.35)";
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.style.fontSize = "16px";
|
||||
header.style.fontWeight = "bold";
|
||||
header.style.display = "flex";
|
||||
header.style.alignItems = "center";
|
||||
header.style.gap = "8px";
|
||||
header.innerHTML = isDanger ? `⚠️ ${title}` : `ℹ️ ${title}`;
|
||||
|
||||
const desc = document.createElement("div");
|
||||
desc.style.fontSize = "13px";
|
||||
desc.style.lineHeight = "1.5";
|
||||
desc.style.color = "#d4d4d8";
|
||||
desc.textContent = message;
|
||||
|
||||
const btnRow = document.createElement("div");
|
||||
btnRow.style.display = "flex";
|
||||
btnRow.style.justifyContent = "flex-end";
|
||||
btnRow.style.gap = "8px";
|
||||
btnRow.style.marginTop = "8px";
|
||||
|
||||
const cancelBtn = document.createElement("button");
|
||||
cancelBtn.textContent = "Cancel";
|
||||
cancelBtn.style.padding = "8px 16px";
|
||||
cancelBtn.style.borderRadius = "6px";
|
||||
cancelBtn.style.border = "1px solid #52525b";
|
||||
cancelBtn.style.background = "#27272a";
|
||||
cancelBtn.style.color = "#f4f4f5";
|
||||
cancelBtn.style.cursor = "pointer";
|
||||
cancelBtn.onclick = () => overlay.remove();
|
||||
|
||||
const confirmBtn = document.createElement("button");
|
||||
confirmBtn.textContent = "Confirm";
|
||||
confirmBtn.style.padding = "8px 16px";
|
||||
confirmBtn.style.borderRadius = "6px";
|
||||
confirmBtn.style.border = "none";
|
||||
confirmBtn.style.fontWeight = "600";
|
||||
confirmBtn.style.cursor = "pointer";
|
||||
if (isDanger) {
|
||||
confirmBtn.style.background = "#dc2626";
|
||||
confirmBtn.style.color = "#ffffff";
|
||||
} else {
|
||||
confirmBtn.style.background = "#2563eb";
|
||||
confirmBtn.style.color = "#ffffff";
|
||||
}
|
||||
|
||||
confirmBtn.onclick = async () => {
|
||||
overlay.remove();
|
||||
if (onConfirm) await onConfirm();
|
||||
};
|
||||
|
||||
btnRow.append(cancelBtn, confirmBtn);
|
||||
modal.append(header, desc, btnRow);
|
||||
overlay.appendChild(modal);
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
function closePowerPopup() {
|
||||
if (activePopup) {
|
||||
activePopup.remove();
|
||||
activePopup = null;
|
||||
}
|
||||
}
|
||||
|
||||
function showPowerSidePopup(anchorElement) {
|
||||
if (activePopup) {
|
||||
closePowerPopup();
|
||||
return;
|
||||
}
|
||||
|
||||
const popup = document.createElement("div");
|
||||
popup.className = "help-center-popup sidebar-left leafflow-power-popup";
|
||||
popup.setAttribute("data-testid", "power-popup");
|
||||
|
||||
const rect = anchorElement.getBoundingClientRect();
|
||||
popup.style.left = `${Math.round(rect.right + 8)}px`;
|
||||
popup.style.bottom = `${Math.max(12, Math.round(window.innerHeight - rect.bottom))}px`;
|
||||
|
||||
const menu = document.createElement("div");
|
||||
menu.className = "help-center-menu flex flex-col items-start gap-1";
|
||||
menu.setAttribute("role", "menu");
|
||||
menu.setAttribute("aria-label", "Power Menu");
|
||||
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "w-full";
|
||||
|
||||
const nav = document.createElement("nav");
|
||||
nav.className = "flex w-full flex-col gap-1";
|
||||
nav.setAttribute("role", "menubar");
|
||||
|
||||
// Cancel Armed Action Button (if armed)
|
||||
if (currentPowerState.pending_action) {
|
||||
const cancelBtn = document.createElement("button");
|
||||
cancelBtn.type = "button";
|
||||
cancelBtn.className = "help-menu-item armed-cancel-item";
|
||||
cancelBtn.setAttribute("role", "menuitem");
|
||||
cancelBtn.innerHTML = `
|
||||
<div class="help-menu-icon-container"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="15" y1="9" x2="9" y2="15"></line><line x1="9" y1="9" x2="15" y2="15"></line></svg></div>
|
||||
<span class="menu-label">Cancel ${currentPowerState.pending_action.toUpperCase()}</span>
|
||||
`;
|
||||
cancelBtn.onclick = async (e) => {
|
||||
e.stopPropagation();
|
||||
closePowerPopup();
|
||||
await api.fetchApi("/leafflow/power/arm", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: null })
|
||||
});
|
||||
await fetchPowerStatus();
|
||||
if (app.extensionManager?.toast?.add) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: "info",
|
||||
summary: "🍃 LeafFlow Power",
|
||||
detail: "Scheduled power action has been cancelled.",
|
||||
life: 3000
|
||||
});
|
||||
}
|
||||
};
|
||||
nav.appendChild(cancelBtn);
|
||||
}
|
||||
|
||||
// 1. Restart Server (Immediate)
|
||||
const restartItem = document.createElement("button");
|
||||
restartItem.type = "button";
|
||||
restartItem.className = "help-menu-item";
|
||||
restartItem.setAttribute("role", "menuitem");
|
||||
restartItem.innerHTML = `
|
||||
<div class="help-menu-icon-container"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"></polyline><polyline points="1 20 1 14 7 14"></polyline><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path></svg></div>
|
||||
<span class="menu-label">Restart Server (Immediate)</span>
|
||||
`;
|
||||
restartItem.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
closePowerPopup();
|
||||
openConfirmModal(
|
||||
"Restart ComfyUI Immediately?",
|
||||
"Are you sure you want to restart ComfyUI immediately? Any active prompt generation will be interrupted.",
|
||||
async () => {
|
||||
if (app.extensionManager?.toast?.add) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: "warn",
|
||||
summary: "🔄 Restarting ComfyUI",
|
||||
detail: "The server process is restarting. The UI will reload shortly.",
|
||||
life: 8000
|
||||
});
|
||||
}
|
||||
await api.fetchApi("/leafflow/power/restart", { method: "POST" });
|
||||
setTimeout(() => window.location.reload(), 2500);
|
||||
},
|
||||
true
|
||||
);
|
||||
};
|
||||
nav.appendChild(restartItem);
|
||||
|
||||
// 2. Shutdown Server (Immediate)
|
||||
const shutdownItem = document.createElement("button");
|
||||
shutdownItem.type = "button";
|
||||
shutdownItem.className = "help-menu-item danger-item";
|
||||
shutdownItem.setAttribute("role", "menuitem");
|
||||
shutdownItem.innerHTML = `
|
||||
<div class="help-menu-icon-container"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18.36 6.64a9 9 0 1 1-12.73 0"></path><line x1="12" y1="2" x2="12" y2="12"></line></svg></div>
|
||||
<span class="menu-label">Shutdown Server (Immediate)</span>
|
||||
`;
|
||||
shutdownItem.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
closePowerPopup();
|
||||
openConfirmModal(
|
||||
"Shutdown ComfyUI Server?",
|
||||
"Are you sure you want to SHUT DOWN the ComfyUI server immediately? The process will exit.",
|
||||
async () => {
|
||||
if (app.extensionManager?.toast?.add) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: "error",
|
||||
summary: "🛑 ComfyUI Shutting Down",
|
||||
detail: "The server process is terminating.",
|
||||
life: 6000
|
||||
});
|
||||
}
|
||||
await api.fetchApi("/leafflow/power/shutdown", { method: "POST" });
|
||||
},
|
||||
true
|
||||
);
|
||||
};
|
||||
nav.appendChild(shutdownItem);
|
||||
|
||||
// Divider
|
||||
const divider = document.createElement("div");
|
||||
divider.className = "menu-divider";
|
||||
nav.appendChild(divider);
|
||||
|
||||
// 3. Restart After Queue Finish
|
||||
const restartQueueItem = document.createElement("button");
|
||||
restartQueueItem.type = "button";
|
||||
restartQueueItem.className = "help-menu-item";
|
||||
restartQueueItem.setAttribute("role", "menuitem");
|
||||
restartQueueItem.innerHTML = `
|
||||
<div class="help-menu-icon-container"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg></div>
|
||||
<span class="menu-label">Restart After Queue Finish</span>
|
||||
`;
|
||||
restartQueueItem.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
closePowerPopup();
|
||||
openConfirmModal(
|
||||
"Schedule Restart After Queue Finish?",
|
||||
"ComfyUI will wait until all remaining prompts in the queue finish and the system returns to idle before restarting. (Note: If queue is paused, it will not restart).",
|
||||
async () => {
|
||||
await api.fetchApi("/leafflow/power/arm", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "restart" })
|
||||
});
|
||||
await fetchPowerStatus();
|
||||
if (app.extensionManager?.toast?.add) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: "warn",
|
||||
summary: "⏳ Restart Scheduled",
|
||||
detail: "ComfyUI will restart automatically when the queue finishes.",
|
||||
life: 6000
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
nav.appendChild(restartQueueItem);
|
||||
|
||||
// 4. Shutdown After Queue Finish
|
||||
const shutdownQueueItem = document.createElement("button");
|
||||
shutdownQueueItem.type = "button";
|
||||
shutdownQueueItem.className = "help-menu-item danger-item";
|
||||
shutdownQueueItem.setAttribute("role", "menuitem");
|
||||
shutdownQueueItem.innerHTML = `
|
||||
<div class="help-menu-icon-container"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path></svg></div>
|
||||
<span class="menu-label">Shutdown After Queue Finish</span>
|
||||
`;
|
||||
shutdownQueueItem.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
closePowerPopup();
|
||||
openConfirmModal(
|
||||
"Schedule Shutdown After Queue Finish?",
|
||||
"ComfyUI will wait until all queued prompts complete and the system returns to idle before shutting down completely.",
|
||||
async () => {
|
||||
await api.fetchApi("/leafflow/power/arm", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "shutdown" })
|
||||
});
|
||||
await fetchPowerStatus();
|
||||
if (app.extensionManager?.toast?.add) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: "warn",
|
||||
summary: "🌙 Shutdown Scheduled",
|
||||
detail: "ComfyUI will shut down automatically when the queue finishes.",
|
||||
life: 6000
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
nav.appendChild(shutdownQueueItem);
|
||||
|
||||
wrap.appendChild(nav);
|
||||
menu.appendChild(wrap);
|
||||
popup.appendChild(menu);
|
||||
document.body.appendChild(popup);
|
||||
activePopup = popup;
|
||||
|
||||
// Close on click outside or escape
|
||||
const outsideListener = (e) => {
|
||||
if (popup && !popup.contains(e.target) && !anchorElement.contains(e.target)) {
|
||||
closePowerPopup();
|
||||
document.removeEventListener("pointerdown", outsideListener);
|
||||
document.removeEventListener("keydown", keyListener);
|
||||
}
|
||||
};
|
||||
const keyListener = (e) => {
|
||||
if (e.key === "Escape") {
|
||||
closePowerPopup();
|
||||
document.removeEventListener("pointerdown", outsideListener);
|
||||
document.removeEventListener("keydown", keyListener);
|
||||
}
|
||||
};
|
||||
setTimeout(() => {
|
||||
document.addEventListener("pointerdown", outsideListener);
|
||||
document.addEventListener("keydown", keyListener);
|
||||
}, 10);
|
||||
}
|
||||
|
||||
function attachBottomSidebarButton() {
|
||||
const bottomGroup = document.querySelector(".sidebar-item-group.mt-auto, .side-bar .mt-auto, [data-testid='bottom-sidebar']");
|
||||
if (!bottomGroup) return;
|
||||
|
||||
if (document.getElementById("leafflow-power-bottom-btn")) {
|
||||
updateSidebarButtonVisuals();
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.createElement("button");
|
||||
btn.id = "leafflow-power-bottom-btn";
|
||||
btn.type = "button";
|
||||
btn.className = "relative inline-flex items-center justify-center gap-2 touch-manipulation whitespace-nowrap appearance-none font-medium font-inter transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([width]):not([height])]:size-4 [&_svg]:shrink-0 bg-transparent text-muted-foreground hover:bg-secondary-background-hover h-8 rounded-lg p-2 text-xs side-bar-button cursor-pointer border-none comfy-power-btn";
|
||||
btn.setAttribute("aria-label", "Power");
|
||||
btn.setAttribute("data-testid", "power-button");
|
||||
btn.setAttribute("data-pd-tooltip", "true");
|
||||
btn.setAttribute("title", "Power (Restart / Shutdown)");
|
||||
|
||||
btn.innerHTML = `
|
||||
<div class="side-bar-button-content flex flex-col items-center gap-2">
|
||||
<div class="sidebar-icon-wrapper relative">
|
||||
${getPowerIconSvg(18)}
|
||||
</div>
|
||||
<span class="side-bar-button-label line-clamp-2 w-max max-w-[calc(var(--sidebar-width)-var(--sidebar-padding))] text-center text-2xs wrap-break-word whitespace-normal">Power</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
btn.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
showPowerSidePopup(btn);
|
||||
};
|
||||
|
||||
// Insert at the top of the bottom group (above Help Center)
|
||||
bottomGroup.insertBefore(btn, bottomGroup.firstChild);
|
||||
updateSidebarButtonVisuals();
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: EXTENSION_NAME,
|
||||
async setup() {
|
||||
injectStyles();
|
||||
await fetchPowerStatus();
|
||||
|
||||
// 1. Attach native button in bottom sidebar
|
||||
attachBottomSidebarButton();
|
||||
const observer = new MutationObserver(() => attachBottomSidebarButton());
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
// 2. Fallback listener on any matching button
|
||||
setTimeout(() => {
|
||||
const btns = document.querySelectorAll('.comfy-power-btn, [aria-label*="Power"], .side-bar-button:has(.pi-power)');
|
||||
btns.forEach(b => {
|
||||
b.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
showPowerSidePopup(b);
|
||||
}, true);
|
||||
});
|
||||
updateSidebarButtonVisuals();
|
||||
}, 1000);
|
||||
|
||||
try {
|
||||
api.addEventListener("leafflow_power_status", (e) => {
|
||||
currentPowerState = e.detail || currentPowerState;
|
||||
updateSidebarButtonVisuals();
|
||||
});
|
||||
} catch (_) {}
|
||||
|
||||
setInterval(fetchPowerStatus, 3000);
|
||||
}
|
||||
});
|
||||
+136
-29
@@ -57,6 +57,50 @@ if (typeof document !== "undefined") {
|
||||
}
|
||||
}
|
||||
|
||||
// Global Non-Fatal Warning & Native Node Error Highlighter
|
||||
try {
|
||||
api.addEventListener("leafflow_node_error_state", (e) => {
|
||||
const { node_id, title, message, fallback } = e.detail || {};
|
||||
if (node_id && app.graph) {
|
||||
const targetNode = app.graph.getNodeById(Number(node_id)) || app.graph.getNodeById(String(node_id));
|
||||
if (targetNode) {
|
||||
targetNode.has_errors = true;
|
||||
targetNode.bgcolor = "#7f1d1d";
|
||||
targetNode.color = "#ef4444";
|
||||
targetNode.setDirtyCanvas(true, true);
|
||||
if (typeof targetNode.onExecutionError === "function") {
|
||||
targetNode.onExecutionError(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (app.extensionManager?.toast?.add) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: "warn",
|
||||
summary: `🍃 ${title || "LeafFlow"}`,
|
||||
detail: `${message} (Auto-resolved with ${fallback})`,
|
||||
life: 6000
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
api.addEventListener("leafflow_toast", (e) => {
|
||||
const data = e.detail || {};
|
||||
const title = data.title || "LeafFlow Notice";
|
||||
const message = data.message || "";
|
||||
const type = data.type || "warn";
|
||||
if (app.extensionManager?.toast?.add) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: type === "error" ? "error" : "warn",
|
||||
summary: `🍃 ${title}`,
|
||||
detail: message,
|
||||
life: 6000
|
||||
});
|
||||
} else if (app.ui?.dialog) {
|
||||
console.warn(`[LeafFlow] ${title}: ${message}`);
|
||||
}
|
||||
});
|
||||
} catch (_) {}
|
||||
|
||||
/**
|
||||
* Custom renderer for real, styled action buttons in ComfyUI Settings Modal.
|
||||
*/
|
||||
@@ -134,16 +178,48 @@ function renderSettingButton(label, workingText, successText, onClickHandler) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatic Settings Migration:
|
||||
* Seamlessly copies over existing user settings from older LeafFlow setting ID formats
|
||||
* so users never lose their saved API keys, tokens, or preferences.
|
||||
*/
|
||||
function migrateSavedSettings() {
|
||||
try {
|
||||
if (typeof localStorage === "undefined") return;
|
||||
const keysToMigrate = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const k = localStorage.key(i);
|
||||
if (k && (k.includes("LeafFlow.") || k.includes("leafflow."))) {
|
||||
keysToMigrate.push(k);
|
||||
}
|
||||
}
|
||||
for (const oldKey of keysToMigrate) {
|
||||
const val = localStorage.getItem(oldKey);
|
||||
if (val === null) continue;
|
||||
// Convert dot-numbered formats like "LeafFlow.1. 🖼️..." -> "LeafFlow.1 - 🖼️..."
|
||||
const newKey = oldKey.replace(/LeafFlow\.(\d+)\.\s+/g, "LeafFlow.$1 - ");
|
||||
if (newKey !== oldKey && localStorage.getItem(newKey) === null) {
|
||||
localStorage.setItem(newKey, val);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[LeafFlow] Settings migration skipped:", e);
|
||||
}
|
||||
}
|
||||
migrateSavedSettings();
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfyUI.LeafFlow.Settings",
|
||||
async setup() {
|
||||
migrateSavedSettings();
|
||||
|
||||
// =========================================================================
|
||||
// GRUPPE 1: 1. 🖼️ Visual Loaders
|
||||
// GRUPPE 1: 1 - 🖼️ Visual Loaders
|
||||
// =========================================================================
|
||||
|
||||
// 1.0 Custom Node Colors Toggle
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.1. 🖼️ Visual Loaders.00_EnableCustomColors",
|
||||
id: "LeafFlow.1 - 🖼️ Visual Loaders.00_EnableCustomColors",
|
||||
name: "Enable Custom LeafFlow Node Colors",
|
||||
type: "boolean",
|
||||
defaultValue: true,
|
||||
@@ -152,7 +228,7 @@ app.registerExtension({
|
||||
|
||||
// 1.1 Civitai API Key
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.1. 🖼️ Visual Loaders.01_CivitaiApiKey",
|
||||
id: "LeafFlow.1 - 🖼️ Visual Loaders.01_CivitaiApiKey",
|
||||
name: "Civitai API Key",
|
||||
type: "text",
|
||||
defaultValue: "",
|
||||
@@ -169,7 +245,7 @@ app.registerExtension({
|
||||
|
||||
// 1.2 Enable Civitai Auto-Scraping Toggle (default: true)
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.1. 🖼️ Visual Loaders.02_EnableCivitaiScraping",
|
||||
id: "LeafFlow.1 - 🖼️ Visual Loaders.02_EnableCivitaiScraping",
|
||||
name: "Enable Civitai Auto-Scraping",
|
||||
type: "boolean",
|
||||
defaultValue: true,
|
||||
@@ -185,7 +261,7 @@ app.registerExtension({
|
||||
|
||||
// 1.3 TMDB Access Token
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.1. 🖼️ Visual Loaders.03_TMDBApiKey",
|
||||
id: "LeafFlow.1 - 🖼️ Visual Loaders.03_TMDBApiKey",
|
||||
name: "TMDB Access Token",
|
||||
type: "text",
|
||||
defaultValue: "",
|
||||
@@ -202,7 +278,7 @@ app.registerExtension({
|
||||
|
||||
// 1.4 Enable TMDB Auto-Scraping Toggle (default: false)
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.1. 🖼️ Visual Loaders.04_EnableTMDBScraping",
|
||||
id: "LeafFlow.1 - 🖼️ Visual Loaders.04_EnableTMDBScraping",
|
||||
name: "Enable TMDB Auto-Scraping",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
@@ -218,7 +294,7 @@ app.registerExtension({
|
||||
|
||||
// 1.5 Enable LoRA Usage Tracking (default: true)
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.1. 🖼️ Visual Loaders.05_EnableLoraUsage",
|
||||
id: "LeafFlow.1 - 🖼️ Visual Loaders.05_EnableLoraUsage",
|
||||
name: "Enable LoRA Usage Tracking",
|
||||
type: "boolean",
|
||||
defaultValue: true,
|
||||
@@ -234,7 +310,7 @@ app.registerExtension({
|
||||
|
||||
// 1.6 Reset Scrapes Cache Button
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.1. 🖼️ Visual Loaders.06_ResetScrapesCache",
|
||||
id: "LeafFlow.1 - 🖼️ Visual Loaders.06_ResetScrapesCache",
|
||||
name: "Reset Failed Scrapes Cache",
|
||||
type: "button",
|
||||
defaultValue: "🗑️ Clear Scrapes Cache",
|
||||
@@ -267,12 +343,12 @@ app.registerExtension({
|
||||
|
||||
|
||||
// =========================================================================
|
||||
// GRUPPE 2: 2. 🔄 Prompt Queue Iterator
|
||||
// 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",
|
||||
id: "LeafFlow.2 - 🔄 Prompt Iterator.01_ClearOnLaunch",
|
||||
name: "Clear State on Launch",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
@@ -288,7 +364,7 @@ app.registerExtension({
|
||||
|
||||
// 2.2 Reset Prompt Iterator Queues Now
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.2. 🔄 Prompt Iterator.02_ResetActiveQueues",
|
||||
id: "LeafFlow.2 - 🔄 Prompt Iterator.02_ResetActiveQueues",
|
||||
name: "Reset Active Queues State",
|
||||
type: "button",
|
||||
defaultValue: "🔄 Reset All Queues",
|
||||
@@ -321,12 +397,12 @@ app.registerExtension({
|
||||
|
||||
|
||||
// =========================================================================
|
||||
// GRUPPE 3: 3. 📋 Prompt Actions
|
||||
// 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",
|
||||
id: "LeafFlow.3 - 📋 Prompt Actions.01_EnableAssetsCopyPromptButton",
|
||||
name: "Show \"Copy Prompt\" Button on Images",
|
||||
type: "boolean",
|
||||
defaultValue: true,
|
||||
@@ -335,21 +411,39 @@ app.registerExtension({
|
||||
|
||||
// 3.2 Show Right-Click "Copy Prompt" Menu Action
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.3. 📋 Prompt Actions.02_EnableContextMenuCopyPrompt",
|
||||
id: "LeafFlow.3 - 📋 Prompt Actions.02_EnableContextMenuCopyPrompt",
|
||||
name: "Show Right-Click \"Copy Prompt\" Menu Action",
|
||||
type: "boolean",
|
||||
defaultValue: true,
|
||||
tooltip: "Adds '📋 Copy Prompt' to node right-click context menus.",
|
||||
});
|
||||
|
||||
// 3.3 Show "Save to Prompt Bookmarks" Button on Images & Context Menu
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.3 - 📋 Prompt Actions.03_EnableSaveToPromptSaver",
|
||||
name: "Show \"Save to Prompt Saver\" Action",
|
||||
type: "boolean",
|
||||
defaultValue: true,
|
||||
tooltip: "Adds 🔖 'Save to Prompt Bookmarks' to image hover overlay bars and node context menus.",
|
||||
});
|
||||
|
||||
// 3.4 Show "Inspect Asset" (Zoom) Button on Image Overlays
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.3 - 📋 Prompt Actions.04_EnableInspectAssetButton",
|
||||
name: "Show \"Inspect Asset\" (Zoom) Button on Images",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
tooltip: "Restores the 🔍 'Inspect asset' (zoom in) button directly on image cards in the Assets pane next to Download, Copy Prompt, and Bookmarks (moved behind the 3-dots menu in newer ComfyUI versions).",
|
||||
});
|
||||
|
||||
|
||||
// =========================================================================
|
||||
// GRUPPE 4: 4. ⏸️ Pause & Resume Controls
|
||||
// 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",
|
||||
id: "LeafFlow.4 - ⏸️ Pause Controls.01_DefaultStateOnLaunch",
|
||||
name: "Default State on Launch",
|
||||
type: "combo",
|
||||
options: ["Paused", "Running"],
|
||||
@@ -366,7 +460,7 @@ app.registerExtension({
|
||||
|
||||
// 4.2 Default Pause Queue Mode on Launch
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.4. ⏸️ Pause Controls.02_DefaultPauseAction",
|
||||
id: "LeafFlow.4 - ⏸️ Pause Controls.02_DefaultPauseAction",
|
||||
name: "Default Pause Action",
|
||||
type: "combo",
|
||||
options: ["Finish Active Prompt", "Instant Resume Node"],
|
||||
@@ -384,7 +478,7 @@ app.registerExtension({
|
||||
|
||||
// 4.3 Enable Pause Queue Toolbar Button
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.4. ⏸️ Pause Controls.03_EnableToolbarButton",
|
||||
id: "LeafFlow.4 - ⏸️ Pause Controls.03_EnableToolbarButton",
|
||||
name: "Enable Top Toolbar Button",
|
||||
type: "boolean",
|
||||
defaultValue: true,
|
||||
@@ -399,7 +493,7 @@ app.registerExtension({
|
||||
|
||||
// 4.4 Toolbar Button Unpaused Color
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.4. ⏸️ Pause Controls.04_ToolbarButtonUnpausedColor",
|
||||
id: "LeafFlow.4 - ⏸️ Pause Controls.04_ToolbarButtonUnpausedColor",
|
||||
name: "Toolbar Button Unpaused Color",
|
||||
type: "text",
|
||||
defaultValue: "#16a34a",
|
||||
@@ -411,7 +505,7 @@ app.registerExtension({
|
||||
|
||||
// 4.5 Toolbar Button Paused Color
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.4. ⏸️ Pause Controls.05_ToolbarButtonPausedColor",
|
||||
id: "LeafFlow.4 - ⏸️ Pause Controls.05_ToolbarButtonPausedColor",
|
||||
name: "Toolbar Button Paused Color",
|
||||
type: "text",
|
||||
defaultValue: "#ea580c",
|
||||
@@ -423,7 +517,7 @@ app.registerExtension({
|
||||
|
||||
// 4.6 Enable System Tray Icon
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.4. ⏸️ Pause Controls.06_EnableTrayIcon",
|
||||
id: "LeafFlow.4 - ⏸️ Pause Controls.06_EnableTrayIcon",
|
||||
name: "Enable System Tray Icon",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
@@ -439,12 +533,12 @@ app.registerExtension({
|
||||
|
||||
|
||||
// =========================================================================
|
||||
// GRUPPE 5: 5. 💾 Persistent Queue (Auto-Recovery)
|
||||
// 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",
|
||||
id: "LeafFlow.5 - 💾 Persistent Queue.01_EnablePersistentQueue",
|
||||
name: "Enable Persistent Queue (Auto-Recovery)",
|
||||
type: "boolean",
|
||||
defaultValue: true,
|
||||
@@ -460,7 +554,7 @@ app.registerExtension({
|
||||
|
||||
// 5.2 Persistent Queue Restored Launch State
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.5. 💾 Persistent Queue.02_RecoveryLaunchState",
|
||||
id: "LeafFlow.5 - 💾 Persistent Queue.02_RecoveryLaunchState",
|
||||
name: "Recovery Launch State",
|
||||
type: "combo",
|
||||
options: ["Match Default", "Force Paused", "Force Running"],
|
||||
@@ -477,12 +571,12 @@ app.registerExtension({
|
||||
|
||||
|
||||
// =========================================================================
|
||||
// GRUPPE 6: 6. 🖼️ Assets & History Restore
|
||||
// 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",
|
||||
id: "LeafFlow.6 - 🖼️ Assets Restore.01_RestoreAssetsOnLaunch",
|
||||
name: "Restore Assets on Launch",
|
||||
type: "boolean",
|
||||
defaultValue: true,
|
||||
@@ -498,7 +592,7 @@ app.registerExtension({
|
||||
|
||||
// 6.2 Restored Assets Count
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.6. 🖼️ Assets Restore.02_RestoredAssetsCount",
|
||||
id: "LeafFlow.6 - 🖼️ Assets Restore.02_RestoredAssetsCount",
|
||||
name: "Restored Assets Count",
|
||||
type: "number",
|
||||
defaultValue: 64,
|
||||
@@ -515,12 +609,12 @@ app.registerExtension({
|
||||
|
||||
|
||||
// =========================================================================
|
||||
// GRUPPE 7: 7. 🩺 Diagnostics & Debug
|
||||
// GRUPPE 7: 7 - 🩺 Diagnostics & Debug
|
||||
// =========================================================================
|
||||
|
||||
// 7.1 Export Debug Profile Button
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.7. 🩺 Diagnostics.01_ExportDebugProfile",
|
||||
id: "LeafFlow.7 - 🩺 Diagnostics.01_ExportDebugProfile",
|
||||
name: "Export Debug Profile",
|
||||
type: "button",
|
||||
defaultValue: "📥 Export Debug Profile",
|
||||
@@ -565,5 +659,18 @@ app.registerExtension({
|
||||
URL.revokeObjectURL(url);
|
||||
})
|
||||
});
|
||||
// =========================================================================
|
||||
// GRUPPE 8: 8 - 🎨 Batch Queue Visuals
|
||||
// =========================================================================
|
||||
|
||||
// 8.1 Enable Batch Queue Grouping Lines
|
||||
app.ui.settings.addSetting({
|
||||
id: "LeafFlow.BatchQueue.Enabled",
|
||||
name: "Show Batch Queue 1D Lines",
|
||||
type: "boolean",
|
||||
defaultValue: true,
|
||||
tooltip: "Renders 1D git-graph style colored lines indicating batch groupings and contiguous segments on queued items."
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+28
-9
@@ -9,22 +9,33 @@ app.registerExtension({
|
||||
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 || [])];
|
||||
const running = queue.queue_running || queue.Running || [];
|
||||
const pending = queue.queue_pending || queue.Pending || [];
|
||||
const allItems = [...running, ...pending];
|
||||
let patched = false;
|
||||
for (const item of allItems) {
|
||||
const promptId = item[1];
|
||||
const promptData = item[2];
|
||||
if (promptId && promptData) {
|
||||
const promptExecution = rgthree.SERVICE.getOrMakePrompt(promptId);
|
||||
if (!promptExecution.totalNodes) {
|
||||
if (!promptExecution.totalNodes || !promptExecution.promptApi) {
|
||||
promptExecution.setPrompt({ output: promptData });
|
||||
if (rgthree.SERVICE.promptsMap) {
|
||||
rgthree.SERVICE.promptsMap.set(promptId, promptExecution);
|
||||
}
|
||||
patched = true;
|
||||
}
|
||||
if (running.some(r => r[1] === promptId)) {
|
||||
if (!rgthree.SERVICE.currentExecution || !rgthree.SERVICE.currentExecution.totalNodes) {
|
||||
rgthree.SERVICE.currentExecution = promptExecution;
|
||||
patched = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (patched) {
|
||||
rgthree.SERVICE.dispatchProgressUpdate();
|
||||
console.log("[LeafFlow] Synced recovered queue with rgthree-comfy.");
|
||||
console.log("[LeafFlow] Synced recovered queue with rgthree-comfy progress bar.");
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -42,7 +53,8 @@ app.registerExtension({
|
||||
});
|
||||
|
||||
// Wait for server to sync queue state back to clients, then patch rgthree
|
||||
setTimeout(patchRgthree, 500);
|
||||
setTimeout(patchRgthree, 150);
|
||||
setTimeout(patchRgthree, 600);
|
||||
} catch (e) {
|
||||
console.error("[LeafFlow] Error claiming queue ownership:", e);
|
||||
}
|
||||
@@ -50,16 +62,23 @@ app.registerExtension({
|
||||
};
|
||||
|
||||
// Delay execution until after app & API client ID initialization
|
||||
setTimeout(claimQueueOwnership, 1000);
|
||||
setTimeout(claimQueueOwnership, 500);
|
||||
setTimeout(patchRgthree, 800);
|
||||
|
||||
if (api && api.addEventListener) {
|
||||
api.addEventListener("status", () => {
|
||||
claimQueueOwnership();
|
||||
}, { once: true });
|
||||
setTimeout(patchRgthree, 200);
|
||||
});
|
||||
|
||||
// Also run patch anytime an execution starts just in case it was triggered externally
|
||||
api.addEventListener("execution_start", () => {
|
||||
setTimeout(patchRgthree, 100);
|
||||
api.addEventListener("execution_start", (e) => {
|
||||
setTimeout(patchRgthree, 50);
|
||||
});
|
||||
|
||||
api.addEventListener("executing", (e) => {
|
||||
if (e && e.detail) {
|
||||
patchRgthree();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user