Compare commits

..
105 Commits
Author SHA1 Message Date
KOFiblto a9ee9239f3 Fix: Remove duplicate alias mappings from NODE_DISPLAY_NAME_MAPPINGS so nodes only appear once in menu 2026-08-25 09:55:12 +02:00
KOFiblto 06ddb6540b Feat: Implement deterministic Hash-Indexed Prompt Queue Iterator with live progress display, Reset Counter button, and Open Queue File button 2026-08-25 09:53:01 +02:00
KOFiblto e6ab7910a7 Refactor: Remove BackToPlaceholder and UndoPlaceholder nodes 2026-08-25 09:23:49 +02:00
KOFiblto 3473644b75 Docs: Update AGENTS.md architecture and clean stale references in image_loader.js 2026-08-25 09:17:31 +02:00
KOFiblto a98b8ebb18 Refactor: Remove legacy Favorite Prompts feature, enhance Copy Prompt with settings toggles, update node colors to Leaf Green palette, and add Custom Colors toggle 2026-08-25 09:09:43 +02:00
KOFiblto ff969b8455 Feat: Add delete_image toggle to LoadImageFromFolder, format group titles with dot (1., 2.), and add distinct emerald styling to settings action buttons 2026-08-24 17:54:50 +02:00
KOFiblto bebb9f6e7f Feat: Add Diagnostics & Debug Profile Export endpoint, update CONTRIBUTING.md rule, and update README.md 2026-08-24 00:54:31 +02:00
KOFiblto 5e3d357304 Chore: Update install.py, pyproject.toml version to 2.1.0, and CHANGELOG.md 2026-08-24 00:45:58 +02:00
KOFiblto 49a2e9d8fd Fix: Address codebase audit findings (empty folder check, XML escaping, live iterator listener, centralized get_env_setting, cleanup failed_scrapes wipe) 2026-08-24 00:42:22 +02:00
KOFiblto cc44174547 Feat: Add explicit subcategory group headings, clean option titles, and split Queue Control into 3 dedicated groups (Pause, Persistent Queue, Assets Restore) 2026-08-23 23:42:32 +02:00
KOFiblto 7e950bae86 Fix: Optimize setting IDs with numbered prefixes for ComfyUI V2 Vue Settings UI and preserve legacy settings lookups 2026-08-23 23:37:02 +02:00
KOFiblto 9e011874ea Fix: Ensure get_leafflow_user_dir strictly resolves to ComfyUI/user/default/LeafFlow across all nodes and tray/restore managers 2026-08-22 13:20:25 +02:00
KOFiblto 5225d8ea17 Feat: Restructure Settings menu into 4 logical groups with real styled action buttons and updated defaults 2026-08-22 12:41:06 +02:00
KOFiblto d92d115e96 Refactor: Standardize on AGENTS.md (plural) as authoritative master doc and remove legacy AGENT.md 2026-08-22 12:36:03 +02:00
KOFiblto 59d7d1efa8 Docs: Simplify agent instruction files to clean cross-references and avoid duplication 2026-08-22 12:32:08 +02:00
KOFiblto f8411cad14 Docs: Enforce strict no-direct-push rule, add complete markdown cross-reference index, and add CLAUDE/GEMINI/AGENTS pointers 2026-08-22 12:28:09 +02:00
KOFiblto 2e7674a387 Refactor: Store all user data centrally in ComfyUI/user/default/LeafFlow and document migration SOP in AGENT.md 2026-08-22 12:22:46 +02:00
KOFiblto 88c14feea7 CI: Add piexif and pystray dependencies and mock stubs to test_helper 2026-08-22 10:47:32 +02:00
KOFiblto f4fddec96d CI: Add aiohttp dependency to GitHub Actions test workflow and test_helper stub 2026-08-22 10:41:46 +02:00
KOFiblto 92b06c3f41 Test & CI: Add comprehensive unit test suite covering all nodes and configure GitHub Actions CI workflow 2026-08-22 10:32:03 +02:00
KOFiblto 3e910d8fd9 Refactor: Extract Snapchat bridge into standalone ComfyUI-Snapchat-Bridge package and add LeafFlowTextSplit documentation 2026-08-22 03:24:14 +02:00
KOFiblto e149843077 Feat: Add wait_for_new_message toggle and chat event polling to LeafFlowSnapchatReadMessage node 2026-08-22 02:12:54 +02:00
KOFiblto ccff1bd256 Fix: Anti-bot session retention with ignore_default_args, unified viewport, and accurate www.snapchat.com logout detection 2026-08-22 02:02:46 +02:00
KOFiblto b6f63ab7f8 Fix: Add IS_CHANGED classmethod to LeafFlowSnapchatReadMessage so ComfyUI re-executes on every queue run without cache skipping 2026-08-22 01:57:18 +02:00
KOFiblto 1ce384d2d8 Feat: Add LeafFlowSnapchatReadMessage node to read text messages from recipient chat 2026-08-22 01:28:19 +02:00
KOFiblto a603b8175d Feat: Output rendered image with classic Snapchat text banner on node output IMAGE slot 2026-08-22 00:59:14 +02:00
KOFiblto 7782c50b5c Feat: Implement asynchronous non-blocking dispatch with background worker queue for seamless workflow continuation 2026-08-22 00:57:15 +02:00
KOFiblto c9f55c28c1 Fix: Robust auth_state.json session tracking, clean logout clearing, and Unicode cp1252 print encoding in snapchat_login.py 2026-08-22 00:49:07 +02:00
KOFiblto fac1de762a Feat: Add LeafFlowTextSplit node and integrate advanced debug_screenshots and widget groupings in Snapchat node 2026-08-21 22:29:27 +02:00
KOFiblto 6bbe02eecd Fix: Robust recipient search resolution and exact TYX6O Send button locator for ComfyUI camera snap dispatch 2026-08-21 22:16:13 +02:00
KOFiblto 18d1d80b15 Fix: Isolate Playwright execution in dedicated thread to prevent ComfyUI asyncio event loop collisions 2026-08-21 22:10:15 +02:00
KOFiblto c5aeb01db0 Feat: Unified 3-in-1 Snapchat Dispatcher (text message, pure snap, snap + caption bar) with mirror compensation 2026-08-21 22:02:50 +02:00
KOFiblto 919d7a1f42 Feat: Complete direct chat camera snap flow with selfie-mirror compensation and desktop User-Agent 2026-08-21 21:54:55 +02:00
KOFiblto a185d5091f Fix: Target recipient search input and checkboxes strictly within the Send To modal dialog 2026-08-21 21:50:54 +02:00
KOFiblto b96dab6eb5 Feat: Complete verified end-to-end Snapchat camera snap automation with continuous WebRTC stream, spacebar capture, and clean metadata stripping 2026-08-21 21:45:43 +02:00
KOFiblto 2650192596 Feat: Support both visual display name and account username in Snapchat recipient search 2026-08-21 21:31:19 +02:00
KOFiblto 1fade6be5d Feat: Authenticate Snapchat via ComfyUI Settings modal (Login/Logout) and match reference banner geometry 2026-08-21 21:19:35 +02:00
KOFiblto d319916fb9 Feat: Add dynamic native resolution support (1536x2720) and classic Snapchat caption banner overlay 2026-08-21 21:14:51 +02:00
KOFiblto 628b074d84 Feat: Add snapchat_login.py 1-click Google SSO session authenticator 2026-08-21 18:22:15 +02:00
KOFiblto 8757cbadb6 Feat: Add Snapchat Camera Snap node with Playwright virtual webcam injection for authentic live red snaps 2026-08-21 18:14:07 +02:00
KOFiblto fa8cd0c77b Docs: Add strict branch naming classification and integration SOP to AGENT.md 2026-08-19 00:31:09 +02:00
KOFiblto a3f5ae33f2 Refactor: Rename custom node pack from ComfyUI-FlowControl to ComfyUI-LeafFlow across all nodes, settings, categories, docs, and assets 2026-08-19 00:27:38 +02:00
KOFiblto ebf5b3e61a Fix: Explicit pixel container sizing for Scrollable mode and persist tray icon setting 2026-08-19 00:24:14 +02:00
KOFiblto 4480a17488 Merge branch fix/display-mode-swappable-toggle 2026-08-19 00:20:46 +02:00
KOFiblto 0b1af84172 Merge branch feature/prompt-iterator-and-tray-improvements 2026-08-19 00:20:46 +02:00
KOFiblto ee63ca5ab5 Feature: Include multi-root smart path resolution in nodes/utils.py 2026-08-19 00:20:41 +02:00
KOFiblto 8a3d4d2e48 Fix: Unified swappable display_mode (Scrollable vs Show All) with responsive auto-resizing on both LoRA and Image Loaders 2026-08-18 18:29:57 +02:00
KOFiblto 2d91535777 Fix: Update Live Latent Preview margins to top 70px, bottom 40px 2026-08-17 21:18:50 +02:00
KOFiblto bd9faa4ee6 Fix: Remove destructive canvas widget overwriting in prompt_iterator.js 2026-08-17 20:39:46 +02:00
KOFiblto 08a6563378 Fix: Set safe vertical margins (top 76px, bottom 30px) on preview container 2026-08-17 20:39:41 +02:00
KOFiblto 128f37ac48 Feature: Prompt Iterator privacy reset & state isolation, image cache pruning, and enhanced tray pause actions 2026-08-17 18:00:24 +02:00
KOFiblto abc2fd6ee5 Fix: Resolve PreviewLatentLive 9:16 expansion via absolute containment & deep subgraph sampler detection 2026-08-17 00:28:19 +02:00
Mathias Kornschober b99d14916e Feature/restore assets on launch (#4)
* Feat: Add automatic Assets Pane and History restoration on ComfyUI launch

* Feat: Add auto-install for dependencies, detailed debug logger file, and API debug endpoint

* Fix: Streamline system tray menu to Status and Pause/Resume toggle only, add auto-sync triggers for Media Assets

* Fix: Attach jobId and nodeId user_metadata to registered assets for Vue Media Assets Generated tab filter

* Fix: Remove recursive status event loop, rate-limit restore logs, and prevent loop triggers

* Fix: Purge disk log files and prompt recording, use RFC4122 UUIDs for ComfyUI jobs validation, fix PIL dependency check

* Fix: Populate create_time and execution timestamps in restored history items to satisfy ComfyUI Zod schema
2026-08-17 00:21:22 +02:00
Mathias Kornschober 40e6079c34 Fix/repo audit remediation (#3)
* Fix (Preview Aspect Ratio): Center white outline box and position width label below bottom bar and height label next to left bar

* Refactor: Create central nodes/utils.py module for pretty name parsing and path sanitization

* Refactor: Standardize LoRA loader and text finder options and rename to VisualLoraLoader and TextLoraFinder

* Refactor: Rename VisualImageLoader and add advanced display mode and sorting options

* Refactor: Update class names, parameters, display names, and categories across all nodes

* Docs: Update node reference documentation with standardized names and options

* Fix: Resolve aspect ratio box bounding and prevent aspect ratio lock on preview nodes

* Fix: Render crop handle bracket style for aspect ratio preview node

* Feat: Support sequential and random no-replace selection modes, fix tile alignment, and enforce 1/5th crop handle proportions

* Fix: Dynamic positioning of dimension labels, 5-digit padding, curved crop corners, and render caching

* Fix: Instant snap rendering without transitions, balanced 65px horizontal padding, and unconstrained 1:1 node resizing

* Fix: Adjust min sizes to [100, 100] for PreviewLatentLive and [230, 160] for PreviewAspect

* Fix: Optimize canvas draw performance by bypassing canvas pass when DOM widget is active

* Fix: Detect aspect ratios in prompts with attached trailing punctuation like 16:9.

* Fix: Set Sequential as default selection mode for VisualLoraLoader

* Fix: Re-parse prompt blocks in PromptQueueIterator when cached list is empty

* Fix: Add automatic LRU state key pruning to prevent unlimited growth of state files

* Fix: Synchronize frontend extensions with standardized node names and widget keys

* Fix: Enhance backend path resolution, OS notification safety, and import cleanliness

* Docs: Update .env.example with full setting template and add readme metadata to pyproject.toml

* Feat: Add OS system tray icon with real-time status colors and outside-browser queue controls

* Docs: Add AGENT.md developer and agent standard operating procedure guide
2026-08-16 20:30:46 +02:00
Mathias Kornschober a020c826ad Feature/preview aspect ratio (#2)
* Docs: Update README.md and WALKTHROUGH.md with dual emoji node names

* Fix: Add missing import re to nodes/favorite_prompts.py resolving flake8 CI F821 error

* Feat (Visual LoRA Loader): Add advanced visual sorting options (sort_loras_by, sort_folders_by, folder_position) with lazy image decoding & DocumentFragment DOM batching

* Fix (Visual LoRA Loader): Resolve JavaScript duplicate loop syntax error causing empty grid render & fix wildcard subfolder header mapping

* Feat (Visual LoRA Loader): Add content_alignment advanced option (Left Aligned vs Right Aligned) for side-by-side canvas zooming UX

* Fix (Assets UI): Place Favorites and Copy Prompt buttons inside the white icon group between Zoom in and More options

* Fix (Visual LoRA Loader & Assets Overlay): Restore multi-LoRA load_lora execution resolving broken loras & trailing bracket bug, and enforce full button text visibility in asset hover bar

* Fix (Assets UI): Make Favorites and Copy Prompt icon-only by default and expand text labels smoothly on hover

* Fix (Assets UI): Use fixed square icon-only buttons with native tooltip windows on hover

* Feat: Add Preview Image Size & Aspect Ratio node and fix AspectRatioFinder regex trailing punctuation bug

* Fix (Preview Aspect Ratio): Update UI preview box to sleek dark card with sharp square-edged white outline and smooth node resizing

* Fix: Update preview aspect ratio box to use default node background with subtle squared rounded corners

* Fix: Remove output sockets and show_dimensions toggle from PreviewImageSizeAspectRatio making it a pure visual display node

* Fix (Preview Aspect Ratio): Add HTML DOM custom widget for ComfyUI V2 Vue UI node body rendering
2026-08-10 19:28:03 +02:00
Mathias Kornschober bc62af2c35 Docs: Update README.md and WALKTHROUGH.md with dual emoji node names (#1)
* Docs: Update README.md and WALKTHROUGH.md with dual emoji node names

* Fix: Add missing import re to nodes/favorite_prompts.py resolving flake8 CI F821 error
2026-08-08 19:09:14 +02:00
KOFiblto 7107740f71 Docs: Add CONTRIBUTING.md and GitHub Bug Report Issue Template 2026-08-08 18:58:26 +02:00
KOFiblto 64af37a73e Feat: Add dual emojis to NODE_DISPLAY_NAME_MAPPINGS, multiline text widget & live canvas text mutation to PromptQueueIterator, and place Favorites/Copy Prompt in top white button group 2026-08-08 16:11:46 +02:00
KOFiblto 9a096e73eb Fix (UI & Previews): Move Favorites/Copy Prompt to standard top hover action bar & constrain PreviewLatentLive to exact node dimensions 2026-08-08 01:01:03 +02:00
KOFiblto 7bf9363777 Fix (UI): Remove invasive DOM polling loop applyKeyMasking and eye icon button injection 2026-08-07 23:29:16 +02:00
KOFiblto 445c4500b5 Security: Remove hardcoded TMDB API key fallback and local file system path leak from get_api_keys 2026-08-07 23:27:41 +02:00
KOFiblto af3606677f Settings: Add API key whitespace trimming, user-friendly pause mode labels, enhanced tooltips, and toolbar color reset buttons 2026-08-07 23:20:35 +02:00
KOFiblto 5b52d88daa Docs: Clean README.md by removing internal evaluation section and keeping user-facing tabs 2026-08-07 23:18:17 +02:00
KOFiblto 455b122e05 Docs: Update README.md with closed-by-default details tabs for all 15 nodes & ComfyUI settings evaluation 2026-08-07 23:16:09 +02:00
KOFiblto 5936cda2e7 Fixes (Step 9): Add directory exception guard to LoadRecentOutputs 2026-08-07 23:14:04 +02:00
KOFiblto e9ca2935e6 Fixes (Step 7): Support decimal aspect ratio bounds in AspectRatioFinder 2026-08-07 23:13:56 +02:00
KOFiblto de1d23911b Fixes (Step 5): Scope PromptQueueIterator cache by unique_id & set advanced=True on MultiTextReplacer options 2026-08-07 23:13:43 +02:00
KOFiblto 4569fbeb98 Fixes (Step 4): Sanitize subcategory input in favorite_prompts.py to prevent illegal OS character errors 2026-08-07 23:13:34 +02:00
KOFiblto 7601b266ca Fixes (Step 2): Rename UndoPlaceholder to BackToPlaceholder with updated description & backward-compatibility alias 2026-08-07 23:13:24 +02:00
KOFiblto 71f5edb5bb Fixes (Step 1 & 3): Remove legacy V1 FolderLoraLoaderVisualPretty node & mark output_name/display_mode as advanced=True 2026-08-07 23:13:14 +02:00
KOFiblto 102b0882e6 v2.1.0-beta.34: Add MultiTextReplacer node (Multi Text Replacer) & move Assets Pane buttons into 3-dots submenu 2026-08-07 22:59:40 +02:00
KOFiblto 58d4925c28 v2.1.0-beta.33: Add PromptQueueIterator node (Prompt Queue Iterator) with persistent queue state & customizable separators 2026-08-07 22:39:46 +02:00
KOFiblto 22a5d248c9 v2.1.0-beta.32: Add LoraTextFinder node (Text LoRA Finder) & restore aspect_ratio output to AspectRatioFinder 2026-08-06 00:56:00 +02:00
KOFiblto 70ffb2a661 v2.1.0-beta.31: Update Assets Pane copy prompt button to use 📋 copy icon 2026-08-04 01:35:28 +02:00
KOFiblto 9ac769fa22 v2.1.0-beta.30: Make text input optional on AspectRatioFinder and use parse_pretty_name for TMDB scraping 2026-08-03 18:08:04 +02:00
KOFiblto bd6bbb7b6f v2.1.0-beta.29: Update AspectRatioFinder to force text input, outputs width & height, add min_mp, max_mp and advanced multiple_of inputs; Fix TMDB person search by cleaning version/folder noise and adding fallback terms 2026-08-03 18:01:38 +02:00
KOFiblto f0ed280d6d v2.1.0-beta.28: Add EnableLoraUsage setting, add 4 >100x usage special effects, fix TMDB Bearer token & API key scraping, make Live Latent Preview fit inside node bounds 2026-08-03 17:28:55 +02:00
KOFiblto 47a9caa8bd v2.1.0-beta.27: Fix color picker loop, add AspectRatioFinder node, fix hidden widget line in LoRA loader, update output_name and lora_name output labels 2026-08-03 17:21:25 +02:00
KOFiblto 38fb60ac78 v2.1.0-beta.26: Remove Load Workflow button, fix Copy Prompt emoji 2026-08-02 21:40:10 +02:00
KOFiblto 44a07af659 v2.1.0-beta.25: Fix FavoritePromptLoader mask slot output, add Copy Prompt and Load Workflow buttons to assets pane 2026-08-02 21:35:17 +02:00
KOFiblto 7259978dc8 v2.1.0-beta.24: Fix Assets pane button disappearing due to Vue hover overlay conflict 2026-08-01 04:18:14 +02:00
KOFiblto 7260d2d04a v2.1.0-beta.23: Fix Assets Pane hover menu injection for Star button 2026-08-01 01:29:50 +02:00
KOFiblto 748b3e1636 v2.1.0-beta.22: Fix TMDB API Key, add UI setting for favorites, and fix popup crash 2026-08-01 01:25:07 +02:00
KOFiblto 0b88844001 v2.1.0-beta.21: Fix Favorites Loader directory parsing and missing folder_path 2026-08-01 01:01:24 +02:00
KOFiblto 95ff2ed97a v2.1.0-beta.20: Real Category Dropdown for Save Popup & Folder structure UI for Load Favorites 2026-08-01 00:58:12 +02:00
KOFiblto 0eef846640 v2.1.0-beta.19: Improve save favorite popup UI and fix FavoritePromptLoader category combo 2026-07-31 23:36:34 +02:00
KOFiblto 3476a41c24 v2.1.0-beta.18: Fix Fav Loader grid display and add smart save popup modal 2026-07-31 23:15:29 +02:00
KOFiblto 2c057ca539 v2.1.0-beta.17: Update Fav Prompt icons to Leaf and inject directly into Assets grid items 2026-07-31 23:04:44 +02:00
KOFiblto 6754b429f3 v2.1.0-beta.16: Add Favorite Prompts saving and loading (Idea 1+2 hybrid) 2026-07-31 12:53:46 +02:00
KOFiblto 86c3a9aba7 v2.1.0-beta.15: Fix PreviewLatentLive getting stuck on Auto and detached DOM references 2026-07-31 12:47:41 +02:00
KOFiblto 96bdc8bb38 Fix GitHub Actions release permissions (add contents: write) 2026-07-31 03:23:42 +02:00
KOFiblto 083f20a36f v2.1.0-beta.14: Fix parse_pretty_name camelCase splitting to preserve acronyms 2026-07-29 17:03:41 +02:00
KOFiblto 09273849c5 v2.1.0-beta.13: Add folder path formatting documentation 2026-07-29 16:46:48 +02:00
KOFiblto f47619b37a v2.1.0-beta.12: Fix wildcard folder path handling across all Loaders 2026-07-29 16:46:16 +02:00
KOFiblto 46e5ffb69c v2.1.0-beta.11: Fix LoadRecentOutputs empty string and wildcard handling 2026-07-29 16:44:42 +02:00
KOFiblto b62a643920 v2.1.0-beta.10: Add output_name toggle to Visual Lora Loader and update Walkthrough 2026-07-27 12:57:53 +02:00
KOFiblto 6ee057e3d8 v2.1.0-beta.9: Fix canvas overlays blocking clicks on hidden widgets in V2 2026-07-27 12:53:58 +02:00
KOFiblto 782e0709e2 v2.1.0-beta.8: Support rgthree-comfy progress bar sync for recovered queues 2026-07-27 12:41:06 +02:00
KOFiblto 4692503594 v2.1.0-beta.7: Fix V2 entrapment bug in hidden LiteGraph DOM 2026-07-27 12:22:10 +02:00
KOFiblto dcab859972 v2.1.0-beta.6: Make V2 injection completely immune to UI class changes 2026-07-27 12:18:36 +02:00
KOFiblto 056604ebdb v2.1.0-beta.5: Fix PersistentQueue to wait for first client connection to assign restored queue ownership 2026-07-27 11:16:15 +02:00
KOFiblto dd7c8e79eb v2.1.0-beta.4: Fix V2 startup detection edge-case for Vue frontend 2026-07-27 11:13:11 +02:00
KOFiblto 9d2c335f2b v2.1.0-beta.3: Fix Vue V2 UI frontend detection bug on startup 2026-07-27 10:44:29 +02:00
KOFiblto a0a404e676 v2.1.0-beta.2: Fix capitalization, add GitHub Walkthrough, double check OS errors and UI 2026-07-27 02:03:32 +02:00
KOFiblto 0592906b1c v2.1.0-beta.1: Cross-platform OS notifications and LiteGraph UI support 2026-07-27 02:00:13 +02:00
61 changed files with 5960 additions and 1046 deletions
+17 -3
View File
@@ -1,8 +1,22 @@
# ComfyUI-FlowControl Environment Configuration Example
# Copy or rename this file to .env to configure custom API keys.
# ComfyUI-LeafFlow Environment Configuration Example
# Copy or rename this file to .env to configure custom API keys and default behaviors.
# Optional Civitai API Key for auto-downloading LoRA preview thumbnails
CIVITAI_API_KEY=
# Optional TMDB API Key for celebrity image preview search
# Optional TMDB API Key / Read Access Token for celebrity image preview search
TMDB_API_KEY=
# Feature toggles
ENABLE_CIVITAI_SCRAPING=true
ENABLE_TMDB_SCRAPING=true
ENABLE_LORA_USAGE=true
ENABLE_PERSISTENT_QUEUE=true
ENABLE_TRAY_ICON=false
ENABLE_ASSETS_RESTORE=true
RESTORE_ASSETS_COUNT=64
# Launch defaults
DEFAULT_PAUSE_STATE=Paused
DEFAULT_PAUSE_MODE=after_finish
PERSISTENT_QUEUE_RESTORED_STATE=Match Default
+18 -17
View File
@@ -1,29 +1,30 @@
---
name: Bug report
about: Create a report to help us improve ComfyUI-FlowControl
name: 🐛 Bug Report
about: Create a report to help us fix a bug or unexpected behavior
title: '[BUG] '
labels: 'bug'
labels: bug
assignees: ''
---
**Describe the bug**
**Describe the Bug**
A clear and concise description of what the bug is.
**ComfyUI & System Information**
- ComfyUI Version:
- Frontend Version (V1 / V2):
- OS (Windows / Linux / macOS):
- Browser:
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
1. Load node '...'
2. Connect input '...'
3. Queue prompt
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Expected Behavior**
A clear description of what you expected to happen.
**Console Logs / Screenshots**
If applicable, paste terminal console logs or screenshots to help explain your problem.
**Environment (please complete the following information):**
- ComfyUI Version / UI Version: [e.g. Nodes 2.0 Vue UI or LiteGraph V1]
- OS: [e.g. Windows 11, Ubuntu 22.04, macOS Sonoma]
- Browser: [e.g. Chrome, Firefox, Edge]
**Console Logs / Traceback**
```text
Paste full terminal error traceback here (if applicable)
```
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: Feature request
about: Suggest an idea for ComfyUI-FlowControl
about: Suggest an idea for ComfyUI-LeafFlow
title: '[FEAT] '
labels: 'enhancement'
assignees: ''
+2
View File
@@ -8,6 +8,8 @@ on:
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Create GitHub Release
+35
View File
@@ -0,0 +1,35 @@
name: Run Unit Tests
on:
push:
branches: [ "**" ]
pull_request:
branches: [ "**" ]
jobs:
test:
name: Test on Python ${{ matrix.python-version }} (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
python-version: ['3.10', '3.11', '3.12']
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install Python Dependencies
run: |
python -m pip install --upgrade pip
pip install Pillow torch numpy aiohttp piexif
- name: Execute LeafFlow Unit Test Suite
run: |
python tests/run_tests.py
+4
View File
@@ -13,6 +13,10 @@ persistent_queue.json.tmp
lora_usage.json
failed_scrapes.json
image_prompts_cache.json
assets_restore_debug.json
assets_restore_debug.log
user/
*.json.tmp
# IDE and OS metadata
.idea/
+268
View File
@@ -0,0 +1,268 @@
# 🤖 Agent & Developer Standard Operating Procedure (SOP)
## `ComfyUI-LeafFlow` Contributor & Automation Guide
This guide defines the engineering checklist and architecture standards for all AI agents (Antigravity, Claude, Gemini, Cursor, Copilot) and human developers contributing to **`ComfyUI-LeafFlow`**.
For Git workflow, branch protection, and Pull Request guidelines, see **[`CONTRIBUTING.md`](./CONTRIBUTING.md)**.
---
## 📚 Repository Markdown Index
| File | Purpose & Contents | Link |
| :--- | :--- | :--- |
| **`AGENTS.md`** | Authoritative developer & agent standard operating procedure, engineering checklists, architecture rules. | [AGENTS.md](file:///d:/GenAI/ComfyUI/ComfyUI/custom_nodes/ComfyUI-LeafFlow/AGENTS.md) |
| **`CONTRIBUTING.md`** | Community contribution guidelines, strict branch rules, PR submission workflow. | [CONTRIBUTING.md](file:///d:/GenAI/ComfyUI/ComfyUI/custom_nodes/ComfyUI-LeafFlow/CONTRIBUTING.md) |
| **`README.md`** | Main project homepage, installation guide, complete node catalog with parameter tables and UI settings guide. | [README.md](file:///d:/GenAI/ComfyUI/ComfyUI/custom_nodes/ComfyUI-LeafFlow/README.md) |
| **`WALKTHROUGH.md`** | Step-by-step user tutorials, example workflows, visual load guides, and batch prompting patterns. | [WALKTHROUGH.md](file:///d:/GenAI/ComfyUI/ComfyUI/custom_nodes/ComfyUI-LeafFlow/WALKTHROUGH.md) |
| **`CHANGELOG.md`** | Strict Keep-A-Changelog semantic version release history across all updates. | [CHANGELOG.md](file:///d:/GenAI/ComfyUI/ComfyUI/custom_nodes/ComfyUI-LeafFlow/CHANGELOG.md) |
| **`SECURITY.md`** | Security policies, supported versions, and private vulnerability disclosure instructions. | [SECURITY.md](file:///d:/GenAI/ComfyUI/ComfyUI/custom_nodes/ComfyUI-LeafFlow/SECURITY.md) |
| **`CLAUDE.md`** | AI agent instruction pointer. | [CLAUDE.md](file:///d:/GenAI/ComfyUI/ComfyUI/custom_nodes/ComfyUI-LeafFlow/CLAUDE.md) |
| **`GEMINI.md`** | AI agent instruction pointer. | [GEMINI.md](file:///d:/GenAI/ComfyUI/ComfyUI/custom_nodes/ComfyUI-LeafFlow/GEMINI.md) |
| **`AGENTS.md`** | AI agent instruction pointer. | [AGENTS.md](file:///d:/GenAI/ComfyUI/ComfyUI/custom_nodes/ComfyUI-LeafFlow/AGENTS.md) |
| **`bug_report.md`** | GitHub issue template for reporting reproducible defects. | [bug_report.md](file:///d:/GenAI/ComfyUI/ComfyUI/custom_nodes/ComfyUI-LeafFlow/.github/ISSUE_TEMPLATE/bug_report.md) |
| **`feature_request.md`** | GitHub issue template for proposing new nodes or UI features. | [feature_request.md](file:///d:/GenAI/ComfyUI/ComfyUI/custom_nodes/ComfyUI-LeafFlow/.github/ISSUE_TEMPLATE/feature_request.md) |
---
## 📑 Table of Contents
1. [Architecture & Directory Layout](#1-architecture--directory-layout)
2. [Checklist: Adding a New Node](#2-checklist-adding-a-new-node)
3. [Checklist: Adding a New Setting / Feature Flag](#3-checklist-adding-a-new-setting--feature-flag)
4. [Checklist: Updating Dependencies](#4-checklist-updating-dependencies)
5. [Cross-Platform & Safe Coding Standards](#5-cross-platform--safe-coding-standards)
6. [User Data & Storage Architecture (`ComfyUI/user/default/LeafFlow/`)](#7-user-data--storage-architecture-comfyuiuserdefaultleafflow)
7. [Git, Branching & Commit Conventions](#6-git-branching--commit-conventions)
---
## 1. Architecture & Directory Layout
```
ComfyUI-LeafFlow/
├── __init__.py # Node registration (NODE_CLASS_MAPPINGS), web endpoints (/leafflow/settings)
├── install.py # Dependency installer script for ComfyUI Manager
├── pyproject.toml # Package metadata and pip dependencies
├── .env.example # Template of all supported environment variables
├── .gitignore # Exclusion rules for caches, states, and credentials
├── nodes/ # Python backend node implementations
│ ├── aspect_ratio.py # Text & preview aspect ratio calculation nodes
│ ├── auto_watcher.py # Automated folder watch image loaders
│ ├── decision_node.py # Inline pause/gate decision node & notifications
│ ├── text_split.py # Forward/backward & regex text splitter node
│ ├── image_loader.py # Visual image browser & EXIF/PNG metadata parser
│ ├── load_recent.py # Recent outputs loader node
│ ├── lora_finder.py # Text-based prompt LoRA scanner & patcher
│ ├── lora_loader.py # Folder & visual LoRA loaders + Civitai/TMDB scrapers
│ ├── preview_latent.py # Live latent WebSocket preview node
│ ├── prompt_iterator.py # Multiline prompt queue batch popper
│ ├── queue_control.py # Pause Queue, Persistent Queue hooks, and server routes
│ ├── text_replacer.py # Multiline regex & list text replacer node
│ ├── tray_icon.py # OS system tray icon manager & status indicator
│ └── utils.py # Shared path sanitization, string parsing, and formatting helpers
├── web/ # Frontend extensions & static assets
│ ├── leafflow_colors.js # Node theme colors (LiteGraph canvas & Vue V2)
│ ├── leafflow_settings.js# Native ComfyUI Settings panel registrations
│ ├── pause_queue.js # Top toolbar Pause/Continue button group & dropdown
│ ├── pause_queue.css # Toolbar styling
│ ├── persistent_queue.js # Queue crash-recovery client ownership claimer
│ ├── preview_node.js # Live latent canvas preview widget
│ └── js/ # Node-specific UI widgets (visual pickers, buttons, aspect preview)
│ ├── decision_node.js
│ ├── copy_prompt.js
│ ├── folder_lora_loader.js
│ ├── image_loader.js
│ ├── preview_aspect_ratio.js
│ ├── preview_manager.js
│ └── prompt_iterator.js
├── README.md # Main documentation & complete node reference
├── WALKTHROUGH.md # End-user guide for workflows and features
└── CHANGELOG.md # Version release history
```
---
## 2. Checklist: Adding a New Node
When implementing a new custom node, you **must complete all 5 steps**:
### Step 2.1: Python Backend (`nodes/<module>.py`)
- [ ] Define standard class attributes:
- `INPUT_TYPES(cls)`: Return dict with `required`, `optional`, and `hidden` widgets. Always specify defaults, min/max/step for numbers.
- `RETURN_TYPES`: Tuple of output types (e.g. `("IMAGE", "STRING")`).
- `RETURN_NAMES`: Tuple of human-readable output labels.
- `FUNCTION`: Exact method name to execute.
- `CATEGORY`: Categorized under `"🍃 LeafFlow/Loaders"`, `"🍃 LeafFlow/Utils"`, `"🍃 LeafFlow/Automation"`, or `"🍃 LeafFlow/Previews"`.
- `DESCRIPTION`: Clear multi-line string explaining node functionality and parameters.
- [ ] Use `sanitize_folder_path(folder_input, default_dir)` from `nodes.utils` for any path/directory arguments (handles Windows/Linux slashes, wildcards `*`, and relative paths cleanly).
- [ ] Ensure dummy tensors are valid 4D float32 batches `torch.zeros((1, 64, 64, 3), dtype=torch.float32)` for empty image states.
- [ ] Use `comfy.model_management.throw_exception_if_processing_interrupted()` inside long loops.
### Step 2.2: Export & Map in `__init__.py`
- [ ] Import the node class into [`__init__.py`](file:///d:/GenAI/ComfyUI/ComfyUI/custom_nodes/ComfyUI-LeafFlow/__init__.py).
- [ ] Register in `NODE_CLASS_MAPPINGS`:
```python
NODE_CLASS_MAPPINGS = {
"YourNewNodeName": YourNewNodeClass,
# ...
}
```
- [ ] Register in `NODE_DISPLAY_NAME_MAPPINGS` with leaf prefix and emoji:
```python
NODE_DISPLAY_NAME_MAPPINGS = {
"YourNewNodeName": "🍃 🏷️ Your Display Name",
# ...
}
```
- [ ] If renaming or replacing an existing node, **always preserve the old name in `NODE_CLASS_MAPPINGS` as an alias** to avoid breaking user workflows!
### Step 2.3: Frontend Extensions & Colors (`web/`)
- [ ] If the node needs custom canvas/DOM widgets, implement the extension under `web/js/<feature>.js`.
- [ ] In `nodeCreated(node)` or `beforeRegisterNodeDef(nodeType, nodeData)`, **always check both `node.comfyClass` and `node.type`**, and support aliases:
```javascript
const isTargetNode = ["YourNodeName", "YourOldAlias"].includes(node.comfyClass) ||
["YourNodeName", "YourOldAlias"].includes(node.type);
```
- [ ] Register node theme colors in [`web/leafflow_colors.js`](file:///d:/GenAI/ComfyUI/ComfyUI/custom_nodes/ComfyUI-LeafFlow/web/leafflow_colors.js):
- Loaders: Emerald Green (`color: "#059669"`, `bgcolor: "#047857"`)
- Automation & Utils: Amber (`color: "#d97706"`, `bgcolor: "#b45309"`)
- Previews & Decisions: Violet (`color: "#7c3aed"`, `bgcolor: "#6d28d9"`)
### Step 2.4: Documentation (`README.md` & `WALKTHROUGH.md`)
- [ ] Add a collapsible `<details>` section in [`README.md`](file:///d:/GenAI/ComfyUI/ComfyUI/custom_nodes/ComfyUI-LeafFlow/README.md) under `## 📦 Individual Nodes Reference`:
- Node summary tag: `<summary><b>🍃 🏷️ Display Name</b> (<code>ClassName</code>)</summary>`
- `#### Overview`
- `#### Inputs & Widgets`
- `#### Outputs`
- [ ] If the node introduces a new workflow pattern, add a section in [`WALKTHROUGH.md`](file:///d:/GenAI/ComfyUI/ComfyUI/custom_nodes/ComfyUI-LeafFlow/WALKTHROUGH.md).
### Step 2.5: Changelog
- [ ] Add entry under `### Added` in [`CHANGELOG.md`](file:///d:/GenAI/ComfyUI/ComfyUI/custom_nodes/ComfyUI-LeafFlow/CHANGELOG.md).
---
## 3. Checklist: Adding a New Setting / Feature Flag
When introducing a configurable option or feature toggle:
### Step 3.1: `.env.example`
- [ ] Add the variable with default value and explanation in [`.env.example`](file:///d:/GenAI/ComfyUI/ComfyUI/custom_nodes/ComfyUI-LeafFlow/.env.example):
```ini
# Brief description of what this setting controls
YOUR_NEW_SETTING=true
```
### Step 3.2: Backend Settings Synchronization (`__init__.py`)
- [ ] In `get_settings()` route: parse and return the setting.
- [ ] In `save_settings()` route: read value from incoming JSON, persist to `.env` file via `new_lines`, update `os.environ`, and trigger any dynamic runtime manager updates.
### Step 3.3: Frontend Settings Registration (`web/leafflow_settings.js`)
- [ ] Register via `app.ui.settings.addSetting({...})`:
```javascript
app.ui.settings.addSetting({
id: "LeafFlow.YourSettingName",
name: "🍃 LeafFlow: Your Setting Display Name",
type: "boolean" /* or "text", "combo" */,
defaultValue: false,
tooltip: "Descriptive tooltip explaining user impact.",
onChange(value) {
api.fetchApi("/leafflow/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ your_setting_key: value })
}).catch(() => {});
}
});
```
### Step 3.4: Documentation
- [ ] Update `## ⚙️ ComfyUI Settings Menu Reference` in [`README.md`](file:///d:/GenAI/ComfyUI/ComfyUI/custom_nodes/ComfyUI-LeafFlow/README.md).
- [ ] Update `## 4. Settings & Configuration` in [`WALKTHROUGH.md`](file:///d:/GenAI/ComfyUI/ComfyUI/custom_nodes/ComfyUI-LeafFlow/WALKTHROUGH.md).
---
## 4. Checklist: Updating Dependencies
If a feature requires a new Python package:
- [ ] Add package to `dependencies` in [`pyproject.toml`](file:///d:/GenAI/ComfyUI/ComfyUI/custom_nodes/ComfyUI-LeafFlow/pyproject.toml).
- [ ] Add package to `dependencies` list in [`install.py`](file:///d:/GenAI/ComfyUI/ComfyUI/custom_nodes/ComfyUI-LeafFlow/install.py).
- [ ] Check imports gracefully handle missing optional libraries (e.g. `try... except ImportError`).
---
## 5. Cross-Platform & Safe Coding Standards
1. **Terminal Console Safety (Windows `cp1252` encoding)**:
- **Never include raw unicode emojis in backend `print()` calls** (e.g. write `print("[LeafFlow] Started")` instead of `print("[LeafFlow] 🍃 ...")`).
- Windows consoles running on default codepages will raise fatal `UnicodeEncodeError: 'charmap' codec can't encode character` if raw emoji bytes hit standard output.
2. **Path Sanitization**:
- Never assume `/` or `\` separators; always use `os.path.join()`, `os.path.normpath()`, or `sanitize_folder_path()`.
3. **OS Platform Detection**:
- Use `sys.platform` (`'win32'`, `'darwin'`, `'linux'`) rather than POSIX-only calls like `os.uname()`.
4. **Non-Blocking & Daemon Threads**:
- All background threads (scraping, tray icons, persistent queue sync) must set `daemon=True` so they do not block ComfyUI shutdown.
5. **Bytecode Validation**:
- Before committing, always run:
```bash
python -m compileall .
```
Ensure 0 syntax errors across all modules.
---
## 7. User Data & Storage Architecture (`ComfyUI/user/default/LeafFlow/`)
All user-specific runtime states, API credentials, usage counters, and temporary caches **MUST** be stored centrally in `ComfyUI/user/default/LeafFlow/` using the helper `get_leafflow_user_dir()` in `nodes/utils.py`:
```python
from .utils import get_leafflow_user_dir
USER_DIR = get_leafflow_user_dir()
STATE_FILE = os.path.join(USER_DIR, "your_state.json")
```
### Stored User Files:
- `ComfyUI/user/default/LeafFlow/.env` — API keys and persistent settings
- `ComfyUI/user/default/LeafFlow/lora_usage.json` — LoRA selection counts and usage badges
- `ComfyUI/user/default/LeafFlow/image_prompts_cache.json` — Cached positive prompts extracted from images
- `ComfyUI/user/default/LeafFlow/failed_scrapes.json` — Failed Civitai/TMDB scraping attempts cache
- `ComfyUI/user/default/LeafFlow/prompt_iterator_state.json` — Current iteration pointers and popped prompt queues
- `ComfyUI/user/default/LeafFlow/persistent_queue.json` — Real-time queue crash recovery state
### Future Migration Hooks SOP:
If legacy files need to be migrated in future multi-user releases or version upgrades:
1. In `nodes/utils.py`, define a startup check `migrate_legacy_user_data()`.
2. Inspect `custom_nodes/ComfyUI-LeafFlow/` for legacy files (`.env`, `lora_usage.json`, etc.).
3. Atomically move them to `get_leafflow_user_dir()` using `shutil.move()` or copy + delete.
4. Log a single clean notification `[LeafFlow] 🍃 Migrated legacy user data to ComfyUI/user/default/LeafFlow/`.
---
## 6. Git, Branching & Commit Conventions
### 6.1 Strict Branch Naming Classification
Never create misleading or overly narrow branch names when multiple components, fixes, and features are bundled together!
- **Atomic Feature Branches** (`feature/<feature-name>`):
- Used **ONLY** when implementing a single, isolated new feature or node.
- Examples: `feature/prompt-iterator-privacy`, `feature/visual-loaders-pathing`
- **Atomic Bug Fix Branches** (`fix/<bug-name>`):
- Used **ONLY** for targeted bug fixes.
- Examples: `fix/preview-latent-insets`, `fix/tray-icon-persistence`
- **Comprehensive Integration & Release Branches** (`release/vX.Y.Z-<summary>` or `dev/<summary>`):
- **MANDATORY**: Whenever bundling multiple features, fixes, rebrands, or architectural updates into a combined testing or release candidate, use a `release/...` or `dev/...` branch name.
- **STRICTLY PROHIBITED**: Never disguise a multi-feature/fix release bundle under a single isolated `feature/<narrow-name>`.
- Examples: `release/v2.2.0-leafflow-update`, `dev/v2.2.0-full-integration`
### 6.2 Excluded Files & Privacy
Never commit sensitive keys, runtime states, or caches:
- `.env`, `.env.local`
- `persistent_queue.json`, `lora_usage.json`, `failed_scrapes.json`, `image_prompts_cache.json`
- `user/` directory
### 6.3 Commit Messages
Use atomic, descriptive English commit headers:
- `Feat: ...` (New features or nodes)
- `Fix: ...` (Bug fixes, UI sync repairs)
- `Docs: ...` (Documentation, README, Walkthrough updates)
- `Refactor: ...` (Code restructuring or rebrands)
- `Release: ...` (Consolidated version releases)
+36 -1
View File
@@ -1,10 +1,45 @@
# Changelog
All notable changes to `ComfyUI-FlowControl` will be documented in this file.
All notable changes to `ComfyUI-LeafFlow` will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.1.0] - 2026-08-24
### Added
- **Centralized ComfyUI User Directory (`ComfyUI/user/default/LeafFlow/`)**:
- All runtime JSON state (`lora_usage.json`, `lora_loader_state.json`, `image_prompts_cache.json`, `failed_scrapes.json`, `prompt_iterator_state.json`, `persistent_queue.json`, `.env`) now strictly lives inside the ComfyUI user folder rather than polluting the node directory.
- **Structured ComfyUI V2 Settings Menu**:
- Restructured settings into 6 explicit subcategories with clear titles, info tooltips, and real styled action buttons (`🗑️ Reset Scrapes Cache`, `🔄 Reset All Queues`).
- **Prompt Queue Iterator Live Sync**:
- Added interactive Reset widget button directly onto the canvas node and WebSocket live synchronization for multiline prompt text as items are popped.
- **Cross-Platform Matrix CI**:
- Automated GitHub Actions unit testing across Python 3.10, 3.11, 3.12 on both Ubuntu and Windows with 56 automated unit tests.
### Fixed
- Fixed empty folder matching edge case in `BackToPlaceholder` (`undo_placeholder.py`).
- Fixed XML entity escaping for native Windows Toast Notifications in `LeafFlowDecision`.
- Removed accidental scrape cache reset during minor settings updates in `__init__.py`.
- Removed obsolete `playwright` dependency from `pyproject.toml` and `install.py`.
---
## [2.0.0] - 2026-08-20
### Added
- **🍃 ⭐ Favorite Prompts**: Preview and loader system for saved favorite prompts and generation presets with subfolder categorizing.
- **🍃 📐 Text Aspect Ratio Finder & Preview**: Dynamic aspect ratio parser (e.g. `16:9`, `2:3`) with resolution calculation and visual aspect box preview.
- **🍃 🔎 Text LoRA Finder & Loader**: Automatic text prompt scanning for LoRA names with model/clip loading and pretty name formatting.
- **🍃 🔄 Prompt Queue Iterator**: Multiline batch prompt queue iterator with Pop, Cycle, and Random modes.
- **🍃 🔤 Multi Text Replacer**: High-performance multi-target find & replace node with protection against recursive replacement loops.
- **🍃 ✂️ Text Split**: Split strings into two outputs using literal delimiters or regex patterns (forward/backward).
- **🍃 ⏸️ LeafFlow Decision**: Interactive mid-workflow pause node with UI buttons (Continue / Cancel / Stop) and desktop notifications.
- **🍃 🔔 System Tray Integration**: OS notification area tray icon with real-time queue status colors and outside-browser queue controls.
- **🍃 🖼️ Assets & History Restore**: Automatic restoration of recent generations into the ComfyUI Assets / History pane on startup.
---
## [1.0.0] - 2026-07-24
### Added
+5
View File
@@ -0,0 +1,5 @@
# AI Agent Guidelines
All AI agents (Claude, Gemini, Antigravity, Cursor, Copilot) contributing to this repository must follow the instructions in:
- [`AGENTS.md`](./AGENTS.md) — Standard Operating Procedure & Architecture
- [`CONTRIBUTING.md`](./CONTRIBUTING.md) — Git Workflow & Contribution Rules
+77 -14
View File
@@ -1,19 +1,82 @@
# Contributing to ComfyUI-FlowControl
# 🤝 Contributing to ComfyUI-LeafFlow
Thank you for your interest in contributing to ComfyUI-FlowControl!
Thank you for your interest in contributing to **`ComfyUI-LeafFlow`**! We welcome bug reports, feature suggestions, documentation updates, and pull requests from the community.
## Development Workflow
Please take a moment to review these guidelines before submitting an issue or pull request.
1. Fork the repository on GitHub.
2. Clone your fork locally into `ComfyUI/custom_nodes/ComfyUI-FlowControl`.
3. Create a feature branch: `git checkout -b feature/my-feature`.
4. Make your changes and test locally in ComfyUI.
5. Ensure code is formatted cleanly and free of hardcoded personal paths or credentials.
6. Commit your changes: `git commit -m "Add new feature"`.
7. Push to your branch and submit a Pull Request.
---
## Code Guidelines
## 🐛 Reporting Issues & Bugs
- **Python**: Follow PEP 8 style guidelines. Ensure thread operations use atomic writes (`.tmp` + `os.replace`).
- **JavaScript**: Follow ES Module conventions. Use `import { app } from "../../scripts/app.js"`.
- **Security**: Never commit personal paths, hardcoded keys, or sensitive `.env` files.
Before opening a new issue:
1. **Check Existing Issues**: Search the [Issue Tracker](https://github.com/KOFiblto/ComfyUI-LeafFlow/issues) to see if your bug or feature request has already been reported.
2. **Include Reproduction Steps**: Provide a clear step-by-step description of how to reproduce the bug.
3. **Include Logs & Environment Details**:
- **ComfyUI Version** (V2 Vue UI or Classic V1 LiteGraph)
- **OS** (Windows, Linux, macOS)
- **Browser** (Chrome, Firefox, Edge)
- Full Python console log traceback (if execution errored).
---
## 🛠️ Contribution Workflow (Feature Branches & PRs)
### Branch and Pull Request Policy
Direct pushes to the `main` branch are restricted. All changes, bug fixes, and new features must be developed on a dedicated feature or fix branch and merged via Pull Request.
All development follows a **Feature Branch $\rightarrow$ Pull Request** workflow:
```
1. Fork/Branch Repository --> 2. Create Feature Branch --> 3. Commit Changes --> 4. Open Pull Request
```
### Step 1: Fork and Clone
Fork the repository on GitHub, then clone your fork locally:
```bash
git clone https://github.com/YOUR_USERNAME/ComfyUI-LeafFlow.git
cd ComfyUI-LeafFlow
```
### Step 2: Create a Feature Branch
Create a descriptive branch for your change:
```bash
git checkout -b feature/short-description
# Example: git checkout -b feature/live-preview-aspect-ratio
```
### Step 3: Coding & Style Guidelines
- **Python Style:** Match existing repository conventions. Keep imports at module scope. Do not add unnecessary `try/except` swallow blocks.
- **No Internet Requests in Core Execution:** All node execution must run locally. Outbound network requests (e.g. Civitai/TMDB thumbnail scraping) must strictly require user consent via settings.
- **No Hardcoded Keys or Absolute System Paths:** Do not commit personal system paths (`C:\Users\...` or `D:\...`), API keys, or private tokens.
### Step 4: Commit Your Changes
Use concise, imperative subject lines for commits:
- `Feat: ...` (New node or feature)
- `Fix: ...` (Bug fix)
- `Docs: ...` (Documentation update)
- `Refactor: ...` (Code cleanup or restructuring)
Example:
```bash
git add .
git commit -m "Fix: Resolve aspect ratio bounding box in Live Latent Preview"
```
### Step 5: Push & Submit a Pull Request
Push your branch to your fork on GitHub and open a Pull Request:
```bash
git push origin feature/short-description
```
1. Open [Pull Requests](https://github.com/KOFiblto/ComfyUI-LeafFlow/pulls) $\rightarrow$ Click **New pull request**.
2. Select `base: main` $\leftarrow$ `compare: feature/short-description`.
3. Provide a brief summary of what changed and how you verified your fix.
4. Wait for code review approval and automated status check validation!
---
## 📜 Code of Conduct & Mascot
- Be respectful and constructive when commenting on issues and reviewing pull requests.
- The official mascot of ComfyUI is a very cute anime girl with massive fennec ears, a big fluffy tail, long blonde wavy hair, and blue eyes. Treat her with respect!
Thank you for helping make `ComfyUI-LeafFlow` better for everyone!
+5
View File
@@ -0,0 +1,5 @@
# AI Agent Guidelines
All AI agents (Claude, Gemini, Antigravity, Cursor, Copilot) contributing to this repository must follow the instructions in:
- [`AGENTS.md`](./AGENTS.md) — Standard Operating Procedure & Architecture
- [`CONTRIBUTING.md`](./CONTRIBUTING.md) — Git Workflow & Contribution Rules
+313 -92
View File
@@ -1,106 +1,331 @@
# 🍃 ComfyUI-FlowControl
# 🍃 ComfyUI-LeafFlow
A unified custom node suite for **ComfyUI** featuring real-time queue controls, mid-generation pausing, queue crash recovery, visual model pickers, live latent canvas previews, folder automation, and prompt utilities.
A unified custom node suite for **ComfyUI** featuring real-time queue controls, mid-generation pausing, queue crash recovery, visual model pickers, live latent canvas previews, folder automation, prompt utilities, and resolution tools.
> ⚠️ **Important Notice**: This custom node pack (`ComfyUI-FlowControl`) is engineered exclusively for the **New ComfyUI Frontend (v2)**. The legacy/old ComfyUI frontend is not supported.
> ⚠️ **Notice**: Fully compatible with both **ComfyUI Frontend V2 (Nodes 2.0 / Vue UI)** and **Classic ComfyUI Frontend (Nodes 1.0 / LiteGraph)**.
---
## 🌟 Features & Included Nodes
## 📦 Individual Nodes Reference
Every node in this suite is prefixed with the signature **`🍃`** brand icon for quick identification on your canvas and search menu.
Expand any node below to view its description, inputs, outputs, and usage documentation.
### ⏸️ Queue & Workflow Control (`FlowControl/Queue`)
<details>
<summary><b>🍃 📁 LoRA Loader (Folder)</b> (<code>FolderLoraLoader</code>)</summary>
1. **`🍃 Pause Queue` (Global Integration)**
- Adds a native-styled **Pause / Continue** button directly into the ComfyUI V2 top action bar.
- **Pause (Finish)**: Completes the currently active generation, then holds the queue before starting the next prompt.
- **Pause (Instant)**: Pauses immediately before the next node executes in the active workflow (resumable mid-workflow without losing progress).
- **Visual Indicators**: Green when unpaused, Orange when pausing/paused with pulsing glow animation.
- **Status Text**: Displays `Pausing (Finish)...` or `Pausing (Instant)...` while waiting for execution to reach the pause gate, then transforms into `Continue` with a play icon `▶`.
#### Overview
Loads a LoRA by folder path using raw filename matching or formatted names with customizable output formatting options.
2. **`🍃 Persistent Queue` (Global Integration)**
- Automatically saves all queued workflows to `persistent_queue.json` in real time on disk.
- If ComfyUI crashes, suffers a power outage, or is closed, all unfinished prompts are automatically restored to the queue on next boot in a paused state.
- **Session Ownership Claiming**: Automatically re-assigns restored queue items to your active browser tab's `client_id` when you open the page, removing the *"Running in another tab"* warning.
#### Inputs & Widgets
- **`model`** (`MODEL`): Input model.
- **`clip`** (`CLIP`): Input CLIP.
- **`folder`** (`STRING`): Subfolder filter (e.g. `Celebrities`, `PonyV6`, `*`).
- **`lora_name`** (`COMBO`): Dropdown listing available LoRA files in the target folder.
- **`strength_model`** (`FLOAT`): Model weight strength (default `1.0`).
- **`strength_clip`** (`FLOAT`): CLIP weight strength (default `1.0`).
- **`output_format`** (`COMBO`, *Advanced*): Choose output format (`Parsed Name`, `Filename`, `Filename without extension`, `Relative Path`, `Full Path`, `Custom Regex`).
- **`custom_regex`** (`STRING`, *Advanced*): Pattern used when `output_format` is `Custom Regex`.
3. **`🍃 FlowControl Decision`**
- A drop-in node that pauses workflow execution at a specific point and prompts you with a UI popup.
- **Continue**: Resumes execution normally (outputs `False`).
- **Cancel**: Cancels this branch of the workflow (outputs `True`, allowing you to route it into a Switch node).
- **Stop Workflow**: Instantly kills the entire active generation queue.
- **Windows Notification**: Optionally sends a native Windows 11 toast notification when it's waiting for your input.
#### Outputs
- **`MODEL`**: Patched model.
- **`CLIP`**: Patched CLIP.
- **`lora_name`** (`STRING`): Formatted name or filename of the loaded LoRA.
</details>
<details>
<summary><b>🍃 ✨ LoRA Loader (Pretty)</b> (<code>FolderLoraLoaderPretty</code>)</summary>
#### Overview
Loads a LoRA using formatted pretty names (e.g. `Ana De Armas V1` instead of `krea2_Ana-De-Armas_v1.safetensors`).
#### Inputs & Widgets
- **`model`** (`MODEL`): Input model.
- **`clip`** (`CLIP`): Input CLIP.
- **`folder`** (`STRING`): Subfolder filter.
- **`lora_name`** (`COMBO`): Dropdown listing formatted LoRA names.
- **`strength_model`** (`FLOAT`): Model weight strength.
- **`strength_clip`** (`FLOAT`): CLIP weight strength.
#### Outputs
- **`MODEL`**: Patched model.
- **`CLIP`**: Patched CLIP.
- **`lora_name`** (`STRING`): Selected LoRA name.
</details>
<details>
<summary><b>🍃 🖼️ Visual LoRA Loader</b> (<code>VisualLoraLoader</code> / alias <code>FolderLoraLoaderVisualPrettyV2</code>)</summary>
#### Overview
Visual thumbnail browser for LoRAs with Civitai SHA256 search & TMDB auto-scraping, popularity rank badges (🔥), and multi-selection support.
#### Inputs & Widgets
- **`model`** (`MODEL`): Input model.
- **`clip`** (`CLIP`): Input CLIP.
- **`folder`** (`STRING`): Subfolder filter.
- **`strength_model`** (`FLOAT`): Model strength.
- **`strength_clip`** (`FLOAT`): CLIP strength.
- **`display_mode`** (`COMBO`, *Advanced*): `Scrollable` vs `Show All`.
- **`sort_loras_by`** (`COMBO`, *Advanced*): Sort by `Name (A-Z)`, `Name (Z-A)`, `Date Modified (Newest First)`, `Date Modified (Oldest First)`, or `Usage (Most Used First)`.
#### Outputs
- **`MODEL`**: Patched model.
- **`CLIP`**: Patched CLIP.
- **`lora_name`** (`STRING`): Comma-separated list of active LoRA names.
</details>
<details>
<summary><b>🍃 📷 Visual Image Loader</b> (<code>VisualImageLoader</code> / alias <code>ImageLoaderVisualPrettyV2</code>)</summary>
#### Overview
Visual thumbnail browser for image folders with instant preview selection and EXIF positive prompt metadata extraction.
#### Inputs & Widgets
- **`folder`** (`STRING`): Folder path to load images from.
- **`display_mode`** (`COMBO`, *Advanced*): `Scrollable` vs `Show All`.
- **`sort_images_by`** (`COMBO`, *Advanced*): `Name (A-Z)`, `Name (Z-A)`, `Date Modified (Newest First)`, `Date Modified (Oldest First)`.
#### Outputs
- **`IMAGE`**: Selected image tensor.
- **`positive_prompt`** (`STRING`): Extracted prompt metadata.
- **`width`** (`INT`): Image width.
- **`height`** (`INT`): Image height.
</details>
<details>
<summary><b>🍃 📂 Load Image From Folder</b> (<code>LoadImageFromFolder</code>)</summary>
#### Overview
Monitors a folder for incoming images, loads the target image into a PyTorch tensor, with an optional toggle to delete the image after loading.
#### Inputs & Widgets
- **`folder`** (`STRING`): Watch directory path (default `input/watch`).
- **`wait_if_folder_is_empty`** (`BOOLEAN`): Poll until image arrives vs return dummy tensor immediately.
- **`rescan_interval`** (`INT`): Polling frequency in seconds.
- **`sort_by`** (`COMBO`): Sort by `date_modified`, `date_created`, or `name`.
- **`regex_filter`** (`STRING`): Regex pattern to filter filenames.
- **`delete_image`** (`BOOLEAN`, *Default: false*): If enabled, deletes the image file from disk after loading.
#### Outputs
- **`image`** (`IMAGE`): Loaded image tensor.
- **`has_image`** (`BOOLEAN`): True if image loaded, False if folder empty.
</details>
<details>
<summary><b>🍃 ⏱️ Recent Outputs</b> (<code>LoadRecentOutputs</code>)</summary>
#### Overview
Loads the N newest images from an output directory with step-through index selection.
#### Inputs & Widgets
- **`output_folder`** (`STRING`): Target output directory.
- **`amount`** (`INT`): Number of newest images to pool (1-100).
- **`index`** (`INT`): Step index selection.
#### Outputs
- **`IMAGE`**: Output image tensor.
</details>
<details>
<summary><b>🍃 👁️ Live Latent Preview</b> (<code>PreviewLatentLiveNode</code>)</summary>
#### Overview
Canvas rendering node that listens to sampler WebSocket latent binary streams and displays real-time live previews on the canvas during generation.
#### Inputs & Outputs
- **Category**: `LeafFlow/Previews`
- **Output Node**: True
</details>
<details>
<summary><b>🍃 ⏸️ LeafFlow Decision</b> (<code>LeafFlowDecision</code>)</summary>
#### Overview
Pauses workflow execution at a specific step and displays an inline UI popup with Continue, Cancel, or Stop Workflow actions, plus optional native OS desktop notifications.
#### Inputs & Widgets
- **`disable`** (`BOOLEAN`): Bypass decision gate.
- **`send_os_notification`** (`BOOLEAN`): Trigger native OS desktop toast on pause.
- **`timeout`** (`INT`): Auto-continue timeout in seconds (-1 = infinite).
#### Outputs
- **`cancel`** (`BOOLEAN`): False on Continue, True on Cancel (for branch routing).
</details>
<details>
<summary><b>🍃 📐 Text Aspect Ratio Finder</b> (<code>TextAspectRatioFinder</code> / alias <code>AspectRatioFinder</code>)</summary>
#### Overview
Parses input text for aspect ratios (e.g. `16:9`, `2.35:1`), syntax-checks them, and calculates pixel resolution for a target megapixel target.
#### Inputs & Widgets
- **`aspect_ratios`** (`STRING`): Allowed ratio list.
- **`search_mode`** (`COMBO`): `First match (Front)` vs `Last match (Back)`.
- **`target_mp`** (`FLOAT`): Target megapixels (e.g. `1.0 MP`).
- **`default_aspect_ratio`** (`COMBO`): Fallback ratio if none found.
- **`multiple_of`** (`INT`, *Advanced*): Dimension step multiple (default `8`).
- **`min_mp`** (`FLOAT`, *Advanced*): Minimum megapixel limit.
- **`max_mp`** (`FLOAT`, *Advanced*): Maximum megapixel limit.
- **`text`** (`STRING`, *Optional Input*): Input text to search.
#### Outputs
- **`width`** (`INT`): Computed width in pixels.
- **`height`** (`INT`): Computed height in pixels.
- **`aspect_ratio`** (`STRING`): Detected or fallback aspect ratio string.
- **`cleaned_text`** (`STRING`): Input text with the aspect ratio token stripped.
</details>
<details>
<summary><b>🍃 📐 Preview Image Size & Aspect Ratio</b> (<code>PreviewImageSizeAspectRatio</code>)</summary>
#### Overview
Computes image dimensions and aspect ratio from an input image tensor and formats the result for previewing.
#### Inputs & Widgets
- **`image`** (`IMAGE`): Input image tensor.
#### Outputs
- **`width`** (`INT`): Image width.
- **`height`** (`INT`): Image height.
- **`aspect_ratio`** (`STRING`): Closest matched aspect ratio string.
</details>
<details>
<summary><b>🍃 🔎 Text LoRA Finder & Loader</b> (<code>TextLoraFinder</code> / alias <code>LoraTextFinder</code>)</summary>
#### Overview
Scans input prompt text for `<lora:name:strength>` tags or names matching a folder on disk, dynamically applies them, and returns patched models along with sanitized prompt text.
#### Inputs & Widgets
- **`model`** (`MODEL`): Input model.
- **`clip`** (`CLIP`): Input CLIP.
- **`folder`** (`STRING`): Subfolder to search for LoRA files.
- **`fallback_strength_model`** (`FLOAT`): Default model strength if omitted in prompt tag.
- **`fallback_strength_clip`** (`FLOAT`): Default CLIP strength if omitted in prompt tag.
- **`clean_prompt`** (`BOOLEAN`): Strips matched `<lora:...>` tags from output text.
- **`text`** (`STRING`, *Optional Input*): Input prompt text.
#### Outputs
- **`MODEL`**: Patched model.
- **`CLIP`**: Patched CLIP.
- **`text`** (`STRING`): Cleaned prompt text.
- **`lora_name`** (`STRING`): Comma-separated list of loaded LoRAs.
</details>
<details>
<summary><b>🍃 🔄 Prompt Queue Iterator</b> (<code>PromptQueueIterator</code>)</summary>
#### Overview
Parses multiline prompts or batch text blocks separated by double newlines (`\n\n`), automatically popping the top prompt per queue iteration and keeping remaining prompts in memory.
#### Inputs & Widgets
- **`prompts`** (`STRING`, Multiline): Queue of prompt blocks.
- **`mode`** (`COMBO`): `Double Newline (\n\n)` vs `Single Line (\n)`.
- **`delete_after_queue`** (`BOOLEAN`): Pop executed block from widget text.
#### Outputs
- **`active_prompt`** (`STRING`): The current prompt block for this run.
- **`remaining_prompts`** (`STRING`): The queue of remaining prompts.
- **`remaining_count`** (`INT`): Count of remaining items in queue.
</details>
<details>
<summary><b>🍃 🔤 Multi Text Replacer</b> (<code>MultiTextReplacer</code>)</summary>
#### Overview
Performs multiple text replacements in a single step using comma-separated or newline-separated find/replace lists, with support for exact phrases and regex matching.
#### Inputs & Widgets
- **`text`** (`STRING`, Multiline): Input text to modify.
- **`find_text`** (`STRING`, Multiline): Search terms (comma-separated or lines).
- **`replace_text`** (`STRING`, Multiline): Replacement terms.
- **`case_sensitive`** (`BOOLEAN`): Toggle case-sensitive matching.
- **`use_regex`** (`BOOLEAN`): Treat find patterns as regular expressions.
#### Outputs
- **`text`** (`STRING`): Modified text.
</details>
<details>
<summary><b>🍃 ✂️ Text Split</b> (<code>LeafFlowTextSplit</code>)</summary>
#### Overview
Splits text into two parts at a specified delimiter. Supports forward (from start) and backward (from end) search, as well as regular expressions.
#### Inputs & Widgets
- **`text`** (`STRING`, Multiline): Input text to split.
- **`delimiter`** (`STRING`): Character or string to split on (e.g. `,`, `---`, `\n`).
- **`search_from`** (`COMBO`): `Forward (First Match)` vs `Backward (Last Match)`.
- **`use_regex`** (`BOOLEAN`): Treat delimiter as a regular expression pattern.
- **`strip_whitespace`** (`BOOLEAN`): Trims surrounding whitespace from output strings.
#### Outputs
- **`left_text`** (`STRING`): Text before the split delimiter.
- **`right_text`** (`STRING`): Text after the split delimiter.
- **`delimiter_found`** (`BOOLEAN`): True if delimiter was found, False otherwise.
</details>
---
### 🎨 Visual Loaders & Pickers (`FlowControl/Loaders`)
3. **`🍃 Visual LoRA Loader`**
- Visual LoRA selector supporting folder filtering (e.g. `Celebrity/*`) and formatted pretty names (e.g. `Ana De Armas V1`).
- Automatically tracks usage frequency (`lora_usage.json`).
- **Custom Preview Images**: Simply save a preview image file with the exact same name as your LoRA in the same folder (e.g. `my_lora.png`, `my_lora.jpg`, or `my_lora.webp` next to `my_lora.safetensors`).
- **Automated Civitai SHA256 Search**: Computes SHA256 file hashes and queries Civitai's API (`/api/v1/model-versions/by-hash/{hash}`) with optional `CIVITAI_API_KEY` authorization to download model preview thumbnails.
- **TMDB Fallback**: Optional celebrity preview search via TMDB API key.
4. **`🍃 Visual Image Loader`**
- Visual image folder browser with instant visual preview pickers.
- Extracts positive prompt metadata from PNG parameters and EXIF user comments.
- Outputs `IMAGE`, `positive_prompt`, `width`, and `height`.
5. **`🍃 Recent Outputs`**
- Cycles through recently generated images by step index for seamless integration with ComfyUI's sidebar/assets panel.
- Supports custom `output_folder` paths.
- Uses an `index` paired with an `amount` to chronologically step through the X newest images in a batch.
---
### ⚙️ Automation, Previews & Utilities (`FlowControl/Automation`, `FlowControl/Previews`, `FlowControl/Utils`)
6. **`🍃 Load image from folder`**
- Monitors a directory for incoming images (`.png`, `.jpg`, `.jpeg`, `.webp`), converts them into PyTorch image tensors, and removes processed files.
- **`wait_if_folder_is_empty` Toggle**:
- `True` (Default): Safely polls until an image arrives in the directory at your chosen `rescan_interval` (seconds).
- `False`: Immediately checks the directory. If an image is present, loads it. If no image is present, returns a dummy tensor and `has_image=False` immediately without blocking.
- **Advanced Sorting**: Supports sorting incoming images by `date_modified`, `date_created`, or `name`.
- **Regex Filtering**: Use standard Python regex strings (e.g. `^img_.*\.png$`) to target specific file formats or naming conventions.
7. **`🍃 Undo Placeholder`**
- Scans text prompt strings for names matching LoRA files in a specified folder filter and restores placeholder tokens like `%celeb%`.
8. **`🍃 Live Latent Preview`**
- Custom canvas node that listens to WebSocket latent preview binary streams (`b_preview`, `b_preview_with_metadata`) during sampler execution and renders real-time sampler previews directly inside the node canvas.
---
## 📋 Complete Node Overview Table
| Node Class | Display Name | Category | Primary Function |
| :--- | :--- | :--- | :--- |
| `FlowControlDecision` | **`🍃 FlowControl Decision`** | `FlowControl/Queue` | Inline workflow pausing with Continue/Cancel UI and native notifications |
| `FolderLoraLoaderVisualPrettyV2` | **`🍃 Visual LoRA Loader`** | `FlowControl/Loaders` | Visual LoRA picker with SHA256 Civitai/TMDB previews |
| `ImageLoaderVisualPrettyV2` | **`🍃 Visual Image Loader`** | `FlowControl/Loaders` | Visual image browser with EXIF prompt extraction |
| `LoadRecentOutputs` | **`🍃 Recent Outputs`** | `FlowControl/Loaders` | Cycles recent output images for assets/sidebar |
| `LoadImageFromFolder` | **`🍃 Load image from folder`** | `FlowControl/Automation` | Folder image watcher with regex and intelligent sorting |
| `UndoPlaceholder` | **`🍃 Undo Placeholder`** | `FlowControl/Utils` | Replaces pretty LoRA names with `%placeholder%` tokens |
| `PreviewLatentLive` | **`🍃 Live Latent Preview`** | `FlowControl/Previews` | Canvas real-time sampler latent preview display |
---
## ⚙️ ComfyUI Settings Menu Integration
## ⚙️ ComfyUI Settings Menu Reference
Configure options directly under ComfyUI Settings (⚙ gear icon):
- **🍃 FlowControl: Enable Civitai Auto-Scraping**: Automatically download model preview images from Civitai via SHA256 file hashes.
- **🍃 FlowControl: Civitai API Key**: Optional key for Civitai SHA256 model preview search (`Authorization: Bearer <key>`).
- **🍃 FlowControl: Enable TMDB Auto-Scraping**: Automatically download celebrity preview images from TMDB.
- **🍃 FlowControl: TMDB API Key**: Optional key for celebrity preview search.
- **🍃 FlowControl: Default Pause Queue State on Launch**: Sets whether the queue should start `Unpaused` or `Paused` on boot.
- **🍃 FlowControl: Default Pause Queue Mode on Launch**: Sets the default pause behavior (`Finish Active Prompt` vs `Instant Resume Node`).
- **🍃 FlowControl: Persistent Queue Restored Launch State**: Controls what happens to crash-recovered queue items (`Start Paused` or `Start Unpaused`).
- **🍃 FlowControl: Pause Button Unpaused Color**: Customizable hex color for the toolbar unpaused state.
- **🍃 FlowControl: Pause Button Paused Color**: Customizable hex color for the toolbar paused/pausing state.
- **🍃 FlowControl: Enable Pause Queue Toolbar Button**: Toggle top action bar Pause & Continue button ON/OFF.
- **🍃 FlowControl: Enable Persistent Queue (Auto-Recovery)**: Toggle the real-time crash recovery queue saving on or off.
<details open>
<summary><b>1. 🖼️ Visual Loaders (Civitai & TMDB Duo)</b></summary>
- **`Enable Custom LeafFlow Node Colors`** (`boolean`, *Default: true*): Applies a vibrant Leaf Green color theme to LeafFlow nodes on the canvas. When disabled, nodes use default ComfyUI colors.
- **`Civitai API Key`** (`text`): Optional key for Civitai SHA256 model preview search (`Authorization: Bearer <key>`).
- **`Enable Civitai Auto-Scraping`** (`boolean`, *Default: true*): Automatically download model preview images from Civitai via SHA256 file hashes. (Note: Local SHA256 hash searching always works).
- **`TMDB Access Token`** (`text`): Optional key or v4 Read Access Token (`eyJ...`) for celebrity preview search.
- **`Enable TMDB Auto-Scraping`** (`boolean`, *Default: false*): Automatically download celebrity preview images from TMDB.
- **`Enable LoRA Usage Tracking`** (`boolean`, *Default: true*): Toggle LoRA usage counting and visual rank badges (🔥, Gold, Silver, Bronze) in the picker.
- **`Reset Failed Scrapes Cache`** (*Button: `🗑️ Clear Scrapes Cache`*): Clears failed scrape history so Civitai/TMDB can retry downloading missing preview images.
</details>
<details open>
<summary><b>2. 🔄 Prompt Iterator</b></summary>
- **`Clear State on Launch`** (`boolean`, *Default: false*): Privacy toggle to empty `prompt_iterator_state.json` on ComfyUI startup.
- **`Reset All Queues`** (*Button: `🔄 Reset All Queues`*): Immediately empties all active prompt queues and resets iterator state.
</details>
<details open>
<summary><b>3. 📋 Prompt Actions</b></summary>
- **`Show "Copy Prompt" Button on Images`** (`boolean`, *Default: true*): Shows the 📋 "Copy Prompt" overlay action button when hovering over generated images in the Assets / History pane and preview nodes.
- **`Show Right-Click "Copy Prompt" Menu Action`** (`boolean`, *Default: true*): Adds "📋 Copy Prompt" to node right-click context menus.
</details>
<details open>
<summary><b>4. ⏸️ Pause Controls</b></summary>
- **`Default State on Launch`** (`combo`, *Default: `Paused`*): Sets whether the queue starts `Paused` or `Running` on boot.
- **`Default Pause Action`** (`combo`, *Default: `Finish Active Prompt`*): Sets default pause behavior (`Finish Active Prompt` vs `Instant Resume Node`).
- **`Enable Top Toolbar Button`** (`boolean`, *Default: true*): Toggle top action bar Pause & Continue button group ON/OFF.
- **`Toolbar Button Unpaused Color`** (`text`, *Default: `#16a34a`*): Hex color for the toolbar unpaused/running state.
- **`Toolbar Button Paused Color`** (`text`, *Default: `#ea580c`*): Hex color for the toolbar paused state.
- **`Enable System Tray Icon`** (`boolean`, *Default: false*): Displays an OS system tray icon with real-time queue status colors and outside-browser controls.
</details>
<details open>
<summary><b>5. 💾 Persistent Queue</b></summary>
- **`Persistent Queue (Auto-Recovery)`** (`boolean`, *Default: true*): Automatically saves unfinished queue items and restores them after restart/crash.
- **`Recovery Launch State`** (`combo`, *Default: `Match Default`*): Override launch state when restored queue items are recovered on startup (`Match Default`, `Force Paused`, `Force Running`).
</details>
<details open>
<summary><b>6. 🖼️ Assets & History Restore</b></summary>
- **`Restore Assets on Launch`** (`boolean`, *Default: true*): Automatically restores your latest generated images into the Assets / History pane on startup.
- **`Restored Assets Count`** (`number`, *Default: 64*): The number of newest images from the output folder to populate into the Assets pane.
</details>
<details open>
<summary><b>7. 🩺 Diagnostics & Debug</b></summary>
- **`Export Debug Profile`** (*Button: `📥 Export Debug Profile`*): Exports non-sensitive system environment details (OS, Python, PyTorch, LeafFlow settings, local cache counts) to a JSON file to share when reporting bugs or requesting assistance. Sensitive API keys and tokens are never exported.
</details>
---
@@ -117,17 +342,13 @@ Configure options directly under ComfyUI Settings (⚙ gear icon):
1. Open your terminal and navigate to your ComfyUI `custom_nodes` directory:
```bash
cd ComfyUI/custom_nodes
git clone https://github.com/KOFiblto/ComfyUI-FlowControl.git
git clone https://github.com/KOFiblto/ComfyUI-LeafFlow.git
```
2. Restart ComfyUI.
3. Open ComfyUI Settings (⚙ gear menu) to configure API keys or feature toggles if desired.
---
## 🤝 Contributing
Contributions are welcome! Please check out [CONTRIBUTING.md](CONTRIBUTING.md) for local development guidelines, security policy, and coding standards.
## 📜 License
Distributed under the [MIT License](LICENSE).
+1 -1
View File
@@ -2,7 +2,7 @@
## Reporting Security Issues
We take the security of ComfyUI-FlowControl seriously. If you discover a security vulnerability or sensitive key exposure, please **DO NOT** open a public issue.
We take the security of ComfyUI-LeafFlow seriously. If you discover a security vulnerability or sensitive key exposure, please **DO NOT** open a public issue.
Instead, please report security vulnerabilities directly to the maintainer via GitHub private vulnerability reporting.
+89
View File
@@ -0,0 +1,89 @@
# 🍃 ComfyUI-LeafFlow Complete Walkthrough
Welcome to the full guide for ComfyUI-LeafFlow! This custom node suite is designed to give you unprecedented control over your generations, visual asset management, and queue resilience.
This walkthrough covers everything from basic setup to advanced workflows across both the new ComfyUI V2 (Vue) and the classic ComfyUI (LiteGraph).
---
## 1. Global Features
### The Pause Queue Toolbar
Upon installing the extension, you will notice a new **Green/Orange Pause Button** nestled next to your standard "Queue Prompt" button.
- **Green (Running)**: ComfyUI operates normally.
- **Orange (Pausing/Paused)**: ComfyUI is holding the queue.
You have two pause modes, accessible by clicking the small dropdown arrow next to the Pause button:
1. **Pause (Finish)**: This will let your currently generating workflow finish completely, but it will intercept and hold any subsequent workflows in the queue until you click Continue.
2. **Pause (Instant)**: This tells ComfyUI to freeze *immediately* before executing the next node in your active workflow. You can resume precisely from where you left off later without losing progress!
### The System Tray Icon (Outside-Browser Queue Control)
Want to check your queue status or pause/resume generations while working in other apps (Photoshop, Blender, games) without opening ComfyUI in your browser?
1. Open ComfyUI Settings (⚙️ gear icon) and enable **🍃 LeafFlow: Enable System Tray Icon** (or set `ENABLE_TRAY_ICON=true` in `.env`).
2. LeafFlow will spawn an OS system tray icon in your Windows/macOS/Linux taskbar notification area:
- **Green Icon (❚❚)**: Indicates ComfyUI queue is actively running.
- **Orange Icon (▶)**: Indicates ComfyUI queue is paused and waiting for you to continue.
- **Orange Icon (❚❚)**: Indicates ComfyUI is in the middle of a generation and will pause as soon as the active workflow/node finishes.
3. Left-click the tray icon to quickly toggle between **Pause** and **Continue**.
4. Right-click the tray icon to choose explicit actions: **Pause (Finish)**, **Pause (Instant)**, **Continue / Resume**, switch default pause modes, or quickly **Open ComfyUI in Browser**.
### The Persistent Queue (Crash Recovery)
You don't need to interact with this—it runs silently in the background!
Every time you queue a prompt, LeafFlow saves the entire queue state to your hard drive in real time. If your computer crashes, power goes out, or you accidentally close the terminal, ComfyUI will automatically restore your unfinished queue upon next boot (in a paused state, so it doesn't overwhelm your GPU before you're ready).
### Automatic Assets Pane & History Restore
In standard ComfyUI, restarting your server wipes the in-memory execution history, leaving your sidebar Assets Pane / History tab completely blank on launch.
- LeafFlow automatically scans your `output` folder (read-only) on server startup and pre-populates the history with your latest **64 images** (or custom count).
- All images are loaded in strict chronological order with embedded workflow and prompt graphs intact.
- Features like **Copy Prompt** and **Copy Workflow** immediately work with restored images as if they were generated in your current session!
- You can adjust the restored image count or toggle this feature in **ComfyUI Settings (⚙️)** under **`🍃 LeafFlow: Restore Assets on Launch`** and **`🍃 LeafFlow: Restored Assets Count`**.
---
## 2. Advanced Nodes
### 🍃 ⏸️ LeafFlow Decision
Sometimes you run a complex workflow that takes hours (e.g., upscaling), but you want to check a low-res preview first to ensure it's worth the compute time.
1. Drop the **`🍃 ⏸️ LeafFlow Decision`** node into your workflow between the low-res and high-res stages.
2. When the execution reaches this node, your workflow will pause, and a native desktop notification (Windows/macOS/Linux) will alert you!
3. You will see a popup UI on your ComfyUI canvas giving you three choices:
- **Continue**: Proceeds with the workflow normally.
- **Cancel**: Outputs a `True` signal. You can route this into a Switch node to bypass the high-res stage entirely.
- **Stop Workflow**: Instantly aborts the generation queue, exactly like clicking the red X in ComfyUI.
### 🍃 📂 Load Image From Folder
An absolute powerhouse for automation. Point this node at any folder on your hard drive, and it will automatically ingest incoming images, convert them into tensors, and delete the original files to keep the folder clean.
- **wait_if_folder_is_empty**: If set to `True`, the workflow will patiently pause and poll the folder at your `rescan_interval` until an image arrives. Perfect for linking ComfyUI to external tools like Photoshop or Blender auto-exports!
- **Regex Filtering**: Only want it to grab images that start with `render_`? Just use standard regex like `^render_.*\.png$`.
### 🍃 🖼️ Visual LoRA & Image Loaders
Tired of guessing what a LoRA does based on its filename?
1. Use the **`🍃 🖼️ Visual LoRA Loader`**. It natively extracts preview images if you place an identically named `.png`/`.jpg` next to your `.safetensors` file.
2. If you enable the **Civitai Auto-Scraper** in your ComfyUI Settings, the node will calculate the SHA256 hash of your LoRA and automatically download the official preview thumbnail directly from Civitai!
3. **Pretty Name String Output**: The node outputs the parsed, human-readable name of the loaded LoRA (e.g. "Addison Rae V1") by default, allowing you to route it into a text prompt or watermark node. You can easily switch this via the `output_name` toggle on the node to output the raw filename without extension instead.
### 🍃 ⏱️ Recent Outputs
Want to quickly reference your recent generations without digging through Windows Explorer? The **`🍃 ⏱️ Recent Outputs`** node cycles chronologically through your `output/` directory, pulling your freshest creations right back into the workflow canvas.
---
## 3. Formatting Folder Paths
Several nodes across this suite (e.g. *Load Image From Folder*, *Recent Outputs*, *Visual Image Loader*, *Visual LoRA Loader*) take a `folder` or `folder_path` string as input. Here is how they work:
- **Empty String**: Leaving a field completely empty (or spaces) will often default to the sensible native directory for that node (e.g., leaving it empty on *Recent Outputs* will automatically target ComfyUI's root `output/` directory).
- **Absolute Paths**: You can paste full absolute paths from your OS, e.g., `C:\Users\Name\Pictures\Renders` or `/home/user/images`.
- **Relative Paths**: If you use a relative path like `input/watch`, it resolves relative to your root ComfyUI directory.
- **Wildcard Filters (`*`)**:
- **For the LoRA Loader**: Ending a path with `*` acts as a wildcard directory filter. For example, if you input `style/*`, the loader will recursively display all LoRAs located inside any subfolder that begins with `style/`.
- **For File Loaders**: If you accidentally copy a path from Windows Explorer that ends in `\*` or `*`, these nodes are smart enough to automatically strip the trailing asterisk so the folder path still resolves perfectly without throwing an OS Error.
---
## 4. Settings & Configuration
Click the standard ComfyUI Settings gear ⚙️ to access LeafFlow's configurations. Here you can:
- Safely enter and mask API Keys for Civitai and TMDB scraping.
- Change the colors of the Pause Queue buttons via built-in color pickers.
- Adjust the default boot behavior for the Persistent Queue (e.g., forcing ComfyUI to always boot in a Paused state).
> **Cross-Platform Note**: All of the visual nodes, pause features, and OS notifications are fully compatible with Windows, macOS, and Linux, and seamlessly adapt to both the modern ComfyUI V2 interface and the classic LiteGraph UI!
+224 -24
View File
@@ -1,65 +1,111 @@
import os
import sys
import subprocess
from aiohttp import web
from server import PromptServer
from .nodes.queue_control import PauseQueueNode, PersistentQueueNode, setup_queue_control_routes
def ensure_dependencies():
dep_map = {
"pystray": "pystray",
"piexif": "piexif",
"PIL": "Pillow",
"numpy": "numpy"
}
for mod_name, pip_name in dep_map.items():
try:
__import__(mod_name)
except ImportError:
try:
print(f"[ComfyUI-LeafFlow] Auto-installing missing dependency: {pip_name}")
subprocess.check_call([sys.executable, "-m", "pip", "install", pip_name])
except Exception as e:
print(f"[ComfyUI-LeafFlow] Warning: Failed to install {pip_name}: {e}")
ensure_dependencies()
from .nodes.queue_control import setup_queue_control_routes, tray_manager
from .nodes.lora_loader import (
FolderLoraLoader,
FolderLoraLoaderPretty,
FolderLoraLoaderVisualPretty,
VisualLoraLoader,
FolderLoraLoaderVisualPrettyV2
)
from .nodes.image_loader import ImageLoaderVisualPrettyV2
from .nodes.undo_placeholder import UndoPlaceholder
from .nodes.image_loader import VisualImageLoader, ImageLoaderVisualPrettyV2
from .nodes.auto_watcher import LoadImageFromFolder
from .nodes.load_recent import LoadRecentOutputs
from .nodes.preview_latent import PreviewLatentLiveNode
from .nodes.decision_node import FlowControlDecision
from .nodes.decision_node import LeafFlowDecision
from .nodes.aspect_ratio import TextAspectRatioFinder, AspectRatioFinder, PreviewImageSizeAspectRatio
from .nodes.lora_finder import TextLoraFinder, LoraTextFinder
from .nodes.prompt_iterator import PromptQueueIterator
from .nodes.text_replacer import MultiTextReplacer
from .nodes.text_split import LeafFlowTextSplit
NODE_CLASS_MAPPINGS = {
"FolderLoraLoader": FolderLoraLoader,
"FolderLoraLoaderPretty": FolderLoraLoaderPretty,
"FolderLoraLoaderVisualPretty": FolderLoraLoaderVisualPretty,
"FolderLoraLoaderVisualPrettyV2": FolderLoraLoaderVisualPrettyV2,
"ImageLoaderVisualPrettyV2": ImageLoaderVisualPrettyV2,
"UndoPlaceholder": UndoPlaceholder,
"VisualLoraLoader": VisualLoraLoader,
"VisualImageLoader": VisualImageLoader,
"LoadImageFromFolder": LoadImageFromFolder,
"LoadRecentOutputs": LoadRecentOutputs,
"PreviewLatentLive": PreviewLatentLiveNode,
"FlowControlDecision": FlowControlDecision
"LeafFlowDecision": LeafFlowDecision,
"TextAspectRatioFinder": TextAspectRatioFinder,
"PreviewImageSizeAspectRatio": PreviewImageSizeAspectRatio,
"TextLoraFinder": TextLoraFinder,
"PromptQueueIterator": PromptQueueIterator,
"MultiTextReplacer": MultiTextReplacer,
"LeafFlowTextSplit": LeafFlowTextSplit,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"FolderLoraLoader": "🍃 LoRA Loader (by Folder)",
"FolderLoraLoaderPretty": "🍃 LoRA Loader (Pretty)",
"FolderLoraLoaderVisualPretty": "🍃 Visual LoRA Picker",
"FolderLoraLoaderVisualPrettyV2": "🍃 Visual LoRA Loader",
"ImageLoaderVisualPrettyV2": "🍃 Visual Image Loader",
"UndoPlaceholder": "🍃 Undo Placeholder",
"LoadImageFromFolder": "🍃 Load image from folder",
"LoadRecentOutputs": "🍃 Recent Outputs",
"PreviewLatentLive": "🍃 Live Latent Preview",
"FlowControlDecision": "🍃 FlowControl Decision"
"FolderLoraLoader": "🍃 📁 LoRA Loader (Folder)",
"FolderLoraLoaderPretty": "🍃 ✨ LoRA Loader (Pretty)",
"VisualLoraLoader": "🍃 🖼️ Visual LoRA Loader",
"VisualImageLoader": "🍃 📷 Visual Image Loader",
"LoadImageFromFolder": "🍃 📂 Load Image From Folder",
"LoadRecentOutputs": "🍃 ⏱️ Recent Outputs",
"PreviewLatentLive": "🍃 👁️ Live Latent Preview",
"LeafFlowDecision": "🍃 ⏸️ LeafFlow Decision",
"TextAspectRatioFinder": "🍃 📐 Text Aspect Ratio Finder",
"PreviewImageSizeAspectRatio": "🍃 📐 Preview Image Size & Aspect Ratio",
"TextLoraFinder": "🍃 🔎 Text LoRA Finder & Loader",
"PromptQueueIterator": "🍃 🔄 Prompt Queue Iterator",
"MultiTextReplacer": "🍃 🔤 Multi Text Replacer",
"LeafFlowTextSplit": "🍃 ✂️ Text Split"
}
WEB_DIRECTORY = "./web"
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
from .nodes.utils import get_leafflow_user_dir, get_env_setting
server = PromptServer.instance
setup_queue_control_routes(server)
try:
from .prompt_bookmarks.api import register_routes as register_prompt_bookmarks_routes
register_prompt_bookmarks_routes()
except Exception as e:
pass
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
ENV_FILE = os.path.join(CURRENT_DIR, ".env")
USER_DIR = get_leafflow_user_dir()
ENV_FILE = os.path.join(USER_DIR, ".env")
routes = server.routes
print("[ComfyUI-FlowControl] 🍃 Loaded 11 nodes & visual endpoints successfully.")
print("[ComfyUI-LeafFlow] 🍃 Loaded 13 nodes & visual endpoints successfully.")
@routes.get("/leafflow/settings")
@routes.get("/flow_control/settings")
async def get_settings(request):
civitai_key = os.getenv("CIVITAI_API_KEY", "")
tmdb_key = os.getenv("TMDB_API_KEY", "")
enable_tray = os.getenv("ENABLE_TRAY_ICON", "false").lower() in ["true", "1", "yes"]
enable_assets_restore = os.getenv("ENABLE_ASSETS_RESTORE", "true").lower() in ["true", "1", "yes"]
restore_assets_count = int(os.getenv("RESTORE_ASSETS_COUNT", "64"))
if os.path.exists(ENV_FILE):
try:
with open(ENV_FILE, "r", encoding="utf-8") as f:
@@ -69,13 +115,26 @@ async def get_settings(request):
civitai_key = line.split("=", 1)[1].strip()
elif line.startswith("TMDB_API_KEY="):
tmdb_key = line.split("=", 1)[1].strip()
elif line.startswith("ENABLE_TRAY_ICON="):
enable_tray = line.split("=", 1)[1].strip().lower() in ["true", "1", "yes"]
elif line.startswith("ENABLE_ASSETS_RESTORE="):
enable_assets_restore = line.split("=", 1)[1].strip().lower() in ["true", "1", "yes"]
elif line.startswith("RESTORE_ASSETS_COUNT="):
try:
restore_assets_count = int(line.split("=", 1)[1].strip())
except Exception:
pass
except Exception:
pass
return web.json_response({
"civitai_api_key": civitai_key,
"tmdb_api_key": tmdb_key
"tmdb_api_key": tmdb_key,
"enable_tray_icon": enable_tray,
"enable_assets_restore": enable_assets_restore,
"restore_assets_count": restore_assets_count
})
@routes.post("/leafflow/settings")
@routes.post("/flow_control/settings")
async def save_settings(request):
try:
@@ -87,6 +146,11 @@ async def save_settings(request):
default_pause_mode = data.get("default_pause_mode")
enable_civitai = data.get("enable_civitai_scraping")
enable_tmdb = data.get("enable_tmdb_scraping")
enable_lora_usage = data.get("enable_lora_usage")
enable_tray_icon = data.get("enable_tray_icon")
enable_assets_restore = data.get("enable_assets_restore")
restore_assets_count = data.get("restore_assets_count")
clear_prompt_iterator_on_launch = data.get("clear_prompt_iterator_on_launch")
restored_state = data.get("persistent_queue_restored_state")
lines = []
@@ -105,6 +169,11 @@ async def save_settings(request):
has_pause_mode = False
has_civ_enable = False
has_tmdb_enable = False
has_lora_usage = False
has_tray_enable = False
has_assets_restore = False
has_restore_count = False
has_clear_prompt_iterator = False
has_restored_state = False
for line in lines:
@@ -129,6 +198,21 @@ async def save_settings(request):
elif line.strip().startswith("ENABLE_TMDB_SCRAPING=") and enable_tmdb is not None:
new_lines.append(f"ENABLE_TMDB_SCRAPING={enable_tmdb}\n")
has_tmdb_enable = True
elif line.strip().startswith("ENABLE_LORA_USAGE=") and enable_lora_usage is not None:
new_lines.append(f"ENABLE_LORA_USAGE={enable_lora_usage}\n")
has_lora_usage = True
elif line.strip().startswith("ENABLE_TRAY_ICON=") and enable_tray_icon is not None:
new_lines.append(f"ENABLE_TRAY_ICON={enable_tray_icon}\n")
has_tray_enable = True
elif line.strip().startswith("ENABLE_ASSETS_RESTORE=") and enable_assets_restore is not None:
new_lines.append(f"ENABLE_ASSETS_RESTORE={enable_assets_restore}\n")
has_assets_restore = True
elif line.strip().startswith("RESTORE_ASSETS_COUNT=") and restore_assets_count is not None:
new_lines.append(f"RESTORE_ASSETS_COUNT={restore_assets_count}\n")
has_restore_count = True
elif line.strip().startswith("CLEAR_PROMPT_ITERATOR_ON_LAUNCH=") and clear_prompt_iterator_on_launch is not None:
new_lines.append(f"CLEAR_PROMPT_ITERATOR_ON_LAUNCH={clear_prompt_iterator_on_launch}\n")
has_clear_prompt_iterator = True
elif line.strip().startswith("PERSISTENT_QUEUE_RESTORED_STATE=") and restored_state is not None:
new_lines.append(f"PERSISTENT_QUEUE_RESTORED_STATE={restored_state}\n")
has_restored_state = True
@@ -149,6 +233,16 @@ async def save_settings(request):
new_lines.append(f"ENABLE_CIVITAI_SCRAPING={enable_civitai}\n")
if not has_tmdb_enable and enable_tmdb is not None:
new_lines.append(f"ENABLE_TMDB_SCRAPING={enable_tmdb}\n")
if not has_lora_usage and enable_lora_usage is not None:
new_lines.append(f"ENABLE_LORA_USAGE={enable_lora_usage}\n")
if not has_tray_enable and enable_tray_icon is not None:
new_lines.append(f"ENABLE_TRAY_ICON={enable_tray_icon}\n")
if not has_assets_restore and enable_assets_restore is not None:
new_lines.append(f"ENABLE_ASSETS_RESTORE={enable_assets_restore}\n")
if not has_restore_count and restore_assets_count is not None:
new_lines.append(f"RESTORE_ASSETS_COUNT={restore_assets_count}\n")
if not has_clear_prompt_iterator and clear_prompt_iterator_on_launch is not None:
new_lines.append(f"CLEAR_PROMPT_ITERATOR_ON_LAUNCH={clear_prompt_iterator_on_launch}\n")
if not has_restored_state and restored_state is not None:
new_lines.append(f"PERSISTENT_QUEUE_RESTORED_STATE={restored_state}\n")
@@ -159,8 +253,114 @@ async def save_settings(request):
os.environ["CIVITAI_API_KEY"] = civitai_key
if tmdb_key is not None:
os.environ["TMDB_API_KEY"] = tmdb_key
if enable_lora_usage is not None:
os.environ["ENABLE_LORA_USAGE"] = enable_lora_usage
if enable_tray_icon is not None:
os.environ["ENABLE_TRAY_ICON"] = str(enable_tray_icon)
tray_enabled_bool = str(enable_tray_icon).lower() in ["true", "1", "yes"]
tray_manager.set_enabled(tray_enabled_bool)
if enable_assets_restore is not None:
os.environ["ENABLE_ASSETS_RESTORE"] = str(enable_assets_restore)
if restore_assets_count is not None:
os.environ["RESTORE_ASSETS_COUNT"] = str(restore_assets_count)
if clear_prompt_iterator_on_launch is not None:
os.environ["CLEAR_PROMPT_ITERATOR_ON_LAUNCH"] = str(clear_prompt_iterator_on_launch)
except Exception as e:
print(f"[FlowControl] 🍃 Error saving settings to .env: {e}")
print(f"[LeafFlow] 🍃 Error saving settings to .env: {e}")
return web.json_response({"status": "ok"})
@routes.post("/leafflow/scrapes/clear")
async def clear_scrapes_endpoint(request):
try:
failed_file = os.path.join(USER_DIR, "failed_scrapes.json")
with open(failed_file, "w", encoding="utf-8") as f:
f.write("{}")
return web.json_response({"status": "ok"})
except Exception as e:
print(f"[LeafFlow] 🍃 Error clearing scrapes cache: {e}")
return web.json_response({"status": "error", "message": str(e)}, status=500)
@routes.get("/leafflow/debug/export")
async def export_debug_profile(request):
try:
import platform
import json
from datetime import datetime, timezone
import torch
# Safe GPU / hardware information
cuda_avail = torch.cuda.is_available() if hasattr(torch, "cuda") else False
device_name = torch.cuda.get_device_name(0) if cuda_avail else "CPU"
# Check API key configuration status without revealing sensitive strings
civitai_key = os.getenv("CIVITAI_API_KEY", "")
tmdb_key = os.getenv("TMDB_API_KEY", "")
if os.path.exists(ENV_FILE):
try:
with open(ENV_FILE, "r", encoding="utf-8") as f:
for line in f:
if line.strip().startswith("CIVITAI_API_KEY=") and not civitai_key:
civitai_key = line.split("=", 1)[1].strip()
elif line.startswith("TMDB_API_KEY=") and not tmdb_key:
tmdb_key = line.split("=", 1)[1].strip()
except Exception:
pass
# Calculate counts of local storage files safely
def safe_json_count(filename):
p = os.path.join(USER_DIR, filename)
if os.path.exists(p):
try:
with open(p, "r", encoding="utf-8") as f:
data = json.load(f)
return len(data) if isinstance(data, (list, dict)) else 0
except Exception:
pass
return 0
debug_profile = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"package": "ComfyUI-LeafFlow",
"version": "2.1.0",
"system": {
"os": platform.system(),
"os_release": platform.release(),
"os_version": platform.version(),
"architecture": platform.machine(),
"python_version": sys.version.split()[0],
"torch_version": getattr(torch, "__version__", "unknown"),
"cuda_available": cuda_avail,
"device_name": device_name,
},
"registered_nodes_count": len(NODE_CLASS_MAPPINGS),
"settings": {
"enable_persistent_queue": get_env_setting("ENABLE_PERSISTENT_QUEUE", "true"),
"default_pause_state": get_env_setting("DEFAULT_PAUSE_STATE", "Paused"),
"default_pause_mode": get_env_setting("DEFAULT_PAUSE_MODE", "after_finish"),
"enable_civitai_scraping": get_env_setting("ENABLE_CIVITAI_SCRAPING", "true"),
"enable_tmdb_scraping": get_env_setting("ENABLE_TMDB_SCRAPING", "true"),
"enable_lora_usage": get_env_setting("ENABLE_LORA_USAGE", "true"),
"enable_tray_icon": get_env_setting("ENABLE_TRAY_ICON", "false"),
"enable_assets_restore": get_env_setting("ENABLE_ASSETS_RESTORE", "true"),
"restore_assets_count": get_env_setting("RESTORE_ASSETS_COUNT", "64"),
"clear_prompt_iterator_on_launch": get_env_setting("CLEAR_PROMPT_ITERATOR_ON_LAUNCH", "false"),
"persistent_queue_restored_state": get_env_setting("PERSISTENT_QUEUE_RESTORED_STATE", "Match Default"),
"civitai_api_key_configured": bool(civitai_key and civitai_key.strip()),
"tmdb_api_key_configured": bool(tmdb_key and tmdb_key.strip()),
},
"local_storage_stats": {
"tracked_loras": safe_json_count("lora_usage.json"),
"lora_cycle_states": safe_json_count("lora_loader_state.json"),
"image_prompts_cached": safe_json_count("image_prompts_cache.json"),
"failed_scrapes_cached": safe_json_count("failed_scrapes.json"),
"prompt_iterator_queues": safe_json_count("prompt_iterator_state.json"),
"persisted_queue_items": safe_json_count("persistent_queue.json"),
}
}
return web.json_response(debug_profile)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
+9 -6
View File
@@ -3,16 +3,19 @@ import sys
import subprocess
def main():
print("[ComfyUI-FlowControl] Checking dependencies...")
dependencies = ["pillow", "piexif", "numpy"]
print("[ComfyUI-LeafFlow] Checking dependencies...")
dependencies = ["pillow", "piexif", "numpy", "pystray"]
for dep in dependencies:
try:
__import__(dep)
except ImportError:
print(f"[ComfyUI-FlowControl] Installing missing dependency: {dep}")
subprocess.check_call([sys.executable, "-m", "pip", "install", dep])
print("[ComfyUI-FlowControl] Installation check complete! Restart ComfyUI to activate.")
print(f"[ComfyUI-LeafFlow] Installing missing dependency: {dep}")
try:
subprocess.check_call([sys.executable, "-m", "pip", "install", dep])
except Exception as e:
print(f"[ComfyUI-LeafFlow] Warning: Failed to install {dep}: {e}")
print("[ComfyUI-LeafFlow] Dependency check complete! Ready for ComfyUI.")
if __name__ == "__main__":
main()
+1
View File
@@ -0,0 +1 @@
# Package marker for LeafFlow nodes
+230
View File
@@ -0,0 +1,230 @@
import re
import math
class TextAspectRatioFinder:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"aspect_ratios": ("STRING", {
"default": "1:1, 2:3, 3:2, 3:4, 4:3, 9:16, 16:9, 21:9",
"multiline": False
}),
"search_mode": (["First match (Front)", "Last match (Back)"], {
"default": "First match (Front)"
}),
"target_mp": ("FLOAT", {
"default": 1.0,
"min": 0.1,
"max": 16.0,
"step": 0.1,
"display": "number"
}),
"default_aspect_ratio": ([
"1:1", "2:3", "3:2", "3:4", "4:3", "9:16", "16:9", "21:9"
], {
"default": "1:1"
}),
"multiple_of": ("INT", {
"default": 8,
"min": 8,
"max": 128,
"step": 4,
"advanced": True
}),
"min_mp": ("FLOAT", {
"default": 0.1,
"min": 0.01,
"max": 64.0,
"step": 0.1,
"display": "number",
"advanced": True
}),
"max_mp": ("FLOAT", {
"default": 16.0,
"min": 0.1,
"max": 64.0,
"step": 0.1,
"display": "number",
"advanced": True
}),
},
"optional": {
"text": ("STRING", {"forceInput": True}),
}
}
RETURN_TYPES = ("INT", "INT", "STRING")
RETURN_NAMES = ("width", "height", "aspect_ratio")
FUNCTION = "find_aspect_ratio"
CATEGORY = "🍃 LeafFlow/Utils"
DESCRIPTION = "Searches text for aspect ratios (e.g. 16:9), syntax checks them, and calculates width & height for target megapixels."
def find_aspect_ratio(
self,
aspect_ratios="1:1, 2:3, 3:2, 3:4, 4:3, 9:16, 16:9, 21:9",
search_mode="First match (Front)",
target_mp=1.0,
default_aspect_ratio="1:1",
multiple_of=8,
min_mp=0.1,
max_mp=16.0,
text=None
):
text_str = str(text) if text is not None else ""
min_v = float(min_mp)
max_v = max(min_v, float(max_mp))
effective_mp = max(min_v, min(max_v, float(target_mp)))
valid_ratios = []
raw_items = [item.strip() for item in str(aspect_ratios or "").split(",") if item.strip()]
for item in raw_items:
match = re.match(r'^(\d+(?:\.\d+)?)\s*[:xX/]\s*(\d+(?:\.\d+)?)$', item)
if match:
w_val = float(match.group(1))
h_val = float(match.group(2))
if w_val > 0 and h_val > 0:
w_str = str(int(w_val)) if w_val.is_integer() else str(w_val)
h_str = str(int(h_val)) if h_val.is_integer() else str(h_val)
ratio_fmt = f"{w_str}:{h_str}"
if ratio_fmt not in valid_ratios:
valid_ratios.append(ratio_fmt)
if not valid_ratios:
valid_ratios = ["1:1", "2:3", "3:2", "3:4", "4:3", "9:16", "16:9", "21:9"]
found_ratio = None
matches = []
if text_str:
# 1. Match configured target aspect ratios
for ratio in valid_ratios:
w_p, h_p = ratio.split(":")
pattern = r'(?<![\d.])' + re.escape(w_p) + r'\s*[:xX/]\s*' + re.escape(h_p) + r'(?!\d|\.\d)'
for m in re.finditer(pattern, text_str):
matches.append((m.start(), ratio))
# 2. General ratio pattern fallback to catch any valid ratio format (e.g. 16:9., 21:9, 1.77:1) regardless of trailing punctuation
gen_pattern = r'(?<![\d.])(\d+(?:\.\d+)?)\s*[:xX/]\s*(\d+(?:\.\d+)?)(?!\d|\.\d)'
for m in re.finditer(gen_pattern, text_str):
try:
w_val = float(m.group(1))
h_val = float(m.group(2))
if w_val > 0 and h_val > 0:
w_str = str(int(w_val)) if w_val.is_integer() else str(w_val)
h_str = str(int(h_val)) if h_val.is_integer() else str(h_val)
ratio_fmt = f"{w_str}:{h_str}"
if not any(m_pos == m.start() for m_pos, _ in matches):
matches.append((m.start(), ratio_fmt))
except Exception:
pass
if matches:
matches.sort(key=lambda x: x[0])
if search_mode in ["Back", "Last match (Back)"]:
found_ratio = matches[-1][1]
else:
found_ratio = matches[0][1]
else:
found_ratio = default_aspect_ratio
try:
w_part, h_part = map(float, str(found_ratio).split(":"))
if w_part <= 0 or h_part <= 0:
raise ValueError("Dimensions must be positive")
except Exception:
w_part, h_part = 1.0, 1.0
found_ratio = "1:1"
r = w_part / h_part
total_pixels = effective_mp * 1024.0 * 1024.0
raw_h = math.sqrt(total_pixels / r)
raw_w = raw_h * r
m = max(4, int(multiple_of))
width = max(m, int(round(raw_w / m)) * m)
height = max(m, int(round(raw_h / m)) * m)
return (width, height, found_ratio)
from server import PromptServer
class PreviewImageSizeAspectRatio:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {},
"optional": {
"width": ("INT", {"default": 0, "min": 0, "max": 16384, "forceInput": True}),
"height": ("INT", {"default": 0, "min": 0, "max": 16384, "forceInput": True}),
"aspect_ratio": ("STRING", {"forceInput": True}),
"ratio_float": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 100.0, "step": 0.01, "forceInput": True}),
},
"hidden": {
"unique_id": "UNIQUE_ID",
}
}
RETURN_TYPES = ()
RETURN_NAMES = ()
FUNCTION = "process_preview"
CATEGORY = "🍃 LeafFlow/Utils"
OUTPUT_NODE = True
DESCRIPTION = "Visual preview node that displays image aspect ratio and dimension summary."
def process_preview(
self,
width=None,
height=None,
aspect_ratio=None,
ratio_float=None,
unique_id="default"
):
w_val = int(width) if width is not None and int(width) > 0 else 0
h_val = int(height) if height is not None and int(height) > 0 else 0
ar_str = str(aspect_ratio).strip() if aspect_ratio is not None else ""
r_flt = float(ratio_float) if ratio_float is not None and float(ratio_float) > 0 else 0.0
calc_ratio = 1.0
display_text = "1 x 1"
if w_val > 0 and h_val > 0:
calc_ratio = w_val / h_val
display_text = f"{w_val} x {h_val}"
elif ar_str:
match = re.match(r'^(\d+(?:\.\d+)?)\s*[:xX/]\s*(\d+(?:\.\d+)?)$', ar_str)
if match:
w_p = float(match.group(1))
h_p = float(match.group(2))
if w_p > 0 and h_p > 0:
calc_ratio = w_p / h_p
w_disp = int(w_p) if w_p.is_integer() else w_p
h_disp = int(h_p) if h_p.is_integer() else h_p
display_text = f"{w_disp} x {h_disp}"
else:
display_text = ar_str
elif r_flt > 0:
calc_ratio = r_flt
if r_flt >= 1.0:
w_disp = round(r_flt, 2)
display_text = f"{w_disp} x 1"
else:
h_disp = round(1.0 / r_flt, 2)
display_text = f"1 x {h_disp}"
try:
PromptServer.instance.send_sync("leafflow_update_preview_aspect_ratio", {
"node_id": str(unique_id),
"ratio": calc_ratio,
"display_text": display_text
})
except Exception:
pass
return {}
# Alias for backward compatibility
AspectRatioFinder = TextAspectRatioFinder
+236
View File
@@ -0,0 +1,236 @@
import os
import sys
import json
import time
import uuid
import hashlib
import datetime
import logging
from PIL import Image
import folder_paths
from .utils import get_leafflow_user_dir
USER_DIR = get_leafflow_user_dir()
ENV_FILE = os.path.join(USER_DIR, ".env")
def get_env_setting(key, default_val):
if os.path.exists(ENV_FILE):
try:
with open(ENV_FILE, "r", encoding="utf-8") as f:
for line in f:
if line.strip().startswith(f"{key}="):
return line.strip().split("=", 1)[1].strip()
except Exception:
pass
return default_val
def is_assets_restore_enabled():
val = get_env_setting("ENABLE_ASSETS_RESTORE", "true").lower()
return val in ["true", "1", "yes"]
def get_assets_restore_count():
try:
val = int(get_env_setting("RESTORE_ASSETS_COUNT", "64"))
return max(1, min(1000, val))
except Exception:
return 64
class AssetsRestoreManager:
def __init__(self):
self._restored_on_launch = False
self.last_debug_report = {}
def extract_image_metadata(self, filepath):
"""Extracts embedded prompt and workflow graph metadata from image (read-only)."""
prompt_dict = {}
workflow_dict = {}
positive_prompt = ""
try:
with Image.open(filepath) as img:
if img.format == "PNG":
prompt_raw = img.info.get("prompt")
workflow_raw = img.info.get("workflow")
parameters_raw = img.info.get("parameters")
if prompt_raw:
try:
prompt_dict = json.loads(prompt_raw) if isinstance(prompt_raw, str) else prompt_raw
except Exception:
pass
if workflow_raw:
try:
workflow_dict = json.loads(workflow_raw) if isinstance(workflow_raw, str) else workflow_raw
except Exception:
pass
if parameters_raw:
positive_prompt = str(parameters_raw)
except Exception:
pass
return prompt_dict, workflow_dict, positive_prompt
def scan_output_images(self, base_output_dir, limit=64):
"""Scans output directory recursively (strictly read-only) and returns top `limit` newest images."""
if not os.path.exists(base_output_dir):
return []
valid_extensions = {".png", ".jpg", ".jpeg", ".webp", ".PNG", ".JPG", ".JPEG", ".WEBP"}
found_images = []
norm_base = os.path.normpath(base_output_dir)
for root, _, files in os.walk(norm_base):
for file in files:
_, ext = os.path.splitext(file)
if ext in valid_extensions:
full_path = os.path.join(root, file)
try:
mtime = os.path.getmtime(full_path)
rel_dir = os.path.relpath(root, norm_base)
subfolder = "" if rel_dir == "." else rel_dir.replace("\\", "/")
found_images.append({
"filepath": full_path,
"filename": file,
"subfolder": subfolder,
"mtime": mtime
})
except Exception:
pass
# Sort newest first, take limit, then reverse to chronological (oldest to newest)
found_images.sort(key=lambda x: x["mtime"], reverse=True)
top_images = found_images[:limit]
top_images.reverse()
return top_images
def restore_on_launch(self, server, limit=None, force=False):
"""Populates PromptQueue.history on ComfyUI startup with the latest generated images."""
if self._restored_on_launch and not force:
return 0
if limit is None:
limit = get_assets_restore_count()
enabled = is_assets_restore_enabled()
if not enabled:
self._restored_on_launch = True
return 0
base_output_dir = folder_paths.get_output_directory()
output_exists = os.path.exists(base_output_dir)
images = self.scan_output_images(base_output_dir, limit=limit) if output_exists else []
restored_count = 0
prompt_queue = getattr(server, "prompt_queue", None)
if prompt_queue is not None:
with prompt_queue.mutex:
for idx, item in enumerate(images, 1):
filepath = item["filepath"]
filename = item["filename"]
subfolder = item["subfolder"]
rel_key = f"{subfolder}/{filename}" if subfolder else filename
prompt_id = str(uuid.uuid5(uuid.NAMESPACE_URL, f"leafflow:{rel_key}"))
prompt_dict, workflow_dict, positive_prompt = self.extract_image_metadata(filepath)
output_node_id = "9"
if prompt_dict:
for nid, nval in prompt_dict.items():
if isinstance(nval, dict) and "SaveImage" in nval.get("class_type", ""):
output_node_id = str(nid)
break
extra_pnginfo = {}
if workflow_dict:
extra_pnginfo["workflow"] = workflow_dict
if positive_prompt:
extra_pnginfo["positive_prompt"] = positive_prompt
mtime_ms = int(item["mtime"] * 1000)
history_entry = {
"prompt": [
idx,
prompt_id,
prompt_dict,
{
"create_time": mtime_ms,
"extra_pnginfo": extra_pnginfo,
"client_id": "leafflow_restored"
},
[output_node_id]
],
"outputs": {
output_node_id: {
"images": [
{
"filename": filename,
"subfolder": subfolder,
"type": "output"
}
]
}
},
"status": {
"status_str": "success",
"completed": True,
"messages": [
["execution_start", {"timestamp": mtime_ms}],
["execution_success", {"timestamp": mtime_ms}]
]
}
}
prompt_queue.history[prompt_id] = history_entry
restored_count += 1
# Ingest into modern ComfyUI asset database if --enable-assets is active
try:
from app.assets.services.ingest import register_file_in_place
res = register_file_in_place(abs_path=filepath, name=filename, tags=["output"])
if res and hasattr(res, "ref") and res.ref and hasattr(res.ref, "id"):
try:
from app.database.db import create_session, init_db
from app.assets.database.queries import set_reference_metadata
init_db()
with create_session() as session:
set_reference_metadata(session, reference_id=res.ref.id, metadata={
"jobId": prompt_id,
"nodeId": output_node_id,
"filename": filename,
"subfolder": subfolder
})
session.commit()
except Exception:
pass
except Exception:
pass
self._restored_on_launch = True
self.last_debug_report = {
"timestamp": datetime.datetime.now().isoformat(),
"status": "success",
"enabled": enabled,
"configured_limit": limit,
"total_scanned_images": len(images),
"restored_count": restored_count
}
if restored_count > 0:
print(f"[LeafFlow] Restored {restored_count} recent image(s) into Assets / History pane.")
if server:
try:
server.queue_updated()
except Exception:
pass
return restored_count
assets_restore_manager = AssetsRestoreManager()
+21 -16
View File
@@ -5,6 +5,7 @@ import numpy as np
import re
from PIL import Image, ImageOps
import comfy.model_management
from .utils import sanitize_folder_path
class LoadImageFromFolder:
@classmethod
@@ -14,16 +15,17 @@ class LoadImageFromFolder:
"folder": ("STRING", {"default": "input/watch"}),
"wait_if_folder_is_empty": ("BOOLEAN", {"default": True}),
"rescan_interval": ("INT", {"default": 2, "min": 1, "max": 60}),
"sort_by": (["date_modified", "date_created", "name"],),
"sort_by": (["date_modified", "date_created", "name"], {"default": "date_modified"}),
"regex_filter": ("STRING", {"default": ".*"}),
"delete_image": ("BOOLEAN", {"default": False, "tooltip": "If enabled, deletes the image file from disk after loading it. Default is False."}),
}
}
RETURN_TYPES = ("IMAGE", "BOOLEAN")
RETURN_NAMES = ("image", "has_image")
FUNCTION = "watch"
CATEGORY = "🍃 FlowControl/Automation"
DESCRIPTION = "Loads an image from a folder, optionally waiting if the folder is empty."
CATEGORY = "🍃 LeafFlow/Automation"
DESCRIPTION = "Loads an image from a folder, optionally waiting if the folder is empty, with an optional toggle to delete the image after loading."
@classmethod
def IS_CHANGED(s, **kwargs):
@@ -34,16 +36,17 @@ class LoadImageFromFolder:
# Returns a 1x1 0-tensor for no-image state
return torch.zeros((1, 64, 64, 3), dtype=torch.float32)
def load_and_remove_image(self, filepath):
def load_and_remove_image(self, filepath, delete_image=False):
with Image.open(filepath) as img:
i = ImageOps.exif_transpose(img)
image_array = np.array(i.convert("RGB")).astype(np.float32) / 255.0
img_tensor = torch.from_numpy(image_array)[None,]
try:
os.remove(filepath)
except Exception as e:
print(f"[FlowControl] Warning: Failed to remove processed file '{filepath}': {e}")
if delete_image:
try:
os.remove(filepath)
except Exception as e:
print(f"[LeafFlow] Warning: Failed to remove processed file '{filepath}': {e}")
return img_tensor
@@ -52,7 +55,7 @@ class LoadImageFromFolder:
try:
regex = re.compile(regex_filter)
except Exception as e:
print(f"[FlowControl] Invalid regex: {e}. Falling back to .*")
print(f"[LeafFlow] Invalid regex: {e}. Falling back to .*")
regex = re.compile(".*")
files = []
@@ -85,7 +88,9 @@ 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):
def watch(self, folder, wait_if_folder_is_empty, rescan_interval, sort_by, regex_filter, delete_image=False, **kwargs):
folder = sanitize_folder_path(folder, default_dir="input/watch")
if wait_if_folder_is_empty:
while True:
comfy.model_management.throw_exception_if_processing_interrupted()
@@ -96,12 +101,12 @@ class LoadImageFromFolder:
if files:
filepath = files[0] # Pick the first one based on sorting
try:
img_tensor = self.load_and_remove_image(filepath)
img_tensor = self.load_and_remove_image(filepath, delete_image=delete_image)
return (img_tensor, True)
except Exception as e:
print(f"[FlowControl] Error processing {filepath}: {e}")
print(f"[LeafFlow] Error processing {filepath}: {e}")
except Exception as e:
print(f"[FlowControl] Error accessing directory {folder}: {e}")
print(f"[LeafFlow] Error accessing directory {folder}: {e}")
time.sleep(rescan_interval)
else:
@@ -111,12 +116,12 @@ class LoadImageFromFolder:
if files:
filepath = files[0]
try:
img_tensor = self.load_and_remove_image(filepath)
img_tensor = self.load_and_remove_image(filepath, delete_image=delete_image)
return (img_tensor, True)
except Exception as e:
print(f"[FlowControl] Error processing {filepath}: {e}")
print(f"[LeafFlow] Error processing {filepath}: {e}")
except Exception as e:
print(f"[FlowControl] Error accessing directory {folder}: {e}")
print(f"[LeafFlow] Error accessing directory {folder}: {e}")
# No image present or folder missing -> return dummy tensor & False immediately
return (self.create_dummy_image(), False)
+43 -26
View File
@@ -1,4 +1,5 @@
import os
import sys
import threading
import subprocess
from server import PromptServer
@@ -36,13 +37,13 @@ class DecisionManager:
return False
class FlowControlDecision:
class LeafFlowDecision:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"disable": ("BOOLEAN", {"default": False}),
"send_windows_notification": ("BOOLEAN", {"default": False}),
"send_os_notification": ("BOOLEAN", {"default": False}),
"timeout": ("INT", {"default": -1, "min": -1, "max": 3600}),
},
"hidden": {
@@ -53,13 +54,16 @@ class FlowControlDecision:
RETURN_TYPES = ("BOOLEAN",)
RETURN_NAMES = ("cancel",)
FUNCTION = "decide"
CATEGORY = "🍃 FlowControl"
DESCRIPTION = "Pauses execution and waits for your input via the UI buttons.\n\n- 'Continue' outputs False (0).\n- 'Cancel' outputs True (1) so you can route it into a Switch node to bypass later nodes.\n- 'Stop Workflow' instantly aborts the entire ComfyUI generation queue."
CATEGORY = "🍃 LeafFlow/Utils"
DESCRIPTION = "Pauses execution and waits for your input via the UI buttons.\n\n- 'Continue' outputs False (0).\n- 'Cancel' outputs True (1) so you can route it into a Switch node to bypass later nodes.\n- 'Stop Workflow' instantly aborts the entire ComfyUI generation queue.\n- 'OS Notification': Sends a native desktop toast (Windows/macOS/Linux) when waiting."
def send_notification(self, title, message):
if os.name == 'nt':
try:
# Using native PowerShell Toast Notification API (no third party modules required)
try:
if sys.platform == 'win32' or os.name == 'nt':
# Windows native PowerShell Toast Notification with XML escaping
import xml.sax.saxutils
esc_title = xml.sax.saxutils.escape(str(title))
esc_msg = xml.sax.saxutils.escape(str(message))
ps_script = f"""
$ErrorActionPreference = 'Stop'
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
@@ -69,8 +73,8 @@ $template = @"
<toast>
<visual>
<binding template="ToastText02">
<text id="1">{title}</text>
<text id="2">{message}</text>
<text id="1">{esc_title}</text>
<text id="2">{esc_msg}</text>
</binding>
</visual>
</toast>
@@ -82,10 +86,19 @@ $toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("ComfyUI").Show($toast)
"""
subprocess.Popen(["powershell", "-NoProfile", "-Command", ps_script], creationflags=subprocess.CREATE_NO_WINDOW)
except Exception as e:
print(f"[FlowControl] Failed to send Windows notification: {e}")
elif sys.platform == 'darwin':
# macOS native AppleScript Notification
escape_title = title.replace('"', '\\"')
escape_message = message.replace('"', '\\"')
apple_script = f'display notification "{escape_message}" with title "{escape_title}"'
subprocess.Popen(["osascript", "-e", apple_script])
else:
# Linux dbus notify-send
subprocess.Popen(["notify-send", title, message])
except Exception as e:
print(f"[LeafFlow] Failed to send OS notification: {e}")
def decide(self, disable, send_windows_notification, timeout, unique_id=None):
def decide(self, disable, send_os_notification, timeout, unique_id=None):
if disable:
return (False,)
@@ -93,24 +106,24 @@ $toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
return (False,)
# Notify frontend
PromptServer.instance.send_sync("flowcontrol_decision_waiting", {"node_id": unique_id})
PromptServer.instance.send_sync("leafflow_decision_waiting", {"node_id": unique_id})
if send_windows_notification:
self.send_notification("ComfyUI FlowControl", "Workflow paused! Waiting for your decision.")
if send_os_notification:
self.send_notification("ComfyUI LeafFlow", "Workflow paused! Waiting for your decision.")
# Wait for user input
event = threading.Event()
DecisionManager.register_wait(unique_id, event)
print(f"[FlowControl] Node {unique_id} is waiting for user decision...")
print(f"[LeafFlow] Node {unique_id} is waiting for user decision...")
if timeout > 0:
waited = event.wait(timeout)
if not waited:
print(f"[FlowControl] Node {unique_id} timed out. Auto-continuing...")
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("flowcontrol_decision_resolved", {"node_id": unique_id})
PromptServer.instance.send_sync("leafflow_decision_resolved", {"node_id": unique_id})
return (False,)
else:
event.wait()
@@ -119,30 +132,34 @@ $toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
action = DecisionManager.get_action(unique_id)
# Notify frontend that the decision has been resolved
PromptServer.instance.send_sync("flowcontrol_decision_resolved", {"node_id": unique_id})
PromptServer.instance.send_sync("leafflow_decision_resolved", {"node_id": unique_id})
if action == "cancel":
print(f"[FlowControl] Node {unique_id} cancelled.")
print(f"[LeafFlow] Node {unique_id} cancelled.")
return (True,)
elif action == "stop":
print(f"[FlowControl] Node {unique_id} stopped workflow.")
print(f"[LeafFlow] Node {unique_id} stopped workflow.")
try:
# Attempt to cancel current execution queue
PromptServer.instance.prompt_queue.cancel_current_execution()
except:
except Exception:
pass
try:
comfy.model_management.interrupt_current_processing()
except Exception:
pass
# Raise exception to halt this thread instantly
try:
raise comfy.model_management.InterruptProcessingException("Workflow stopped by FlowControl Decision Node.")
raise comfy.model_management.InterruptProcessingException("Workflow stopped by LeafFlow Decision Node.")
except AttributeError:
raise Exception("Workflow stopped by FlowControl Decision Node.")
raise Exception("Workflow stopped by LeafFlow Decision Node.")
print(f"[FlowControl] Node {unique_id} continuing.")
print(f"[LeafFlow] Node {unique_id} continuing.")
return (False,)
# Register API Route
@PromptServer.instance.routes.post("/flowcontrol/decision")
@PromptServer.instance.routes.post("/leafflow/decision")
async def handle_decision(request):
try:
data = await request.json()
+83 -40
View File
@@ -8,10 +8,13 @@ import piexif
import piexif.helper
from aiohttp import web
from server import PromptServer
import folder_paths
from .utils import sanitize_folder_path, get_leafflow_user_dir
IMAGE_CATEGORY = "🍃 FlowControl/Loaders"
IMAGE_CATEGORY = "🍃 LeafFlow/Loaders"
CURRENT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CACHE_FILE = os.path.join(CURRENT_DIR, "image_prompts_cache.json")
USER_DIR = get_leafflow_user_dir()
CACHE_FILE = os.path.join(USER_DIR, "image_prompts_cache.json")
def parse_positive_from_parameters(parameters):
if not parameters:
@@ -48,7 +51,7 @@ def extract_metadata_from_image(filepath):
except Exception:
pass
except Exception as e:
print(f"[FlowControl] Error extracting metadata: {e}")
print(f"[LeafFlow] Error extracting metadata: {e}")
return positive_prompt, width, height
def load_prompts_cache():
@@ -65,7 +68,7 @@ def save_prompts_cache(cache):
with open(CACHE_FILE, "w", encoding="utf-8") as f:
json.dump(cache, f, indent=4, ensure_ascii=False)
except Exception as e:
print(f"[FlowControl] Failed to save prompts cache: {e}")
print(f"[LeafFlow] Failed to save prompts cache: {e}")
server = PromptServer.instance
routes = server.routes
@@ -73,10 +76,32 @@ routes = server.routes
@routes.get("/image_loader/get_images")
async def get_images_endpoint(request):
folder = request.query.get("folder", "")
if not folder or not os.path.exists(folder):
if not folder:
folder = request.query.get("folder_path", "")
resolved_folder = sanitize_folder_path(folder)
if not resolved_folder or not os.path.exists(resolved_folder):
return web.json_response({"names": [], "mapping": {}, "prompts": {}})
image_extensions = {".png", ".jpg", ".jpeg", ".webp", ".PNG", ".JPG", ".JPEG", ".WEBP"}
raw_files = []
for root, dirs, files in os.walk(resolved_folder):
for file in files:
ext = os.path.splitext(file)[1]
if ext in image_extensions:
full_path = os.path.normpath(os.path.join(root, file))
try:
mtime = os.path.getmtime(full_path)
except Exception:
mtime = 0
rel_path = os.path.relpath(full_path, resolved_folder).replace("\\", "/")
raw_files.append((rel_path, full_path, mtime))
# Sort files by newest modified first
raw_files.sort(key=lambda x: x[2], reverse=True)
names = []
mapping = {}
prompts = {}
@@ -84,33 +109,36 @@ async def get_images_endpoint(request):
cache = load_prompts_cache()
cache_modified = False
for root, dirs, files in os.walk(folder):
for file in files:
ext = os.path.splitext(file)[1]
if ext in image_extensions:
full_path = os.path.normpath(os.path.join(root, file))
rel_path = os.path.relpath(full_path, folder).replace("\\", "/")
names.append(rel_path)
mapping[rel_path] = rel_path
try:
mtime = os.path.getmtime(full_path)
cached_entry = cache.get(full_path)
if cached_entry and cached_entry.get("mtime") == mtime:
prompt_text = cached_entry.get("prompt", "")
else:
prompt_text, _, _ = extract_metadata_from_image(full_path)
cache[full_path] = {
"mtime": mtime,
"prompt": prompt_text
}
cache_modified = True
except Exception as e:
print(f"[FlowControl] Error checking cache for {file}: {e}")
prompt_text = ""
prompts[rel_path] = prompt_text
# Extract prompt metadata for the most recent 128 images to keep cache small and fast
METADATA_LIMIT = 128
for i, (rel_path, full_path, mtime) in enumerate(raw_files):
names.append(rel_path)
mapping[rel_path] = rel_path
if i < METADATA_LIMIT:
try:
cached_entry = cache.get(full_path)
if cached_entry and cached_entry.get("mtime") == mtime:
prompt_text = cached_entry.get("prompt", "")
else:
prompt_text, _, _ = extract_metadata_from_image(full_path)
cache[full_path] = {
"mtime": mtime,
"prompt": prompt_text
}
cache_modified = True
except Exception as e:
prompt_text = ""
prompts[rel_path] = prompt_text
else:
prompts[rel_path] = ""
# Prune cache to maximum 256 entries
if len(cache) > 256:
cache_keys = list(cache.keys())[-256:]
cache = {k: cache[k] for k in cache_keys}
cache_modified = True
if cache_modified:
save_prompts_cache(cache)
@@ -124,9 +152,13 @@ async def get_images_endpoint(request):
@routes.get("/image_loader/get_thumbnail")
async def get_thumbnail_endpoint(request):
folder = request.query.get("folder", "")
if not folder:
folder = request.query.get("folder_path", "")
resolved_folder = sanitize_folder_path(folder)
image_rel = request.query.get("image", "")
full_path = os.path.normpath(os.path.join(folder, image_rel))
full_path = os.path.normpath(os.path.join(resolved_folder, image_rel))
if not os.path.exists(full_path):
return web.Response(status=404)
@@ -142,20 +174,25 @@ async def get_thumbnail_endpoint(request):
@routes.get("/image_loader/get_full_image")
async def get_full_image_endpoint(request):
folder = request.query.get("folder", "")
if not folder:
folder = request.query.get("folder_path", "")
resolved_folder = sanitize_folder_path(folder)
image_rel = request.query.get("image", "")
full_path = os.path.normpath(os.path.join(folder, image_rel))
full_path = os.path.normpath(os.path.join(resolved_folder, image_rel))
if not os.path.exists(full_path):
return web.Response(status=404)
return web.FileResponse(full_path)
class ImageLoaderVisualPrettyV2:
class VisualImageLoader:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"folder_path": ("STRING", {"default": ""}),
"display_mode": (["Scrollable", "Show All"], {"default": "Scrollable"}),
"folder": ("STRING", {"default": ""}),
"display_mode": (["Scrollable", "Show All"], {"default": "Scrollable", "advanced": True}),
"sort_images_by": (["Name (A-Z)", "Name (Z-A)", "Date Modified (Newest First)", "Date Modified (Oldest First)"], {"default": "Date Modified (Newest First)", "advanced": True}),
},
"hidden": {
"_selected_image": ("STRING", {"default": ""}),
@@ -172,12 +209,15 @@ class ImageLoaderVisualPrettyV2:
def IS_CHANGED(cls, **kwargs):
return kwargs.get("_selected_image", "")
def load_image(self, folder_path, display_mode="Scrollable", _selected_image=""):
if not folder_path or not _selected_image:
def load_image(self, folder="", display_mode="Scrollable", sort_images_by="Date Modified (Newest First)", folder_path=None, _selected_image="", **kwargs):
input_dir = folder if folder else (folder_path or "")
resolved_folder = sanitize_folder_path(input_dir)
if not resolved_folder or not _selected_image:
empty_image = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
return (empty_image, "", 0, 0)
full_path = os.path.normpath(os.path.join(folder_path, _selected_image))
full_path = os.path.normpath(os.path.join(resolved_folder, _selected_image))
if not os.path.exists(full_path):
empty_image = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
return (empty_image, "", 0, 0)
@@ -194,3 +234,6 @@ class ImageLoaderVisualPrettyV2:
image = torch.from_numpy(image)[None,]
return (image, positive_prompt, width, height)
# Alias for backward compatibility
ImageLoaderVisualPrettyV2 = VisualImageLoader
+20 -13
View File
@@ -3,8 +3,9 @@ import torch
import numpy as np
from PIL import Image, ImageOps
import folder_paths
from .utils import sanitize_folder_path
LOADER_CATEGORY = "🍃 FlowControl/Loaders"
LOADER_CATEGORY = "🍃 LeafFlow/Loaders"
class LoadRecentOutputs:
@classmethod
@@ -29,10 +30,11 @@ class LoadRecentOutputs:
DESCRIPTION = "Cycles through recently generated output images.\n\nExplanation of Index:\n- 'amount' grabs the X newest images from the folder.\n- 'index' selects which of those X images to output.\n- For example, if amount=64, setting index=0 to 63 in a batch will load each of the 64 newest images one by one.\n- If index exceeds amount, it loops back around (e.g. index 64 = index 0)."
def load_single_image(self, output_folder, amount, index):
if output_folder == "output":
clean_input = (output_folder or "").strip()
if clean_input == "output" or not clean_input:
output_dir = folder_paths.get_output_directory()
else:
output_dir = output_folder
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)
@@ -40,15 +42,20 @@ class LoadRecentOutputs:
valid_extensions = ('.png', '.jpg', '.jpeg', '.webp', '.bmp')
file_list = []
for root, _, files in os.walk(output_dir):
for file in files:
if file.lower().endswith(valid_extensions):
path = os.path.join(root, file)
try:
mtime = os.path.getmtime(path)
file_list.append((path, mtime))
except OSError:
continue
try:
for root, dirs, files in os.walk(output_dir):
for file in files:
if file.lower().endswith(valid_extensions):
path = os.path.join(root, file)
try:
mtime = os.path.getmtime(path)
file_list.append((path, mtime))
except OSError:
continue
except Exception as e:
print(f"[LeafFlow] 🍃 Error scanning output folder '{output_dir}': {e}")
dummy = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
return (dummy,)
if not file_list:
dummy = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
@@ -68,7 +75,7 @@ class LoadRecentOutputs:
image = np.array(image).astype(np.float32) / 255.0
image = torch.from_numpy(image).unsqueeze(0)
except Exception as e:
print(f"[FlowControl] Error loading recent output image {target_file}: {e}")
print(f"[LeafFlow] Error loading recent output image {target_file}: {e}")
image = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
return (image,)
+165
View File
@@ -0,0 +1,165 @@
import os
import re
import folder_paths
import comfy.sd
from comfy.utils import load_torch_file
from .lora_loader import (
get_filtered_loras_mapping,
increment_lora_usage,
LORA_CATEGORY,
LORA_OUTPUT_FORMAT_CHOICES
)
from .utils import parse_pretty_name, format_lora_output_name
class TextLoraFinder:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model": ("MODEL",),
"clip": ("CLIP",),
"folder": ("STRING", {"default": ""}),
"search_for": ([
"Parsed Name",
"Filename",
"Filename without extension",
"Custom Regex"
], {"default": "Parsed Name"}),
"custom_regex": ("STRING", {"default": "", "advanced": True}),
"search_mode": (["First match (Front)", "Last match (Back)"], {"default": "First match (Front)"}),
"find_amount": ("INT", {"default": 1, "min": 1, "max": 20, "step": 1}),
"strength_model": ("FLOAT", {"default": 1.0, "min": -100.0, "max": 100.0, "step": 0.01}),
"strength_clip": ("FLOAT", {"default": 1.0, "min": -100.0, "max": 100.0, "step": 0.01}),
"output_format": (LORA_OUTPUT_FORMAT_CHOICES, {"default": "Parsed Name", "advanced": True}),
},
"optional": {
"text": ("STRING", {"forceInput": True}),
}
}
RETURN_TYPES = ("MODEL", "CLIP", "STRING")
RETURN_NAMES = ("MODEL", "CLIP", "loras")
FUNCTION = "find_and_load_loras"
CATEGORY = LORA_CATEGORY
DESCRIPTION = "Scans input text for LoRA names, automatically loads matched LoRAs into Model/CLIP, and outputs the formatted list."
def find_and_load_loras(
self,
model,
clip,
folder="",
search_for="Parsed Name",
custom_regex="",
search_mode="First match (Front)",
find_amount=1,
strength_model=1.0,
strength_clip=1.0,
output_format="Parsed Name",
text=None,
start_from=None,
prompt=None,
**kwargs
):
active_mode = search_mode
if start_from is not None and search_mode == "First match (Front)":
if start_from == "Back":
active_mode = "Last match (Back)"
elif start_from == "Front":
active_mode = "First match (Front)"
input_text = text if text is not None else prompt
text_str = str(input_text) if input_text is not None else ""
if not text_str:
return (model, clip, "")
mapping = get_filtered_loras_mapping(folder, pretty=True)
if not mapping:
return (model, clip, "")
matches = []
seen_paths = set()
if search_for == "Custom Regex":
if custom_regex and custom_regex.strip():
try:
pattern = re.compile(custom_regex, re.IGNORECASE)
for m in pattern.finditer(text_str):
matched_text = m.group(0).strip()
for display_name, sys_path in mapping.items():
if display_name in ["[ NONE ]", "[ RANDOM ]"] or not sys_path or sys_path == "[ NONE ]":
continue
if sys_path in seen_paths:
continue
pretty_val = re.sub(r'\s+V\d+(\.\d+)?$', '', parse_pretty_name(sys_path), flags=re.IGNORECASE).strip()
fname = os.path.basename(sys_path)
fname_noext = os.path.splitext(fname)[0]
if (matched_text.lower() in [pretty_val.lower(), fname.lower(), fname_noext.lower(), display_name.lower()]):
matches.append((m.start(), display_name, sys_path))
seen_paths.add(sys_path)
except Exception as e:
print(f"[LeafFlow] 🍃 Invalid custom regex pattern '{custom_regex}': {e}")
else:
for display_name, sys_path in mapping.items():
if display_name in ["[ NONE ]", "[ RANDOM ]"] or not sys_path or sys_path == "[ NONE ]":
continue
if sys_path in seen_paths:
continue
if search_for in ["Pretty Name", "Parsed Name"]:
target_name = re.sub(r'\s+V\d+(\.\d+)?$', '', parse_pretty_name(sys_path), flags=re.IGNORECASE).strip()
elif search_for == "Filename":
target_name = os.path.basename(sys_path)
else: # Filename without extension
target_name = os.path.splitext(os.path.basename(sys_path))[0]
if not target_name:
continue
pattern = r'(?<!\w)' + re.escape(target_name) + r'(?!\w)'
for m in re.finditer(pattern, text_str, flags=re.IGNORECASE):
matches.append((m.start(), display_name, sys_path))
seen_paths.add(sys_path)
if not matches:
return (model, clip, "")
matches.sort(key=lambda x: x[0])
num_to_take = max(1, int(find_amount))
if active_mode in ["Back", "Last match (Back)"]:
selected_matches = matches[-num_to_take:]
else:
selected_matches = matches[:num_to_take]
current_model = model
current_clip = clip
formatted_names = []
for start_idx, disp_name, sys_path in selected_matches:
lora_path = folder_paths.get_full_path("loras", sys_path)
if not lora_path or not os.path.exists(lora_path):
continue
if strength_model != 0 or strength_clip != 0:
try:
lora_weights = load_torch_file(lora_path, safe_load=True)
current_model, current_clip = comfy.sd.load_lora_for_models(
current_model, current_clip, lora_weights, strength_model, strength_clip
)
increment_lora_usage(sys_path)
except Exception as e:
print(f"[LeafFlow] 🍃 Error loading LoRA '{sys_path}': {e}")
continue
else:
increment_lora_usage(sys_path)
out_name = format_lora_output_name(sys_path, disp_name, output_format=output_format, custom_regex=custom_regex)
if out_name:
formatted_names.append(out_name)
return (current_model, current_clip, ", ".join(formatted_names))
# Alias for backward compatibility
LoraTextFinder = TextLoraFinder
+230 -141
View File
@@ -2,8 +2,9 @@ import os
import re
import fnmatch
import json
import folder_paths
import time
import hashlib
import folder_paths
import urllib.request
import urllib.parse
import threading
@@ -12,10 +13,54 @@ from server import PromptServer
from comfy.utils import load_torch_file
import comfy.sd
LORA_CATEGORY = "🍃 FlowControl/Loaders"
from .utils import (
parse_pretty_name,
parse_pretty_name_with_version,
format_lora_output_name,
sanitize_folder_path,
get_leafflow_user_dir
)
LORA_CATEGORY = "🍃 LeafFlow/Loaders"
LORA_OUTPUT_FORMAT_CHOICES = [
"Parsed Name",
"Filename",
"Filename without extension",
"Relative Path",
"Full Path",
"Custom Regex"
]
CURRENT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ENV_FILE = os.path.join(CURRENT_DIR, ".env")
USER_DIR = get_leafflow_user_dir()
ENV_FILE = os.path.join(USER_DIR, ".env")
LORA_STATE_FILE = os.path.join(USER_DIR, "lora_loader_state.json")
def ensure_user_dir():
os.makedirs(USER_DIR, exist_ok=True)
def load_lora_cycle_state():
ensure_user_dir()
if os.path.exists(LORA_STATE_FILE):
try:
with open(LORA_STATE_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
pass
return {}
def save_lora_cycle_state(state):
ensure_user_dir()
try:
# Auto-prune abandoned state keys if count exceeds 100 entries
if len(state) > 100:
excess_keys = list(state.keys())[:-100]
for k in excess_keys:
del state[k]
with open(LORA_STATE_FILE, "w", encoding="utf-8") as f:
json.dump(state, f, indent=4, ensure_ascii=False)
except Exception as e:
print(f"[LeafFlow] Error saving lora loader cycle state: {e}")
def get_api_keys():
civitai_key = os.getenv("CIVITAI_API_KEY", "")
@@ -31,42 +76,14 @@ def get_api_keys():
tmdb_key = line.split("=", 1)[1].strip()
except Exception:
pass
return civitai_key, tmdb_key
return civitai_key.strip(), tmdb_key.strip()
scraping_thread = None
scraping_lock = threading.Lock()
def parse_pretty_name(filepath):
base = os.path.splitext(os.path.basename(filepath))[0]
parts = base.split('_')
if len(parts) >= 2:
name_part = parts[1]
name_part = re.sub(r'(?<!^)(?=[A-Z])', ' ', name_part)
name_part = name_part.replace('-', ' ')
words = name_part.split()
formatted_words = []
for word in words:
if word.upper() in ["NSFW", "LORA", "V1", "V2", "V3", "V4", "FP16", "HM"]:
formatted_words.append(word.upper())
else:
formatted_words.append(word.capitalize())
return " ".join(formatted_words)
return base.replace('-', ' ')
def parse_pretty_name_with_version(filepath):
base = os.path.splitext(os.path.basename(filepath))[0]
parts = base.split('_')
pretty_name = parse_pretty_name(filepath)
if len(parts) >= 3:
version_candidate = parts[2].strip()
if re.match(r'^v[0-9]+(\.[0-9]+)?$', version_candidate, re.IGNORECASE):
return f"{pretty_name} {version_candidate.upper()}"
return pretty_name
def load_usage_data():
usage_file = os.path.join(CURRENT_DIR, "lora_usage.json")
usage_file = os.path.join(USER_DIR, "lora_usage.json")
usage_data = {}
if os.path.exists(usage_file):
try:
@@ -79,21 +96,23 @@ def load_usage_data():
else:
usage_data[k] = usage_data.get(k, 0) + v
except Exception as e:
print(f"[FlowControl] Error loading usage data: {e}")
print(f"[LeafFlow] Error loading usage data: {e}")
return usage_data
def increment_lora_usage(lora_path):
if not lora_path or lora_path == "[ NONE ]":
return
if get_env_setting("ENABLE_LORA_USAGE", "true").lower() != "true":
return
try:
usage_file = os.path.join(CURRENT_DIR, "lora_usage.json")
usage_file = os.path.join(USER_DIR, "lora_usage.json")
usage_data = load_usage_data()
pretty_key = parse_pretty_name(lora_path)
usage_data[pretty_key] = usage_data.get(pretty_key, 0) + 1
with open(usage_file, "w", encoding="utf-8") as f:
json.dump(usage_data, f, indent=4, ensure_ascii=False)
except Exception as e:
print(f"[FlowControl] Error updating lora usage: {e}")
print(f"[LeafFlow] Error updating lora usage: {e}")
def find_preview_image(lora_relative_path):
lora_full_path = folder_paths.get_full_path("loras", lora_relative_path)
@@ -133,7 +152,7 @@ def scrape_missing_images_sync():
return
civitai_key, tmdb_key = get_api_keys()
failed_scrapes_file = os.path.join(CURRENT_DIR, "failed_scrapes.json")
failed_scrapes_file = os.path.join(USER_DIR, "failed_scrapes.json")
failed_scrapes = {}
if os.path.exists(failed_scrapes_file):
try:
@@ -163,7 +182,6 @@ def scrape_missing_images_sync():
success = False
# 1. Try Civitai API search by file SHA256 hash if enabled
if civitai_enabled:
try:
file_hash = get_file_sha256(lora_path)
@@ -184,31 +202,50 @@ def scrape_missing_images_sync():
with urllib.request.urlopen(img_req, timeout=15) as img_resp:
with open(dest_path, "wb") as f:
f.write(img_resp.read())
print(f"[FlowControl] Scraped Civitai preview for {pretty_name}")
print(f"[LeafFlow] Scraped Civitai preview for {pretty_name}")
success = True
except Exception:
pass
# 2. Try TMDB API search if enabled and TMDB key is provided
if not success and tmdb_enabled and tmdb_key:
try:
url = f"https://api.themoviedb.org/3/search/person?api_key={tmdb_key}&query={urllib.parse.quote(pretty_name)}&include_adult=true"
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
with urllib.request.urlopen(req, timeout=10) as response:
data = json.loads(response.read().decode('utf-8'))
results = data.get("results", [])
if results and len(results) > 0:
profile_path = results[0].get("profile_path")
if profile_path:
img_url = f"https://image.tmdb.org/t/p/w500{profile_path}"
dest_path = os.path.splitext(lora_path)[0] + ".jpg"
img_req = urllib.request.Request(img_url, headers={'User-Agent': 'Mozilla/5.0'})
with urllib.request.urlopen(img_req, timeout=15) as img_resp:
with open(dest_path, "wb") as f:
f.write(img_resp.read())
print(f"[FlowControl] Scraped TMDB preview for {pretty_name}")
success = True
except Exception:
clean_person = parse_pretty_name(lora)
search_terms = [clean_person]
words = clean_person.split()
if len(words) >= 2:
search_terms.append(" ".join(words[1:]))
tmdb_headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'}
if tmdb_key.startswith("eyJ"):
tmdb_headers['Authorization'] = f"Bearer {tmdb_key}"
for term in search_terms:
if success:
break
encoded_term = urllib.parse.quote(term)
if tmdb_key.startswith("eyJ"):
url = f"https://api.themoviedb.org/3/search/person?query={encoded_term}&include_adult=true"
else:
tmdb_headers['Authorization'] = f"Bearer {tmdb_key}"
url = f"https://api.themoviedb.org/3/search/person?api_key={tmdb_key}&query={encoded_term}&include_adult=true"
req = urllib.request.Request(url, headers=tmdb_headers)
with urllib.request.urlopen(req, timeout=10) as response:
data = json.loads(response.read().decode('utf-8'))
results = data.get("results", [])
for res in results:
profile_path = res.get("profile_path")
if profile_path:
img_url = f"https://image.tmdb.org/t/p/w500{profile_path}"
dest_path = os.path.splitext(lora_path)[0] + ".jpg"
img_req = urllib.request.Request(img_url, headers=tmdb_headers)
with urllib.request.urlopen(img_req, timeout=15) as img_resp:
with open(dest_path, "wb") as f:
f.write(img_resp.read())
print(f"[LeafFlow] Scraped TMDB preview for {pretty_name} (via '{term}')")
success = True
break
except Exception as tmdb_err:
pass
if not success:
@@ -222,7 +259,7 @@ def scrape_missing_images_sync():
except Exception:
pass
except Exception as e:
print(f"[FlowControl] Error in preview scraping thread: {e}")
print(f"[LeafFlow] Error in preview scraping thread: {e}")
def start_async_scraping():
global scraping_thread
@@ -235,7 +272,7 @@ def get_filtered_loras_mapping(folder_filter, pretty=False):
all_loras = folder_paths.get_filename_list("loras")
mapping = {"[ NONE ]": "[ NONE ]"}
raw_filter = folder_filter.replace("\\", "/").strip()
raw_filter = (folder_filter or "").replace("\\", "/").strip()
is_wildcard = raw_filter.endswith("*") or raw_filter == ""
clean_filter = raw_filter.rstrip("*").strip("/")
@@ -250,10 +287,13 @@ def get_filtered_loras_mapping(folder_filter, pretty=False):
display_name += parse_pretty_name_with_version(lora) if pretty else lora
mapping[display_name] = lora
elif is_wildcard:
if norm_lora.lower().startswith(clean_filter.lower() + "/"):
subfolder_path = lora_dir[len(clean_filter):].strip("/")
sub_parts = subfolder_path.split('/')
sub_name = sub_parts[-1] if (sub_parts and sub_parts[0]) else ""
if clean_filter == "" or norm_lora.lower().startswith(clean_filter.lower() + "/") or norm_lora.lower() == clean_filter.lower():
subfolder_path = lora_dir[len(clean_filter):].strip("/") if clean_filter else lora_dir
sub_parts = [p for p in subfolder_path.split('/') if p]
if sub_parts:
sub_name = sub_parts[-1]
else:
sub_name = clean_filter.split('/')[-1] if clean_filter else ""
display_name = ""
if sub_name and pretty:
@@ -271,7 +311,6 @@ def get_filtered_loras_mapping(folder_filter, pretty=False):
mapping[display_name] = lora
return mapping
# Endpoints for visual popup UI
server = PromptServer.instance
routes = server.routes
@@ -285,24 +324,35 @@ async def get_loras_endpoint(request):
if scrape_on_new:
start_async_scraping()
usage_data = load_usage_data()
all_loras = folder_paths.get_filename_list("loras")
usage_enabled = get_env_setting("ENABLE_LORA_USAGE", "true").lower() == "true"
usage_by_path = {}
mtime_by_path = {}
all_loras = folder_paths.get_filename_list("loras")
usage_data = load_usage_data() if usage_enabled else {}
for lora in all_loras:
pretty_name = parse_pretty_name(lora)
usage_by_path[lora] = usage_data.get(pretty_name, 0)
if usage_enabled:
usage_by_path[lora] = usage_data.get(pretty_name, 0)
full_path = folder_paths.get_full_path("loras", lora)
if full_path and os.path.exists(full_path):
try:
mtime_by_path[lora] = os.path.getmtime(full_path)
except OSError:
mtime_by_path[lora] = 0
return web.json_response({
"names": list(mapping.keys()),
"mapping": mapping,
"usage": usage_by_path
"usage": usage_by_path,
"mtime": mtime_by_path
})
@routes.get("/folder_lora_loader/get_preview")
async def get_preview_endpoint(request):
system_path = request.query.get("system_path", "")
# Fallback to old behavior for older UI components
lora_name = request.query.get("lora", "")
folder = request.query.get("folder", "")
pretty = request.query.get("pretty", "true").lower() == "true"
@@ -329,13 +379,16 @@ class FolderLoraLoader:
"lora_name": (["[ NONE ]"], {}),
"strength_model": ("FLOAT", {"default": 1.0, "min": -100.0, "max": 100.0, "step": 0.01}),
"strength_clip": ("FLOAT", {"default": 1.0, "min": -100.0, "max": 100.0, "step": 0.01}),
"output_format": (LORA_OUTPUT_FORMAT_CHOICES, {"default": "Filename", "advanced": True}),
"custom_regex": ("STRING", {"default": "", "advanced": True}),
},
"hidden": {
"_selected_lora": ("STRING", {"default": "[ NONE ]"}),
}
}
RETURN_TYPES = ("MODEL", "CLIP")
RETURN_TYPES = ("MODEL", "CLIP", "STRING")
RETURN_NAMES = ("MODEL", "CLIP", "lora_name")
FUNCTION = "load_lora"
CATEGORY = LORA_CATEGORY
DESCRIPTION = "LoRA Loader filtered by folder directory."
@@ -344,35 +397,67 @@ class FolderLoraLoader:
def VALIDATE_INPUTS(cls, **kwargs):
return True
def load_lora(self, model, clip, folder, lora_name, strength_model, strength_clip, _selected_lora="[ NONE ]"):
active_lora = _selected_lora if _selected_lora else lora_name
def load_lora(self, model, clip, folder, lora_name, strength_model, strength_clip, output_format="Filename", custom_regex="", output_name=None, _selected_lora="[ NONE ]"):
active_format = output_name if output_name is not None else output_format
active_lora = _selected_lora if _selected_lora and _selected_lora != "[ NONE ]" else lora_name
if active_lora == "[ NONE ]" or not active_lora:
return (model, clip)
return (model, clip, "")
mapping = get_filtered_loras_mapping(folder, pretty=False)
resolved_path = mapping.get(active_lora)
if not resolved_path or resolved_path == "[ NONE ]":
lora_path = folder_paths.get_full_path("loras", active_lora)
else:
lora_path = folder_paths.get_full_path("loras", resolved_path)
all_loras = folder_paths.get_filename_list("loras")
raw_filter = (folder or "").replace("\\", "/").strip().rstrip("/").lower()
for lora in all_loras:
norm_lora = lora.replace("\\", "/").lower()
if raw_filter and not norm_lora.startswith(raw_filter):
continue
if lora == active_lora or parse_pretty_name(lora) == active_lora:
resolved_path = lora
break
out_name = format_lora_output_name(resolved_path, active_lora, output_format=active_format, custom_regex=custom_regex)
if not resolved_path:
return (model, clip, out_name)
lora_path = folder_paths.get_full_path("loras", resolved_path)
if not lora_path or not os.path.exists(lora_path):
return (model, clip)
return (model, clip, out_name)
lora = load_torch_file(lora_path, safe_load=True)
model_lora, clip_lora = comfy.sd.load_lora_for_models(
model, clip, lora, strength_model, strength_clip
)
return (model_lora, clip_lora)
return (model_lora, clip_lora, out_name)
class FolderLoraLoaderPretty(FolderLoraLoader):
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"model": ("MODEL",),
"clip": ("CLIP",),
"folder": ("STRING", {"default": ""}),
"lora_name": (["[ NONE ]"], {}),
"strength_model": ("FLOAT", {"default": 1.0, "min": -100.0, "max": 100.0, "step": 0.01}),
"strength_clip": ("FLOAT", {"default": 1.0, "min": -100.0, "max": 100.0, "step": 0.01}),
"output_format": (LORA_OUTPUT_FORMAT_CHOICES, {"default": "Parsed Name", "advanced": True}),
"custom_regex": ("STRING", {"default": "", "advanced": True}),
},
"hidden": {
"_selected_lora": ("STRING", {"default": "[ NONE ]"}),
}
}
RETURN_TYPES = ("MODEL", "CLIP", "STRING")
RETURN_NAMES = ("MODEL", "CLIP", "pretty_name")
RETURN_NAMES = ("MODEL", "CLIP", "lora_name")
DESCRIPTION = "LoRA Loader filtered by folder directory with formatted pretty names."
def load_lora(self, model, clip, folder, lora_name, strength_model, strength_clip, _selected_lora="[ NONE ]"):
active_lora = _selected_lora if _selected_lora else lora_name
def load_lora(self, model, clip, folder, lora_name, strength_model, strength_clip, output_format="Parsed Name", custom_regex="", output_name=None, _selected_lora="[ NONE ]"):
active_format = output_name if output_name is not None else output_format
active_lora = lora_name if lora_name != "[ NONE ]" else _selected_lora
if active_lora == "[ NONE ]" or not active_lora:
return (model, clip, "")
@@ -389,17 +474,12 @@ class FolderLoraLoaderPretty(FolderLoraLoader):
if " - " in active_lora:
display_name = active_lora.split(" - ", 1)[1]
pretty_name_output = re.sub(r'\s+V\d+(\.\d+)?$', '', display_name, flags=re.IGNORECASE).strip()
if strength_model == 0 and strength_clip == 0:
return (model, clip, pretty_name_output)
mapping = get_filtered_loras_mapping(folder, pretty=True)
resolved_path = mapping.get(active_lora)
if not resolved_path or resolved_path == "[ NONE ]":
all_loras = folder_paths.get_filename_list("loras")
raw_filter = folder.replace("\\", "/").strip().rstrip("/").lower()
raw_filter = (folder or "").replace("\\", "/").strip().rstrip("/").lower()
for lora in all_loras:
norm_lora = lora.replace("\\", "/").lower()
if raw_filter and not norm_lora.startswith(raw_filter):
@@ -408,21 +488,26 @@ class FolderLoraLoaderPretty(FolderLoraLoader):
resolved_path = lora
break
out_name = format_lora_output_name(resolved_path, display_name, output_format=active_format, custom_regex=custom_regex)
if strength_model == 0 and strength_clip == 0:
return (model, clip, out_name)
if not resolved_path:
return (model, clip, "")
return (model, clip, out_name)
lora_path = folder_paths.get_full_path("loras", resolved_path)
if not lora_path or not os.path.exists(lora_path):
return (model, clip, "")
return (model, clip, out_name)
lora = load_torch_file(lora_path, safe_load=True)
model_lora, clip_lora = comfy.sd.load_lora_for_models(
model, clip, lora, strength_model, strength_clip
)
increment_lora_usage(resolved_path)
return (model_lora, clip_lora, pretty_name_output)
return (model_lora, clip_lora, out_name)
class FolderLoraLoaderVisualPretty(FolderLoraLoaderPretty):
class VisualLoraLoader(FolderLoraLoaderPretty):
@classmethod
def INPUT_TYPES(s):
return {
@@ -432,59 +517,35 @@ class FolderLoraLoaderVisualPretty(FolderLoraLoaderPretty):
"folder": ("STRING", {"default": ""}),
"strength_model": ("FLOAT", {"default": 1.0, "min": -100.0, "max": 100.0, "step": 0.01}),
"strength_clip": ("FLOAT", {"default": 1.0, "min": -100.0, "max": 100.0, "step": 0.01}),
},
"hidden": {
"_selected_lora": ("STRING", {"default": "[ NONE ]"}),
}
}
@classmethod
def IS_CHANGED(cls, **kwargs):
selected = kwargs.get("_selected_lora", "[ NONE ]")
if selected == "[ RANDOM ]":
import random
return random.random()
return ""
def load_lora(self, model, clip, folder, strength_model, strength_clip, _selected_lora="[ NONE ]"):
return super().load_lora(
model=model,
clip=clip,
folder=folder,
lora_name=_selected_lora,
strength_model=strength_model,
strength_clip=strength_clip,
_selected_lora=_selected_lora
)
class FolderLoraLoaderVisualPrettyV2(FolderLoraLoaderVisualPretty):
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"model": ("MODEL",),
"clip": ("CLIP",),
"folder": ("STRING", {"default": ""}),
"strength_model": ("FLOAT", {"default": 1.0, "min": -100.0, "max": 100.0, "step": 0.01}),
"strength_clip": ("FLOAT", {"default": 1.0, "min": -100.0, "max": 100.0, "step": 0.01}),
"display_mode": (["Scrollable", "Show All"], {"default": "Scrollable"}),
"display_mode": (["Scrollable", "Show All"], {"default": "Scrollable", "advanced": True}),
"sort_loras_by": (["Name (A-Z)", "Name (Z-A)", "Usage (High to Low)", "Usage (Low to High)", "Date Modified (Newest First)", "Date Modified (Oldest First)"], {"default": "Name (A-Z)", "advanced": True}),
"sort_folders_by": (["Name (A-Z)", "Name (Z-A)", "Total Usage (High to Low)", "Average Usage (High to Low)", "Total LoRAs (Most First)"], {"default": "Name (A-Z)", "advanced": True}),
"folder_position": (["Folders First", "Root LoRAs First"], {"default": "Folders First", "advanced": True}),
"content_alignment": (["Left Aligned", "Right Aligned"], {"default": "Left Aligned", "advanced": True}),
"output_format": (LORA_OUTPUT_FORMAT_CHOICES, {"default": "Parsed Name", "advanced": True}),
"custom_regex": ("STRING", {"default": "", "advanced": True}),
},
"hidden": {
"_selected_lora": ("STRING", {"default": "[]"}),
"_selection_mode": ("STRING", {"default": "All"}),
"_selection_mode": ("STRING", {"default": "Sequential"}),
"_scrape_on_new": ("STRING", {"default": "true"}),
"unique_id": "UNIQUE_ID",
}
}
RETURN_TYPES = ("MODEL", "CLIP", "STRING")
RETURN_NAMES = ("MODEL", "CLIP", "lora_name")
DESCRIPTION = "Visual thumbnail LoRA browser with search, multi-selection, and ranking badges."
@classmethod
def IS_CHANGED(cls, **kwargs):
mode = kwargs.get("_selection_mode", "All")
if mode == "Random":
import random
return random.random()
if mode in ["Random", "Sequential", "Random (No Replace)"]:
return time.time()
return ""
def load_lora(self, model, clip, folder, strength_model, strength_clip, display_mode="Scrollable", _selected_lora="[]", _selection_mode="All", _scrape_on_new="true"):
def load_lora(self, model, clip, folder, strength_model, strength_clip, display_mode="Scrollable", sort_loras_by="Name (A-Z)", sort_folders_by="Name (A-Z)", folder_position="Folders First", content_alignment="Left Aligned", output_format="Parsed Name", custom_regex="", output_name=None, _selected_lora="[]", _selection_mode="All", _scrape_on_new="true", unique_id="default", **kwargs):
active_format = output_name if output_name is not None else output_format
active_lora = _selected_lora if _selected_lora else "[]"
if active_lora == "[ NONE ]" or active_lora == "[]" or not active_lora:
return (model, clip, "")
@@ -498,11 +559,35 @@ class FolderLoraLoaderVisualPrettyV2(FolderLoraLoaderVisualPretty):
else:
loras_to_load = [active_lora]
if _selection_mode == "Random" and len(loras_to_load) > 0:
import random
if _selection_mode in ["Random", "Sequential", "Random (No Replace)"] and len(loras_to_load) > 0:
valid_choices = [item for item in loras_to_load if item != "[ NONE ]" and item]
if valid_choices:
loras_to_load = [random.choice(valid_choices)]
if _selection_mode == "Random":
import random
loras_to_load = [random.choice(valid_choices)]
elif _selection_mode == "Sequential":
state = load_lora_cycle_state()
text_hash = hashlib.sha256(json.dumps(sorted(valid_choices)).encode('utf-8')).hexdigest()[:16]
state_key = f"seq_{unique_id}_{text_hash}"
idx = state.get(state_key, 0)
selected_item = valid_choices[idx % len(valid_choices)]
state[state_key] = idx + 1
save_lora_cycle_state(state)
loras_to_load = [selected_item]
elif _selection_mode == "Random (No Replace)":
state = load_lora_cycle_state()
text_hash = hashlib.sha256(json.dumps(sorted(valid_choices)).encode('utf-8')).hexdigest()[:16]
state_key = f"noreplace_{unique_id}_{text_hash}"
pool = state.get(state_key, [])
pool = [x for x in pool if x in valid_choices]
if not pool:
import random
pool = list(valid_choices)
random.shuffle(pool)
selected_item = pool.pop(0)
state[state_key] = pool
save_lora_cycle_state(state)
loras_to_load = [selected_item]
else:
loras_to_load = []
@@ -523,12 +608,12 @@ class FolderLoraLoaderVisualPrettyV2(FolderLoraLoaderVisualPretty):
if not resolved_path or resolved_path == "[ NONE ]":
all_loras = folder_paths.get_filename_list("loras")
raw_filter = folder.replace("\\", "/").strip().rstrip("/").lower()
raw_filter = (folder or "").replace("\\", "/").strip().rstrip("/").rstrip("*").strip("/").lower()
for lora in all_loras:
norm_lora = lora.replace("\\", "/").lower()
if raw_filter and not norm_lora.startswith(raw_filter):
continue
if parse_pretty_name(lora) == display_name:
if lora == item or parse_pretty_name(lora) == display_name or parse_pretty_name_with_version(lora) == display_name:
resolved_path = lora
break
@@ -545,8 +630,12 @@ class FolderLoraLoaderVisualPrettyV2(FolderLoraLoaderVisualPretty):
)
increment_lora_usage(resolved_path)
clean_display_name = re.sub(r'\s+V\d+(\.\d+)?$', '', display_name, flags=re.IGNORECASE).strip()
loaded_names.append(clean_display_name)
out_name = format_lora_output_name(resolved_path, display_name, output_format=active_format, custom_regex=custom_regex)
if out_name:
loaded_names.append(out_name)
pretty_name_str = ", ".join(loaded_names) if loaded_names else ""
return (current_model, current_clip, pretty_name_str)
# Alias for backward compatibility
FolderLoraLoaderVisualPrettyV2 = VisualLoraLoader
+1 -1
View File
@@ -1,4 +1,4 @@
PREVIEW_CATEGORY = "🍃 FlowControl/Previews"
PREVIEW_CATEGORY = "🍃 LeafFlow/Previews"
class PreviewLatentLiveNode:
@classmethod
+282
View File
@@ -0,0 +1,282 @@
import os
import re
import json
import random
import time
import hashlib
import platform
import subprocess
from aiohttp import web
from server import PromptServer
import folder_paths
from .utils import get_leafflow_user_dir
CURRENT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
USER_DIR = get_leafflow_user_dir()
STATE_FILE = os.path.join(USER_DIR, "prompt_iterator_state.json")
ENV_FILE = os.path.join(USER_DIR, ".env")
def get_env_setting(key, default_val):
if os.path.exists(ENV_FILE):
try:
with open(ENV_FILE, "r", encoding="utf-8") as f:
for line in f:
if line.strip().startswith(f"{key}="):
return line.strip().split("=", 1)[1].strip()
except Exception:
pass
return default_val
def is_clear_prompt_iterator_on_launch():
val = get_env_setting("CLEAR_PROMPT_ITERATOR_ON_LAUNCH", "false").lower()
return val in ["true", "1", "yes"]
def ensure_user_dir():
os.makedirs(USER_DIR, exist_ok=True)
def load_state():
ensure_user_dir()
if os.path.exists(STATE_FILE):
try:
with open(STATE_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict):
return data
except Exception as e:
print(f"[LeafFlow] 🍃 Warning reading prompt iterator state: {e}")
return {}
def save_state(state):
ensure_user_dir()
try:
# Auto-prune abandoned state keys if count exceeds 100 entries
if len(state) > 100:
excess_keys = list(state.keys())[:-100]
for k in excess_keys:
del state[k]
tmp_file = STATE_FILE + ".tmp"
with open(tmp_file, "w", encoding="utf-8") as f:
json.dump(state, f, indent=4, ensure_ascii=False)
if os.path.exists(STATE_FILE):
os.replace(tmp_file, STATE_FILE)
else:
os.rename(tmp_file, STATE_FILE)
except Exception as e:
print(f"[LeafFlow] 🍃 Error saving prompt iterator state: {e}")
def clear_state():
ensure_user_dir()
try:
with open(STATE_FILE, "w", encoding="utf-8") as f:
json.dump({}, f, indent=4)
print("[LeafFlow] 🍃 Prompt iterator state cleared.")
return True
except Exception as e:
print(f"[LeafFlow] 🍃 Error clearing prompt iterator state: {e}")
return False
# Clear state on startup if privacy setting enabled
if is_clear_prompt_iterator_on_launch():
clear_state()
# Register API endpoints
try:
routes = PromptServer.instance.routes
@routes.post("/leafflow/prompt_iterator/reset_node")
async def reset_prompt_iterator_node(request):
try:
data = await request.json()
node_id = str(data.get("node_id", ""))
state = load_state()
matched = False
for k in list(state.keys()):
if node_id == "all" or k.startswith(f"node_{node_id}_") or k == f"node_{node_id}":
if isinstance(state[k], dict):
state[k]["index"] = 0
else:
state[k] = {"index": 0, "total": 0}
matched = True
save_state(state)
if node_id and node_id != "all":
try:
PromptServer.instance.send_sync("leafflow_prompt_iterator_progress", {
"node_id": node_id,
"current_run": 0,
"total_runs": 0,
"status_text": "Reset (0 / --)"
})
except Exception:
pass
return web.json_response({"status": "ok", "reset": matched})
except Exception as e:
return web.json_response({"status": "error", "message": str(e)}, status=500)
@routes.post("/leafflow/prompt_iterator/clear")
@routes.post("/flow_control/prompt_iterator/clear")
async def clear_prompt_iterator_endpoint(request):
success = clear_state()
return web.json_response({"status": "ok" if success else "error"})
@routes.post("/leafflow/prompt_iterator/open_file")
async def open_prompt_iterator_file(request):
ensure_user_dir()
if not os.path.exists(STATE_FILE):
save_state({})
try:
sys_name = platform.system()
if sys_name == "Windows":
os.startfile(STATE_FILE)
elif sys_name == "Darwin":
subprocess.Popen(["open", STATE_FILE])
else:
subprocess.Popen(["xdg-open", STATE_FILE])
return web.json_response({"status": "ok", "path": STATE_FILE})
except Exception as e:
print(f"[LeafFlow] 🍃 Error opening state file: {e}")
return web.json_response({"status": "error", "message": str(e)}, status=500)
except Exception:
pass
def parse_prompt_blocks(text_str, separator):
if not text_str or not text_str.strip():
return []
clean_text = text_str.replace('\r\n', '\n').replace('\r', '\n')
if separator == "Newline":
return [line.strip() for line in clean_text.split('\n') if line.strip()]
elif separator == ">2 Empty Lines":
raw_blocks = re.split(r'(?:\n\s*){3,}', clean_text)
return [b.strip() for b in raw_blocks if b.strip()]
else: # ">1 Empty Line"
raw_blocks = re.split(r'\n\s*\n+', clean_text)
return [b.strip() for b in raw_blocks if b.strip()]
class PromptQueueIterator:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"pop_mode": ([
"Sequential (Loop on End)",
"Sequential (Stop on End)",
"Random (Keep)",
"Random (Cycle)"
], {"default": "Sequential (Loop on End)"}),
"separator": ([
">1 Empty Line",
"Newline",
">2 Empty Lines"
], {"default": ">1 Empty Line"}),
"text": ("STRING", {"default": "", "multiline": True}),
},
"optional": {
"prompt": ("STRING", {"forceInput": True}),
},
"hidden": {
"unique_id": "UNIQUE_ID",
}
}
RETURN_TYPES = ("STRING", "STRING", "INT")
RETURN_NAMES = ("prompt", "remaining_text", "remaining_count")
FUNCTION = "process_queue"
CATEGORY = "🍃 LeafFlow/Utils"
DESCRIPTION = "Deterministically iterates over multiline prompt text blocks per queue run with live progress display and index controls."
@classmethod
def IS_CHANGED(cls, **kwargs):
# Force re-execution so each queue batch item advances the pointer
return time.time()
def process_queue(
self,
pop_mode="Sequential (Loop on End)",
separator=">1 Empty Line",
text="",
prompt=None,
prompt_text=None,
unique_id="default",
**kwargs
):
raw_input = text if text else (prompt if prompt is not None else prompt_text)
text_str = str(raw_input) if raw_input is not None else ""
if not text_str.strip():
return ("", "", 0)
original_blocks = parse_prompt_blocks(text_str, separator)
total_count = len(original_blocks)
if total_count == 0:
return ("", "", 0)
# Calculate stable hash of the complete input text
text_hash = hashlib.sha256(text_str.strip().encode('utf-8')).hexdigest()[:16]
state_key = f"node_{unique_id}_{text_hash}"
# Load state fresh from disk (no stale memory cache)
state = load_state()
node_state = state.get(state_key)
current_index = 0
if isinstance(node_state, dict):
current_index = int(node_state.get("index", 0))
elif isinstance(node_state, int):
current_index = node_state
# Select prompt based on mode and calculate next index
if pop_mode in ["Sequential (Loop on End)", "Cycle / Loop", "Pop Top & Delete"]:
idx = current_index % total_count
selected_prompt = original_blocks[idx]
next_index = (current_index + 1) % total_count
display_run = idx + 1
elif pop_mode in ["Sequential (Stop on End)"]:
if current_index >= total_count:
idx = total_count - 1
selected_prompt = original_blocks[idx]
next_index = total_count
display_run = total_count
else:
idx = current_index
selected_prompt = original_blocks[idx]
next_index = current_index + 1
display_run = idx + 1
elif pop_mode in ["Random (Keep)", "Random (Delete)"]:
idx = random.randint(0, total_count - 1)
selected_prompt = original_blocks[idx]
next_index = current_index + 1
display_run = (current_index % total_count) + 1
else: # Default fallback
idx = current_index % total_count
selected_prompt = original_blocks[idx]
next_index = (current_index + 1) % total_count
display_run = idx + 1
# Calculate remaining items
remaining_count = max(0, total_count - display_run)
join_delim = "\n" if separator == "Newline" else ("\n\n\n" if separator == ">2 Empty Lines" else "\n\n")
remaining_text = join_delim.join(original_blocks[display_run:]) if display_run < total_count else ""
# Update and save state to disk
state[state_key] = {
"index": next_index,
"total": total_count,
"last_run": display_run,
"last_updated": int(time.time()),
"preview": selected_prompt[:60].replace('\n', ' ')
}
save_state(state)
# Send live progress update to UI
try:
PromptServer.instance.send_sync("leafflow_prompt_iterator_progress", {
"node_id": str(unique_id),
"current_run": display_run,
"total_runs": total_count,
"status_text": f"Run {display_run} / {total_count}"
})
except Exception:
pass
return (selected_prompt, remaining_text, remaining_count)
+67 -49
View File
@@ -5,12 +5,16 @@ import asyncio
from aiohttp import web
from server import PromptServer
import nodes
from .tray_icon import TrayIconManager, is_tray_icon_enabled
from .assets_restore import assets_restore_manager, is_assets_restore_enabled, get_assets_restore_count
from .utils import get_leafflow_user_dir
QUEUE_CATEGORY = "🍃 FlowControl/Queue"
QUEUE_CATEGORY = "🍃 LeafFlow/Queue"
CURRENT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PERSISTENT_FILE = os.path.join(CURRENT_DIR, "persistent_queue.json")
ENV_FILE = os.path.join(CURRENT_DIR, ".env")
USER_DIR = get_leafflow_user_dir()
PERSISTENT_FILE = os.path.join(USER_DIR, "persistent_queue.json")
ENV_FILE = os.path.join(USER_DIR, ".env")
def get_env_setting(key, default_val):
if os.path.exists(ENV_FILE):
@@ -169,12 +173,19 @@ class PauseQueueManager:
except Exception:
pass
try:
if 'tray_manager' in globals() and tray_manager:
tray_manager.update_status()
except Exception:
pass
class PersistentQueueManager:
def __init__(self):
self.lock = threading.RLock()
self.persistent_items = []
self.is_restoring = False
self._patched = False
self.has_claimed_once = False
self.load_from_file()
def load_from_file(self):
@@ -239,46 +250,7 @@ class PersistentQueueManager:
self.save_to_file()
print("[PersistentQueue] Cleared all saved queue items.")
def sync_client_id(self, active_client_id):
if not active_client_id:
return
server = PromptServer.instance
if not hasattr(server, "prompt_queue"):
return
queue = server.prompt_queue
updated = False
with queue.mutex:
new_queue = []
for item in queue.queue:
item_list = list(item)
if len(item_list) > 3 and isinstance(item_list[3], dict):
extra_data = dict(item_list[3])
old_client_id = extra_data.get("client_id")
if old_client_id != active_client_id:
extra_data["client_id"] = active_client_id
item_list[3] = extra_data
updated = True
new_queue.append(tuple(item_list))
if updated:
queue.queue.clear()
queue.queue.extend(new_queue)
if updated:
with self.lock:
for entry in self.persistent_items:
if isinstance(entry.get("item"), list) and len(entry["item"]) > 3:
if isinstance(entry["item"][3], dict):
entry["item"][3]["client_id"] = active_client_id
self.save_to_file()
print(f"[PersistentQueue] Re-assigned restored queue items to active client ID '{active_client_id}'.")
try:
if hasattr(server, "get_queue_status"):
server.send_sync("status", {"status": server.get_queue_status()})
except Exception:
pass
def patch_server(self):
if self._patched:
@@ -336,13 +308,18 @@ class PersistentQueueManager:
self._patched = True
print("[PersistentQueue] Server queue hooks patched successfully.")
def restore_queue(self):
def restore_queue(self, active_client_id=None):
if self.has_claimed_once:
return
if not is_persistent_queue_enabled():
print("[PersistentQueue] Auto-recovery is disabled in settings.")
self.has_claimed_once = True
return
server = PromptServer.instance
if not hasattr(server, "prompt_queue") or not self.persistent_items:
self.has_claimed_once = True
return
restored_state_setting = get_env_setting("PERSISTENT_QUEUE_RESTORED_STATE", "Match Default")
@@ -351,28 +328,69 @@ class PersistentQueueManager:
elif restored_state_setting == "Force Running":
pause_manager.set_pause(False)
print(f"[PersistentQueue] Restoring {len(self.persistent_items)} saved queue item(s)...")
print(f"[PersistentQueue] Restoring {len(self.persistent_items)} saved queue item(s) to client '{active_client_id}'...")
self.is_restoring = True
try:
for entry in list(self.persistent_items):
try:
item_tuple = tuple(entry["item"])
server.prompt_queue.put(item_tuple)
item_list = list(entry["item"])
if active_client_id and len(item_list) > 3 and isinstance(item_list[3], dict):
item_list[3]["client_id"] = active_client_id
server.prompt_queue.put(tuple(item_list))
except Exception as e:
print(f"[PersistentQueue] Error restoring prompt {entry.get('prompt_id')}: {e}")
finally:
self.is_restoring = False
self.has_claimed_once = True
# Sync the updated client_id to the persistent file
if active_client_id:
with self.lock:
for entry in self.persistent_items:
if isinstance(entry.get("item"), list) and len(entry["item"]) > 3:
if isinstance(entry["item"][3], dict):
entry["item"][3]["client_id"] = active_client_id
self.save_to_file()
# Force a UI sync so the client sees the newly injected items
try:
if hasattr(server, "get_queue_status"):
server.send_sync("status", {"status": server.get_queue_status()})
except Exception:
pass
pause_manager = PauseQueueManager()
persistent_manager = PersistentQueueManager()
tray_manager = TrayIconManager(pause_manager)
def setup_queue_control_routes(server):
pause_manager.patch_all()
persistent_manager.patch_server()
persistent_manager.restore_queue()
if is_tray_icon_enabled():
tray_manager.start()
if is_assets_restore_enabled():
assets_restore_manager.restore_on_launch(server)
routes = server.routes
@routes.post("/leafflow/assets/restore")
async def restore_assets_endpoint(request):
try:
data = await request.json()
except Exception:
data = {}
limit = data.get("limit")
force = bool(data.get("force", False))
count = assets_restore_manager.restore_on_launch(server, limit=limit, force=force)
return web.json_response({"success": True, "restored": count, "debug": assets_restore_manager.last_debug_report})
@routes.get("/leafflow/assets/debug")
async def get_assets_debug(request):
return web.json_response(assets_restore_manager.last_debug_report)
@routes.get("/pause_queue/status")
async def get_status(request):
pause_manager.patch_all()
@@ -425,8 +443,8 @@ def setup_queue_control_routes(server):
try:
data = await request.json()
client_id = data.get("client_id")
if client_id:
persistent_manager.sync_client_id(client_id)
if client_id and not persistent_manager.has_claimed_once:
persistent_manager.restore_queue(client_id)
except Exception as e:
print(f"[PersistentQueue] Error in claim endpoint: {e}")
return web.json_response({"status": "ok"})
+80
View File
@@ -0,0 +1,80 @@
import re
def parse_find_targets(find_str):
if not find_str:
return []
raw_items = []
# Split strictly by comma
parts = find_str.split(',')
for part in parts:
item = part.strip()
if (item.startswith('"') and item.endswith('"')) or (item.startswith("'") and item.endswith("'")):
item = item[1:-1].strip()
if item:
raw_items.append(item)
return raw_items
class MultiTextReplacer:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"find": ("STRING", {"default": "", "multiline": True}),
"replace": ("STRING", {"default": "", "multiline": False}),
"case_sensitive": ("BOOLEAN", {"default": False, "advanced": True}),
"search_mode": (["Comma Separated List", "Regex Pattern"], {"default": "Comma Separated List", "advanced": True}),
},
"optional": {
"text": ("STRING", {"default": "", "multiline": True, "forceInput": True}),
}
}
RETURN_TYPES = ("STRING", "INT")
RETURN_NAMES = ("text", "replaced_count")
FUNCTION = "replace_text"
CATEGORY = "🍃 LeafFlow/Utils"
DESCRIPTION = "Searches input text for multiple search targets specified in a comma-separated list or regex, and replaces all matches with a replacement string."
def replace_text(
self,
find="",
replace="",
case_sensitive=False,
search_mode="Comma Separated List",
text=None
):
input_text = str(text) if text is not None else ""
if not input_text or not find:
return (input_text, 0)
flags = 0 if case_sensitive else re.IGNORECASE
replaced_count = 0
result_text = input_text
if search_mode == "Comma Separated List":
targets = parse_find_targets(find)
if not targets:
return (input_text, 0)
# Sort targets by length descending so longer phrases match first
targets.sort(key=len, reverse=True)
# Build single unified regex pattern to guarantee 100% protection against recursive replacement loops
escaped_targets = [re.escape(t) for t in targets]
unified_pattern = "(?:" + "|".join(escaped_targets) + ")"
# Count total matches
replaced_count = len(re.findall(unified_pattern, result_text, flags=flags))
# Perform single-pass substitution
result_text = re.sub(unified_pattern, lambda m: replace, result_text, flags=flags)
else: # Regex Pattern
lines = [line.strip() for line in find.splitlines() if line.strip()]
for pattern_str in lines:
try:
count = len(re.findall(pattern_str, result_text, flags=flags))
replaced_count += count
result_text = re.sub(pattern_str, lambda m: replace, result_text, flags=flags)
except Exception as e:
print(f"[LeafFlow] 🍃 Invalid regex pattern '{pattern_str}': {e}")
return (result_text, replaced_count)
+87
View File
@@ -0,0 +1,87 @@
import re
class LeafFlowTextSplit:
"""
LeafFlow Text Node:
Splits input text into two parts (text1 and text2) by either a literal character sequence
or a regular expression pattern, supporting forward (from start) and backward (from end) splitting.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"text": ("STRING", {"default": "", "multiline": True, "placeholder": "Text to split..."}),
"split_by": ("STRING", {"default": "--", "multiline": False, "placeholder": "Character sequence or regex pattern"}),
},
"optional": {
"use_regex": ("BOOLEAN", {"default": False, "tooltip": "Interpret 'split_by' as a Regular Expression instead of a literal string."}),
"split_direction": (["forward (first occurrence from start)", "backward (last occurrence from end)"], {"default": "forward (first occurrence from start)", "advanced": True, "tooltip": "Choose whether to split on the first match from the beginning or the last match from the end."}),
"strip_whitespace": ("BOOLEAN", {"default": False, "advanced": True, "tooltip": "Strip leading/trailing whitespace from the resulting text1 and text2."}),
}
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("text1", "text2")
FUNCTION = "split_text"
CATEGORY = "🍃 LeafFlow/Utils"
DESCRIPTION = "Splits input text into two parts (text1 and text2) by a character sequence or regex pattern, with forward/backward splitting."
def split_text(
self,
text="",
split_by="--",
use_regex=False,
split_direction="forward (first occurrence from start)",
strip_whitespace=False
):
input_text = str(text) if text is not None else ""
delimiter = str(split_by) if split_by is not None else ""
if not input_text or not delimiter:
t1 = input_text.strip() if strip_whitespace else input_text
return (t1, "")
is_forward = "forward" in str(split_direction).lower()
if use_regex:
try:
if is_forward:
match = re.search(delimiter, input_text)
if match:
start, end = match.span()
t1 = input_text[:start]
t2 = input_text[end:]
else:
t1, t2 = input_text, ""
else:
# Backward: find all matches and take the last match
matches = list(re.finditer(delimiter, input_text))
if matches:
last_match = matches[-1]
start, end = last_match.span()
t1 = input_text[:start]
t2 = input_text[end:]
else:
t1, t2 = input_text, ""
except Exception as e:
print(f"[LeafFlow Text Split] Regex error: {e}. Falling back to literal string split.")
if is_forward:
parts = input_text.split(delimiter, 1)
else:
parts = input_text.rsplit(delimiter, 1)
t1 = parts[0]
t2 = parts[1] if len(parts) > 1 else ""
else:
# Literal string sequence split
if is_forward:
parts = input_text.split(delimiter, 1)
else:
parts = input_text.rsplit(delimiter, 1)
t1 = parts[0]
t2 = parts[1] if len(parts) > 1 else ""
if strip_whitespace:
t1 = t1.strip()
t2 = t2.strip()
return (t1, t2)
+272
View File
@@ -0,0 +1,272 @@
import os
import sys
import threading
import webbrowser
from PIL import Image, ImageDraw
from .utils import get_leafflow_user_dir
USER_DIR = get_leafflow_user_dir()
ENV_FILE = os.path.join(USER_DIR, ".env")
def get_env_setting(key, default_val):
if os.path.exists(ENV_FILE):
try:
with open(ENV_FILE, "r", encoding="utf-8") as f:
for line in f:
if line.strip().startswith(f"{key}="):
return line.strip().split("=", 1)[1].strip()
except Exception:
pass
return default_val
def is_tray_icon_enabled():
val = get_env_setting("ENABLE_TRAY_ICON", "false").lower()
return val in ["true", "1", "yes"]
class TrayIconManager:
def __init__(self, pause_manager=None):
self.pause_manager = pause_manager
self._icon = None
self._thread = None
self._lock = threading.RLock()
self._is_running = False
def create_icon_image(self, color_hex="#059669", symbol="pause"):
size = 64
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
# Draw rounded rectangle matching toolbar button styling
draw.rounded_rectangle(
[2, 2, size - 3, size - 3],
radius=16,
fill=color_hex,
outline=(0, 0, 0, 60),
width=1
)
if symbol == "play":
# Play triangle icon (white)
draw.polygon([(24, 18), (24, 46), (46, 32)], fill="white")
else:
# Pause two-bar icon (white)
draw.rectangle([20, 18, 28, 46], fill="white")
draw.rectangle([36, 18, 44, 46], fill="white")
return img
def get_current_colors(self):
unpaused_color = get_env_setting("PAUSE_BUTTON_UNPAUSED_COLOR", "#059669")
paused_color = get_env_setting("PAUSE_BUTTON_PAUSED_COLOR", "#ea580c")
return unpaused_color, paused_color
def get_status_info(self):
if not self.pause_manager:
return "Running", "#059669", "pause", "ComfyUI LeafFlow: Running"
unpaused_color, paused_color = self.get_current_colors()
is_paused = self.pause_manager.paused
is_waiting = self.pause_manager.is_waiting
mode = self.pause_manager.mode
mode_label = "Instant" if mode == "instantly" else "Finish"
if is_paused:
if is_waiting:
status_text = "Paused (Waiting)"
color = paused_color
symbol = "play"
title = f"ComfyUI LeafFlow: Paused ({mode_label}) - Click to Continue"
else:
status_text = f"Pausing ({mode_label})..."
color = paused_color
symbol = "pause"
title = f"ComfyUI LeafFlow: Pausing ({mode_label})... (Waiting to complete)"
else:
status_text = f"Running [Next Pause: {mode_label}]"
color = unpaused_color
symbol = "pause"
title = f"ComfyUI LeafFlow: Running (Mode: {mode_label})"
return status_text, color, symbol, title
def build_menu(self):
try:
import pystray
except ImportError:
return None
status_text, _, _, _ = self.get_status_info()
is_paused = bool(self.pause_manager and self.pause_manager.paused)
is_waiting = bool(self.pause_manager and self.pause_manager.is_waiting)
current_mode = self.pause_manager.mode if self.pause_manager else "after_finish"
def on_toggle_click(icon, item):
if not self.pause_manager:
return
if self.pause_manager.paused:
self.pause_manager.set_pause(False)
else:
self.pause_manager.set_pause(True)
def on_pause_finish_click(icon, item):
if self.pause_manager:
self.pause_manager.set_pause(True, mode="after_finish")
def on_pause_instant_click(icon, item):
if self.pause_manager:
self.pause_manager.set_pause(True, mode="instantly")
def on_continue_click(icon, item):
if self.pause_manager:
self.pause_manager.set_pause(False)
def on_set_mode_finish(icon, item):
if self.pause_manager:
self.pause_manager.set_mode("after_finish")
def on_set_mode_instant(icon, item):
if self.pause_manager:
self.pause_manager.set_mode("instantly")
def on_open_browser(icon, item):
try:
server_url = "http://127.0.0.1:8188"
from server import PromptServer
if hasattr(PromptServer, "instance") and hasattr(PromptServer.instance, "address"):
addr = PromptServer.instance.address
if addr and len(addr) >= 2:
host = addr[0] or "127.0.0.1"
port = addr[1]
server_url = f"http://{host}:{port}"
webbrowser.open(server_url)
except Exception as e:
print(f"[LeafFlow] Error opening browser: {e}")
def on_disable_tray(icon, item):
self.disable_and_stop()
if is_paused:
mode_desc = "Instant" if current_mode == "instantly" else "After Current"
status_display = f"Paused ({mode_desc})"
if is_waiting:
status_display = f"Pausing ({mode_desc})..."
menu_items = [
pystray.MenuItem(f"● Status: {status_display}", None, enabled=False),
pystray.MenuItem("▶ Resume Queue", on_continue_click, default=True),
]
else:
menu_items = [
pystray.MenuItem("● Status: Running", None, enabled=False),
pystray.MenuItem("⏸ Pause (Instant)", on_pause_instant_click, default=False),
pystray.MenuItem("⏳ Pause (After Current)", on_pause_finish_click, default=False),
]
return pystray.Menu(*menu_items)
def start(self):
with self._lock:
if self._is_running or self._icon is not None:
return
try:
import pystray
except ImportError:
try:
import subprocess
print("[LeafFlow] pystray is not installed. Auto-installing pystray...")
subprocess.check_call([sys.executable, "-m", "pip", "install", "pystray"])
import pystray
print("[LeafFlow] Successfully auto-installed pystray.")
except Exception as e:
print(f"[LeafFlow] Failed to auto-install pystray: {e}. System tray icon cannot start.")
return
_, color, symbol, title = self.get_status_info()
icon_img = self.create_icon_image(color, symbol)
menu = self.build_menu()
self._icon = pystray.Icon(
name="ComfyUI-LeafFlow",
icon=icon_img,
title=title,
menu=menu
)
def _run():
try:
self._is_running = True
print("[LeafFlow] System tray icon started.")
self._icon.run()
except Exception as e:
print(f"[LeafFlow] System tray icon error: {e}")
finally:
self._is_running = False
self._icon = None
self._thread = threading.Thread(target=_run, daemon=True)
self._thread.start()
def stop(self):
with self._lock:
if self._icon is not None:
try:
self._icon.stop()
except Exception:
pass
self._icon = None
self._is_running = False
print("[LeafFlow] System tray icon stopped.")
def update_status(self):
with self._lock:
if not self._is_running or self._icon is None:
return
try:
_, color, symbol, title = self.get_status_info()
self._icon.icon = self.create_icon_image(color, symbol)
self._icon.title = title
self._icon.menu = self.build_menu()
except Exception as e:
print(f"[LeafFlow] Error updating system tray status: {e}")
def set_enabled(self, enabled):
if enabled:
if not self._is_running:
self.start()
else:
self.update_status()
else:
if self._is_running:
self.stop()
def disable_and_stop(self):
# Update .env setting and stop tray
lines = []
if os.path.exists(ENV_FILE):
try:
with open(ENV_FILE, "r", encoding="utf-8") as f:
lines = f.readlines()
except Exception:
lines = []
new_lines = []
has_tray = False
for line in lines:
if line.strip().startswith("ENABLE_TRAY_ICON="):
new_lines.append("ENABLE_TRAY_ICON=false\n")
has_tray = True
else:
new_lines.append(line)
if not has_tray:
new_lines.append("ENABLE_TRAY_ICON=false\n")
try:
with open(ENV_FILE, "w", encoding="utf-8") as f:
f.writelines(new_lines)
except Exception:
pass
self.stop()
-80
View File
@@ -1,80 +0,0 @@
import os
import re
import folder_paths
UTILS_CATEGORY = "🍃 FlowControl/Utils"
def parse_pretty_name(filepath):
base = os.path.splitext(os.path.basename(filepath))[0]
parts = base.split('_')
if len(parts) >= 2:
name_part = parts[1]
name_part = re.sub(r'(?<!^)(?=[A-Z])', ' ', name_part)
name_part = name_part.replace('-', ' ')
words = name_part.split()
formatted_words = []
for word in words:
if word.upper() in ["NSFW", "LORA", "V1", "V2", "V3", "V4", "FP16", "HM"]:
formatted_words.append(word.upper())
else:
formatted_words.append(word.capitalize())
return " ".join(formatted_words)
return base.replace('-', ' ')
def get_pretty_names_for_folder(folder):
all_loras = folder_paths.get_filename_list("loras")
raw_filter = os.path.normpath(folder).lower().replace("\\", "/")
raw_filter = raw_filter.rstrip("/* ")
pretty_names = set()
for lora in all_loras:
lora_path = folder_paths.get_full_path("loras", lora)
if not lora_path:
continue
abs_lora = os.path.normpath(lora_path).lower().replace("\\", "/")
rel_lora = lora.lower().replace("\\", "/")
if raw_filter:
if not abs_lora.startswith(raw_filter) and not rel_lora.startswith(raw_filter):
continue
pretty = parse_pretty_name(lora)
if pretty and pretty not in ["[ NONE ]", "[ RANDOM ]"]:
pretty_names.add(pretty)
return sorted(list(pretty_names), key=len, reverse=True)
class UndoPlaceholder:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"prompt": ("STRING", {"forceInput": True}),
"lora_folder": ("STRING", {"default": ""}),
"placeholder": ("STRING", {"default": "%celeb%"}),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("prompt",)
FUNCTION = "undo_placeholder"
CATEGORY = UTILS_CATEGORY
DESCRIPTION = "Scans prompt string for names matching LoRA files in the given folder and replaces them back with a placeholder token (e.g. %celeb%)."
def undo_placeholder(self, prompt, lora_folder, placeholder):
if not prompt or not lora_folder:
return (prompt,)
pretty_names = get_pretty_names_for_folder(lora_folder)
if not pretty_names:
return (prompt,)
modified_prompt = prompt
for name in pretty_names:
escaped_name = re.escape(name)
pattern = re.compile(rf'\b{escaped_name}\b', re.IGNORECASE)
modified_prompt = pattern.sub(placeholder, modified_prompt)
return (modified_prompt,)
+176
View File
@@ -0,0 +1,176 @@
import os
import re
import folder_paths
def get_leafflow_user_dir():
"""
Returns the centralized user data directory: ComfyUI/user/default/LeafFlow/
"""
base_user = None
try:
if hasattr(folder_paths, "get_user_directory"):
user_base = folder_paths.get_user_directory()
if user_base:
# ComfyUI get_user_directory() returns '.../ComfyUI/user'
# Ensure the 'default' user profile directory is used
norm_base = os.path.normpath(user_base)
if os.path.basename(norm_base).lower() == "user":
base_user = os.path.join(norm_base, "default")
else:
base_user = norm_base
except Exception:
base_user = None
if not base_user:
# Fallback to ComfyUI/user/default
root_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
base_user = os.path.join(root_dir, "user", "default")
leafflow_dir = os.path.join(base_user, "LeafFlow")
os.makedirs(leafflow_dir, exist_ok=True)
return leafflow_dir
def get_env_setting(key, default_val):
"""
Reads a key setting from ComfyUI/user/default/LeafFlow/.env safely.
"""
user_dir = get_leafflow_user_dir()
env_file = os.path.join(user_dir, ".env")
if os.path.exists(env_file):
try:
with open(env_file, "r", encoding="utf-8") as f:
for line in f:
if line.strip().startswith(f"{key}="):
return line.strip().split("=", 1)[1].strip()
except Exception:
pass
return default_val
def parse_pretty_name(filepath):
if not filepath or filepath in ["[ NONE ]", "[ RANDOM ]"]:
return ""
base = os.path.splitext(os.path.basename(filepath))[0]
parts = base.split('_')
if len(parts) >= 2:
name_part = parts[1]
name_part = re.sub(r'(?<=[a-z])(?=[A-Z])', ' ', name_part)
name_part = name_part.replace('-', ' ')
words = name_part.split()
formatted_words = []
for word in words:
if word.upper() in ["NSFW", "LORA", "V1", "V2", "V3", "V4", "FP16", "HM"]:
formatted_words.append(word.upper())
else:
formatted_words.append(word.capitalize())
return " ".join(formatted_words)
return base.replace('-', ' ')
def parse_pretty_name_with_version(filepath):
if not filepath or filepath in ["[ NONE ]", "[ RANDOM ]"]:
return ""
base = os.path.splitext(os.path.basename(filepath))[0]
parts = base.split('_')
pretty_name = parse_pretty_name(filepath)
if len(parts) >= 3:
version_candidate = parts[2].strip()
if re.match(r'^v[0-9]+(\.[0-9]+)?$', version_candidate, re.IGNORECASE):
return f"{pretty_name} {version_candidate.upper()}"
return pretty_name
def sanitize_folder_path(folder_input, default_dir=None):
if folder_input is None:
folder_input = ""
clean_folder = str(folder_input).strip()
# Strip any trailing wildcards (e.g. "krea2\*" -> "krea2", "krea2/*" -> "krea2")
clean_folder = re.sub(r'[\*\?]+$', '', clean_folder).rstrip("\\/")
if not clean_folder and default_dir:
clean_folder = default_dir
if not clean_folder:
return ""
# 1. If absolute path, verify directly with case-insensitive check
if os.path.isabs(clean_folder):
norm_path = os.path.normpath(clean_folder)
if os.path.exists(norm_path):
return norm_path
parent_dir = os.path.dirname(norm_path)
base_name = os.path.basename(norm_path)
if os.path.exists(parent_dir):
try:
for entry in os.listdir(parent_dir):
if entry.lower() == base_name.lower():
return os.path.join(parent_dir, entry)
except Exception:
pass
return norm_path
# 2. If relative path, check output directory, input directory, then base path
candidates = []
try:
output_dir = folder_paths.get_output_directory()
if output_dir:
candidates.append(os.path.normpath(os.path.join(output_dir, clean_folder)))
except Exception:
pass
try:
input_dir = folder_paths.get_input_directory()
if input_dir:
candidates.append(os.path.normpath(os.path.join(input_dir, clean_folder)))
except Exception:
pass
try:
if folder_paths.base_path:
candidates.append(os.path.normpath(os.path.join(folder_paths.base_path, clean_folder)))
except Exception:
pass
for cand in candidates:
if os.path.exists(cand):
return cand
for cand in candidates:
parent_dir = os.path.dirname(cand)
base_name = os.path.basename(cand)
if os.path.exists(parent_dir):
try:
for entry in os.listdir(parent_dir):
if entry.lower() == base_name.lower():
return os.path.join(parent_dir, entry)
except Exception:
pass
return candidates[0] if candidates else os.path.normpath(os.path.join(folder_paths.base_path, clean_folder))
def format_lora_output_name(resolved_path, display_name, output_format="Parsed Name", custom_regex=""):
if not resolved_path or resolved_path in ["[ NONE ]", "[ RANDOM ]"]:
return ""
if output_format == "Filename":
return os.path.basename(resolved_path)
elif output_format == "Filename without extension":
return os.path.splitext(os.path.basename(resolved_path))[0]
elif output_format == "Relative Path":
return resolved_path.replace("\\", "/")
elif output_format == "Full Path":
full_p = folder_paths.get_full_path("loras", resolved_path)
return full_p.replace("\\", "/") if full_p else resolved_path
elif output_format == "Custom Regex" and custom_regex and custom_regex.strip():
try:
m = re.search(custom_regex, resolved_path)
if m:
return m.group(0)
except Exception:
pass
return os.path.splitext(os.path.basename(resolved_path))[0]
else: # "Parsed Name" (default)
clean_name = display_name
if " - " in display_name:
clean_name = display_name.split(" - ", 1)[1]
return re.sub(r'\s+V\d+(\.\d+)?$', '', clean_name, flags=re.IGNORECASE).strip()
+7 -6
View File
@@ -1,14 +1,15 @@
[project]
name = "comfyui-flowcontrol"
name = "comfyui-leafflow"
description = "Essential workflow controls, mid-generation pausing, real-time queue persistence, folder watcher, and visual model loaders for ComfyUI."
version = "1.0.0"
version = "2.1.0"
readme = "README.md"
license = "MIT"
dependencies = ["torch", "pillow", "numpy", "piexif"]
dependencies = ["torch", "pillow", "numpy", "piexif", "pystray"]
[project.urls]
Repository = "https://github.com/KOFiblto/ComfyUI-FlowControl"
Repository = "https://github.com/KOFiblto/ComfyUI-LeafFlow"
[tool.comfy]
PublisherId = "KOFiblto"
DisplayName = "ComfyUI-FlowControl"
Icon = ""
DisplayName = "ComfyUI-LeafFlow"
Icon = "https://raw.githubusercontent.com/KOFiblto/ComfyUI-LeafFlow/main/web/logo.png"
+8
View File
@@ -0,0 +1,8 @@
import os
import sys
# Ensure LeafFlow root is at index 0 in sys.path
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if ROOT in sys.path:
sys.path.remove(ROOT)
sys.path.insert(0, ROOT)
+10
View File
@@ -0,0 +1,10 @@
import os
import sys
import unittest
if __name__ == "__main__":
test_dir = os.path.dirname(os.path.abspath(__file__))
suite = unittest.defaultTestLoader.discover(test_dir, pattern="test_*.py")
runner = unittest.TextTestRunner(verbosity=2)
result = runner.run(suite)
sys.exit(0 if result.wasSuccessful() else 1)
+80
View File
@@ -0,0 +1,80 @@
import os
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import test_helper
from nodes.aspect_ratio import TextAspectRatioFinder, PreviewImageSizeAspectRatio
class TestAspectRatioNodes(unittest.TestCase):
def setUp(self):
self.finder = TextAspectRatioFinder()
self.preview_node = PreviewImageSizeAspectRatio()
def test_find_16_9_standard(self):
w, h, ratio = self.finder.find_aspect_ratio(
text="A cinematic landscape, 16:9 aspect ratio, 8k",
target_mp=1.0,
multiple_of=8
)
self.assertEqual(ratio, "16:9")
self.assertGreater(w, h)
self.assertEqual(w % 8, 0)
self.assertEqual(h % 8, 0)
mp = (w * h) / 1_000_000.0
self.assertAlmostEqual(mp, 1.0, delta=0.1)
def test_find_9_16_portrait(self):
w, h, ratio = self.finder.find_aspect_ratio(
text="Portrait photo, 9:16 vertical format",
target_mp=1.0,
multiple_of=8
)
self.assertEqual(ratio, "9:16")
self.assertGreater(h, w)
self.assertEqual(w % 8, 0)
self.assertEqual(h % 8, 0)
def test_search_mode_front_vs_back(self):
text = "Start with 16:9 but override with 1:1 at the end"
w_front, h_front, r_front = self.finder.find_aspect_ratio(
text=text,
search_mode="First match (Front)"
)
self.assertEqual(r_front, "16:9")
w_back, h_back, r_back = self.finder.find_aspect_ratio(
text=text,
search_mode="Last match (Back)"
)
self.assertEqual(r_back, "1:1")
def test_fallback_to_default_when_no_ratio_found(self):
w, h, ratio = self.finder.find_aspect_ratio(
text="No ratio mentioned here at all",
default_aspect_ratio="4:3",
target_mp=1.0
)
self.assertEqual(ratio, "4:3")
def test_edge_case_empty_or_none_text(self):
w1, h1, r1 = self.finder.find_aspect_ratio(text="", default_aspect_ratio="1:1")
self.assertEqual(r1, "1:1")
w2, h2, r2 = self.finder.find_aspect_ratio(text=None, default_aspect_ratio="1:1")
self.assertEqual(r2, "1:1")
def test_preview_process(self):
with patch("nodes.aspect_ratio.PromptServer.instance.send_sync") as mock_send:
res = self.preview_node.process_preview(
width=1920,
height=1080,
unique_id="test_node"
)
self.assertEqual(res, {})
mock_send.assert_called_once()
if __name__ == "__main__":
unittest.main()
+73
View File
@@ -0,0 +1,73 @@
import os
import sys
import unittest
import tempfile
import shutil
from PIL import Image
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import test_helper
from nodes.auto_watcher import LoadImageFromFolder
class TestAutoWatcher(unittest.TestCase):
def setUp(self):
self.node = LoadImageFromFolder()
self.temp_dir = tempfile.mkdtemp()
def tearDown(self):
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_create_dummy_image(self):
dummy = self.node.create_dummy_image()
self.assertEqual(dummy.shape, (1, 64, 64, 3))
def test_get_filtered_files_by_regex(self):
img = Image.new("RGB", (64, 64), color="blue")
f1 = os.path.join(self.temp_dir, "test_01.png")
f2 = os.path.join(self.temp_dir, "test_02.jpg")
f3 = os.path.join(self.temp_dir, "other.png")
f4 = os.path.join(self.temp_dir, "ignored.txt")
img.save(f1)
img.save(f2)
img.save(f3)
with open(f4, "w") as f:
f.write("text file")
files = self.node.get_filtered_files(self.temp_dir, sort_by="name", regex_filter=r"test_\d+")
self.assertEqual(len(files), 2)
self.assertIn(f1, files)
self.assertIn(f2, files)
self.assertNotIn(f3, files)
self.assertNotIn(f4, files)
def test_empty_folder_returns_dummy(self):
empty_dir = os.path.join(self.temp_dir, "empty")
os.makedirs(empty_dir, exist_ok=True)
img_tensor, has_image = self.node.watch(
folder=empty_dir,
wait_if_folder_is_empty=False,
rescan_interval=1,
sort_by="date_modified",
regex_filter=".*"
)
self.assertFalse(has_image)
self.assertEqual(img_tensor.shape, (1, 64, 64, 3))
def test_load_and_remove_image_with_delete_toggle(self):
img = Image.new("RGB", (64, 64), color="red")
img_path = os.path.join(self.temp_dir, "test_del.png")
img.save(img_path)
# 1. delete_image = False (default: file must remain on disk)
tensor = self.node.load_and_remove_image(img_path, delete_image=False)
self.assertEqual(tensor.shape, (1, 64, 64, 3))
self.assertTrue(os.path.exists(img_path), "File should not be deleted when delete_image=False")
# 2. delete_image = True (file must be removed from disk)
tensor2 = self.node.load_and_remove_image(img_path, delete_image=True)
self.assertEqual(tensor2.shape, (1, 64, 64, 3))
self.assertFalse(os.path.exists(img_path), "File should be deleted when delete_image=True")
if __name__ == "__main__":
unittest.main()
+38
View File
@@ -0,0 +1,38 @@
import os
import sys
import unittest
import threading
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import test_helper
from nodes.decision_node import DecisionManager, LeafFlowDecision
class TestDecisionNode(unittest.TestCase):
def setUp(self):
self.node = LeafFlowDecision()
def test_decision_manager_register_and_trigger(self):
uid = "test_decision_123"
event = threading.Event()
DecisionManager.register_wait(uid, event)
self.assertIn(uid, DecisionManager._waiting_events)
triggered = DecisionManager.trigger_action(uid, "cancel")
self.assertTrue(triggered)
self.assertTrue(event.is_set())
action = DecisionManager.get_action(uid)
self.assertEqual(action, "cancel")
self.assertNotIn(uid, DecisionManager._waiting_events)
def test_decision_disabled_mode(self):
res = self.node.decide(disable=True, send_os_notification=False, timeout=-1, unique_id="disabled_node")
self.assertEqual(res, (False,))
def test_decision_manager_unknown_id_default(self):
action = DecisionManager.get_action("non_existent_id")
self.assertEqual(action, "continue")
if __name__ == "__main__":
unittest.main()
+83
View File
@@ -0,0 +1,83 @@
import os
import sys
import types
from unittest.mock import MagicMock
LEAFFLOW_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Ensure LeafFlow root is prioritized at the very start of sys.path
while LEAFFLOW_ROOT in sys.path:
sys.path.remove(LEAFFLOW_ROOT)
sys.path.insert(0, LEAFFLOW_ROOT)
# If 'nodes' in sys.modules is ComfyUI's root nodes.py file, replace it with LeafFlow's nodes package
if "nodes" in sys.modules:
nodes_mod = sys.modules["nodes"]
if not hasattr(nodes_mod, "__path__") or not getattr(nodes_mod, "__file__", "").startswith(LEAFFLOW_ROOT):
del sys.modules["nodes"]
# Provide lightweight mock stubs for ComfyUI environment if running in standalone CI / test runner
if "aiohttp" not in sys.modules:
try:
import aiohttp
except ImportError:
aiohttp_mock = types.ModuleType("aiohttp")
web_mock = types.ModuleType("aiohttp.web")
web_mock.json_response = MagicMock(return_value={})
web_mock.Response = MagicMock()
aiohttp_mock.web = web_mock
sys.modules["aiohttp"] = aiohttp_mock
sys.modules["aiohttp.web"] = web_mock
if "piexif" not in sys.modules:
try:
import piexif
except ImportError:
piexif_mock = types.ModuleType("piexif")
piexif_mock.load = MagicMock(return_value={})
piexif_mock.dump = MagicMock(return_value=b"")
piexif_mock.insert = MagicMock()
sys.modules["piexif"] = piexif_mock
if "pystray" not in sys.modules:
try:
import pystray
except ImportError:
pystray_mock = types.ModuleType("pystray")
sys.modules["pystray"] = pystray_mock
if "comfy" not in sys.modules:
comfy_mock = types.ModuleType("comfy")
mm_mock = types.ModuleType("comfy.model_management")
mm_mock.throw_exception_if_processing_interrupted = MagicMock()
comfy_mock.model_management = mm_mock
sd_mock = types.ModuleType("comfy.sd")
sd_mock.load_lora_for_models = MagicMock(side_effect=lambda m, c, l, sm, sc: (m, c))
comfy_mock.sd = sd_mock
utils_mock = types.ModuleType("comfy.utils")
utils_mock.load_torch_file = MagicMock(return_value={})
comfy_mock.utils = utils_mock
sys.modules["comfy"] = comfy_mock
sys.modules["comfy.model_management"] = mm_mock
sys.modules["comfy.sd"] = sd_mock
sys.modules["comfy.utils"] = utils_mock
if "folder_paths" not in sys.modules:
fp_mock = types.ModuleType("folder_paths")
fp_mock.get_input_directory = MagicMock(return_value=os.path.join(LEAFFLOW_ROOT, "input"))
fp_mock.get_output_directory = MagicMock(return_value=os.path.join(LEAFFLOW_ROOT, "output"))
fp_mock.get_temp_directory = MagicMock(return_value=os.path.join(LEAFFLOW_ROOT, "temp"))
fp_mock.get_filename_list = MagicMock(return_value=[])
fp_mock.get_full_path = MagicMock(return_value="")
fp_mock.base_path = LEAFFLOW_ROOT
sys.modules["folder_paths"] = fp_mock
if "server" not in sys.modules:
server_mock = types.ModuleType("server")
ps_mock = MagicMock()
ps_mock.instance.routes = MagicMock()
server_mock.PromptServer = ps_mock
sys.modules["server"] = server_mock
+41
View File
@@ -0,0 +1,41 @@
import os
import sys
import unittest
import tempfile
import shutil
from PIL import Image
from unittest.mock import patch
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import test_helper
from nodes.load_recent import LoadRecentOutputs
class TestLoadRecent(unittest.TestCase):
def setUp(self):
self.node = LoadRecentOutputs()
self.temp_dir = tempfile.mkdtemp()
def tearDown(self):
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_load_single_image_finds_recent_image(self):
img = Image.new("RGB", (64, 64), color="red")
f1 = os.path.join(self.temp_dir, "recent_01.png")
f2 = os.path.join(self.temp_dir, "recent_02.png")
img.save(f1)
img.save(f2)
with patch("nodes.load_recent.folder_paths.get_output_directory", return_value=self.temp_dir):
out_tensor, = self.node.load_single_image(output_folder=self.temp_dir, amount=2, index=0)
self.assertEqual(out_tensor.shape[0], 1)
self.assertEqual(out_tensor.shape[1], 64)
self.assertEqual(out_tensor.shape[2], 64)
def test_load_recent_empty_directory_returns_dummy(self):
with patch("nodes.load_recent.folder_paths.get_output_directory", return_value=self.temp_dir):
out_tensor, = self.node.load_single_image(output_folder=self.temp_dir, amount=5, index=0)
self.assertEqual(out_tensor.shape, (1, 64, 64, 3))
if __name__ == "__main__":
unittest.main()
+58
View File
@@ -0,0 +1,58 @@
import os
import sys
import unittest
from unittest.mock import patch, MagicMock
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import test_helper
from nodes.lora_finder import TextLoraFinder
class TestLoraFinder(unittest.TestCase):
def setUp(self):
self.finder = TextLoraFinder()
def test_empty_or_none_text(self):
model, clip, loras = self.finder.find_and_load_loras(
model="MODEL_OBJ",
clip="CLIP_OBJ",
text=""
)
self.assertEqual(model, "MODEL_OBJ")
self.assertEqual(clip, "CLIP_OBJ")
self.assertEqual(loras, "")
m2, c2, l2 = self.finder.find_and_load_loras(
model="MODEL_OBJ",
clip="CLIP_OBJ",
text=None
)
self.assertEqual(l2, "")
@patch("nodes.lora_finder.get_filtered_loras_mapping")
@patch("nodes.lora_finder.folder_paths.get_full_path")
@patch("nodes.lora_finder.os.path.exists")
@patch("nodes.lora_finder.load_torch_file")
@patch("nodes.lora_finder.comfy.sd.load_lora_for_models")
def test_find_and_load_lora_match(self, mock_load_lora, mock_load_torch, mock_exists, mock_get_path, mock_mapping):
mock_mapping.return_value = {
"Ana De Armas": "celebrities/krea2_ana-de-armas_v1.safetensors"
}
mock_get_path.return_value = "/models/loras/celebrities/krea2_ana-de-armas_v1.safetensors"
mock_exists.return_value = True
mock_load_torch.return_value = {}
mock_load_lora.return_value = ("PATCHED_MODEL", "PATCHED_CLIP")
model, clip, loras = self.finder.find_and_load_loras(
model="MODEL",
clip="CLIP",
text="A photo of Ana De Armas in evening dress",
search_for="Parsed Name",
output_format="Parsed Name"
)
self.assertEqual(model, "PATCHED_MODEL")
self.assertEqual(clip, "PATCHED_CLIP")
self.assertIn("Ana De Armas", loras)
if __name__ == "__main__":
unittest.main()
+180
View File
@@ -0,0 +1,180 @@
import os
import sys
import unittest
import tempfile
import json
import shutil
from unittest.mock import patch
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import test_helper
from nodes.prompt_iterator import PromptQueueIterator, parse_prompt_blocks, load_state, save_state
class TestPromptQueueIterator(unittest.TestCase):
def setUp(self):
self.node = PromptQueueIterator()
self.temp_dir = tempfile.mkdtemp()
self.state_file = os.path.join(self.temp_dir, "test_state.json")
def tearDown(self):
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_parse_prompt_blocks_newline(self):
text = "Prompt 1\nPrompt 2\n\nPrompt 3"
blocks = parse_prompt_blocks(text, separator="Newline")
self.assertEqual(blocks, ["Prompt 1", "Prompt 2", "Prompt 3"])
def test_parse_prompt_blocks_empty_lines(self):
text = "Block 1 line 1\nBlock 1 line 2\n\nBlock 2"
blocks = parse_prompt_blocks(text, separator=">1 Empty Line")
self.assertEqual(len(blocks), 2)
self.assertEqual(blocks[0], "Block 1 line 1\nBlock 1 line 2")
self.assertEqual(blocks[1], "Block 2")
def test_sequential_10_batch_execution(self):
"""
Verify that 10 prompts are executed in exact sequential order (0 to 9).
"""
prompts_10 = "\n\n".join([f"Prompt A{i}" for i in range(10)])
state_db = {}
def mock_load():
return dict(state_db)
def mock_save(new_state):
nonlocal state_db
state_db = dict(new_state)
with patch("nodes.prompt_iterator.load_state", side_effect=mock_load), \
patch("nodes.prompt_iterator.save_state", side_effect=mock_save), \
patch("nodes.prompt_iterator.PromptServer.instance.send_sync"):
for expected_idx in range(10):
p, rem_text, rem_count = self.node.process_queue(
pop_mode="Sequential (Loop on End)",
separator=">1 Empty Line",
text=prompts_10,
unique_id="node_10_test"
)
self.assertEqual(p, f"Prompt A{expected_idx}")
self.assertEqual(rem_count, 10 - (expected_idx + 1))
def test_multi_pack_isolation_pack_a_and_pack_b(self):
"""
Verify Pack A (10 prompts) and Pack B (5 prompts) run with complete isolation.
"""
pack_a = "\n\n".join([f"PackA_{i}" for i in range(10)])
pack_b = "\n\n".join([f"PackB_{i}" for i in range(5)])
state_db = {}
def mock_load():
return dict(state_db)
def mock_save(new_state):
nonlocal state_db
state_db = dict(new_state)
with patch("nodes.prompt_iterator.load_state", side_effect=mock_load), \
patch("nodes.prompt_iterator.save_state", side_effect=mock_save), \
patch("nodes.prompt_iterator.PromptServer.instance.send_sync"):
# Run 3 items of Pack A
p0, _, _ = self.node.process_queue(text=pack_a, unique_id="shared_node")
p1, _, _ = self.node.process_queue(text=pack_a, unique_id="shared_node")
p2, _, _ = self.node.process_queue(text=pack_a, unique_id="shared_node")
self.assertEqual(p0, "PackA_0")
self.assertEqual(p1, "PackA_1")
self.assertEqual(p2, "PackA_2")
# Run Pack B -> Must start cleanly at PackB_0!
pb0, _, _ = self.node.process_queue(text=pack_b, unique_id="shared_node")
pb1, _, _ = self.node.process_queue(text=pack_b, unique_id="shared_node")
self.assertEqual(pb0, "PackB_0")
self.assertEqual(pb1, "PackB_1")
# Switch back to Pack A -> Must continue at PackA_3!
p3, _, _ = self.node.process_queue(text=pack_a, unique_id="shared_node")
self.assertEqual(p3, "PackA_3")
def test_text_modification_resets_counter_to_zero(self):
"""
Changing the prompt text (deleting 3 items) creates a new hash and starts at 0.
"""
pack_10 = "\n\n".join([f"Item_{i}" for i in range(10)])
pack_7 = "\n\n".join([f"Item_{i}" for i in range(7)]) # 3 deleted
state_db = {}
def mock_load():
return dict(state_db)
def mock_save(new_state):
nonlocal state_db
state_db = dict(new_state)
with patch("nodes.prompt_iterator.load_state", side_effect=mock_load), \
patch("nodes.prompt_iterator.save_state", side_effect=mock_save), \
patch("nodes.prompt_iterator.PromptServer.instance.send_sync"):
# Run 2 items of 10-pack
self.node.process_queue(text=pack_10, unique_id="edit_node")
self.node.process_queue(text=pack_10, unique_id="edit_node")
# Switch to edited 7-pack -> Must start at Item_0!
first_of_7, _, rem_count = self.node.process_queue(text=pack_7, unique_id="edit_node")
self.assertEqual(first_of_7, "Item_0")
self.assertEqual(rem_count, 6)
def test_forced_counter_reset(self):
"""
Manual counter reset forces next execution back to 0.
"""
pack = "P0\n\nP1\n\nP2\n\nP3"
state_db = {}
def mock_load():
return dict(state_db)
def mock_save(new_state):
nonlocal state_db
state_db = dict(new_state)
with patch("nodes.prompt_iterator.load_state", side_effect=mock_load), \
patch("nodes.prompt_iterator.save_state", side_effect=mock_save), \
patch("nodes.prompt_iterator.PromptServer.instance.send_sync"):
# Run P0, P1
p0, _, _ = self.node.process_queue(text=pack, unique_id="reset_test")
p1, _, _ = self.node.process_queue(text=pack, unique_id="reset_test")
self.assertEqual(p0, "P0")
self.assertEqual(p1, "P1")
# Simulate Reset Counter (set index to 0)
for k in list(state_db.keys()):
if "reset_test" in k:
state_db[k]["index"] = 0
# Next run must be P0 again!
p0_again, _, _ = self.node.process_queue(text=pack, unique_id="reset_test")
self.assertEqual(p0_again, "P0")
def test_empty_text_edge_case(self):
prompt, remaining_text, remaining_count = self.node.process_queue(
text="",
unique_id="empty_node"
)
self.assertEqual(prompt, "")
self.assertEqual(remaining_text, "")
self.assertEqual(remaining_count, 0)
def test_none_text_edge_case(self):
prompt, remaining_text, remaining_count = self.node.process_queue(
text=None,
unique_id="none_node"
)
self.assertEqual(prompt, "")
self.assertEqual(remaining_text, "")
self.assertEqual(remaining_count, 0)
if __name__ == "__main__":
unittest.main()
+93
View File
@@ -0,0 +1,93 @@
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import test_helper
from nodes.text_replacer import MultiTextReplacer, parse_find_targets
class TestMultiTextReplacer(unittest.TestCase):
def setUp(self):
self.node = MultiTextReplacer()
def test_parse_find_targets_basic(self):
targets = parse_find_targets("dog, cat, bird")
self.assertEqual(targets, ["dog", "cat", "bird"])
def test_parse_find_targets_with_quotes(self):
targets = parse_find_targets('"dog", "cat", "bird"')
self.assertEqual(targets, ["dog", "cat", "bird"])
def test_replace_comma_separated_list(self):
text, count = self.node.replace_text(
find="dog, cat, bird",
replace="animal",
case_sensitive=False,
search_mode="Comma Separated List",
text="I have a dog and a cat and a bird."
)
self.assertEqual(text, "I have a animal and a animal and a animal.")
self.assertEqual(count, 3)
def test_replace_longest_match_first(self):
text, count = self.node.replace_text(
find="dog, hot dog",
replace="food",
search_mode="Comma Separated List",
text="I ate a hot dog."
)
self.assertEqual(text, "I ate a food.")
self.assertEqual(count, 1)
def test_case_sensitivity(self):
text_ci, count_ci = self.node.replace_text(
find="cat",
replace="feline",
case_sensitive=False,
text="Cat and cat"
)
self.assertEqual(text_ci, "feline and feline")
self.assertEqual(count_ci, 2)
text_cs, count_cs = self.node.replace_text(
find="cat",
replace="feline",
case_sensitive=True,
text="Cat and cat"
)
self.assertEqual(text_cs, "Cat and feline")
self.assertEqual(count_cs, 1)
def test_regex_mode(self):
text, count = self.node.replace_text(
find=r"\b\d{4}\b",
replace="YEAR",
search_mode="Regex Pattern",
text="In 1999 and 2024."
)
self.assertEqual(text, "In YEAR and YEAR.")
self.assertEqual(count, 2)
def test_edge_case_empty_text(self):
text, count = self.node.replace_text(find="test", replace="new", text="")
self.assertEqual(text, "")
self.assertEqual(count, 0)
def test_edge_case_none_text(self):
text, count = self.node.replace_text(find="test", replace="new", text=None)
self.assertEqual(text, "")
self.assertEqual(count, 0)
def test_edge_case_empty_find(self):
text, count = self.node.replace_text(find="", replace="new", text="Hello")
self.assertEqual(text, "Hello")
self.assertEqual(count, 0)
def test_edge_case_deletion_empty_replace(self):
text, count = self.node.replace_text(find="badword", replace="", text="badword good text")
self.assertEqual(text, " good text")
self.assertEqual(count, 1)
if __name__ == "__main__":
unittest.main()
+114
View File
@@ -0,0 +1,114 @@
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import test_helper
from nodes.text_split import LeafFlowTextSplit
class TestLeafFlowTextSplit(unittest.TestCase):
def setUp(self):
self.node = LeafFlowTextSplit()
def test_forward_literal_split_basic(self):
# User standard case: "Hello --- Bye" with delimiter "--"
t1, t2 = self.node.split_text(
text="Hello --- Bye",
split_by="--",
use_regex=False,
split_direction="forward (first occurrence from start)",
strip_whitespace=False
)
self.assertEqual(t1, "Hello ")
self.assertEqual(t2, "- Bye")
def test_backward_literal_split_basic(self):
# Splitting from end
t1, t2 = self.node.split_text(
text="Hello --- Bye",
split_by="--",
use_regex=False,
split_direction="backward (last occurrence from end)",
strip_whitespace=False
)
self.assertEqual(t1, "Hello -")
self.assertEqual(t2, " Bye")
def test_strip_whitespace(self):
t1, t2 = self.node.split_text(
text=" Left Part --- Right Part ",
split_by="---",
use_regex=False,
strip_whitespace=True
)
self.assertEqual(t1, "Left Part")
self.assertEqual(t2, "Right Part")
def test_forward_regex_split(self):
t1, t2 = self.node.split_text(
text="prompt_v1__step_100__seed_42",
split_by=r"__step_\d+__",
use_regex=True,
split_direction="forward (first occurrence from start)"
)
self.assertEqual(t1, "prompt_v1")
self.assertEqual(t2, "seed_42")
def test_backward_regex_split(self):
t1, t2 = self.node.split_text(
text="tag1#123tag2#456tag3",
split_by=r"#\d+",
use_regex=True,
split_direction="backward (last occurrence from end)"
)
self.assertEqual(t1, "tag1#123tag2")
self.assertEqual(t2, "tag3")
def test_edge_case_empty_text(self):
t1, t2 = self.node.split_text(text="", split_by="--")
self.assertEqual(t1, "")
self.assertEqual(t2, "")
def test_edge_case_none_text(self):
t1, t2 = self.node.split_text(text=None, split_by="--")
self.assertEqual(t1, "")
self.assertEqual(t2, "")
def test_edge_case_empty_delimiter(self):
t1, t2 = self.node.split_text(text="Some text", split_by="")
self.assertEqual(t1, "Some text")
self.assertEqual(t2, "")
def test_edge_case_delimiter_not_found(self):
t1, t2 = self.node.split_text(text="Hello World", split_by="---")
self.assertEqual(t1, "Hello World")
self.assertEqual(t2, "")
def test_edge_case_delimiter_at_start(self):
t1, t2 = self.node.split_text(text="--Rest of message", split_by="--")
self.assertEqual(t1, "")
self.assertEqual(t2, "Rest of message")
def test_edge_case_delimiter_at_end(self):
t1, t2 = self.node.split_text(text="First part--", split_by="--")
self.assertEqual(t1, "First part")
self.assertEqual(t2, "")
def test_edge_case_invalid_regex_fallback(self):
t1, t2 = self.node.split_text(
text="Hello [unclosed bracket World",
split_by="[unclosed bracket",
use_regex=True
)
self.assertEqual(t1, "Hello ")
self.assertEqual(t2, " World")
def test_multiline_text_split(self):
text = "Line 1\nLine 2\n---\nLine 3\nLine 4"
t1, t2 = self.node.split_text(text=text, split_by="\n---\n")
self.assertEqual(t1, "Line 1\nLine 2")
self.assertEqual(t2, "Line 3\nLine 4")
if __name__ == "__main__":
unittest.main()
+55
View File
@@ -0,0 +1,55 @@
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import test_helper
from nodes.utils import (
parse_pretty_name,
parse_pretty_name_with_version,
sanitize_folder_path,
format_lora_output_name
)
class TestUtils(unittest.TestCase):
def test_parse_pretty_name(self):
name = parse_pretty_name("krea2_ana-de-armas_v1.safetensors")
self.assertEqual(name, "Ana De Armas")
def test_parse_pretty_name_special_keywords(self):
name = parse_pretty_name("tag_cyberpunk-nsfw-v2.safetensors")
self.assertIn("NSFW", name)
self.assertIn("V2", name)
def test_parse_pretty_name_with_version(self):
name_v = parse_pretty_name_with_version("krea2_ana-de-armas_v1.safetensors")
self.assertEqual(name_v, "Ana De Armas V1")
def test_format_lora_output_name_modes(self):
rel_path = os.path.join("celebrities", "ana_de_armas.safetensors")
parsed = format_lora_output_name(rel_path, "Ana De Armas", "Parsed Name")
self.assertEqual(parsed, "Ana De Armas")
filename = format_lora_output_name(rel_path, "Ana De Armas", "Filename")
self.assertEqual(filename, "ana_de_armas.safetensors")
no_ext = format_lora_output_name(rel_path, "Ana De Armas", "Filename without extension")
self.assertEqual(no_ext, "ana_de_armas")
custom_reg = format_lora_output_name(rel_path, "Ana De Armas", "Custom Regex", custom_regex=r"ana_([a-z]+)")
self.assertEqual(custom_reg, "ana_de")
def test_sanitize_folder_path_wildcard_stripping(self):
clean = sanitize_folder_path("input/watch/*")
self.assertNotIn("*", clean)
def test_edge_cases_empty_or_none(self):
self.assertEqual(parse_pretty_name(""), "")
self.assertEqual(parse_pretty_name(None), "")
self.assertEqual(parse_pretty_name("[ NONE ]"), "")
self.assertEqual(parse_pretty_name("[ RANDOM ]"), "")
if __name__ == "__main__":
unittest.main()
-23
View File
@@ -1,23 +0,0 @@
import { app } from "/scripts/app.js";
const COLOR_MAP = {
"PauseQueueNode": { color: "#059669", bgcolor: "#047857" },
"PersistentQueueNode": { color: "#059669", bgcolor: "#047857" },
"FolderLoraLoaderVisualPrettyV2": { color: "#059669", bgcolor: "#047857" },
"ImageLoaderVisualPrettyV2": { color: "#059669", bgcolor: "#047857" },
"LoadRecentOutputs": { color: "#059669", bgcolor: "#047857" },
"AutoWatcherNode": { color: "#d97706", bgcolor: "#b45309" },
"UndoPlaceholder": { color: "#d97706", bgcolor: "#b45309" },
"PreviewLatentLive": { color: "#7c3aed", bgcolor: "#6d28d9" }
};
app.registerExtension({
name: "ComfyUI.FlowControl.Colors",
async nodeCreated(node) {
if (node && node.type && COLOR_MAP[node.type]) {
const theme = COLOR_MAP[node.type];
node.color = theme.color;
node.bgcolor = theme.bgcolor;
}
}
});
-254
View File
@@ -1,254 +0,0 @@
import { app } from "/scripts/app.js";
import { api } from "/scripts/api.js";
app.registerExtension({
name: "ComfyUI.FlowControl.Settings",
async setup() {
// 1. Enable Civitai Scraping Toggle
app.ui.settings.addSetting({
id: "FlowControl.EnableCivitaiScraping",
name: "🍃 FlowControl: Enable Civitai Auto-Scraping",
type: "boolean",
defaultValue: true,
tooltip: "Enable/disable downloading LoRA preview thumbnails from Civitai.",
onChange(value) {
api.fetchApi("/flow_control/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enable_civitai_scraping: value ? "true" : "false" })
}).catch(() => {});
}
});
// 2. Civitai API Key Setting with Password Masking & Eye Icon Toggle
app.ui.settings.addSetting({
id: "FlowControl.CivitaiAPIKey",
name: "🍃 FlowControl: Civitai API Key",
type: "text",
defaultValue: "",
tooltip: "Optional. Civitai API works publicly without a key for normal models. Only required for NSFW models, private models, or higher API rate limits.",
onChange(value) {
api.fetchApi("/flow_control/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ civitai_api_key: value })
}).catch(() => {});
}
});
// 3. Enable TMDB Scraping Toggle
app.ui.settings.addSetting({
id: "FlowControl.EnableTMDBScraping",
name: "🍃 FlowControl: Enable TMDB Auto-Scraping",
type: "boolean",
defaultValue: true,
tooltip: "Enable/disable downloading celebrity preview thumbnails from TMDB.",
onChange(value) {
api.fetchApi("/flow_control/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enable_tmdb_scraping: value ? "true" : "false" })
}).catch(() => {});
}
});
// 4. TMDB API Key Setting with Password Masking & Eye Icon Toggle
app.ui.settings.addSetting({
id: "FlowControl.TMDBAPIKey",
name: "🍃 FlowControl: TMDB API Key",
type: "text",
defaultValue: "",
tooltip: "Optional. Only needed if auto-scraping celebrity preview thumbnails from TMDB.",
onChange(value) {
api.fetchApi("/flow_control/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tmdb_api_key: value })
}).catch(() => {});
}
});
// 5. Default Pause Queue State (Paused vs Running)
app.ui.settings.addSetting({
id: "FlowControl.DefaultPauseState",
name: "🍃 FlowControl: Default Pause Queue State on Launch",
type: "combo",
options: ["Paused", "Running"],
defaultValue: "Paused",
tooltip: "Choose whether the queue starts in Paused state or Running state on normal ComfyUI startup.",
onChange(value) {
api.fetchApi("/flow_control/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ default_pause_state: value })
}).catch(() => {});
}
});
// 6. Default Pause Queue Mode (Finish vs Instant)
app.ui.settings.addSetting({
id: "FlowControl.DefaultPauseMode",
name: "🍃 FlowControl: Default Pause Queue Mode on Launch",
type: "combo",
options: ["Pause (Finish)", "Pause (Instant)"],
defaultValue: "Pause (Finish)",
tooltip: "Choose default pause behavior mode on ComfyUI startup.",
onChange(value) {
const modeKey = value === "Pause (Instant)" ? "instantly" : "after_finish";
api.fetchApi("/flow_control/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ default_pause_mode: modeKey })
}).catch(() => {});
}
});
// 7. Persistent Queue Restored Launch State Override
app.ui.settings.addSetting({
id: "FlowControl.PersistentQueueRestoredState",
name: "🍃 FlowControl: Persistent Queue Restored Launch State",
type: "combo",
options: ["Match Default", "Force Paused", "Force Running"],
defaultValue: "Match Default",
tooltip: "Override launch state when unfinished queue items are restored on startup (e.g., keep normal launch Running, but force Restored queue startup Paused).",
onChange(value) {
api.fetchApi("/flow_control/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ persistent_queue_restored_state: value })
}).catch(() => {});
}
});
// 8. Toolbar Button Unpaused Color
app.ui.settings.addSetting({
id: "FlowControl.PauseButtonUnpausedColor",
name: "🍃 FlowControl: Pause Button Unpaused Color",
type: "text",
defaultValue: "#059669",
tooltip: "Hex color code for the Pause toolbar button when execution is unpaused/running (default: #059669).",
onChange(value) {
document.documentElement.style.setProperty("--pq-unpaused-color", value || "#059669");
}
});
// 9. Toolbar Button Paused Color
app.ui.settings.addSetting({
id: "FlowControl.PauseButtonPausedColor",
name: "🍃 FlowControl: Pause Button Paused Color",
type: "text",
defaultValue: "#ea580c",
tooltip: "Hex color code for the Pause toolbar button when execution is paused (default: #ea580c).",
onChange(value) {
document.documentElement.style.setProperty("--pq-paused-color", value || "#ea580c");
}
});
// 10. Pause Queue Toolbar Toggle Setting
app.ui.settings.addSetting({
id: "FlowControl.EnablePauseQueue",
name: "🍃 FlowControl: Enable Pause Queue Toolbar Button",
type: "boolean",
defaultValue: true,
tooltip: "Displays the green/orange Pause & Continue button group in top action bar.",
onChange(value) {
const group = document.querySelector(".pq-button-group");
if (group) {
group.style.display = value ? "inline-flex" : "none";
}
}
});
// 11. Enable Persistent Queue Setting
app.ui.settings.addSetting({
id: "FlowControl.EnablePersistentQueue",
name: "🍃 FlowControl: Enable Persistent Queue (Auto-Recovery)",
type: "boolean",
defaultValue: true,
tooltip: "Automatically saves unfinished queue items to disk and restores them after restart/crash.",
onChange(value) {
api.fetchApi("/flow_control/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enable_persistent_queue: value ? "true" : "false" })
}).catch(() => {});
}
});
// Masking password inputs and adding color pickers in ComfyUI settings dialog
const applyKeyMasking = () => {
const inputs = document.querySelectorAll('input');
inputs.forEach(input => {
const settingRow = input.closest('tr') || input.parentElement;
if (!settingRow) return;
const text = settingRow.textContent || "";
// Password masking for API keys
if ((text.includes("Civitai API Key") || text.includes("TMDB API Key")) && !input.dataset.masked) {
input.dataset.masked = "true";
input.type = "password";
input.style.paddingRight = "2.2rem";
const toggleBtn = document.createElement("button");
toggleBtn.type = "button";
toggleBtn.innerHTML = "👁️";
toggleBtn.style.cssText = "position: absolute; right: 0.5rem; background: none; border: none; cursor: pointer; font-size: 1rem; color: #a1a1aa; padding: 2px;";
toggleBtn.title = "Toggle Show/Hide Key";
toggleBtn.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
if (input.type === "password") {
input.type = "text";
toggleBtn.innerHTML = "🙈";
} else {
input.type = "password";
toggleBtn.innerHTML = "👁️";
}
};
const container = input.parentElement;
if (container) {
container.style.position = "relative";
container.appendChild(toggleBtn);
}
}
// Color picker popup for Pause button colors
if ((text.includes("Pause Button Unpaused Color") || text.includes("Pause Button Paused Color")) && !input.dataset.colorPickerAttached) {
input.dataset.colorPickerAttached = "true";
const isUnpaused = text.includes("Unpaused Color");
const colorPicker = document.createElement("input");
colorPicker.type = "color";
colorPicker.value = input.value || (isUnpaused ? "#059669" : "#ea580c");
colorPicker.style.cssText = "width: 28px; height: 28px; border: none; border-radius: 4px; cursor: pointer; padding: 0; background: none; vertical-align: middle; margin-left: 8px;";
colorPicker.oninput = (e) => {
input.value = e.target.value;
input.dispatchEvent(new Event("change", { bubbles: true }));
const varName = isUnpaused ? "--pq-unpaused-color" : "--pq-paused-color";
document.documentElement.style.setProperty(varName, e.target.value);
};
input.oninput = (e) => {
colorPicker.value = e.target.value;
const varName = isUnpaused ? "--pq-unpaused-color" : "--pq-paused-color";
document.documentElement.style.setProperty(varName, e.target.value);
};
const container = input.parentElement;
if (container) {
container.style.display = "flex";
container.style.alignItems = "center";
container.appendChild(colorPicker);
}
}
});
};
setInterval(() => {
applyKeyMasking();
}, 1000);
}
});
+174
View File
@@ -0,0 +1,174 @@
import { app } from "/scripts/app.js";
import { api } from "/scripts/api.js";
/**
* Fallback clipboard copy helper that works in non-secure HTTP and headless contexts.
*/
async function copyToClipboard(text) {
if (!text) return false;
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (e) {
console.warn("[LeafFlow] Navigator clipboard failed, trying textarea fallback:", e);
}
}
try {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.left = "-9999px";
textarea.style.top = "-9999px";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
const successful = document.execCommand("copy");
document.body.removeChild(textarea);
return successful;
} catch (err) {
console.error("[LeafFlow] Clipboard copy fallback failed:", err);
return false;
}
}
/**
* Helper to fetch image metadata and extract the positive prompt text.
*/
async function copyImagePrompt(imgSrc) {
if (!imgSrc) return false;
try {
let url = new URL(imgSrc, window.location.origin);
let filename = url.searchParams.get("filename");
let type = url.searchParams.get("type") || "output";
let subfolder = url.searchParams.get("subfolder") || "";
if (!filename) return false;
// Try extracting via LeafFlow /view_image_prompt endpoint if available or direct PNG extraction
const promptUrl = `/leafflow/view_image_prompt?filename=${encodeURIComponent(filename)}&type=${encodeURIComponent(type)}&subfolder=${encodeURIComponent(subfolder)}`;
const response = await api.fetchApi(promptUrl);
if (response.ok) {
const data = await response.json();
if (data && data.prompt) {
return await copyToClipboard(data.prompt);
}
}
} catch (e) {
console.warn("[LeafFlow] Direct prompt fetch failed:", e);
}
return false;
}
// 1. Hook into Node Context Menu (Right Click on Image Nodes)
app.registerExtension({
name: "ComfyUI.LeafFlow.CopyPrompt",
async beforeRegisterNodeDef(nodeType, nodeData) {
const origGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function (_, options) {
if (origGetExtraMenuOptions) origGetExtraMenuOptions.apply(this, arguments);
const enabled = app.ui.settings.getSettingValue(
"LeafFlow.3. 📋 Prompt Actions.02_EnableContextMenuCopyPrompt",
true
);
if (!enabled) return;
if (this.imgs && this.imgs.length > 0) {
options.push({
content: "📋 Copy Prompt",
callback: async () => {
const img = this.imgs[this.imageIndex || 0];
if (!img || !img.src) return;
const success = await copyImagePrompt(img.src);
if (success) {
alert("Prompt copied to clipboard!");
} else {
alert("LeafFlow: Could not extract positive prompt from this image.");
}
},
});
}
};
},
});
// 2. Hook into Standard Top Hover Action Bar over Image Cards in Assets Pane & Canvas Previews
function injectHoverCopyAction(overlayBar) {
if (!overlayBar || overlayBar.querySelector(".leafflow-hover-copy")) return;
const enabled = app.ui.settings.getSettingValue(
"LeafFlow.3. 📋 Prompt Actions.01_EnableAssetsCopyPromptButton",
true
);
if (!enabled) return;
// Locate the white icon group container (div.flex.shrink-0) inside the overlay
const iconGroup =
overlayBar.querySelector(".flex.shrink-0") ||
overlayBar.querySelector('button[aria-label="Zoom in"]')?.parentElement ||
overlayBar;
// Locate parent image card or asset item container
const parentCard = overlayBar.closest(
"div[data-virtual-grid-item], .asset-card, [data-node-id], .lg-node, div.relative"
);
const img = parentCard ? parentCard.querySelector("img") : overlayBar.parentElement?.querySelector("img");
if (!img || !img.src) return;
// Find the 'More options' button (3-dots ellipsis) to insert before it
const moreBtn =
iconGroup.querySelector('button[aria-label="More options"]') ||
iconGroup.querySelector('button[aria-label="More"]') ||
iconGroup.lastElementChild;
const copyBtn = document.createElement("button");
copyBtn.className =
"leafflow-hover-btn leafflow-hover-copy relative inline-flex items-center justify-center cursor-pointer touch-manipulation appearance-none border-none text-xs font-medium font-inter transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-white text-gray-700 hover:bg-gray-100 size-8 p-0 rounded-none pointer-events-auto border-r border-gray-200 shrink-0";
copyBtn.title = "Copy Prompt";
copyBtn.setAttribute("aria-label", "Copy Prompt");
copyBtn.setAttribute("data-pd-tooltip", "true");
copyBtn.innerHTML = "<span class='text-sm pointer-events-none'>📋</span>";
copyBtn.onclick = async (e) => {
e.preventDefault();
e.stopPropagation();
const success = await copyImagePrompt(img.src);
if (success) {
copyBtn.innerHTML = "<span class='text-sm pointer-events-none'>✅</span>";
setTimeout(() => (copyBtn.innerHTML = "<span class='text-sm pointer-events-none'>📋</span>"), 2000);
} else {
copyBtn.innerHTML = "<span class='text-sm pointer-events-none'>❌</span>";
setTimeout(() => (copyBtn.innerHTML = "<span class='text-sm pointer-events-none'>📋</span>"), 2000);
}
};
if (moreBtn && moreBtn.parentElement === iconGroup) {
iconGroup.insertBefore(copyBtn, moreBtn);
} else {
iconGroup.appendChild(copyBtn);
}
}
// Observe DOM mutations to attach the copy button whenever a hover overlay bar appears
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === "childList") {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
const selectors =
'.absolute.top-2, .absolute.top-1, .asset-card-overlay, [data-testid="asset-card-actions"]';
if (node.matches?.(selectors)) {
injectHoverCopyAction(node);
} else if (node.querySelectorAll) {
const overlays = node.querySelectorAll(selectors);
overlays.forEach((overlay) => injectHoverCopyAction(overlay));
}
}
});
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
+19 -19
View File
@@ -4,7 +4,7 @@ import { api } from "/scripts/api.js";
// Create global styles for the decision buttons
const styles = document.createElement("style");
styles.textContent = `
.flowcontrol-decision-btn {
.leafflow-decision-btn {
margin: 4px;
padding: 6px 12px;
border: none;
@@ -14,19 +14,19 @@ styles.textContent = `
color: white;
transition: all 0.2s;
}
.flowcontrol-decision-btn:disabled {
.leafflow-decision-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
filter: grayscale(100%);
}
.flowcontrol-btn-continue { background-color: #2e7d32; }
.flowcontrol-btn-continue:hover:not(:disabled) { background-color: #388e3c; }
.flowcontrol-btn-cancel { background-color: #f57c00; }
.flowcontrol-btn-cancel:hover:not(:disabled) { background-color: #fb8c00; }
.flowcontrol-btn-stop { background-color: #d32f2f; }
.flowcontrol-btn-stop:hover:not(:disabled) { background-color: #f44336; }
.leafflow-btn-continue { background-color: #2e7d32; }
.leafflow-btn-continue:hover:not(:disabled) { background-color: #388e3c; }
.leafflow-btn-cancel { background-color: #f57c00; }
.leafflow-btn-cancel:hover:not(:disabled) { background-color: #fb8c00; }
.leafflow-btn-stop { background-color: #d32f2f; }
.leafflow-btn-stop:hover:not(:disabled) { background-color: #f44336; }
.flowcontrol-decision-container {
.leafflow-decision-container {
display: flex;
flex-direction: column;
align-items: center;
@@ -38,9 +38,9 @@ styles.textContent = `
document.head.appendChild(styles);
app.registerExtension({
name: "Comfy.FlowControlDecision",
name: "Comfy.LeafFlowDecision",
async setup() {
api.addEventListener("flowcontrol_decision_waiting", (event) => {
api.addEventListener("leafflow_decision_waiting", (event) => {
const data = event.detail;
if (data && data.node_id) {
const node = app.graph.getNodeById(data.node_id);
@@ -50,7 +50,7 @@ app.registerExtension({
}
});
api.addEventListener("flowcontrol_decision_resolved", (event) => {
api.addEventListener("leafflow_decision_resolved", (event) => {
const data = event.detail;
if (data && data.node_id) {
const node = app.graph.getNodeById(data.node_id);
@@ -61,22 +61,22 @@ app.registerExtension({
});
},
async nodeCreated(node) {
if (node.comfyClass === "FlowControlDecision") {
if (node.comfyClass === "LeafFlowDecision") {
const container = document.createElement("div");
container.className = "flowcontrol-decision-container";
container.className = "leafflow-decision-container";
const btnContinue = document.createElement("button");
btnContinue.className = "flowcontrol-decision-btn flowcontrol-btn-continue";
btnContinue.className = "leafflow-decision-btn leafflow-btn-continue";
btnContinue.innerText = "Continue Workflow";
btnContinue.disabled = true;
const btnCancel = document.createElement("button");
btnCancel.className = "flowcontrol-decision-btn flowcontrol-btn-cancel";
btnCancel.className = "leafflow-decision-btn leafflow-btn-cancel";
btnCancel.innerText = "Cancel (Return True)";
btnCancel.disabled = true;
const btnStop = document.createElement("button");
btnStop.className = "flowcontrol-decision-btn flowcontrol-btn-stop";
btnStop.className = "leafflow-decision-btn leafflow-btn-stop";
btnStop.innerText = "Stop Workflow";
btnStop.disabled = true;
@@ -87,7 +87,7 @@ app.registerExtension({
const sendDecision = async (action) => {
node.disableDecisionButtons();
try {
await api.fetchApi("/flowcontrol/decision", {
await api.fetchApi("/leafflow/decision", {
method: "POST",
body: JSON.stringify({
node_id: node.id.toString(),
@@ -95,7 +95,7 @@ app.registerExtension({
}),
});
} catch (e) {
console.error("[FlowControl] Failed to send decision:", e);
console.error("[LeafFlow] Failed to send decision:", e);
}
};
+357 -96
View File
@@ -20,6 +20,24 @@ function getFolderBorderColor(folderName) {
// Global CSS injection for the visual node styling
const visualStyles = document.createElement("style");
visualStyles.textContent = `
.lora-visual-container.right-aligned-mode .lora-folder-grid,
.lora-visual-container.right-aligned-mode .lora-none-container {
display: flex !important;
flex-wrap: wrap !important;
justify-content: flex-end !important;
gap: 8px !important;
}
.lora-visual-container .lora-folder-grid,
.lora-visual-container .lora-none-container {
display: flex !important;
flex-wrap: wrap !important;
justify-content: flex-start !important;
gap: 8px !important;
}
.lora-visual-container .lora-tile {
width: var(--lora-tile-size, 80px) !important;
flex-shrink: 0 !important;
}
.lora-visual-container {
width: 100%;
display: flex;
@@ -28,15 +46,36 @@ visualStyles.textContent = `
border-radius: 8px;
padding: 8px;
box-sizing: border-box;
height: 100%;
min-height: 0;
max-height: 100%;
overflow-y: hidden;
}
.lora-visual-container.vue-mode {
height: 400px;
min-height: 200px;
resize: vertical;
.lora-grid-container {
flex: 1 1 0px;
min-height: 0;
max-height: 100%;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
box-sizing: border-box;
padding-right: 4px;
}
.lora-visual-container.litegraph-mode {
height: 100%;
.lora-grid-container::-webkit-scrollbar {
width: 6px;
}
.lora-grid-container::-webkit-scrollbar-track {
background: rgba(0,0,0,0.1);
border-radius: 4px;
}
.lora-grid-container::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.15);
border-radius: 4px;
}
.lora-grid-container::-webkit-scrollbar-thumb:hover {
background: rgba(255,255,255,0.3);
}
.lora-visual-container.grid-layout {
display: grid;
@@ -46,20 +85,6 @@ visualStyles.textContent = `
display: flex;
flex-direction: column;
}
.lora-visual-container::-webkit-scrollbar {
width: 6px;
}
.lora-visual-container::-webkit-scrollbar-track {
background: rgba(0,0,0,0.1);
border-radius: 4px;
}
.lora-visual-container::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.15);
border-radius: 4px;
}
.lora-visual-container::-webkit-scrollbar-thumb:hover {
background: rgba(255,255,255,0.3);
}
.lora-none-container {
width: 100%;
display: grid;
@@ -272,6 +297,71 @@ visualStyles.textContent = `
border: 1px solid #fff !important;
box-shadow: 0 0 6px rgba(205, 127, 50, 0.8) !important;
}
/* Over 100x Special Effects & Animations */
@keyframes pulseGold100 {
0% { box-shadow: 0 0 8px #ffd700, inset 0 0 4px #ffe600; border-color: #ffd700; }
50% { box-shadow: 0 0 20px #ffaa00, inset 0 0 10px #ffea00; border-color: #ffffff; }
100% { box-shadow: 0 0 8px #ffd700, inset 0 0 4px #ffe600; border-color: #ffd700; }
}
@keyframes pulseSilver100 {
0% { box-shadow: 0 0 8px #e2e8f0, inset 0 0 4px #ffffff; border-color: #cbd5e1; }
50% { box-shadow: 0 0 20px #ffffff, inset 0 0 10px #f8fafc; border-color: #ffffff; }
100% { box-shadow: 0 0 8px #e2e8f0, inset 0 0 4px #ffffff; border-color: #cbd5e1; }
}
@keyframes pulseBronze100 {
0% { box-shadow: 0 0 8px #cd7f32, inset 0 0 4px #d97706; border-color: #cd7f32; }
50% { box-shadow: 0 0 20px #f59e0b, inset 0 0 10px #fbbf24; border-color: #ffedd5; }
100% { box-shadow: 0 0 8px #cd7f32, inset 0 0 4px #d97706; border-color: #cd7f32; }
}
@keyframes pulseOver100 {
0% { box-shadow: 0 0 6px #a855f7; border-color: #a855f7; }
50% { box-shadow: 0 0 16px #c084fc; border-color: #e9d5ff; }
100% { box-shadow: 0 0 6px #a855f7; border-color: #a855f7; }
}
.lora-tile.rank-1-100:not(.selected) {
animation: pulseGold100 2s infinite ease-in-out !important;
}
.lora-tile.rank-2-100:not(.selected) {
animation: pulseSilver100 2s infinite ease-in-out !important;
}
.lora-tile.rank-3-100:not(.selected) {
animation: pulseBronze100 2s infinite ease-in-out !important;
}
.lora-tile.over-100:not(.selected) {
animation: pulseOver100 2s infinite ease-in-out !important;
}
.lora-tile-badge.rank-1-100 {
background: linear-gradient(135deg, #fff700, #ff8800) !important;
color: #000 !important;
border: 1.5px solid #ffffff !important;
box-shadow: 0 0 10px #ffd700 !important;
font-weight: 900 !important;
}
.lora-tile-badge.rank-2-100 {
background: linear-gradient(135deg, #ffffff, #64748b) !important;
color: #000 !important;
border: 1.5px solid #ffffff !important;
box-shadow: 0 0 10px #f8fafc !important;
font-weight: 900 !important;
}
.lora-tile-badge.rank-3-100 {
background: linear-gradient(135deg, #f59e0b, #78350f) !important;
color: #fff !important;
border: 1.5px solid #ffffff !important;
box-shadow: 0 0 10px #f59e0b !important;
font-weight: 900 !important;
}
.lora-tile-badge.over-100 {
background: linear-gradient(135deg, #a855f7, #581c87) !important;
color: #fff !important;
border: 1.5px solid #f3e8ff !important;
box-shadow: 0 0 10px #c084fc !important;
font-weight: 900 !important;
}
/* Top 3 Glow styling for non-selected tiles */
.lora-tile.rank-1:not(.selected) {
border-color: #ffd700 !important;
@@ -292,16 +382,17 @@ app.registerExtension({
name: "Comfy.FolderLoraLoader",
async nodeCreated(node) {
// --- 1. ORIGINAL DROPDOWN NODES ---
if (node.comfyClass === "FolderLoraLoader" || node.comfyClass === "FolderLoraLoaderPretty") {
if (node.comfyClass === "FolderLoraLoader" || node.comfyClass === "FolderLoraLoaderPretty" || node.type === "FolderLoraLoader" || node.type === "FolderLoraLoaderPretty") {
const folderWidget = node.widgets.find(w => w.name === "folder");
const loraWidget = node.widgets.find(w => w.name === "lora_name");
let hiddenWidget = node.widgets.find(w => w.name === "_selected_lora");
if (!hiddenWidget) {
hiddenWidget = node.addWidget("text", "_selected_lora", "[ NONE ]", () => {}, { serialize: true });
hiddenWidget.type = "hidden";
hiddenWidget.computeSize = () => [0, -4];
}
hiddenWidget.type = "hidden";
hiddenWidget.hidden = true;
hiddenWidget.computeSize = () => [0, 0];
if (folderWidget && loraWidget) {
let activeRequest = null;
@@ -593,22 +684,30 @@ app.registerExtension({
}
tilePrettyName = tilePrettyName.replace(/\s+V\d+(\.\d+)?$/i, "").trim();
const systemPath = data.mapping[loraName];
const usageCount = (data.usage && systemPath) ? (data.usage[systemPath] || 0) : 0;
const isOver100 = usageCount >= 100;
let rankClass = "";
if (ranks.rank1.includes(tilePrettyName)) rankClass = "rank-1";
else if (ranks.rank2.includes(tilePrettyName)) rankClass = "rank-2";
else if (ranks.rank3.includes(tilePrettyName)) rankClass = "rank-3";
if (ranks.rank1.includes(tilePrettyName)) {
rankClass = isOver100 ? "rank-1-100" : "rank-1";
} else if (ranks.rank2.includes(tilePrettyName)) {
rankClass = isOver100 ? "rank-2-100" : "rank-2";
} else if (ranks.rank3.includes(tilePrettyName)) {
rankClass = isOver100 ? "rank-3-100" : "rank-3";
} else if (isOver100) {
rankClass = "over-100";
}
if (rankClass) {
tile.classList.add(rankClass);
}
const systemPath = data.mapping[loraName];
const usageCount = (data.usage && systemPath) ? (data.usage[systemPath] || 0) : 0;
if (usageCount > 0) {
const badge = document.createElement("div");
badge.className = "lora-tile-badge";
if (rankClass) badge.classList.add(rankClass);
badge.innerText = usageCount;
badge.innerText = isOver100 ? `🔥 ${usageCount}` : usageCount;
tile.appendChild(badge);
}
@@ -668,7 +767,7 @@ app.registerExtension({
}
// --- 3. VISUAL PRETTY PICKER V2 (Collapsible folders & Multi-selection) ---
if (node.comfyClass === "FolderLoraLoaderVisualPrettyV2") {
if (node.comfyClass === "FolderLoraLoaderVisualPrettyV2" || node.comfyClass === "VisualLoraLoader" || node.type === "FolderLoraLoaderVisualPrettyV2" || node.type === "VisualLoraLoader") {
node.size = [380, 360];
node.computeSize = function() {
@@ -686,14 +785,17 @@ app.registerExtension({
if (widgets.length > 1) {
const keep = widgets.find(w => w.value && w.value !== "[ NONE ]" && w.value !== "[]") || widgets[0];
node.widgets = node.widgets.filter(w => w.name !== "_selected_lora" || w === keep);
keep.hidden = true;
return keep;
}
if (widgets.length === 1) {
widgets[0].hidden = true;
return widgets[0];
}
const w = node.addWidget("text", "_selected_lora", "[]", () => {}, { serialize: true });
w.type = "hidden";
w.computeSize = () => [0, -4];
w.hidden = true;
w.computeSize = () => [0, 0];
return w;
};
const hiddenWidget = getHiddenWidget();
@@ -703,14 +805,17 @@ app.registerExtension({
if (widgets.length > 1) {
const keep = widgets.find(w => w.value && w.value !== "All") || widgets[0];
node.widgets = node.widgets.filter(w => w.name !== "_selection_mode" || w === keep);
keep.hidden = true;
return keep;
}
if (widgets.length === 1) {
widgets[0].hidden = true;
return widgets[0];
}
const w = node.addWidget("text", "_selection_mode", "All", () => {}, { serialize: true });
const w = node.addWidget("text", "_selection_mode", "Sequential", () => {}, { serialize: true });
w.type = "hidden";
w.computeSize = () => [0, -4];
w.hidden = true;
w.computeSize = () => [0, 0];
return w;
};
const hiddenModeWidget = getHiddenModeWidget();
@@ -720,14 +825,17 @@ app.registerExtension({
if (widgets.length > 1) {
const keep = widgets.find(w => w.value && (w.value === "true" || w.value === "false")) || widgets[0];
node.widgets = node.widgets.filter(w => w.name !== "_scrape_on_new" || w === keep);
keep.hidden = true;
return keep;
}
if (widgets.length === 1) {
widgets[0].hidden = true;
return widgets[0];
}
const w = node.addWidget("text", "_scrape_on_new", "true", () => {}, { serialize: true });
w.type = "hidden";
w.computeSize = () => [0, -4];
w.hidden = true;
w.computeSize = () => [0, 0];
return w;
};
const hiddenScrapeWidget = getHiddenScrapeWidget();
@@ -737,29 +845,69 @@ app.registerExtension({
const initialZoom = localStorage.getItem("comfy_lora_picker_zoom") || "80";
viewContainer.style.setProperty("--lora-tile-size", `${initialZoom}px`);
const gridContainer = document.createElement("div");
gridContainer.className = "lora-grid-container";
// Isolate mouse wheel scroll events from triggering canvas zooming/panning
viewContainer.addEventListener("wheel", (e) => {
e.stopPropagation();
});
let userSavedHeight = 420;
const applyDisplayMode = (mode) => {
const isShowAll = mode === "Show All";
if (isShowAll) {
viewContainer.style.setProperty("height", "auto", "important");
viewContainer.style.setProperty("max-height", "none", "important");
viewContainer.style.setProperty("overflow-y", "visible", "important");
gridContainer.style.setProperty("height", "auto", "important");
gridContainer.style.setProperty("max-height", "none", "important");
gridContainer.style.setProperty("overflow-y", "visible", "important");
gridContainer.style.setProperty("flex", "none", "important");
setTimeout(() => {
const contentH = gridContainer.scrollHeight || 0;
const neededHeight = contentH + 160;
node.setSize([node.size[0], Math.max(360, neededHeight)]);
if (app.graph) app.graph.setDirtyCanvas(true, true);
}, 50);
} else {
viewContainer.classList.remove("show-all-mode");
viewContainer.classList.add("scrollable-mode");
const restoredH = node.userCustomHeight || userSavedHeight || 420;
const containerH = Math.max(200, restoredH - 100);
viewContainer.style.setProperty("height", `${containerH}px`, "important");
viewContainer.style.setProperty("max-height", `${containerH}px`, "important");
viewContainer.style.setProperty("overflow-y", "hidden", "important");
gridContainer.style.setProperty("height", "100%", "important");
gridContainer.style.setProperty("max-height", "100%", "important");
gridContainer.style.setProperty("overflow-y", "auto", "important");
gridContainer.style.setProperty("flex", "1 1 0px", "important");
node.setSize([node.size[0], Math.max(360, restoredH)]);
if (app.graph) app.graph.setDirtyCanvas(true, true);
}
};
node.computeSize = function() {
const displayModeWidget = node.widgets ? node.widgets.find(w => w.name === "display_mode") : null;
if (displayModeWidget && displayModeWidget.value === "Show All" && viewContainer) {
return [node.size[0], Math.max(360, viewContainer.scrollHeight + 190)];
if (displayModeWidget && displayModeWidget.value === "Show All" && gridContainer) {
return [node.size[0], Math.max(360, (gridContainer.scrollHeight || 0) + 160)];
}
return [node.size[0], Math.max(360, node.size[1])];
};
const updateNodeSize = () => {
const displayModeWidget = node.widgets ? node.widgets.find(w => w.name === "display_mode") : null;
if (displayModeWidget && displayModeWidget.value === "Show All") {
const neededHeight = viewContainer.scrollHeight + 190;
node.setSize([node.size[0], Math.max(360, neededHeight)]);
if (app.graph) app.graph.setDirtyCanvas(true, true);
}
const mode = displayModeWidget ? displayModeWidget.value : "Scrollable";
applyDisplayMode(mode);
};
// --- Control Bar for 2-Way HTML Toggle Button ---
// --- Control Bar for Mode Selector ---
const controlBar = document.createElement("div");
controlBar.className = "lora-control-bar";
@@ -771,39 +919,47 @@ app.registerExtension({
toggleLabel.innerText = "Mode:";
toggleContainer.appendChild(toggleLabel);
const allBtn = document.createElement("button");
allBtn.className = "lora-toggle-btn active";
allBtn.innerText = "All Loras";
const modeSelect = document.createElement("select");
modeSelect.className = "lora-mode-select";
modeSelect.style.background = "#252525";
modeSelect.style.border = "1px solid #353535";
modeSelect.style.color = "#fff";
modeSelect.style.fontSize = "10px";
modeSelect.style.fontWeight = "bold";
modeSelect.style.padding = "2px 4px";
modeSelect.style.borderRadius = "4px";
modeSelect.style.cursor = "pointer";
const randomBtn = document.createElement("button");
randomBtn.className = "lora-toggle-btn";
randomBtn.innerText = "Random Lora";
const modes = [
{ value: "All", label: "All Loras" },
{ value: "Random", label: "Random (With Dupes)" },
{ value: "Sequential", label: "Sequential Cycle" },
{ value: "Random (No Replace)", label: "Random (No Dupes)" }
];
modes.forEach(m => {
const opt = document.createElement("option");
opt.value = m.value;
opt.innerText = m.label;
modeSelect.appendChild(opt);
});
const syncModeToggleUI = () => {
const curModeWidget = getHiddenModeWidget();
if (curModeWidget.value === "Random") {
randomBtn.classList.add("active");
allBtn.classList.remove("active");
if (curModeWidget.value && modes.some(m => m.value === curModeWidget.value)) {
modeSelect.value = curModeWidget.value;
} else {
allBtn.classList.add("active");
randomBtn.classList.remove("active");
modeSelect.value = "Sequential";
}
};
allBtn.addEventListener("click", () => {
getHiddenModeWidget().value = "All";
modeSelect.addEventListener("change", (e) => {
getHiddenModeWidget().value = e.target.value;
syncModeToggleUI();
node.triggerSlotEvent?.(0);
});
randomBtn.addEventListener("click", () => {
getHiddenModeWidget().value = "Random";
syncModeToggleUI();
node.triggerSlotEvent?.(0);
});
toggleContainer.appendChild(allBtn);
toggleContainer.appendChild(randomBtn);
toggleContainer.appendChild(modeSelect);
controlBar.appendChild(toggleContainer);
// Scrape Toggle Container
@@ -851,6 +1007,7 @@ app.registerExtension({
controlBar.appendChild(scrapeToggleContainer);
viewContainer.appendChild(controlBar);
viewContainer.appendChild(gridContainer);
const domWidget = node.addDOMWidget("lora_visual_picker", "HTML", viewContainer, {
getValue() { return getHiddenWidget().value; },
@@ -862,6 +1019,22 @@ app.registerExtension({
return [node.size[0] - 30, node.size[1] - 190];
};
const onResize = node.onResize;
node.onResize = function(size) {
if (onResize) onResize.apply(this, arguments);
const displayModeWidget = node.widgets ? node.widgets.find(w => w.name === "display_mode") : null;
const isShowAll = displayModeWidget && displayModeWidget.value === "Show All";
if (!isShowAll && size) {
node.userCustomHeight = size[1];
userSavedHeight = size[1];
if (viewContainer && viewContainer.style) {
const h = Math.max(200, size[1] - 100);
viewContainer.style.setProperty("height", `${h}px`, "important");
viewContainer.style.setProperty("max-height", `${h}px`, "important");
}
}
};
const zoomWidget = node.addWidget(
"slider",
"tile_size",
@@ -963,11 +1136,7 @@ app.registerExtension({
if (activeRequest !== currentRequest) return;
const data = await response.json();
// Keep the control bar and clear the rest
const noneContainerExist = viewContainer.querySelector(".lora-none-container");
if (noneContainerExist) noneContainerExist.remove();
const foldersExist = viewContainer.querySelectorAll(".lora-folder-container");
foldersExist.forEach(f => f.remove());
gridContainer.innerHTML = "";
foldersMap = {};
foldersMap["Root"] = [];
@@ -1007,6 +1176,7 @@ app.registerExtension({
const ranks = getTop3Ranks();
// --- Group items by folder ---
const mtimeMap = data.mtime || {};
data.names.forEach(loraName => {
if (loraName === "[ NONE ]") return;
const parts = loraName.split(" - ");
@@ -1021,13 +1191,95 @@ app.registerExtension({
foldersMap[subFolder].push({ loraName, prettyName, usageCount, systemPath });
});
// Sort folders: Root first, others alphabetically
const sortedFolders = Object.keys(foldersMap).sort((a, b) => {
if (a === "Root") return -1;
if (b === "Root") return 1;
return a.localeCompare(b);
// Read sorting widgets
const getWidgetVal = (wName, defaultVal) => {
const w = node.widgets ? node.widgets.find(x => x.name === wName) : null;
return w ? w.value : defaultVal;
};
const sortLorasMode = getWidgetVal("sort_loras_by", "Name (A-Z)");
const sortFoldersMode = getWidgetVal("sort_folders_by", "Name (A-Z)");
const folderPosMode = getWidgetVal("folder_position", "Folders First");
const contentAlignMode = getWidgetVal("content_alignment", "Left Aligned");
if (contentAlignMode === "Right Aligned") {
viewContainer.classList.add("right-aligned-mode");
} else {
viewContainer.classList.remove("right-aligned-mode");
}
// Sort individual LoRAs inside each folder
Object.keys(foldersMap).forEach(subFolder => {
foldersMap[subFolder].sort((a, b) => {
if (sortLorasMode === "Name (Z-A)") {
return b.prettyName.localeCompare(a.prettyName);
} else if (sortLorasMode === "Usage (High to Low)") {
return (b.usageCount || 0) - (a.usageCount || 0) || a.prettyName.localeCompare(b.prettyName);
} else if (sortLorasMode === "Usage (Low to High)") {
return (a.usageCount || 0) - (b.usageCount || 0) || a.prettyName.localeCompare(b.prettyName);
} else if (sortLorasMode === "Date Modified (Newest First)") {
const timeA = mtimeMap[a.systemPath] || 0;
const timeB = mtimeMap[b.systemPath] || 0;
return timeB - timeA || a.prettyName.localeCompare(b.prettyName);
} else if (sortLorasMode === "Date Modified (Oldest First)") {
const timeA = mtimeMap[a.systemPath] || 0;
const timeB = mtimeMap[b.systemPath] || 0;
return timeA - timeB || a.prettyName.localeCompare(b.prettyName);
} else { // "Name (A-Z)" default
return a.prettyName.localeCompare(b.prettyName);
}
});
});
// Sort folders (excluding Root for position ordering)
const nonRootFolders = Object.keys(foldersMap).filter(f => f !== "Root").sort((a, b) => {
const itemsA = foldersMap[a] || [];
const itemsB = foldersMap[b] || [];
const totalA = itemsA.reduce((sum, i) => sum + (i.usageCount || 0), 0);
const totalB = itemsB.reduce((sum, i) => sum + (i.usageCount || 0), 0);
const avgA = itemsA.length > 0 ? totalA / itemsA.length : 0;
const avgB = itemsB.length > 0 ? totalB / itemsB.length : 0;
if (sortFoldersMode === "Name (Z-A)") {
return b.localeCompare(a);
} else if (sortFoldersMode === "Total Usage (High to Low)") {
return totalB - totalA || a.localeCompare(b);
} else if (sortFoldersMode === "Average Usage (High to Low)") {
return avgB - avgA || a.localeCompare(b);
} else if (sortFoldersMode === "Total LoRAs (Most First)") {
return itemsB.length - itemsA.length || a.localeCompare(b);
} else { // "Name (A-Z)" default
return a.localeCompare(b);
}
});
// Combine folder keys based on folder_position
let sortedFolders = [];
const hasRoot = foldersMap["Root"] && foldersMap["Root"].length > 0;
if (folderPosMode === "Root LoRAs First") {
if (hasRoot) sortedFolders.push("Root");
sortedFolders.push(...nonRootFolders);
} else {
sortedFolders.push(...nonRootFolders);
if (hasRoot) sortedFolders.push("Root");
}
// Atomic DOM Fragment construction for maximum performance
const fragment = document.createDocumentFragment();
// Setup IntersectionObserver for high-performance lazy image decoding
const imageObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
if (img.dataset.src) {
img.src = img.dataset.src;
img.removeAttribute("data-src");
}
observer.unobserve(img);
}
});
}, { root: gridContainer, rootMargin: "150px" });
// --- Render folder groups ---
sortedFolders.forEach(subFolder => {
const items = foldersMap[subFolder];
@@ -1068,7 +1320,6 @@ app.registerExtension({
checkbox.className = "lora-folder-checkbox";
checkbox.title = "Select/Deselect all in folder";
// Prevent header toggle click when clicking checkbox
checkbox.addEventListener("click", (e) => {
e.stopPropagation();
const isChecked = checkbox.checked;
@@ -1089,7 +1340,6 @@ app.registerExtension({
header.appendChild(checkbox);
// Collapse/Expand functionality
header.addEventListener("click", () => {
const collapsed = folderContainer.classList.toggle("collapsed");
header.classList.toggle("collapsed", collapsed);
@@ -1119,25 +1369,32 @@ app.registerExtension({
}
const img = document.createElement("img");
img.loading = "lazy";
img.dataset.src = `/folder_lora_loader/get_preview?system_path=${encodeURIComponent(item.systemPath)}`;
img.src = img.dataset.src;
imageObserver.observe(img);
img.onerror = () => {
img.remove();
const fallback = document.createElement("div");
fallback.className = "lora-tile-fallback";
fallback.innerText = item.prettyName; // V2 shows only prettyName inside the folder section!
fallback.innerText = item.prettyName;
tile.prepend(fallback);
};
tile.appendChild(img);
// Rank classification based on versionless pretty name
let tilePrettyName = item.prettyName.replace(/\s+V\d+(\.\d+)?$/i, "").trim();
const isOver100 = item.usageCount >= 100;
let rankClass = "";
if (ranks.rank1.includes(tilePrettyName)) rankClass = "rank-1";
else if (ranks.rank2.includes(tilePrettyName)) rankClass = "rank-2";
else if (ranks.rank3.includes(tilePrettyName)) rankClass = "rank-3";
if (ranks.rank1.includes(tilePrettyName)) {
rankClass = isOver100 ? "rank-1-100" : "rank-1";
} else if (ranks.rank2.includes(tilePrettyName)) {
rankClass = isOver100 ? "rank-2-100" : "rank-2";
} else if (ranks.rank3.includes(tilePrettyName)) {
rankClass = isOver100 ? "rank-3-100" : "rank-3";
} else if (isOver100) {
rankClass = "over-100";
}
if (rankClass) {
tile.classList.add(rankClass);
@@ -1147,16 +1404,15 @@ app.registerExtension({
const badge = document.createElement("div");
badge.className = "lora-tile-badge";
if (rankClass) badge.classList.add(rankClass);
badge.innerText = item.usageCount;
badge.innerText = isOver100 ? `🔥 ${item.usageCount}` : item.usageCount;
tile.appendChild(badge);
}
const label = document.createElement("div");
label.className = "lora-tile-label";
label.innerText = item.prettyName; // V2 shows only prettyName inside the folder section!
label.innerText = item.prettyName;
tile.appendChild(label);
// Tile Click Handler (supporting Multi-Selection via Ctrl / Meta key)
tile.addEventListener("click", (e) => {
let currentSelected = getSelectedLoras();
@@ -1181,9 +1437,12 @@ app.registerExtension({
});
folderContainer.appendChild(grid);
viewContainer.appendChild(folderContainer);
fragment.appendChild(folderContainer);
});
// Append entire DocumentFragment to gridContainer
gridContainer.appendChild(fragment);
// Set initial checkboxes state
updateFolderCheckboxes();
setTimeout(() => updateNodeSize(), 50);
@@ -1197,6 +1456,17 @@ app.registerExtension({
debounceTimer = setTimeout(() => updateVisualGrid(), 350);
};
["sort_loras_by", "sort_folders_by", "folder_position", "content_alignment"].forEach(wName => {
const w = node.widgets ? node.widgets.find(x => x.name === wName) : null;
if (w) {
const origCb = w.callback;
w.callback = function() {
if (origCb) origCb.apply(this, arguments);
updateVisualGrid();
};
}
});
const originalOnConfigure = node.onConfigure;
node.onConfigure = function(config) {
if (originalOnConfigure) originalOnConfigure.apply(this, arguments);
@@ -1236,18 +1506,9 @@ app.registerExtension({
const origCallback = displayModeWidget.callback;
displayModeWidget.callback = function(val) {
if (origCallback) origCallback.apply(this, arguments);
if (val === "Show All") {
viewContainer.style.overflowY = "hidden";
} else {
viewContainer.style.overflowY = "auto";
}
setTimeout(() => updateNodeSize(), 50);
applyDisplayMode(val || displayModeWidget.value);
};
if (displayModeWidget.value === "Show All") {
viewContainer.style.overflowY = "hidden";
} else {
viewContainer.style.overflowY = "auto";
}
applyDisplayMode(displayModeWidget.value || "Scrollable");
}
updateVisualGrid();
}, 100);
+112 -47
View File
@@ -14,17 +14,10 @@ visualStyles.textContent = `
border: 1px solid #222;
border-radius: 6px;
box-sizing: border-box;
overflow-y: hidden;
}
.img-visual-container.vue-mode {
height: 400px;
min-height: 200px;
resize: vertical;
}
.img-visual-container.litegraph-mode {
height: 100%;
min-height: 0;
max-height: 100%;
overflow-y: hidden;
}
.img-visual-container::-webkit-scrollbar {
width: 6px;
@@ -210,7 +203,17 @@ document.head.appendChild(visualStyles);
app.registerExtension({
name: "Comfy.ImageLoaderCustom",
async nodeCreated(node) {
if (node.comfyClass === "ImageLoaderVisualPrettyV2" || node.comfyClass === "ImageLoaderCustom") {
const isVisualImageLoader = [
"VisualImageLoader",
"ImageLoaderVisualPrettyV2",
"ImageLoaderCustom",
].includes(node.comfyClass) || [
"VisualImageLoader",
"ImageLoaderVisualPrettyV2",
"ImageLoaderCustom",
].includes(node.type);
if (isVisualImageLoader) {
node.size = [380, 480];
node.computeSize = function() {
@@ -231,11 +234,13 @@ app.registerExtension({
}
}
if (widgets.length === 1) {
widgets[0].type = "hidden";
widgets[0].hidden = true;
widgets[0].computeSize = () => [0, 0];
return widgets[0];
}
const w = node.addWidget("text", name, defaultVal, () => {}, { serialize: true });
w.type = "hidden";
w.hidden = true;
w.computeSize = () => [0, 0];
return w;
@@ -253,11 +258,29 @@ app.registerExtension({
const controlBar = document.createElement("div");
controlBar.className = "img-control-bar";
controlBar.style.display = "flex";
controlBar.style.flexDirection = "row";
controlBar.style.gap = "6px";
const refreshBtn = document.createElement("button");
refreshBtn.innerHTML = "↻";
refreshBtn.title = "Refresh Images";
refreshBtn.style.background = "#1c1c1c";
refreshBtn.style.border = "1px solid #333";
refreshBtn.style.borderRadius = "4px";
refreshBtn.style.color = "#fff";
refreshBtn.style.cursor = "pointer";
refreshBtn.style.padding = "4px 8px";
refreshBtn.style.fontSize = "14px";
refreshBtn.onmouseover = () => refreshBtn.style.background = "#333";
refreshBtn.onmouseout = () => refreshBtn.style.background = "#1c1c1c";
controlBar.appendChild(refreshBtn);
const searchBar = document.createElement("input");
searchBar.type = "text";
searchBar.className = "img-search-bar";
searchBar.placeholder = "Search images or positive prompts...";
searchBar.style.flex = "1";
controlBar.appendChild(searchBar);
viewContainer.appendChild(controlBar);
@@ -267,19 +290,58 @@ app.registerExtension({
node.computeSize = function() {
const displayModeWidget = node.widgets ? node.widgets.find(w => w.name === "display_mode") : null;
if (displayModeWidget && displayModeWidget.value === "Show All" && gridContainer) {
return [node.size[0], Math.max(480, gridContainer.scrollHeight + 140)];
let userSavedHeight = 480;
const applyDisplayMode = (mode) => {
const isShowAll = mode === "Show All";
if (isShowAll) {
viewContainer.style.setProperty("height", "auto", "important");
viewContainer.style.setProperty("max-height", "none", "important");
viewContainer.style.setProperty("overflow-y", "visible", "important");
gridContainer.style.setProperty("height", "auto", "important");
gridContainer.style.setProperty("max-height", "none", "important");
gridContainer.style.setProperty("overflow-y", "visible", "important");
gridContainer.style.setProperty("flex", "none", "important");
setTimeout(() => {
const contentH = gridContainer.scrollHeight || 0;
const neededHeight = contentH + 160;
node.setSize([node.size[0], Math.max(420, neededHeight)]);
if (app.graph) app.graph.setDirtyCanvas(true, true);
}, 50);
} else {
viewContainer.classList.remove("show-all-mode");
viewContainer.classList.add("scrollable-mode");
const restoredH = node.userCustomHeight || userSavedHeight || 480;
const containerH = Math.max(200, restoredH - 100);
viewContainer.style.setProperty("height", `${containerH}px`, "important");
viewContainer.style.setProperty("max-height", `${containerH}px`, "important");
viewContainer.style.setProperty("overflow-y", "hidden", "important");
gridContainer.style.setProperty("height", "100%", "important");
gridContainer.style.setProperty("max-height", "100%", "important");
gridContainer.style.setProperty("overflow-y", "auto", "important");
gridContainer.style.setProperty("flex", "1 1 0px", "important");
node.setSize([node.size[0], Math.max(420, restoredH)]);
if (app.graph) app.graph.setDirtyCanvas(true, true);
}
return [node.size[0], Math.max(480, node.size[1])];
};
node.computeSize = function() {
const displayModeWidget = node.widgets ? node.widgets.find(w => w.name === "display_mode") : null;
if (displayModeWidget && displayModeWidget.value === "Show All" && gridContainer) {
return [node.size[0], Math.max(420, (gridContainer.scrollHeight || 0) + 160)];
}
return [node.size[0], Math.max(420, node.size[1])];
};
const updateNodeSize = () => {
const displayModeWidget = node.widgets ? node.widgets.find(w => w.name === "display_mode") : null;
if (displayModeWidget && displayModeWidget.value === "Show All") {
const neededHeight = gridContainer.scrollHeight + 140;
node.setSize([node.size[0], Math.max(480, neededHeight)]);
if (app.graph) app.graph.setDirtyCanvas(true, true);
}
const mode = displayModeWidget ? displayModeWidget.value : "Scrollable";
applyDisplayMode(mode);
};
const initialZoom = localStorage.getItem("comfy_img_picker_zoom") || "80";
@@ -292,20 +354,21 @@ app.registerExtension({
serialize: false
});
// Detect Vue UI vs LiteGraph
const isVueUI = !!document.querySelector(".lg-node");
if (isVueUI) {
viewContainer.classList.add("vue-mode");
} else {
viewContainer.classList.add("litegraph-mode");
const onResize = node.onResize;
node.onResize = function(size) {
if (onResize) onResize.apply(this, arguments);
if (viewContainer.style) {
viewContainer.style.height = `${size[1] - 80}px`;
const onResize = node.onResize;
node.onResize = function(size) {
if (onResize) onResize.apply(this, arguments);
const displayModeWidget = node.widgets ? node.widgets.find(w => w.name === "display_mode") : null;
const isShowAll = displayModeWidget && displayModeWidget.value === "Show All";
if (!isShowAll && size) {
node.userCustomHeight = size[1];
userSavedHeight = size[1];
if (viewContainer && viewContainer.style) {
const h = Math.max(200, size[1] - 100);
viewContainer.style.setProperty("height", `${h}px`, "important");
viewContainer.style.setProperty("max-height", `${h}px`, "important");
}
};
}
}
};
domWidget.computeSize = function() {
return [node.size[0] - 30, node.size[1] - 140];
@@ -348,8 +411,8 @@ app.registerExtension({
gridContainer.innerHTML = "";
lazyObserver.disconnect();
const folder = folderWidget ? (folderWidget.value || "") : "";
const selectedVal = getHiddenWidget().value;
let folder = folderWidget ? (folderWidget.value || "") : "";
const selectedVal = getHiddenWidget("_selected_image", "").value;
// Group items by subfolder
const foldersMap = {};
@@ -450,7 +513,8 @@ app.registerExtension({
}
const img = document.createElement("img");
img.dataset.src = `/image_loader/get_thumbnail?folder=${encodeURIComponent(folder)}&image=${encodeURIComponent(item.relPath)}`;
let fetchFolder = folder;
img.dataset.src = `/image_loader/get_thumbnail?folder=${encodeURIComponent(fetchFolder)}&image=${encodeURIComponent(item.relPath)}`;
img.onload = () => img.classList.add("loaded");
img.onerror = () => {
@@ -472,7 +536,7 @@ app.registerExtension({
allTiles.forEach(t => t.classList.remove("selected"));
tile.classList.add("selected");
const curWidget = getHiddenWidget();
const curWidget = getHiddenWidget("_selected_image", "");
curWidget.value = item.relPath;
if (curWidget.callback) curWidget.callback(item.relPath);
@@ -496,7 +560,7 @@ app.registerExtension({
};
const updateImagesList = async () => {
const folder = folderWidget ? (folderWidget.value || "") : "";
let folder = folderWidget ? (folderWidget.value || "") : "";
const currentRequest = Symbol();
activeRequest = currentRequest;
@@ -517,6 +581,16 @@ app.registerExtension({
debounceTimer = setTimeout(() => updateImagesList(), 350);
};
}
refreshBtn.onclick = () => {
refreshBtn.style.transform = "rotate(180deg)";
refreshBtn.style.transition = "transform 0.3s";
updateImagesList();
setTimeout(() => {
refreshBtn.style.transform = "none";
refreshBtn.style.transition = "none";
}, 300);
};
searchBar.addEventListener("input", () => {
searchQuery = searchBar.value;
@@ -528,7 +602,7 @@ app.registerExtension({
node.onConfigure = function(config) {
if (originalOnConfigure) originalOnConfigure.apply(this, arguments);
const curWidget = getHiddenWidget();
const curWidget = getHiddenWidget("_selected_image", "");
const widgetIndex = node.widgets.indexOf(curWidget);
const savedValue = (config.widgets_values && widgetIndex !== -1) ? config.widgets_values[widgetIndex] : curWidget.value;
if (savedValue) {
@@ -543,18 +617,9 @@ app.registerExtension({
const origCallback = displayModeWidget.callback;
displayModeWidget.callback = function(val) {
if (origCallback) origCallback.apply(this, arguments);
if (val === "Show All") {
gridContainer.style.overflowY = "hidden";
} else {
gridContainer.style.overflowY = "auto";
}
setTimeout(() => updateNodeSize(), 50);
applyDisplayMode(val || displayModeWidget.value);
};
if (displayModeWidget.value === "Show All") {
gridContainer.style.overflowY = "hidden";
} else {
gridContainer.style.overflowY = "auto";
}
applyDisplayMode(displayModeWidget.value || "Scrollable");
}
updateImagesList();
}, 100);
+314
View File
@@ -0,0 +1,314 @@
import { app } from "/scripts/app.js";
import { api } from "/scripts/api.js";
app.registerExtension({
name: "ComfyUI.LeafFlow.PreviewImageSizeAspectRatio",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "PreviewImageSizeAspectRatio" && nodeData.name !== "TextAspectRatioFinder") return;
if (nodeData.name === "PreviewImageSizeAspectRatio") {
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
if (onNodeCreated) onNodeCreated.apply(this, arguments);
// STRICTLY REMOVE ALL OUTPUT SLOTS (Once on node creation)
this.outputs = [];
if (this.outputs) this.outputs.length = 0;
this.aspect_ratio_data = {
ratio: 1.0,
display_text: "1 x 1"
};
// Minimum size set to [230, 160] to accommodate 5-digit labels on left and right margins cleanly
this.size = [240, 240];
this.min_size = [230, 160];
this.resizable = true;
// PERMANENTLY UNLOCK ASPECT RATIO RESIZING
Object.defineProperty(this, "aspect_ratio", {
get() { return false; },
set(v) {},
configurable: true
});
// DOM Container for ComfyUI V2 Vue UI
const viewContainer = document.createElement("div");
viewContainer.className = "leafflow-ar-preview-container";
viewContainer.style.width = "100%";
viewContainer.style.height = "calc(100% - 110px)";
viewContainer.style.minHeight = "80px";
viewContainer.style.display = "flex";
viewContainer.style.alignItems = "center";
viewContainer.style.justifyContent = "center";
// Equal 65px left and right padding for horizontal balance
viewContainer.style.padding = "10px 65px 35px 65px";
viewContainer.style.boxSizing = "border-box";
viewContainer.style.pointerEvents = "none";
viewContainer.style.position = "relative";
viewContainer.style.overflow = "hidden";
viewContainer.innerHTML = `
<div class="ar-box-wrapper" style="position: relative; display: flex; align-items: center; justify-content: center; width: 50px; height: 50px; transition: none !important;">
<svg class="ar-box-svg" style="position: absolute; top: 0; left: 0; display: block; overflow: visible; pointer-events: none; transition: none !important;" width="50" height="50" viewBox="0 0 50 50">
<path class="ar-crop-path" d="" stroke="#ffffff" stroke-width="2.8" stroke-linecap="round" stroke-linejoin="round" fill="none" style="transition: none !important;"/>
</svg>
<div class="ar-label-height" style="position: absolute; right: 100%; margin-right: 8px; top: 50%; transform: translateY(-50%); font-size: 13px; font-weight: 700; color: #ffffff; font-family: Inter, system-ui, sans-serif; white-space: nowrap;">1</div>
<div class="ar-label-width" style="position: absolute; top: 100%; margin-top: 8px; left: 50%; transform: translateX(-50%); font-size: 13px; font-weight: 700; color: #ffffff; font-family: Inter, system-ui, sans-serif; white-space: nowrap;">1</div>
</div>
`;
this._last_render_cache = "";
this.updateDomPreview = function() {
if (!viewContainer) return;
const data = this.aspect_ratio_data || { ratio: 1.0, display_text: "1 x 1" };
const ratio = Math.max(0.05, Math.min(20.0, data.ratio || 1.0));
let partW = "1";
let partH = "1";
if (data.display_text) {
const str = String(data.display_text).trim();
if (str.includes(" x ")) {
const parts = str.split(" x ");
partW = parts[0];
partH = parts[1] || "";
} else if (str.includes("x")) {
const parts = str.split("x");
partW = parts[0];
partH = parts[1] || "";
} else if (str.includes(":")) {
const parts = str.split(":");
partW = parts[0];
partH = parts[1] || "";
} else {
partW = str;
partH = "";
}
}
const widthEl = viewContainer.querySelector(".ar-label-width");
const heightEl = viewContainer.querySelector(".ar-label-height");
const boxWrapper = viewContainer.querySelector(".ar-box-wrapper");
const svgEl = viewContainer.querySelector(".ar-box-svg");
const pathEl = viewContainer.querySelector(".ar-crop-path");
if (widthEl) widthEl.innerText = partW;
if (heightEl) heightEl.innerText = partH;
if (boxWrapper && svgEl && pathEl) {
const availW = Math.max(20, (this.size[0] || 240) - 130);
const availH = Math.max(20, (this.size[1] || 240) - 150);
let w, h;
if (ratio >= availW / availH) {
w = availW;
h = availW / ratio;
} else {
h = availH;
w = availH * ratio;
}
w = Math.round(w);
h = Math.round(h);
const cacheKey = `${w}_${h}_${partW}_${partH}`;
if (this._last_render_cache === cacheKey) return;
this._last_render_cache = cacheKey;
boxWrapper.style.width = `${w}px`;
boxWrapper.style.height = `${h}px`;
svgEl.setAttribute("width", w);
svgEl.setAttribute("height", h);
svgEl.setAttribute("viewBox", `0 0 ${w} ${h}`);
const cX = w * 0.20;
const cY = h * 0.20;
const r = Math.min(12, Math.min(cX, cY) * 0.5);
const d = `M 0,${cY} L 0,${r} A ${r} ${r} 0 0 1 ${r},0 L ${cX},0 ` +
`M ${w - cX},0 L ${w - r},0 A ${r} ${r} 0 0 1 ${w},${r} L ${w},${cY} ` +
`M 0,${h - cY} L 0,${h - r} A ${r} ${r} 0 0 0 ${r},${h} L ${cX},${h} ` +
`M ${w - cX},${h} L ${w - r},${h} A ${r} ${r} 0 0 0 ${w},${h - r} L ${w},${h - cY} ` +
`M ${w * 0.40},0 L ${w * 0.60},0 ` +
`M ${w * 0.40},${h} L ${w * 0.60},${h} ` +
`M 0,${h * 0.40} L 0,${h * 0.60} ` +
`M ${w},${h * 0.40} L ${w},${h * 0.60}`;
pathEl.setAttribute("d", d);
}
};
this.addDOMWidget("ar_preview_display", "HTML", viewContainer, {
getValue() { return ""; },
setValue(val) {},
serialize: false,
computeSize() { return [100, 80]; }
});
// Canvas drawing fallback for LiteGraph (Classic V1)
const origOnDrawForeground = this.onDrawForeground;
this.onDrawForeground = function(ctx) {
if (origOnDrawForeground) origOnDrawForeground.apply(this, arguments);
if (this.flags?.collapsed) return;
// HIGH-PERFORMANCE OPTIMIZATION:
// If HTML DOM widget is active in document (Vue UI / V2), skip duplicate canvas drawing!
if (viewContainer && viewContainer.isConnected && viewContainer.offsetParent !== null) {
return;
}
const data = this.aspect_ratio_data || { ratio: 1.0, display_text: "1 x 1" };
const ratio = Math.max(0.05, Math.min(20.0, data.ratio || 1.0));
let partW = "1";
let partH = "1";
if (data.display_text) {
const str = String(data.display_text).trim();
if (str.includes(" x ")) {
const parts = str.split(" x ");
partW = parts[0];
partH = parts[1] || "";
} else if (str.includes("x")) {
const parts = str.split("x");
partW = parts[0];
partH = parts[1] || "";
} else if (str.includes(":")) {
const parts = str.split(":");
partW = parts[0];
partH = parts[1] || "";
} else {
partW = str;
partH = "";
}
}
ctx.save();
const startY = 30 + (this.inputs ? this.inputs.length * 20 : 80);
const marginLeft = 65;
const marginRight = 65;
const marginTop = startY + 10;
const marginBottom = 35;
const availW = Math.max(20, this.size[0] - marginLeft - marginRight);
const availH = Math.max(20, this.size[1] - marginTop - marginBottom);
let boxW, boxH;
if (ratio >= availW / availH) {
boxW = availW;
boxH = availW / ratio;
} else {
boxH = availH;
boxW = availH * ratio;
}
const boxX = marginLeft + (availW - boxW) / 2;
const boxY = marginTop + (availH - boxH) / 2;
const cX = boxW * 0.20;
const cY = boxH * 0.20;
const r = Math.min(12, Math.min(cX, cY) * 0.5);
ctx.strokeStyle = "#ffffff";
ctx.lineWidth = 2.8;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.beginPath();
// Top-Left Corner
ctx.moveTo(boxX, boxY + cY);
ctx.lineTo(boxX, boxY + r);
ctx.arcTo(boxX, boxY, boxX + r, boxY, r);
ctx.lineTo(boxX + cX, boxY);
// Top-Right Corner
ctx.moveTo(boxX + boxW - cX, boxY);
ctx.lineTo(boxX + boxW - r, boxY);
ctx.arcTo(boxX + boxW, boxY, boxX + boxW, boxY + r, r);
ctx.lineTo(boxX + boxW, boxY + cY);
// Bottom-Left Corner
ctx.moveTo(boxX, boxY + boxH - cY);
ctx.lineTo(boxX, boxY + boxH - r);
ctx.arcTo(boxX, boxY + boxH, boxX + r, boxY + boxH, r);
ctx.lineTo(boxX + cX, boxY + boxH);
// Bottom-Right Corner
ctx.moveTo(boxX + boxW - cX, boxY + boxH);
ctx.lineTo(boxX + boxW - r, boxY + boxH);
ctx.arcTo(boxX + boxW, boxY + boxH, boxX + boxW, boxY + boxH - r, r);
ctx.lineTo(boxX + boxW, boxY + boxH - cY);
// Top Center Handle
ctx.moveTo(boxX + boxW * 0.40, boxY);
ctx.lineTo(boxX + boxW * 0.60, boxY);
// Bottom Center Handle
ctx.moveTo(boxX + boxW * 0.40, boxY + boxH);
ctx.lineTo(boxX + boxW * 0.60, boxY + boxH);
// Left Center Handle
ctx.moveTo(boxX, boxY + boxH * 0.40);
ctx.lineTo(boxX, boxY + boxH * 0.60);
// Right Center Handle
ctx.moveTo(boxX + boxW, boxY + boxH * 0.40);
ctx.lineTo(boxX + boxW, boxY + boxH * 0.60);
ctx.stroke();
ctx.fillStyle = "#ffffff";
ctx.font = "bold 13px Inter, -apple-system, sans-serif";
if (partH) {
ctx.textAlign = "right";
ctx.textBaseline = "middle";
ctx.fillText(partH, boxX - 8, boxY + boxH / 2);
}
if (partW) {
ctx.textAlign = "center";
ctx.textBaseline = "top";
ctx.fillText(partW, boxX + boxW / 2, boxY + boxH + 8);
}
ctx.restore();
};
nodeType.prototype.computeSize = function() {
return [230, 160];
};
nodeType.prototype.onResize = function(size) {
this.size[0] = Math.max(230, size[0]);
this.size[1] = Math.max(160, size[1]);
if (typeof this.updateDomPreview === "function") {
this.updateDomPreview();
}
};
};
}
}
});
api.addEventListener("leafflow_update_preview_aspect_ratio", (event) => {
const detail = event.detail;
if (!detail || !detail.node_id) return;
const node = app.graph.getNodeById(detail.node_id);
if (node && (node.type === "PreviewImageSizeAspectRatio" || node.type === "TextAspectRatioFinder")) {
node.aspect_ratio_data = {
ratio: detail.ratio,
display_text: detail.display_text
};
if (typeof node.updateDomPreview === "function") {
node.updateDomPreview();
}
app.graph.setDirtyCanvas(true, true);
}
});
+2 -2
View File
@@ -66,8 +66,8 @@ class PreviewManagerClass {
const images = document.querySelectorAll('img[alt*="Live sampling preview"]');
images.forEach(imgEl => {
const src = imgEl.src;
if (src && src.startsWith("blob:") && imgEl.dataset.lastFlowControlSrc !== src) {
imgEl.dataset.lastFlowControlSrc = src;
if (src && src.startsWith("blob:") && imgEl.dataset.lastLeafFlowSrc !== src) {
imgEl.dataset.lastLeafFlowSrc = src;
const nodeEl = imgEl.closest('[data-node-id], [data-widgets-grid-node-id], .lg-node');
const nodeId = nodeEl ? (nodeEl.dataset.nodeId || nodeEl.dataset.widgetsGridNodeId || nodeEl.getAttribute('data-node-id')) : null;
+80
View File
@@ -0,0 +1,80 @@
import { app } from "/scripts/app.js";
import { api } from "/scripts/api.js";
app.registerExtension({
name: "ComfyUI.LeafFlow.PromptQueueIterator",
async setup() {
if (api && api.addEventListener) {
api.addEventListener("leafflow_prompt_iterator_progress", (event) => {
const data = event.detail;
if (!data || !data.node_id) return;
const node = app.graph?.getNodeById(data.node_id);
if (node && node.widgets) {
const statusWidget = node.widgets.find(w => w.name === "🍃 Progress");
if (statusWidget) {
statusWidget.value = `🍃 Run ${data.current_run} / ${data.total_runs}`;
app.graph?.setDirtyCanvas(true, true);
}
}
});
}
},
async nodeCreated(node) {
if (node.comfyClass === "PromptQueueIterator") {
// 1. Live Progress Status Widget
const statusWidget = node.addWidget("text", "🍃 Progress", "🍃 Ready (-- / --)", () => {}, { serialize: false });
statusWidget.disabled = true;
// 2. Reset Counter (0) Button
const resetBtn = node.addWidget("button", "🔄 Reset Counter (0)", null, async () => {
try {
resetBtn.name = "⏳ Resetting...";
app.graph?.setDirtyCanvas(true, true);
const resp = await api.fetchApi("/leafflow/prompt_iterator/reset_node", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ node_id: String(node.id) })
});
const res = await resp.json();
if (res && res.status === "ok") {
resetBtn.name = "✅ Reset to 0!";
statusWidget.value = "🍃 Reset (0 / --)";
} else {
resetBtn.name = "⚠️ Error";
}
} catch (e) {
console.error("[LeafFlow] 🍃 Error resetting counter:", e);
resetBtn.name = "❌ Failed";
}
setTimeout(() => {
resetBtn.name = "🔄 Reset Counter (0)";
app.graph?.setDirtyCanvas(true, true);
}, 1200);
});
resetBtn.serialize = false;
// 3. Open Queue File in Default OS Editor Button
const openFileBtn = node.addWidget("button", "📂 Open Queue File", null, async () => {
try {
openFileBtn.name = "⏳ Opening...";
app.graph?.setDirtyCanvas(true, true);
const resp = await api.fetchApi("/leafflow/prompt_iterator/open_file", { method: "POST" });
const res = await resp.json();
if (res && res.status === "ok") {
openFileBtn.name = "✅ Opened in Editor!";
} else {
openFileBtn.name = "⚠️ Error";
}
} catch (e) {
console.error("[LeafFlow] 🍃 Error opening state file:", e);
openFileBtn.name = "❌ Failed";
}
setTimeout(() => {
openFileBtn.name = "📂 Open Queue File";
app.graph?.setDirtyCanvas(true, true);
}, 1500);
});
openFileBtn.serialize = false;
}
}
});
+47
View File
@@ -0,0 +1,47 @@
import { app } from "/scripts/app.js";
// Vibrant Leaf Green palette matching the 🍃 emoji
const LEAF_GREEN = { color: "#16a34a", bgcolor: "#14532d" };
const LEAF_LIGHT = { color: "#22c55e", bgcolor: "#166534" };
const LEAF_EMERALD = { color: "#059669", bgcolor: "#064e3b" };
const COLOR_MAP = {
// Visual Loaders
"VisualLoraLoader": LEAF_GREEN,
"FolderLoraLoader": LEAF_GREEN,
"FolderLoraLoaderPretty": LEAF_GREEN,
"VisualImageLoader": LEAF_GREEN,
"LoadRecentOutputs": LEAF_GREEN,
// Automation, Flow & Utilities
"LoadImageFromFolder": LEAF_LIGHT,
"TextAspectRatioFinder": LEAF_LIGHT,
"PreviewImageSizeAspectRatio": LEAF_LIGHT,
"TextLoraFinder": LEAF_LIGHT,
"PromptQueueIterator": LEAF_LIGHT,
"MultiTextReplacer": LEAF_LIGHT,
"LeafFlowTextSplit": LEAF_LIGHT,
"LeafFlowDecision": LEAF_LIGHT,
// Queue & Previews
"PreviewLatentLive": LEAF_EMERALD,
"PauseQueueNode": LEAF_EMERALD,
"PersistentQueueNode": LEAF_EMERALD
};
app.registerExtension({
name: "ComfyUI.LeafFlow.Colors",
async nodeCreated(node) {
const enabled = app.ui.settings.getSettingValue(
"LeafFlow.1. 🖼️ Visual Loaders.00_EnableCustomColors",
true
);
if (!enabled) return;
if (node && node.type && COLOR_MAP[node.type]) {
const theme = COLOR_MAP[node.type];
node.color = theme.color;
node.bgcolor = theme.bgcolor;
}
}
});
+569
View File
@@ -0,0 +1,569 @@
import { app } from "/scripts/app.js";
import { api } from "/scripts/api.js";
// Inject CSS to ensure settings buttons look distinct, styled with an emerald theme, and never get text cut off
if (typeof document !== "undefined") {
const styleId = "leafflow-settings-button-styles";
if (!document.getElementById(styleId)) {
const style = document.createElement("style");
style.id = styleId;
style.textContent = `
/* ComfyUI V2 Vue & LiteGraph Settings Button Styling */
button.leafflow-settings-btn,
tr:has([id*="LeafFlow"]) button,
tr:has([id*="leafflow"]) button,
div:has(> [id*="LeafFlow"]) button,
div:has(> [id*="leafflow"]) button,
[data-setting-id*="LeafFlow"] button,
div[class*="setting"]:has(span:contains("LeafFlow")) button {
background: linear-gradient(135deg, #059669 0%, #047857 100%) !important;
border: 1px solid #10b981 !important;
color: #ffffff !important;
font-weight: 600 !important;
font-size: 12px !important;
padding: 6px 14px !important;
border-radius: 6px !important;
width: auto !important;
min-width: max-content !important;
max-width: none !important;
white-space: nowrap !important;
overflow: visible !important;
text-overflow: clip !important;
cursor: pointer !important;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.15) !important;
transition: all 0.2s ease !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
}
button.leafflow-settings-btn:hover,
tr:has([id*="LeafFlow"]) button:hover,
div:has(> [id*="LeafFlow"]) button:hover,
[data-setting-id*="LeafFlow"] button:hover {
background: linear-gradient(135deg, #047857 0%, #059669 100%) !important;
border-color: #34d399 !important;
color: #ffffff !important;
box-shadow: 0 0 10px rgba(16, 185, 129, 0.45) !important;
transform: translateY(-1px) !important;
}
button.leafflow-settings-btn:active,
tr:has([id*="LeafFlow"]) button:active {
transform: translateY(0) !important;
}
`;
document.head.appendChild(style);
}
}
/**
* Custom renderer for real, styled action buttons in ComfyUI Settings Modal.
*/
function renderSettingButton(label, workingText, successText, onClickHandler) {
return (name, setter, value) => {
const container = document.createElement("div");
container.style.cssText = "display: flex; align-items: center; justify-content: flex-end; width: 100%; padding: 2px 0;";
const btn = document.createElement("button");
btn.type = "button";
btn.textContent = label;
btn.className = "p-button leafflow-settings-btn";
btn.style.cssText = `
padding: 6px 14px !important;
background: linear-gradient(135deg, #059669 0%, #047857 100%) !important;
color: #ffffff !important;
border: 1px solid #10b981 !important;
border-radius: 6px !important;
font-weight: 600 !important;
font-size: 12px !important;
cursor: pointer !important;
transition: all 0.2s ease !important;
outline: none !important;
min-width: max-content !important;
width: auto !important;
white-space: nowrap !important;
text-align: center !important;
box-shadow: 0 1px 3px rgba(0,0,0,0.4) !important;
`;
btn.onmouseover = () => {
if (!btn.disabled) {
btn.style.background = "#047857";
btn.style.borderColor = "#34d399";
btn.style.boxShadow = "0 0 10px rgba(16, 185, 129, 0.45)";
}
};
btn.onmouseout = () => {
if (!btn.disabled) {
btn.style.background = "linear-gradient(135deg, #059669 0%, #047857 100%)";
btn.style.borderColor = "#10b981";
btn.style.boxShadow = "0 1px 3px rgba(0,0,0,0.4)";
}
};
btn.onclick = async (e) => {
e.preventDefault();
e.stopPropagation();
btn.disabled = true;
btn.style.opacity = "0.7";
btn.textContent = workingText || "⏳ Working...";
try {
await onClickHandler();
btn.textContent = successText || "✅ Done!";
btn.style.background = "#1b5e20";
btn.style.borderColor = "#2e7d32";
btn.style.color = "#ffffff";
} catch (err) {
console.error("[LeafFlow] Button action failed:", err);
btn.textContent = "❌ Error";
btn.style.background = "#c62828";
btn.style.borderColor = "#e53935";
}
setTimeout(() => {
btn.textContent = label;
btn.disabled = false;
btn.style.opacity = "1";
btn.style.background = "linear-gradient(135deg, #059669 0%, #047857 100%)";
btn.style.borderColor = "#10b981";
}, 1800);
};
container.appendChild(btn);
return container;
};
}
app.registerExtension({
name: "ComfyUI.LeafFlow.Settings",
async setup() {
// =========================================================================
// GRUPPE 1: 1. 🖼️ Visual Loaders
// =========================================================================
// 1.0 Custom Node Colors Toggle
app.ui.settings.addSetting({
id: "LeafFlow.1. 🖼️ Visual Loaders.00_EnableCustomColors",
name: "Enable Custom LeafFlow Node Colors",
type: "boolean",
defaultValue: true,
tooltip: "Applies a fresh Leaf Green color theme to LeafFlow nodes on the canvas. When disabled, nodes use default ComfyUI colors.",
});
// 1.1 Civitai API Key
app.ui.settings.addSetting({
id: "LeafFlow.1. 🖼️ Visual Loaders.01_CivitaiApiKey",
name: "Civitai API Key",
type: "text",
defaultValue: "",
tooltip: "Optional. Civitai SHA256 search works publicly without a key for normal models. Only needed for NSFW/private models or higher rate limits. Whitespace is automatically stripped.",
onChange(value) {
const cleanKey = (value || "").trim();
api.fetchApi("/leafflow/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ civitai_api_key: cleanKey })
}).catch(() => {});
}
});
// 1.2 Enable Civitai Auto-Scraping Toggle (default: true)
app.ui.settings.addSetting({
id: "LeafFlow.1. 🖼️ Visual Loaders.02_EnableCivitaiScraping",
name: "Enable Civitai Auto-Scraping",
type: "boolean",
defaultValue: true,
tooltip: "Toggles automated downloading of preview thumbnails for new LoRAs from Civitai via SHA256 file hashes. Note: SHA256 hash searching will always work regardless of this setting when matching local models.",
onChange(value) {
api.fetchApi("/leafflow/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enable_civitai_scraping: value ? "true" : "false" })
}).catch(() => {});
}
});
// 1.3 TMDB Access Token
app.ui.settings.addSetting({
id: "LeafFlow.1. 🖼️ Visual Loaders.03_TMDBApiKey",
name: "TMDB Access Token",
type: "text",
defaultValue: "",
tooltip: "Optional. Accepts TMDB v3 API keys or TMDB v4 Read Access Tokens (eyJ...). Used for celebrity poster and preview image lookup. Whitespace is automatically stripped.",
onChange(value) {
const cleanKey = (value || "").trim();
api.fetchApi("/leafflow/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tmdb_api_key: cleanKey })
}).catch(() => {});
}
});
// 1.4 Enable TMDB Auto-Scraping Toggle (default: false)
app.ui.settings.addSetting({
id: "LeafFlow.1. 🖼️ Visual Loaders.04_EnableTMDBScraping",
name: "Enable TMDB Auto-Scraping",
type: "boolean",
defaultValue: false,
tooltip: "Toggles automated downloading of celebrity preview thumbnails from TMDB. Default is disabled.",
onChange(value) {
api.fetchApi("/leafflow/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enable_tmdb_scraping: value ? "true" : "false" })
}).catch(() => {});
}
});
// 1.5 Enable LoRA Usage Tracking (default: true)
app.ui.settings.addSetting({
id: "LeafFlow.1. 🖼️ Visual Loaders.05_EnableLoraUsage",
name: "Enable LoRA Usage Tracking",
type: "boolean",
defaultValue: true,
tooltip: "Toggles tracking and displaying LoRA usage counts & visual rank badges (🔥, Gold, Silver, Bronze) in the LoRA picker. Existing usage history is preserved when disabled.",
onChange(value) {
api.fetchApi("/leafflow/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enable_lora_usage: value ? "true" : "false" })
}).catch(() => {});
}
});
// 1.6 Reset Scrapes Cache Button
app.ui.settings.addSetting({
id: "LeafFlow.1. 🖼️ Visual Loaders.06_ResetScrapesCache",
name: "Reset Failed Scrapes Cache",
type: "button",
defaultValue: "🗑️ Clear Scrapes Cache",
tooltip: "Immediately clears failed_scrapes.json so Civitai and TMDB can retry downloading missing preview thumbnails on the next folder scan.",
attrs: {
className: "leafflow-settings-btn",
class: "leafflow-settings-btn",
onClick: async () => {
try {
const resp = await api.fetchApi("/leafflow/scrapes/clear", { method: "POST" });
const data = await resp.json();
if (data && data.status === "ok") {
alert("LeafFlow: Failed scrapes cache successfully reset!");
} else {
alert("LeafFlow: Failed to reset cache.");
}
} catch (err) {
alert("LeafFlow: Error resetting cache: " + err);
}
}
},
render: renderSettingButton("🗑️ Clear Scrapes Cache", "⏳ Clearing...", "✅ Cache Reset!", async () => {
const resp = await api.fetchApi("/leafflow/scrapes/clear", { method: "POST" });
const data = await resp.json();
if (data.status !== "ok") {
throw new Error(data.message || "Failed to reset scrapes cache");
}
})
});
// =========================================================================
// GRUPPE 2: 2. 🔄 Prompt Queue Iterator
// =========================================================================
// 2.1 Clear Prompt Iterator State on Launch (default: false)
app.ui.settings.addSetting({
id: "LeafFlow.2. 🔄 Prompt Iterator.01_ClearOnLaunch",
name: "Clear State on Launch",
type: "boolean",
defaultValue: false,
tooltip: "Privacy setting. When enabled, prompt_iterator_state.json will be emptied automatically every time ComfyUI starts up.",
onChange(value) {
api.fetchApi("/leafflow/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ clear_prompt_iterator_on_launch: value ? "true" : "false" })
}).catch(() => {});
}
});
// 2.2 Reset Prompt Iterator Queues Now
app.ui.settings.addSetting({
id: "LeafFlow.2. 🔄 Prompt Iterator.02_ResetActiveQueues",
name: "Reset Active Queues State",
type: "button",
defaultValue: "🔄 Reset All Queues",
tooltip: "Immediately empties all active prompt queues and resets iterator state across all workflows.",
attrs: {
className: "leafflow-settings-btn",
class: "leafflow-settings-btn",
onClick: async () => {
try {
const resp = await api.fetchApi("/leafflow/prompt_iterator/clear", { method: "POST" });
const data = await resp.json();
if (data && data.status === "ok") {
alert("LeafFlow: Prompt Iterator queues successfully reset!");
} else {
alert("LeafFlow: Failed to reset queues.");
}
} catch (err) {
alert("LeafFlow: Error resetting queues: " + err);
}
}
},
render: renderSettingButton("🔄 Reset All Queues", "⏳ Resetting...", "✅ State Reset!", async () => {
const resp = await api.fetchApi("/leafflow/prompt_iterator/clear", { method: "POST" });
const data = await resp.json();
if (data.status !== "ok") {
throw new Error(data.message || "Failed to clear prompt iterator state");
}
})
});
// =========================================================================
// GRUPPE 3: 3. 📋 Prompt Actions
// =========================================================================
// 3.1 Show "Copy Prompt" Button on Image Overlays
app.ui.settings.addSetting({
id: "LeafFlow.3. 📋 Prompt Actions.01_EnableAssetsCopyPromptButton",
name: "Show \"Copy Prompt\" Button on Images",
type: "boolean",
defaultValue: true,
tooltip: "Shows the 📋 'Copy Prompt' overlay action button when hovering over generated images in the Assets / History pane and preview nodes.",
});
// 3.2 Show Right-Click "Copy Prompt" Menu Action
app.ui.settings.addSetting({
id: "LeafFlow.3. 📋 Prompt Actions.02_EnableContextMenuCopyPrompt",
name: "Show Right-Click \"Copy Prompt\" Menu Action",
type: "boolean",
defaultValue: true,
tooltip: "Adds '📋 Copy Prompt' to node right-click context menus.",
});
// =========================================================================
// GRUPPE 4: 4. ⏸️ Pause & Resume Controls
// =========================================================================
// 4.1 Default Pause Queue State on Launch
app.ui.settings.addSetting({
id: "LeafFlow.4. ⏸️ Pause Controls.01_DefaultStateOnLaunch",
name: "Default State on Launch",
type: "combo",
options: ["Paused", "Running"],
defaultValue: "Paused",
tooltip: "Choose whether execution starts in Paused state or Running state on ComfyUI startup.",
onChange(value) {
api.fetchApi("/leafflow/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ default_pause_state: value })
}).catch(() => {});
}
});
// 4.2 Default Pause Queue Mode on Launch
app.ui.settings.addSetting({
id: "LeafFlow.4. ⏸️ Pause Controls.02_DefaultPauseAction",
name: "Default Pause Action",
type: "combo",
options: ["Finish Active Prompt", "Instant Resume Node"],
defaultValue: "Finish Active Prompt",
tooltip: "Choose default pause behavior when the pause button or hotkey is triggered.",
onChange(value) {
const modeKey = (value === "Instant Resume Node" || value === "Pause (Instant)") ? "instantly" : "after_finish";
api.fetchApi("/leafflow/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ default_pause_mode: modeKey })
}).catch(() => {});
}
});
// 4.3 Enable Pause Queue Toolbar Button
app.ui.settings.addSetting({
id: "LeafFlow.4. ⏸️ Pause Controls.03_EnableToolbarButton",
name: "Enable Top Toolbar Button",
type: "boolean",
defaultValue: true,
tooltip: "Displays the green/orange Pause & Continue button group in the top action bar.",
onChange(value) {
const group = document.querySelector(".pq-button-group");
if (group) {
group.style.display = value ? "inline-flex" : "none";
}
}
});
// 4.4 Toolbar Button Unpaused Color
app.ui.settings.addSetting({
id: "LeafFlow.4. ⏸️ Pause Controls.04_ToolbarButtonUnpausedColor",
name: "Toolbar Button Unpaused Color",
type: "text",
defaultValue: "#16a34a",
tooltip: "Hex color code for the Pause toolbar button when execution is unpaused/running (default: #16a34a).",
onChange(value) {
document.documentElement.style.setProperty("--pq-unpaused-color", value || "#16a34a");
}
});
// 4.5 Toolbar Button Paused Color
app.ui.settings.addSetting({
id: "LeafFlow.4. ⏸️ Pause Controls.05_ToolbarButtonPausedColor",
name: "Toolbar Button Paused Color",
type: "text",
defaultValue: "#ea580c",
tooltip: "Hex color code for the Pause toolbar button when execution is paused (default: #ea580c).",
onChange(value) {
document.documentElement.style.setProperty("--pq-paused-color", value || "#ea580c");
}
});
// 4.6 Enable System Tray Icon
app.ui.settings.addSetting({
id: "LeafFlow.4. ⏸️ Pause Controls.06_EnableTrayIcon",
name: "Enable System Tray Icon",
type: "boolean",
defaultValue: false,
tooltip: "Displays an OS system tray icon with real-time queue status colors and outside-browser queue controls.",
onChange(value) {
api.fetchApi("/leafflow/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enable_tray_icon: value ? "true" : "false" })
}).catch(() => {});
}
});
// =========================================================================
// GRUPPE 5: 5. 💾 Persistent Queue (Auto-Recovery)
// =========================================================================
// 5.1 Enable Persistent Queue (Auto-Recovery)
app.ui.settings.addSetting({
id: "LeafFlow.5. 💾 Persistent Queue.01_EnablePersistentQueue",
name: "Enable Persistent Queue (Auto-Recovery)",
type: "boolean",
defaultValue: true,
tooltip: "Automatically persists unfinished batch queue items to disk and restores them after server or browser crashes.",
onChange(value) {
api.fetchApi("/leafflow/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enable_persistent_queue: value ? "true" : "false" })
}).catch(() => {});
}
});
// 5.2 Persistent Queue Restored Launch State
app.ui.settings.addSetting({
id: "LeafFlow.5. 💾 Persistent Queue.02_RecoveryLaunchState",
name: "Recovery Launch State",
type: "combo",
options: ["Match Default", "Force Paused", "Force Running"],
defaultValue: "Match Default",
tooltip: "Override launch state when unfinished queue items are recovered on startup.",
onChange(value) {
api.fetchApi("/leafflow/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ persistent_queue_restored_state: value })
}).catch(() => {});
}
});
// =========================================================================
// GRUPPE 6: 6. 🖼️ Assets & History Restore
// =========================================================================
// 6.1 Enable Assets / History Restore on Launch
app.ui.settings.addSetting({
id: "LeafFlow.6. 🖼️ Assets Restore.01_RestoreAssetsOnLaunch",
name: "Restore Assets on Launch",
type: "boolean",
defaultValue: true,
tooltip: "Automatically populates the Assets / History pane upon ComfyUI launch with your latest generated images.",
onChange(value) {
api.fetchApi("/leafflow/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enable_assets_restore: value ? "true" : "false" })
}).catch(() => {});
}
});
// 6.2 Restored Assets Count
app.ui.settings.addSetting({
id: "LeafFlow.6. 🖼️ Assets Restore.02_RestoredAssetsCount",
name: "Restored Assets Count",
type: "number",
defaultValue: 64,
tooltip: "Number of newest images from the output folder to restore into the Assets / History pane on launch (default: 64).",
onChange(value) {
const count = parseInt(value, 10) || 64;
api.fetchApi("/leafflow/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ restore_assets_count: count })
}).catch(() => {});
}
});
// =========================================================================
// GRUPPE 7: 7. 🩺 Diagnostics & Debug
// =========================================================================
// 7.1 Export Debug Profile Button
app.ui.settings.addSetting({
id: "LeafFlow.7. 🩺 Diagnostics.01_ExportDebugProfile",
name: "Export Debug Profile",
type: "button",
defaultValue: "📥 Export Debug Profile",
tooltip: "Exports non-sensitive environment diagnostics (OS, Python, PyTorch, LeafFlow settings, local counts) as a JSON file to share when troubleshooting issues.",
attrs: {
className: "leafflow-settings-btn",
class: "leafflow-settings-btn",
onClick: async () => {
const approved = confirm("🍃 ComfyUI-LeafFlow Diagnostics Export\n\nExport system diagnostics for troubleshooting?\n\nNOTE: Sensitive API keys, tokens, file paths, and private prompt texts are automatically stripped and NEVER exported.");
if (!approved) return;
try {
const resp = await api.fetchApi("/leafflow/debug/export");
const data = await resp.json();
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `leafflow_debug_profile_${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
alert("✅ Diagnostics profile exported successfully! You can attach the downloaded JSON file to your bug report or GitHub issue.");
} catch (e) {
console.error("[LeafFlow] 🍃 Error exporting debug profile:", e);
alert("❌ Failed to export debug profile: " + e.message);
}
}
},
render: renderSettingButton("📥 Export Debug Profile", "⏳ Exporting...", "✅ Exported!", async () => {
const resp = await api.fetchApi("/leafflow/debug/export");
const data = await resp.json();
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `leafflow_debug_profile_${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
})
});
}
});
+14 -1
View File
@@ -1,4 +1,4 @@
/* FlowControl PauseQueue CSS */
/* LeafFlow PauseQueue CSS */
:root {
--pq-unpaused-color: #059669;
@@ -25,6 +25,19 @@
animation: pq-pulse-orange 2s infinite;
}
.pq-button-group.litegraph-mode {
display: flex;
width: 100%;
margin: 4px 0;
height: 30px;
border-radius: 0;
}
.pq-button-group.litegraph-mode .pq-main-btn,
.pq-button-group.litegraph-mode .pq-dropdown-btn {
border-radius: 0;
}
.pq-main-btn {
position: relative;
display: inline-flex;
+41 -24
View File
@@ -38,7 +38,7 @@ class PauseQueueUI {
this.updateState(data);
}
} catch (e) {
console.error("[FlowControl] Error fetching status:", e);
console.error("[LeafFlow] Error fetching status:", e);
}
}
@@ -67,7 +67,7 @@ class PauseQueueUI {
this.updateState(data);
}
} catch (e) {
console.error("[FlowControl] Error toggling pause:", e);
console.error("[LeafFlow] Error toggling pause:", e);
}
}
@@ -83,7 +83,7 @@ class PauseQueueUI {
this.updateState(data);
}
} catch (e) {
console.error("[FlowControl] Error setting mode:", e);
console.error("[LeafFlow] Error setting mode:", e);
}
}
@@ -95,44 +95,61 @@ class PauseQueueUI {
this.updateState(data);
}
} catch (e) {
console.error("[FlowControl] Error continuing queue:", e);
console.error("[LeafFlow] Error continuing queue:", e);
}
}
startObserver() {
this.injectUI();
setInterval(() => {
if (!this.groupEl || !document.body.contains(this.groupEl)) {
this.injectUI();
}
this.injectUI();
}, 1000);
}
injectUI() {
// Vue V2 UI Detection
const queueGroup = document.querySelector(".queue-button-group") ||
document.querySelector('[data-testid="queue-button"]')?.closest(".queue-button-group");
if (!queueGroup) return;
const parentDiv = queueGroup.parentElement;
if (!parentDiv) return;
if (queueGroup) {
const parentDiv = queueGroup.parentElement;
if (!parentDiv) return;
if (this.groupEl && parentDiv.contains(this.groupEl)) {
// If it's already in the correct V2 container, do nothing
if (this.groupEl && parentDiv.contains(this.groupEl)) return;
if (!this.groupEl) this.buildElement();
this.groupEl.classList.remove("litegraph-mode");
const dragHandle = parentDiv.querySelector(".drag-handle");
if (dragHandle && dragHandle.nextSibling) {
parentDiv.insertBefore(this.groupEl, dragHandle.nextSibling);
} else {
parentDiv.insertBefore(this.groupEl, queueGroup);
}
this.renderUI();
return;
}
if (!this.groupEl) {
this.buildElement();
}
// If we are definitely in V2, wait for V2 header to render
const isV2 = !!document.querySelector('comfy-app') || !!document.querySelector('#app');
if (isV2) return;
const dragHandle = parentDiv.querySelector(".drag-handle");
if (dragHandle && dragHandle.nextSibling) {
parentDiv.insertBefore(this.groupEl, dragHandle.nextSibling);
} else {
parentDiv.insertBefore(this.groupEl, queueGroup);
}
// LiteGraph V1 UI Detection
if (app.ui && app.ui.menuContainer) {
if (this.groupEl && app.ui.menuContainer.contains(this.groupEl)) return;
if (!this.groupEl) this.buildElement();
this.renderUI();
this.groupEl.classList.add("litegraph-mode");
const queueBtn = app.ui.menuContainer.querySelector(".comfy-queue-btn");
if (queueBtn) {
app.ui.menuContainer.insertBefore(this.groupEl, queueBtn.nextSibling);
} else {
app.ui.menuContainer.appendChild(this.groupEl);
}
this.renderUI();
}
}
buildElement() {
@@ -302,7 +319,7 @@ class PauseQueueUI {
}
app.registerExtension({
name: "ComfyUI.FlowControl.PauseQueue",
name: "ComfyUI.LeafFlow.PauseQueue",
async setup() {
new PauseQueueUI();
}
+38 -2
View File
@@ -2,8 +2,36 @@ import { app } from "/scripts/app.js";
import { api } from "/scripts/api.js";
app.registerExtension({
name: "ComfyUI.FlowControl.PersistentQueue",
name: "ComfyUI.LeafFlow.PersistentQueue",
async setup() {
const patchRgthree = async () => {
try {
const rgthree = await import("/extensions/rgthree-comfy/common/prompt_service.js");
if (rgthree && rgthree.SERVICE) {
const queue = await api.getQueue();
const allItems = [...(queue.Running || []), ...(queue.Pending || [])];
let patched = false;
for (const item of allItems) {
const promptId = item[1];
const promptData = item[2];
if (promptId && promptData) {
const promptExecution = rgthree.SERVICE.getOrMakePrompt(promptId);
if (!promptExecution.totalNodes) {
promptExecution.setPrompt({ output: promptData });
patched = true;
}
}
}
if (patched) {
rgthree.SERVICE.dispatchProgressUpdate();
console.log("[LeafFlow] Synced recovered queue with rgthree-comfy.");
}
}
} catch (e) {
// rgthree-comfy not installed or path changed, ignore
}
};
const claimQueueOwnership = async () => {
if (api && api.clientId) {
try {
@@ -12,8 +40,11 @@ app.registerExtension({
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ client_id: api.clientId })
});
// Wait for server to sync queue state back to clients, then patch rgthree
setTimeout(patchRgthree, 500);
} catch (e) {
console.error("[FlowControl] Error claiming queue ownership:", e);
console.error("[LeafFlow] Error claiming queue ownership:", e);
}
}
};
@@ -25,6 +56,11 @@ app.registerExtension({
api.addEventListener("status", () => {
claimQueueOwnership();
}, { once: true });
// Also run patch anytime an execution starts just in case it was triggered externally
api.addEventListener("execution_start", () => {
setTimeout(patchRgthree, 100);
});
}
}
});
+255 -47
View File
@@ -2,47 +2,137 @@ import { app } from "/scripts/app.js";
import { api } from "/scripts/api.js";
import { PreviewManager } from "./js/preview_manager.js";
// Global CSS injection to guarantee zero-expansion in Vue Node 2.0 and LiteGraph
if (typeof document !== "undefined") {
const styleId = "leafflow-live-preview-styles";
if (!document.getElementById(styleId)) {
const style = document.createElement("style");
style.id = styleId;
style.textContent = `
.leafflow-live-preview-container {
position: absolute !important;
inset: 0 !important;
top: 70px !important;
bottom: 40px !important;
left: 8px !important;
right: 8px !important;
width: auto !important;
height: auto !important;
min-width: 0 !important;
min-height: 0 !important;
max-width: none !important;
max-height: none !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
overflow: hidden !important;
box-sizing: border-box !important;
pointer-events: none !important;
}
.leafflow-live-preview-img {
position: absolute !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100% !important;
max-width: 100% !important;
max-height: 100% !important;
min-width: 0 !important;
min-height: 0 !important;
object-fit: contain !important;
object-position: center !important;
display: none;
pointer-events: none !important;
}
[node-type="PreviewLatentLive"] {
min-height: 0 !important;
min-width: 0 !important;
}
[node-type="PreviewLatentLive"] .leafflow-live-preview-container {
position: absolute !important;
}
`;
document.head.appendChild(style);
}
}
function isSamplerNode(type = "", title = "") {
const lowerType = String(type).toLowerCase();
const lowerTitle = String(title).toLowerCase();
return lowerType.includes("sampler") || lowerType.includes("ksampler") || lowerType.includes("sample") || lowerType.includes("denoise") ||
lowerTitle.includes("sampler") || lowerTitle.includes("ksampler") || lowerTitle.includes("sample") || lowerTitle.includes("denoise");
}
function getKSamplerNodes() {
if (!app || !app.graph) return [];
const samplers = [];
const visitedNodes = new Set();
const traverseGraph = (graph) => {
if (!graph || !graph._nodes) return;
for (const node of graph._nodes) {
if (!node || visitedNodes.has(node.id)) continue;
visitedNodes.add(node.id);
const checkNode = (node, prefix = "") => {
if (!node) return;
const idStr = String(node.id ?? "");
if (!idStr || visitedNodes.has(idStr)) return;
const type = (node.type || node.comfyClass || "").toLowerCase();
const title = (node.title || node.type || "").toLowerCase();
const type = node.type || node.comfyClass || "";
const title = node.title || node.type || `Node ${idStr}`;
if (type.includes("sampler") || type.includes("ksampler") || title.includes("sampler")) {
samplers.push({
id: node.id,
title: node.title || node.type || `Node ${node.id}`
});
}
if (isSamplerNode(type, title)) {
visitedNodes.add(idStr);
const displayTitle = prefix ? `${prefix} > ${title}` : title;
samplers.push({
id: idStr,
title: displayTitle
});
}
if (node.subgraph) {
traverseGraph(node.subgraph);
}
// Deep subgraph / group exploration
const sub = node.subgraph || node.inner_graph || (typeof node.getInnerGraph === "function" ? node.getInnerGraph() : null) || node.sub_nodes || node.nodes;
if (sub) {
traverseGraph(sub, title);
}
};
traverseGraph(app.graph);
// Also check DOM nodes for ComfyUI V2 subgraphs
document.querySelectorAll('[data-node-id], [data-widgets-grid-node-id], .lg-node').forEach(el => {
const id = el.dataset.nodeId || el.dataset.widgetsGridNodeId || el.getAttribute('data-node-id');
const text = (el.innerText || el.textContent || "").split('\n')[0];
if (id && text.toLowerCase().includes("sampler") && !samplers.some(s => String(s.id) === String(id))) {
const titleMatch = text.match(/KSampler[^\n]*/i) || text.match(/Sampler[^\n]*/i);
let title = titleMatch ? titleMatch[0].trim() : `Sampler (${id})`;
if (title.length > 45) title = title.substring(0, 42) + "...";
samplers.push({ id, title });
const traverseGraph = (graph, prefix = "") => {
if (!graph) return;
let nodesList = [];
if (Array.isArray(graph._nodes)) {
nodesList = graph._nodes;
} else if (Array.isArray(graph.nodes)) {
nodesList = graph.nodes;
} else if (graph._nodes_by_id) {
nodesList = Object.values(graph._nodes_by_id);
} else if (typeof graph.values === "function") {
nodesList = Array.from(graph.values());
}
});
for (const node of nodesList) {
checkNode(node, prefix);
}
};
if (app) {
if (app.graph) traverseGraph(app.graph);
if (app.rootGraph && app.rootGraph !== app.graph) traverseGraph(app.rootGraph);
if (app.canvas?.graph && app.canvas.graph !== app.graph) traverseGraph(app.canvas.graph);
}
// Comprehensive DOM search for any rendered nodes in canvas (Vue Node 2.0 and LiteGraph)
if (typeof document !== "undefined") {
document.querySelectorAll('[data-node-id], [data-widgets-grid-node-id], .lg-node, [node-id]').forEach(el => {
const id = el.dataset.nodeId || el.dataset.widgetsGridNodeId || el.getAttribute('data-node-id') || el.getAttribute('node-id');
if (!id || visitedNodes.has(String(id))) return;
const headerEl = el.querySelector('[data-testid^="node-header-"], [data-testid="node-title"], .lg-node-header, .node-title');
const text = (headerEl ? headerEl.innerText : el.innerText) || "";
if (isSamplerNode("", text)) {
visitedNodes.add(String(id));
const titleMatch = text.match(/KSampler[^\n]*/i) || text.match(/Sampler[^\n]*/i) || text.match(/[^\n]+/);
let title = titleMatch ? titleMatch[0].trim() : `Sampler (${id})`;
if (title.length > 45) title = title.substring(0, 42) + "...";
samplers.push({ id: String(id), title });
}
});
}
return samplers;
}
@@ -50,32 +140,66 @@ function getKSamplerNodes() {
function updateAllPreviewNodeDropdowns() {
if (!app || !app.graph) return;
const previewNodes = app.graph._nodes ? app.graph._nodes.filter(n => n && n.type === "PreviewLatentLive") : [];
const previewNodes = [];
const collectPreviewNodes = (g) => {
if (!g) return;
const list = g._nodes || g.nodes || (g._nodes_by_id ? Object.values(g._nodes_by_id) : []);
if (Array.isArray(list)) {
for (const n of list) {
if (n && n.type === "PreviewLatentLive") previewNodes.push(n);
if (n && n.subgraph) collectPreviewNodes(n.subgraph);
}
}
};
collectPreviewNodes(app.graph);
if (app.rootGraph && app.rootGraph !== app.graph) collectPreviewNodes(app.rootGraph);
const currentNodes = getKSamplerNodes();
const currentValues = ["Auto", ...currentNodes.map(n => `${n.title} (${n.id})`)];
for (const node of previewNodes) {
const widget = node.widgets?.find(w => w.name === "Source");
if (widget) {
widget.options.values = currentValues;
if (!currentValues.includes(widget.value)) {
widget.value = "Auto";
const currentVal = widget.value;
widget.options = widget.options || {};
widget.options.values = [...currentValues];
if (currentVal && currentVal !== "Auto" && !currentValues.includes(currentVal)) {
widget.options.values.push(currentVal);
}
if (typeof node.setDirtyCanvas === "function") {
node.setDirtyCanvas(true, true);
}
}
}
}
app.registerExtension({
name: "ComfyUI.FlowControl.PreviewLatentLive",
name: "ComfyUI.LeafFlow.PreviewLatentLive",
async setup() {
PreviewManager.init(app, api);
api.addEventListener("status", () => {
updateAllPreviewNodeDropdowns();
});
if (typeof document !== "undefined") {
document.addEventListener("pointerdown", (e) => {
if (e.target && (e.target.closest?.('[data-testid="widget-select-default-trigger"]') || e.target.closest?.('.lg-node') || e.target.closest?.('[data-node-id]'))) {
updateAllPreviewNodeDropdowns();
}
}, { passive: true });
}
},
async loadedGraph() {
updateAllPreviewNodeDropdowns();
setTimeout(updateAllPreviewNodeDropdowns, 200);
setTimeout(updateAllPreviewNodeDropdowns, 1000);
},
async nodeCreated(node) {
if (node && node.type === "PreviewLatentLive") {
setTimeout(updateAllPreviewNodeDropdowns, 100);
}
},
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name === "PreviewLatentLive") {
@@ -94,48 +218,119 @@ app.registerExtension({
updateAllPreviewNodeDropdowns();
};
this.size = [300, 300];
this.size = [200, 200];
this.min_size = [80, 80];
this.resizable = true;
// Disallow automatic image aspect ratio resizing
this.setSizeForImage = function() {};
Object.defineProperty(this, "aspect_ratio", {
get() { return false; },
set(v) {},
configurable: true
});
this.imgs = [];
this.imageIndex = 0;
// Create robust DOM container for V2 overlay
const viewContainer = document.createElement("div");
viewContainer.style.width = "100%";
viewContainer.style.height = "100%";
viewContainer.className = "leafflow-live-preview-container";
viewContainer.style.position = "absolute";
viewContainer.style.inset = "0";
viewContainer.style.top = "70px";
viewContainer.style.bottom = "40px";
viewContainer.style.left = "8px";
viewContainer.style.right = "8px";
viewContainer.style.display = "flex";
viewContainer.style.alignItems = "center";
viewContainer.style.justifyContent = "center";
viewContainer.style.backgroundColor = "transparent";
viewContainer.style.color = "#888";
viewContainer.style.fontFamily = "Inter, sans-serif";
viewContainer.style.fontSize = "14px";
viewContainer.style.fontSize = "13px";
viewContainer.style.overflow = "hidden";
viewContainer.style.boxSizing = "border-box";
viewContainer.style.pointerEvents = "none";
const fallbackText = document.createElement("div");
fallbackText.className = "leafflow-live-preview-fallback";
fallbackText.innerText = "Waiting for live preview...";
viewContainer.appendChild(fallbackText);
const imgElement = document.createElement("img");
imgElement.className = "leafflow-live-preview-img";
imgElement.style.position = "absolute";
imgElement.style.top = "0";
imgElement.style.left = "0";
imgElement.style.width = "100%";
imgElement.style.height = "100%";
imgElement.style.maxWidth = "100%";
imgElement.style.maxHeight = "100%";
imgElement.style.minWidth = "0";
imgElement.style.minHeight = "0";
imgElement.style.objectFit = "contain";
imgElement.style.objectPosition = "center";
imgElement.style.display = "none";
imgElement.style.pointerEvents = "none";
viewContainer.appendChild(imgElement);
this.previewImgElement = imgElement;
this.previewFallbackText = fallbackText;
// Mount DOM widget
this.addDOMWidget("preview_display", "HTML", viewContainer, {
getValue() { return ""; },
setValue(val) {},
serialize: false
serialize: false,
computeSize() { return [0, 0]; }
});
PreviewManager.registerNode(this);
setTimeout(updateAllPreviewNodeDropdowns, 100);
};
nodeType.prototype.computeSize = function() {
return [80, 80];
};
nodeType.prototype.setSizeForImage = function() {};
nodeType.prototype.onResize = function(size) {
if (size) {
this.size[0] = Math.max(80, size[0]);
this.size[1] = Math.max(80, size[1]);
}
};
nodeType.prototype.onDrawBackground = function(ctx) {
if (this.flags.collapsed) return;
if (this.imgs && this.imgs.length) {
const img = this.imgs[this.imageIndex || 0];
if (!img || !img.complete || (img.naturalWidth === 0 && img.width === 0)) return;
const margin = 8;
const topY = 50;
const maxW = Math.max(10, this.size[0] - margin * 2);
const maxH = Math.max(10, this.size[1] - topY - margin);
if (maxW <= 0 || maxH <= 0) return;
const w = img.naturalWidth || img.width;
const h = img.naturalHeight || img.height;
const imgAspect = w / h;
let drawW = maxW;
let drawH = maxW / imgAspect;
if (drawH > maxH) {
drawH = maxH;
drawW = maxH * imgAspect;
}
const drawX = margin + (maxW - drawW) / 2;
const drawY = topY + (maxH - drawH) / 2;
ctx.drawImage(img, drawX, drawY, drawW, drawH);
}
};
const onRemoved = nodeType.prototype.onRemoved;
nodeType.prototype.onRemoved = function() {
if (onRemoved) {
@@ -150,10 +345,10 @@ app.registerExtension({
const selectedSource = sourceWidget ? sourceWidget.value : "Auto";
if (selectedSource !== "Auto") {
const match = selectedSource.match(/\((\d+)\)$/);
const match = selectedSource.match(/\(([^)]+)\)$/);
if (match) {
const targetId = match[1];
if (samplerId && String(samplerId) !== targetId) {
if (samplerId && String(samplerId) !== String(targetId)) {
return;
}
}
@@ -162,14 +357,27 @@ app.registerExtension({
this.imgs = [img];
this.imageIndex = 0;
// Update DOM elements natively for V2 visibility
if (typeof document !== "undefined") {
const domImgs = document.querySelectorAll(`[data-node-id="${this.id}"] .leafflow-live-preview-img, [data-widgets-grid-node-id="${this.id}"] .leafflow-live-preview-img`);
if (domImgs.length > 0) {
domImgs.forEach(imgEl => {
imgEl.src = img.src;
imgEl.style.display = "block";
const fallback = imgEl.parentElement?.querySelector(".leafflow-live-preview-fallback");
if (fallback) fallback.style.display = "none";
});
}
}
if (this.previewImgElement && this.previewFallbackText && img && img.src) {
this.previewFallbackText.style.display = "none";
this.previewImgElement.src = img.src;
this.previewImgElement.style.display = "block";
}
app.graph.setDirtyCanvas(true, true);
if (app.graph && typeof app.graph.setDirtyCanvas === "function") {
app.graph.setDirtyCanvas(true, true);
}
};
}
}