Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a39bbbf80 | ||
|
|
de3c599991 | ||
|
|
5b3e2b5022 | ||
|
|
82cbd230b1 |
@@ -12,18 +12,25 @@ Every node in this suite is prefixed with the signature **`🍃`** brand icon fo
|
||||
|
||||
### ⏸️ Queue & Workflow Control (`FlowControl/Queue`)
|
||||
|
||||
1. **`🍃 Pause Queue`**
|
||||
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 `▶`.
|
||||
|
||||
2. **`🍃 Persistent Queue`**
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
### 🎨 Visual Loaders & Pickers (`FlowControl/Loaders`)
|
||||
@@ -41,17 +48,21 @@ Every node in this suite is prefixed with the signature **`🍃`** brand icon fo
|
||||
- Outputs `IMAGE`, `positive_prompt`, `width`, and `height`.
|
||||
|
||||
5. **`🍃 Recent Outputs`**
|
||||
- Recursively scans ComfyUI's `output/` directory and cycles through recently generated images by step index for seamless integration with ComfyUI's sidebar/assets panel.
|
||||
- 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. **`🍃 Auto Watcher`**
|
||||
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_for_image` Toggle**:
|
||||
- `True` (Default): Safely polls until an image arrives in the directory. Does not create missing folders automatically or crash if folder is deleted.
|
||||
- `False`: Immediately checks the directory. If an image is present, loads it and returns `(image, has_image=True)`. If no image is present (or folder is missing), returns a dummy tensor and `has_image=False` immediately without blocking.
|
||||
- **`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%`.
|
||||
@@ -65,12 +76,11 @@ Every node in this suite is prefixed with the signature **`🍃`** brand icon fo
|
||||
|
||||
| Node Class | Display Name | Category | Primary Function |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `PauseQueueNode` | **`🍃 Pause Queue`** | `FlowControl/Queue` | V2 action bar Pause (Finish) & Pause (Instant) controls |
|
||||
| `PersistentQueueNode` | **`🍃 Persistent Queue`** | `FlowControl/Queue` | Real-time queue persistence & crash recovery |
|
||||
| `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 |
|
||||
| `AutoWatcherNode` | **`🍃 Auto Watcher`** | `FlowControl/Automation` | Folder image watcher with `wait_for_image` toggle |
|
||||
| `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 |
|
||||
|
||||
|
||||
+9
-9
@@ -11,36 +11,36 @@ from .nodes.lora_loader import (
|
||||
)
|
||||
from .nodes.image_loader import ImageLoaderVisualPrettyV2
|
||||
from .nodes.undo_placeholder import UndoPlaceholder
|
||||
from .nodes.auto_watcher import AutoWatcherNode
|
||||
from .nodes.auto_watcher import LoadImageFromFolder
|
||||
from .nodes.load_recent import LoadRecentOutputs
|
||||
from .nodes.preview_latent import PreviewLatentLiveNode
|
||||
from .nodes.decision_node import FlowControlDecision
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"PauseQueueNode": PauseQueueNode,
|
||||
"PersistentQueueNode": PersistentQueueNode,
|
||||
"FolderLoraLoader": FolderLoraLoader,
|
||||
"FolderLoraLoaderPretty": FolderLoraLoaderPretty,
|
||||
"FolderLoraLoaderVisualPretty": FolderLoraLoaderVisualPretty,
|
||||
"FolderLoraLoaderVisualPrettyV2": FolderLoraLoaderVisualPrettyV2,
|
||||
"ImageLoaderVisualPrettyV2": ImageLoaderVisualPrettyV2,
|
||||
"UndoPlaceholder": UndoPlaceholder,
|
||||
"AutoWatcherNode": AutoWatcherNode,
|
||||
"LoadImageFromFolder": LoadImageFromFolder,
|
||||
"LoadRecentOutputs": LoadRecentOutputs,
|
||||
"PreviewLatentLive": PreviewLatentLiveNode
|
||||
"PreviewLatentLive": PreviewLatentLiveNode,
|
||||
"FlowControlDecision": FlowControlDecision
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"PauseQueueNode": "🍃 Pause Queue",
|
||||
"PersistentQueueNode": "🍃 Persistent Queue",
|
||||
"FolderLoraLoader": "🍃 LoRA Loader (by Folder)",
|
||||
"FolderLoraLoaderPretty": "🍃 LoRA Loader (Pretty)",
|
||||
"FolderLoraLoaderVisualPretty": "🍃 Visual LoRA Picker",
|
||||
"FolderLoraLoaderVisualPrettyV2": "🍃 Visual LoRA Loader",
|
||||
"ImageLoaderVisualPrettyV2": "🍃 Visual Image Loader",
|
||||
"UndoPlaceholder": "🍃 Undo Placeholder",
|
||||
"AutoWatcherNode": "🍃 Auto Watcher",
|
||||
"LoadImageFromFolder": "🍃 Load image from folder",
|
||||
"LoadRecentOutputs": "🍃 Recent Outputs",
|
||||
"PreviewLatentLive": "🍃 Live Latent Preview"
|
||||
"PreviewLatentLive": "🍃 Live Latent Preview",
|
||||
"FlowControlDecision": "🍃 FlowControl Decision"
|
||||
}
|
||||
|
||||
WEB_DIRECTORY = "./web"
|
||||
|
||||
+58
-27
@@ -2,17 +2,20 @@ import os
|
||||
import time
|
||||
import torch
|
||||
import numpy as np
|
||||
import re
|
||||
from PIL import Image, ImageOps
|
||||
import comfy.model_management
|
||||
|
||||
class AutoWatcherNode:
|
||||
class LoadImageFromFolder:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"watch_dir": ("STRING", {"default": "input/watch"}),
|
||||
"interval_sec": ("INT", {"default": 2, "min": 1, "max": 60}),
|
||||
"wait_for_image": ("BOOLEAN", {"default": True}),
|
||||
"folder": ("STRING", {"default": "input/watch"}),
|
||||
"wait_if_folder_is_empty": ("BOOLEAN", {"default": True}),
|
||||
"rescan_interval": ("INT", {"default": 2, "min": 1, "max": 60}),
|
||||
"sort_by": (["date_modified", "date_created", "name"],),
|
||||
"regex_filter": ("STRING", {"default": ".*"}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +23,7 @@ class AutoWatcherNode:
|
||||
RETURN_NAMES = ("image", "has_image")
|
||||
FUNCTION = "watch"
|
||||
CATEGORY = "🍃 FlowControl/Automation"
|
||||
DESCRIPTION = "Monitors a directory for incoming images. Can poll and wait for an image to arrive, or load if available without waiting."
|
||||
DESCRIPTION = "Loads an image from a folder, optionally waiting if the folder is empty."
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(s, **kwargs):
|
||||
@@ -40,52 +43,80 @@ class AutoWatcherNode:
|
||||
try:
|
||||
os.remove(filepath)
|
||||
except Exception as e:
|
||||
print(f"[AutoWatcher] Warning: Failed to remove processed file '{filepath}': {e}")
|
||||
print(f"[FlowControl] Warning: Failed to remove processed file '{filepath}': {e}")
|
||||
|
||||
return img_tensor
|
||||
|
||||
def watch(self, watch_dir, interval_sec, wait_for_image=True):
|
||||
def get_filtered_files(self, folder, sort_by, regex_filter):
|
||||
valid_extensions = ('.png', '.jpg', '.jpeg', '.webp')
|
||||
try:
|
||||
regex = re.compile(regex_filter)
|
||||
except Exception as e:
|
||||
print(f"[FlowControl] Invalid regex: {e}. Falling back to .*")
|
||||
regex = re.compile(".*")
|
||||
|
||||
if wait_for_image:
|
||||
# Poll loop: waits until an image arrives in watch_dir
|
||||
files = []
|
||||
for f in os.listdir(folder):
|
||||
filepath = os.path.join(folder, f)
|
||||
if os.path.isfile(filepath) and f.lower().endswith(valid_extensions):
|
||||
if regex.search(f):
|
||||
files.append(filepath)
|
||||
|
||||
if not files:
|
||||
return []
|
||||
|
||||
valid_files = []
|
||||
for file in files:
|
||||
try:
|
||||
if sort_by == "date_modified":
|
||||
val = os.path.getmtime(file)
|
||||
elif sort_by == "date_created":
|
||||
val = os.path.getctime(file)
|
||||
else:
|
||||
val = file
|
||||
valid_files.append((file, val))
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
if sort_by in ("date_modified", "date_created"):
|
||||
valid_files.sort(key=lambda x: x[1], reverse=True) # newest first
|
||||
elif sort_by == "name":
|
||||
valid_files.sort(key=lambda x: x[1]) # alphabetical
|
||||
|
||||
return [x[0] for x in valid_files]
|
||||
|
||||
def watch(self, folder, wait_if_folder_is_empty, rescan_interval, sort_by, regex_filter):
|
||||
if wait_if_folder_is_empty:
|
||||
while True:
|
||||
comfy.model_management.throw_exception_if_processing_interrupted()
|
||||
|
||||
if os.path.exists(watch_dir) and os.path.isdir(watch_dir):
|
||||
if os.path.exists(folder) and os.path.isdir(folder):
|
||||
try:
|
||||
files = [
|
||||
f for f in os.listdir(watch_dir)
|
||||
if os.path.isfile(os.path.join(watch_dir, f)) and f.lower().endswith(valid_extensions)
|
||||
]
|
||||
files = self.get_filtered_files(folder, sort_by, regex_filter)
|
||||
if files:
|
||||
filepath = os.path.join(watch_dir, files[0])
|
||||
filepath = files[0] # Pick the first one based on sorting
|
||||
try:
|
||||
img_tensor = self.load_and_remove_image(filepath)
|
||||
return (img_tensor, True)
|
||||
except Exception as e:
|
||||
print(f"[AutoWatcher] Error processing {filepath}: {e}")
|
||||
print(f"[FlowControl] Error processing {filepath}: {e}")
|
||||
except Exception as e:
|
||||
print(f"[AutoWatcher] Error accessing directory {watch_dir}: {e}")
|
||||
print(f"[FlowControl] Error accessing directory {folder}: {e}")
|
||||
|
||||
time.sleep(interval_sec)
|
||||
time.sleep(rescan_interval)
|
||||
else:
|
||||
# Non-blocking check: returns immediately if no image is present
|
||||
if os.path.exists(watch_dir) and os.path.isdir(watch_dir):
|
||||
if os.path.exists(folder) and os.path.isdir(folder):
|
||||
try:
|
||||
files = [
|
||||
f for f in os.listdir(watch_dir)
|
||||
if os.path.isfile(os.path.join(watch_dir, f)) and f.lower().endswith(valid_extensions)
|
||||
]
|
||||
files = self.get_filtered_files(folder, sort_by, regex_filter)
|
||||
if files:
|
||||
filepath = os.path.join(watch_dir, files[0])
|
||||
filepath = files[0]
|
||||
try:
|
||||
img_tensor = self.load_and_remove_image(filepath)
|
||||
return (img_tensor, True)
|
||||
except Exception as e:
|
||||
print(f"[AutoWatcher] Error processing {filepath}: {e}")
|
||||
print(f"[FlowControl] Error processing {filepath}: {e}")
|
||||
except Exception as e:
|
||||
print(f"[AutoWatcher] Error accessing directory {watch_dir}: {e}")
|
||||
print(f"[FlowControl] Error accessing directory {folder}: {e}")
|
||||
|
||||
# No image present or folder missing -> return dummy tensor & False immediately
|
||||
return (self.create_dummy_image(), False)
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import os
|
||||
import threading
|
||||
import subprocess
|
||||
from server import PromptServer
|
||||
from aiohttp import web
|
||||
import comfy.model_management
|
||||
|
||||
class DecisionManager:
|
||||
_waiting_events = {}
|
||||
_actions = {}
|
||||
|
||||
@classmethod
|
||||
def register_wait(cls, unique_id, event):
|
||||
cls._waiting_events[unique_id] = event
|
||||
cls._actions[unique_id] = "continue"
|
||||
|
||||
@classmethod
|
||||
def unregister_wait(cls, unique_id):
|
||||
if unique_id in cls._waiting_events:
|
||||
del cls._waiting_events[unique_id]
|
||||
if unique_id in cls._actions:
|
||||
del cls._actions[unique_id]
|
||||
|
||||
@classmethod
|
||||
def get_action(cls, unique_id):
|
||||
action = cls._actions.get(unique_id, "continue")
|
||||
cls.unregister_wait(unique_id)
|
||||
return action
|
||||
|
||||
@classmethod
|
||||
def trigger_action(cls, unique_id, action):
|
||||
if unique_id in cls._waiting_events:
|
||||
cls._actions[unique_id] = action
|
||||
cls._waiting_events[unique_id].set()
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class FlowControlDecision:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"disable": ("BOOLEAN", {"default": False}),
|
||||
"send_windows_notification": ("BOOLEAN", {"default": False}),
|
||||
"timeout": ("INT", {"default": -1, "min": -1, "max": 3600}),
|
||||
},
|
||||
"hidden": {
|
||||
"unique_id": "UNIQUE_ID",
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("BOOLEAN",)
|
||||
RETURN_NAMES = ("cancel",)
|
||||
FUNCTION = "decide"
|
||||
CATEGORY = "🍃 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."
|
||||
|
||||
def send_notification(self, title, message):
|
||||
if os.name == 'nt':
|
||||
try:
|
||||
# Using native PowerShell Toast Notification API (no third party modules required)
|
||||
ps_script = f"""
|
||||
$ErrorActionPreference = 'Stop'
|
||||
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
|
||||
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null
|
||||
|
||||
$template = @"
|
||||
<toast>
|
||||
<visual>
|
||||
<binding template="ToastText02">
|
||||
<text id="1">{title}</text>
|
||||
<text id="2">{message}</text>
|
||||
</binding>
|
||||
</visual>
|
||||
</toast>
|
||||
"@
|
||||
|
||||
$xml = New-Object Windows.Data.Xml.Dom.XmlDocument
|
||||
$xml.LoadXml($template)
|
||||
$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
|
||||
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("ComfyUI").Show($toast)
|
||||
"""
|
||||
subprocess.Popen(["powershell", "-NoProfile", "-Command", ps_script], creationflags=subprocess.CREATE_NO_WINDOW)
|
||||
except Exception as e:
|
||||
print(f"[FlowControl] Failed to send Windows notification: {e}")
|
||||
|
||||
def decide(self, disable, send_windows_notification, timeout, unique_id=None):
|
||||
if disable:
|
||||
return (False,)
|
||||
|
||||
if not unique_id:
|
||||
return (False,)
|
||||
|
||||
# Notify frontend
|
||||
PromptServer.instance.send_sync("flowcontrol_decision_waiting", {"node_id": unique_id})
|
||||
|
||||
if send_windows_notification:
|
||||
self.send_notification("ComfyUI FlowControl", "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...")
|
||||
|
||||
if timeout > 0:
|
||||
waited = event.wait(timeout)
|
||||
if not waited:
|
||||
print(f"[FlowControl] 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})
|
||||
return (False,)
|
||||
else:
|
||||
event.wait()
|
||||
|
||||
# Retrieve action
|
||||
action = DecisionManager.get_action(unique_id)
|
||||
|
||||
# Notify frontend that the decision has been resolved
|
||||
PromptServer.instance.send_sync("flowcontrol_decision_resolved", {"node_id": unique_id})
|
||||
|
||||
if action == "cancel":
|
||||
print(f"[FlowControl] Node {unique_id} cancelled.")
|
||||
return (True,)
|
||||
elif action == "stop":
|
||||
print(f"[FlowControl] Node {unique_id} stopped workflow.")
|
||||
try:
|
||||
# Attempt to cancel current execution queue
|
||||
PromptServer.instance.prompt_queue.cancel_current_execution()
|
||||
except:
|
||||
pass
|
||||
# Raise exception to halt this thread instantly
|
||||
try:
|
||||
raise comfy.model_management.InterruptProcessingException("Workflow stopped by FlowControl Decision Node.")
|
||||
except AttributeError:
|
||||
raise Exception("Workflow stopped by FlowControl Decision Node.")
|
||||
|
||||
print(f"[FlowControl] Node {unique_id} continuing.")
|
||||
return (False,)
|
||||
|
||||
|
||||
# Register API Route
|
||||
@PromptServer.instance.routes.post("/flowcontrol/decision")
|
||||
async def handle_decision(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
node_id = data.get("node_id")
|
||||
action = data.get("action")
|
||||
|
||||
if DecisionManager.trigger_action(node_id, action):
|
||||
return web.json_response({"status": "ok"})
|
||||
else:
|
||||
return web.json_response({"status": "error", "message": "Node is not waiting."}, status=400)
|
||||
except Exception as e:
|
||||
return web.json_response({"status": "error", "message": str(e)}, status=500)
|
||||
@@ -11,6 +11,7 @@ class LoadRecentOutputs:
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"output_folder": ("STRING", {"default": "output"}),
|
||||
"amount": ("INT", {"default": 5, "min": 1, "max": 100, "step": 1}),
|
||||
"index": ("INT", {
|
||||
"default": 0,
|
||||
@@ -25,10 +26,13 @@ class LoadRecentOutputs:
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "load_single_image"
|
||||
CATEGORY = LOADER_CATEGORY
|
||||
DESCRIPTION = "Cycles through recently generated output images saved in ComfyUI output directory by step index."
|
||||
DESCRIPTION = "Cycles through recently generated output images.\n\nExplanation of Index:\n- 'amount' grabs the X newest images from the folder.\n- 'index' selects which of those X images to output.\n- For example, if amount=64, setting index=0 to 63 in a batch will load each of the 64 newest images one by one.\n- If index exceeds amount, it loops back around (e.g. index 64 = index 0)."
|
||||
|
||||
def load_single_image(self, amount, index):
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
def load_single_image(self, output_folder, amount, index):
|
||||
if output_folder == "output":
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
else:
|
||||
output_dir = output_folder
|
||||
|
||||
if not os.path.exists(output_dir):
|
||||
dummy = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
|
||||
|
||||
@@ -300,15 +300,19 @@ async def get_loras_endpoint(request):
|
||||
|
||||
@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"
|
||||
|
||||
mapping = get_filtered_loras_mapping(folder, pretty=pretty)
|
||||
system_relative_path = mapping.get(lora_name, lora_name)
|
||||
if not system_path and lora_name:
|
||||
mapping = get_filtered_loras_mapping(folder, pretty=pretty)
|
||||
system_path = mapping.get(lora_name, lora_name)
|
||||
|
||||
if system_relative_path and system_relative_path != "[ NONE ]":
|
||||
img_path = find_preview_image(system_relative_path)
|
||||
if system_path and system_path != "[ NONE ]":
|
||||
img_path = find_preview_image(system_path)
|
||||
if img_path and os.path.exists(img_path):
|
||||
return web.FileResponse(img_path)
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { api } from "/scripts/api.js";
|
||||
|
||||
// Create global styles for the decision buttons
|
||||
const styles = document.createElement("style");
|
||||
styles.textContent = `
|
||||
.flowcontrol-decision-btn {
|
||||
margin: 4px;
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
color: white;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.flowcontrol-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; }
|
||||
|
||||
.flowcontrol-decision-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 4px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(styles);
|
||||
|
||||
app.registerExtension({
|
||||
name: "Comfy.FlowControlDecision",
|
||||
async setup() {
|
||||
api.addEventListener("flowcontrol_decision_waiting", (event) => {
|
||||
const data = event.detail;
|
||||
if (data && data.node_id) {
|
||||
const node = app.graph.getNodeById(data.node_id);
|
||||
if (node && node.enableDecisionButtons) {
|
||||
node.enableDecisionButtons();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
api.addEventListener("flowcontrol_decision_resolved", (event) => {
|
||||
const data = event.detail;
|
||||
if (data && data.node_id) {
|
||||
const node = app.graph.getNodeById(data.node_id);
|
||||
if (node && node.disableDecisionButtons) {
|
||||
node.disableDecisionButtons();
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
async nodeCreated(node) {
|
||||
if (node.comfyClass === "FlowControlDecision") {
|
||||
const container = document.createElement("div");
|
||||
container.className = "flowcontrol-decision-container";
|
||||
|
||||
const btnContinue = document.createElement("button");
|
||||
btnContinue.className = "flowcontrol-decision-btn flowcontrol-btn-continue";
|
||||
btnContinue.innerText = "Continue Workflow";
|
||||
btnContinue.disabled = true;
|
||||
|
||||
const btnCancel = document.createElement("button");
|
||||
btnCancel.className = "flowcontrol-decision-btn flowcontrol-btn-cancel";
|
||||
btnCancel.innerText = "Cancel (Return True)";
|
||||
btnCancel.disabled = true;
|
||||
|
||||
const btnStop = document.createElement("button");
|
||||
btnStop.className = "flowcontrol-decision-btn flowcontrol-btn-stop";
|
||||
btnStop.innerText = "Stop Workflow";
|
||||
btnStop.disabled = true;
|
||||
|
||||
container.appendChild(btnContinue);
|
||||
container.appendChild(btnCancel);
|
||||
container.appendChild(btnStop);
|
||||
|
||||
const sendDecision = async (action) => {
|
||||
node.disableDecisionButtons();
|
||||
try {
|
||||
await api.fetchApi("/flowcontrol/decision", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
node_id: node.id.toString(),
|
||||
action: action
|
||||
}),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[FlowControl] Failed to send decision:", e);
|
||||
}
|
||||
};
|
||||
|
||||
btnContinue.addEventListener("click", () => sendDecision("continue"));
|
||||
btnCancel.addEventListener("click", () => sendDecision("cancel"));
|
||||
btnStop.addEventListener("click", () => sendDecision("stop"));
|
||||
|
||||
const domWidget = node.addDOMWidget("decision_ui", "HTML", container, {
|
||||
serialize: false
|
||||
});
|
||||
|
||||
// Adjust node size to fit buttons
|
||||
node.size = [300, 240];
|
||||
const originalComputeSize = node.computeSize;
|
||||
node.computeSize = function() {
|
||||
const sz = originalComputeSize ? originalComputeSize.apply(this, arguments) : [300, 240];
|
||||
return [Math.max(300, sz[0]), Math.max(240, sz[1])];
|
||||
};
|
||||
|
||||
domWidget.computeSize = function() {
|
||||
return [node.size[0] - 20, 120];
|
||||
};
|
||||
|
||||
node.enableDecisionButtons = () => {
|
||||
btnContinue.disabled = false;
|
||||
btnCancel.disabled = false;
|
||||
btnStop.disabled = false;
|
||||
node.color = "#3a4a15";
|
||||
node.bgcolor = "#4a6311";
|
||||
if (app.graph) app.graph.setDirtyCanvas(true, true);
|
||||
};
|
||||
|
||||
node.disableDecisionButtons = () => {
|
||||
btnContinue.disabled = true;
|
||||
btnCancel.disabled = true;
|
||||
btnStop.disabled = true;
|
||||
node.color = "";
|
||||
node.bgcolor = "";
|
||||
if (app.graph) app.graph.setDirtyCanvas(true, true);
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -22,15 +22,21 @@ const visualStyles = document.createElement("style");
|
||||
visualStyles.textContent = `
|
||||
.lora-visual-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 150px;
|
||||
overflow-y: auto;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #111;
|
||||
border: 2px solid #222;
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
box-sizing: border-box;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
.lora-visual-container.vue-mode {
|
||||
height: 400px;
|
||||
min-height: 200px;
|
||||
resize: vertical;
|
||||
}
|
||||
.lora-visual-container.litegraph-mode {
|
||||
height: 100%;
|
||||
}
|
||||
.lora-visual-container.grid-layout {
|
||||
display: grid;
|
||||
@@ -294,6 +300,7 @@ app.registerExtension({
|
||||
if (!hiddenWidget) {
|
||||
hiddenWidget = node.addWidget("text", "_selected_lora", "[ NONE ]", () => {}, { serialize: true });
|
||||
hiddenWidget.type = "hidden";
|
||||
hiddenWidget.computeSize = () => [0, -4];
|
||||
}
|
||||
|
||||
if (folderWidget && loraWidget) {
|
||||
@@ -383,22 +390,24 @@ app.registerExtension({
|
||||
|
||||
const folderWidget = node.widgets.find(w => w.name === "folder");
|
||||
|
||||
const getHiddenWidget = () => {
|
||||
const widgets = node.widgets.filter(w => w.name === "_selected_lora");
|
||||
const getHiddenWidget = (name, defaultVal) => {
|
||||
const widgets = node.widgets.filter(w => w.name === name);
|
||||
if (widgets.length > 1) {
|
||||
const keep = widgets.find(w => w.value && w.value !== "[ NONE ]") || widgets[0];
|
||||
node.widgets = node.widgets.filter(w => w.name !== "_selected_lora" || w === keep);
|
||||
return keep;
|
||||
for (let i = 1; i < widgets.length; i++) {
|
||||
node.widgets.splice(node.widgets.indexOf(widgets[i]), 1);
|
||||
}
|
||||
}
|
||||
if (widgets.length === 1) {
|
||||
widgets[0].hidden = true;
|
||||
widgets[0].computeSize = () => [0, 0];
|
||||
return widgets[0];
|
||||
}
|
||||
const w = node.addWidget("text", "_selected_lora", "[ NONE ]", () => {}, { serialize: true });
|
||||
w.type = "hidden";
|
||||
w.computeSize = () => [0, -4];
|
||||
const w = node.addWidget("text", name, defaultVal, () => {}, { serialize: true });
|
||||
w.hidden = true;
|
||||
w.computeSize = () => [0, 0];
|
||||
return w;
|
||||
};
|
||||
const hiddenWidget = getHiddenWidget();
|
||||
const hiddenWidget = getHiddenWidget("_selected_lora", "[ NONE ]");
|
||||
|
||||
// Create Visual HTML DOM Element
|
||||
const viewContainer = document.createElement("div");
|
||||
@@ -413,6 +422,22 @@ app.registerExtension({
|
||||
serialize: false
|
||||
});
|
||||
|
||||
// Detect Vue UI vs LiteGraph by checking for the Vue container class eventually
|
||||
const isVueUI = !!document.querySelector(".lg-node");
|
||||
if (isVueUI) {
|
||||
viewContainer.classList.add("vue-mode");
|
||||
viewContainer.style.height = "160px";
|
||||
} else {
|
||||
viewContainer.classList.add("litegraph-mode");
|
||||
const onResize = node.onResize;
|
||||
node.onResize = function(size) {
|
||||
if (onResize) onResize.apply(this, arguments);
|
||||
if (viewContainer.style) {
|
||||
viewContainer.style.height = `${size[1] - 160}px`;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Adjust sizing constraints dynamically
|
||||
domWidget.computeSize = function() {
|
||||
return [node.size[0] - 30, node.size[1] - 160];
|
||||
@@ -647,7 +672,11 @@ app.registerExtension({
|
||||
node.size = [380, 360];
|
||||
|
||||
node.computeSize = function() {
|
||||
return [380, 320];
|
||||
const displayModeWidget = node.widgets ? node.widgets.find(w => w.name === "display_mode") : null;
|
||||
if (displayModeWidget && displayModeWidget.value === "Show All") {
|
||||
const viewContainer = document.querySelector(".lora-visual-container");
|
||||
}
|
||||
return [node.size[0], Math.max(360, node.size[1])];
|
||||
};
|
||||
|
||||
const folderWidget = node.widgets.find(w => w.name === "folder");
|
||||
@@ -713,6 +742,23 @@ app.registerExtension({
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
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)];
|
||||
}
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Control Bar for 2-Way HTML Toggle Button ---
|
||||
const controlBar = document.createElement("div");
|
||||
controlBar.className = "lora-control-bar";
|
||||
@@ -1073,7 +1119,8 @@ app.registerExtension({
|
||||
}
|
||||
|
||||
const img = document.createElement("img");
|
||||
img.src = `/folder_lora_loader/get_preview?lora=${encodeURIComponent(item.loraName)}&folder=${encodeURIComponent(folder)}&pretty=true`;
|
||||
img.dataset.src = `/folder_lora_loader/get_preview?system_path=${encodeURIComponent(item.systemPath)}`;
|
||||
img.src = img.dataset.src;
|
||||
|
||||
img.onerror = () => {
|
||||
img.remove();
|
||||
@@ -1139,6 +1186,7 @@ app.registerExtension({
|
||||
|
||||
// Set initial checkboxes state
|
||||
updateFolderCheckboxes();
|
||||
setTimeout(() => updateNodeSize(), 50);
|
||||
} catch (e) {
|
||||
console.error("Failed to build visual picker v2", e);
|
||||
}
|
||||
@@ -1181,7 +1229,28 @@ app.registerExtension({
|
||||
|
||||
syncModeToggleUI();
|
||||
syncScrapeToggleUI();
|
||||
setTimeout(() => updateVisualGrid(), 100);
|
||||
|
||||
setTimeout(() => {
|
||||
const displayModeWidget = node.widgets ? node.widgets.find(w => w.name === "display_mode") : null;
|
||||
if (displayModeWidget) {
|
||||
const origCallback = displayModeWidget.callback;
|
||||
displayModeWidget.callback = function(val) {
|
||||
if (origCallback) origCallback.apply(this, arguments);
|
||||
if (val === "Show All") {
|
||||
viewContainer.style.overflowY = "hidden";
|
||||
} else {
|
||||
viewContainer.style.overflowY = "auto";
|
||||
}
|
||||
setTimeout(() => updateNodeSize(), 50);
|
||||
};
|
||||
if (displayModeWidget.value === "Show All") {
|
||||
viewContainer.style.overflowY = "hidden";
|
||||
} else {
|
||||
viewContainer.style.overflowY = "auto";
|
||||
}
|
||||
}
|
||||
updateVisualGrid();
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
});
|
||||
+84
-17
@@ -6,10 +6,6 @@ const visualStyles = document.createElement("style");
|
||||
visualStyles.textContent = `
|
||||
.img-visual-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
@@ -18,6 +14,17 @@ 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%;
|
||||
}
|
||||
.img-visual-container::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
@@ -57,6 +64,7 @@ visualStyles.textContent = `
|
||||
}
|
||||
.img-grid-container {
|
||||
flex: 1 1 0px;
|
||||
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
@@ -206,27 +214,33 @@ app.registerExtension({
|
||||
node.size = [380, 480];
|
||||
|
||||
node.computeSize = function() {
|
||||
return [380, 320];
|
||||
const displayModeWidget = node.widgets ? node.widgets.find(w => w.name === "display_mode") : null;
|
||||
if (displayModeWidget && displayModeWidget.value === "Show All") {
|
||||
// Logic added below
|
||||
}
|
||||
return [node.size[0], Math.max(480, node.size[1])];
|
||||
};
|
||||
|
||||
const folderWidget = node.widgets ? node.widgets.find(w => w.name === "folder_path") : null;
|
||||
|
||||
const getHiddenWidget = () => {
|
||||
const widgets = node.widgets.filter(w => w.name === "_selected_image");
|
||||
const getHiddenWidget = (name, defaultVal) => {
|
||||
const widgets = node.widgets.filter(w => w.name === name);
|
||||
if (widgets.length > 1) {
|
||||
const keep = widgets.find(w => w.value && w.value !== "") || widgets[0];
|
||||
node.widgets = node.widgets.filter(w => w.name !== "_selected_image" || w === keep);
|
||||
return keep;
|
||||
for (let i = 1; i < widgets.length; i++) {
|
||||
node.widgets.splice(node.widgets.indexOf(widgets[i]), 1);
|
||||
}
|
||||
}
|
||||
if (widgets.length === 1) {
|
||||
widgets[0].hidden = true;
|
||||
widgets[0].computeSize = () => [0, 0];
|
||||
return widgets[0];
|
||||
}
|
||||
const w = node.addWidget("text", "_selected_image", "", () => {}, { serialize: true });
|
||||
w.type = "hidden";
|
||||
w.computeSize = () => [0, -4];
|
||||
const w = node.addWidget("text", name, defaultVal, () => {}, { serialize: true });
|
||||
w.hidden = true;
|
||||
w.computeSize = () => [0, 0];
|
||||
return w;
|
||||
};
|
||||
const hiddenWidget = getHiddenWidget();
|
||||
const hiddenWidget = getHiddenWidget("_selected_image", "");
|
||||
|
||||
// Create Visual HTML DOM Layout
|
||||
const viewContainer = document.createElement("div");
|
||||
@@ -251,16 +265,48 @@ app.registerExtension({
|
||||
gridContainer.className = "img-grid-container";
|
||||
viewContainer.appendChild(gridContainer);
|
||||
|
||||
node.computeSize = function() {
|
||||
const displayModeWidget = node.widgets ? node.widgets.find(w => w.name === "display_mode") : null;
|
||||
if (displayModeWidget && displayModeWidget.value === "Show All" && gridContainer) {
|
||||
return [node.size[0], Math.max(480, gridContainer.scrollHeight + 140)];
|
||||
}
|
||||
return [node.size[0], Math.max(480, 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 initialZoom = localStorage.getItem("comfy_img_picker_zoom") || "80";
|
||||
viewContainer.style.setProperty("--img-tile-size", `${initialZoom}px`);
|
||||
|
||||
// Embed DOM Widget inside the node container
|
||||
const domWidget = node.addDOMWidget("img_visual_picker", "HTML", viewContainer, {
|
||||
getValue() { return getHiddenWidget().value; },
|
||||
setValue(val) { getHiddenWidget().value = val; },
|
||||
getValue() { return getHiddenWidget("_selected_image", "").value; },
|
||||
setValue(val) { getHiddenWidget("_selected_image", "").value = val; },
|
||||
serialize: false
|
||||
});
|
||||
|
||||
// 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`;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
domWidget.computeSize = function() {
|
||||
return [node.size[0] - 30, node.size[1] - 140];
|
||||
};
|
||||
@@ -446,6 +492,7 @@ app.registerExtension({
|
||||
|
||||
gridContainer.appendChild(folderContainer);
|
||||
});
|
||||
setTimeout(() => updateNodeSize(), 50);
|
||||
};
|
||||
|
||||
const updateImagesList = async () => {
|
||||
@@ -490,7 +537,27 @@ app.registerExtension({
|
||||
setTimeout(() => updateImagesList(), 100);
|
||||
};
|
||||
|
||||
setTimeout(() => updateImagesList(), 100);
|
||||
setTimeout(() => {
|
||||
const displayModeWidget = node.widgets ? node.widgets.find(w => w.name === "display_mode") : null;
|
||||
if (displayModeWidget) {
|
||||
const origCallback = displayModeWidget.callback;
|
||||
displayModeWidget.callback = function(val) {
|
||||
if (origCallback) origCallback.apply(this, arguments);
|
||||
if (val === "Show All") {
|
||||
gridContainer.style.overflowY = "hidden";
|
||||
} else {
|
||||
gridContainer.style.overflowY = "auto";
|
||||
}
|
||||
setTimeout(() => updateNodeSize(), 50);
|
||||
};
|
||||
if (displayModeWidget.value === "Show All") {
|
||||
gridContainer.style.overflowY = "hidden";
|
||||
} else {
|
||||
gridContainer.style.overflowY = "auto";
|
||||
}
|
||||
}
|
||||
updateImagesList();
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user