Compare commits

...
4 Commits
9 changed files with 572 additions and 89 deletions
+20 -10
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+157
View File
@@ -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)
+7 -3
View File
@@ -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)
+8 -4
View File
@@ -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)
+141
View File
@@ -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);
};
}
}
});
+88 -19
View File
@@ -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
View File
@@ -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);
}
}
});