Compare commits

...
7 Commits
Author SHA1 Message Date
m0rtus59 b93413a729 fixes and improvements 2026-08-28 23:02:52 +05:00
m0rtus59 782d30b490 add ctrl+click for alt actions on cards 2026-08-28 22:02:42 +05:00
m0rtus59 0a2db6b85f add support for misc files 2026-08-28 21:32:46 +05:00
m0rtus59 5a4c9f99a5 improve preview, drag-drop, and queue handling 2026-08-28 20:38:46 +05:00
m0rtus59 141a0872d5 minor fixes 2026-08-22 18:12:03 +05:00
m0rtus59 108ac61bec bump 2026-08-22 17:50:24 +05:00
m0rtus59 b31cfb5f8a remove duplicate tip 2026-08-22 15:48:22 +05:00
9 changed files with 1064 additions and 101 deletions
+4 -1
View File
@@ -17,6 +17,8 @@ A compact sidebar for ComfyUI that replaces the stock queue/history UI with a fa
* Optionally replace the stock Job History sidebar.
* Optional unified top bar for workflow tabs and controls.
* Exclude selected nodes from sidebar results with `Ctrl+Q`.
* [NEW] Ctrl-clicking the sidebar cards opens their file location or the menu for alternative actions.
* [NEW] Ctrl-clicking the delete button places the generated file to the recycle bin.
<img width="385" height="336" alt="image" src="https://github.com/user-attachments/assets/743fbf85-9281-4561-9dd0-cd9af4d8c0e4" />
@@ -52,9 +54,10 @@ Open **Settings → Comfy Sidebar** to configure:
| Shortcut | Action |
| ------------- | -------------------------------------------------- |
| `Q` | Toggle ComfySidebar |
| `Ctrl+Q` | Toggle sidebar output filtering for selected nodes |
| `Ctrl+Q` | Exclude the selected node from the sidebar outputs |
| `Click` | Open result preview |
| `Shift+Click` | Compare another result |
| `Ctrl+Click` | Open file location/alternative action |
## 📝 Notes
+174 -4
View File
@@ -1,8 +1,178 @@
# Standard ComfyUI extension loader
import os
import platform
import subprocess
from aiohttp import web
from server import PromptServer
import folder_paths
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
# Tells ComfyUI to serve and load the contents of the js/ folder
WEB_DIRECTORY = "./js"
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
def is_path_safe(base_dir, target_path):
"""Prevents Path Traversal attacks."""
real_base = os.path.realpath(base_dir)
real_target = os.path.realpath(target_path)
return real_target == real_base or real_target.startswith(real_base + os.sep)
def get_all_candidate_roots(subfolder="", folder_type="output"):
"""Gathers all potential ComfyUI base directories (output, model folders, extra paths)."""
roots = []
try:
roots.append(folder_paths.get_output_directory())
except Exception:
pass
# Include specific model categories (loras, checkpoints, etc.)
categories = ["loras", "checkpoints", "vae", "unet", "clip", "embeddings", "upscale_models"]
if subfolder and subfolder in folder_paths.folder_names_and_paths:
try:
roots.extend(folder_paths.get_folder_paths(subfolder))
except Exception:
pass
for cat in categories:
try:
roots.extend(folder_paths.get_folder_paths(cat))
except Exception:
pass
try:
roots.append(folder_paths.get_input_directory())
roots.append(folder_paths.get_temp_directory())
except Exception:
pass
# Deduplicate while preserving existence
unique = []
for r in roots:
if r and r not in unique and os.path.exists(r):
unique.append(r)
return unique
def resolve_existing_path(filename="", subfolder="", folder_type="output"):
"""Finds the actual location of a file or folder across all candidate root directories."""
candidates = get_all_candidate_roots(subfolder, folder_type)
if filename:
for root in candidates:
if subfolder:
p1 = os.path.join(root, subfolder, filename)
if os.path.isfile(p1) and is_path_safe(root, p1):
return p1, root
p2 = os.path.join(root, filename)
if os.path.isfile(p2) and is_path_safe(root, p2):
return p2, root
for root in candidates:
if subfolder:
d1 = os.path.join(root, subfolder)
if os.path.isdir(d1) and is_path_safe(root, d1):
return d1, root
if os.path.isdir(root):
return root, root
return None, None
def open_file_in_os(target_path):
"""Fast, native cross-platform explorer launch."""
system = platform.system()
if system == "Windows":
norm_path = os.path.normpath(target_path)
if not os.path.exists(norm_path):
raise FileNotFoundError(f"Path not found: {norm_path}")
if os.path.isfile(norm_path):
subprocess.Popen(["explorer", "/select,", norm_path])
else:
os.startfile(norm_path)
elif system == "Darwin": # macOS
if not os.path.exists(target_path):
raise FileNotFoundError(f"Path not found: {target_path}")
if os.path.isfile(target_path):
subprocess.Popen(["open", "-R", target_path])
else:
subprocess.Popen(["open", target_path])
else: # Linux / Unix
folder = os.path.dirname(target_path) if os.path.isfile(target_path) else target_path
if not os.path.exists(folder):
raise FileNotFoundError(f"Path not found: {folder}")
if "DISPLAY" in os.environ or "WAYLAND_DISPLAY" in os.environ:
subprocess.Popen(["xdg-open", folder])
else:
raise RuntimeError("Headless Linux server detected (no desktop GUI available).")
@PromptServer.instance.routes.post("/comfy-sidebar/open-folder")
async def open_folder_handler(request):
try:
data = await request.json()
filename = (data.get("filename") or "").strip()
subfolder = (data.get("subfolder") or "").strip()
folder_type = (data.get("type") or "output").strip()
target_path, _ = resolve_existing_path(filename, subfolder, folder_type)
if not target_path:
target_path = folder_paths.get_output_directory()
open_file_in_os(target_path)
return web.json_response({"success": True, "path": target_path})
except Exception as e:
return web.json_response({"error": str(e)}, status=400)
@PromptServer.instance.routes.post("/comfy-sidebar/delete-file")
async def delete_file_handler(request):
try:
data = await request.json()
filename = (data.get("filename") or "").strip()
subfolder = (data.get("subfolder") or "").strip()
folder_type = (data.get("type") or "output").strip()
if not filename:
return web.json_response({"error": "No filename provided"}, status=400)
target_file, root = resolve_existing_path(filename, subfolder, folder_type)
if not target_file or not os.path.isfile(target_file) or not is_path_safe(root, target_file):
return web.json_response({"error": "File not found or unsafe path"}, status=404)
# Try moving to Recycle Bin / Trash first, fallback to os.remove
try:
import send2trash
send2trash.send2trash(target_file)
except Exception:
os.remove(target_file)
return web.json_response({"success": True, "deleted": target_file})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
@PromptServer.instance.routes.post("/comfy-sidebar/promote-output")
async def promote_output_handler(request):
try:
import shutil
data = await request.json()
filename = data.get("filename", "")
subfolder = data.get("subfolder", "")
folder_type = data.get("type", "output")
target_file, root = resolve_existing_path(filename, subfolder, folder_type)
if not target_file or not os.path.isfile(target_file):
return web.json_response({"error": "File not found"}, status=404)
input_dir = folder_paths.get_input_directory()
dest_path = os.path.join(input_dir, filename)
shutil.copy2(target_file, dest_path)
return web.json_response({"name": filename})
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
+24 -30
View File
@@ -234,9 +234,16 @@ const createMediaElement = (src, muted = false) => {
el.controls = false;
}
Object.assign(el.style, {
gridArea: "1 / 1", maxWidth: "100%", maxHeight: "80vh",
width: "auto", height: "auto", objectFit: "contain",
pointerEvents: "none", userSelect: "none", webkitUserSelect: "none"
gridArea: "1 / 1",
maxWidth: "calc(100vw - 120px)",
maxHeight: "calc(100vh - 120px)",
width: "auto",
height: "auto",
objectFit: "contain",
pointerEvents: "none",
userSelect: "none",
webkitUserSelect: "none",
display: "block"
});
el.src = isVideo ? src + "#t=0.001" : src;
return el;
@@ -363,7 +370,7 @@ function createComparisonViewer(baseSrc, onDestroy = () => {}) {
Object.assign(scrollContainer.style, {
position: "absolute", top: "0", left: "0", width: "100%", height: "100%",
display: "flex", overflow: "auto", boxSizing: "border-box",
padding: "54px 28px 48px 28px", scrollbarWidth: "thin",
padding: "48px 24px", scrollbarWidth: "thin",
scrollbarColor: "#555 rgba(0, 0, 0, 0.3)", zIndex: "15"
});
overlay.container.appendChild(scrollContainer);
@@ -386,22 +393,11 @@ function createComparisonViewer(baseSrc, onDestroy = () => {}) {
const wrapper = document.createElement("div");
Object.assign(wrapper.style, {
position: "relative", display: "grid", placeItems: "center",
maxWidth: "85%", maxHeight: "85%", margin: "auto",
flexShrink: "0", cursor: isBaseVideo ? "default" : "zoom-in"
margin: "auto", flexShrink: "0",
cursor: isBaseVideo ? "default" : "zoom-in"
});
scrollContainer.appendChild(wrapper);
const hintPrompt = document.createElement("div");
Object.assign(hintPrompt.style, {
position: "absolute", bottom: "16px", left: "50%", transform: "translateX(-50%)",
zIndex: "30", color: "#888", fontSize: "12px", fontFamily: "sans-serif",
pointerEvents: "none", background: "rgba(10,10,10,0.75)", padding: "4px 10px",
borderRadius: "4px", backdropFilter: "blur(4px)", boxShadow: "0 2px 6px rgba(0,0,0,0.4)"
});
hintPrompt.innerHTML = 'Tip: Hold <span style="color:#aaa;font-weight:bold;">Shift</span> while clicking sidebar cards to compare outputs side-by-side.';
if (isBaseVideo) hintPrompt.style.display = "none";
overlay.container.appendChild(hintPrompt);
let mediaA = createMediaElement(baseSrc, false);
wrapper.appendChild(mediaA);
let isZoomed = false;
@@ -460,13 +456,13 @@ function createComparisonViewer(baseSrc, onDestroy = () => {}) {
mediaA.style.height = "100%";
mediaA.style.objectFit = "fill";
} else {
wrapper.style.maxWidth = "85%";
wrapper.style.maxHeight = "85%";
wrapper.style.maxWidth = "none";
wrapper.style.maxHeight = "none";
wrapper.style.width = "auto";
wrapper.style.height = "auto";
wrapper.style.cursor = isBaseVideo ? "default" : "zoom-in";
mediaA.style.maxWidth = "100%";
mediaA.style.maxHeight = "80vh";
mediaA.style.maxWidth = "calc(100vw - 120px)";
mediaA.style.maxHeight = "calc(100vh - 120px)";
mediaA.style.width = "auto";
mediaA.style.height = "auto";
mediaA.style.objectFit = "contain";
@@ -525,21 +521,19 @@ function createComparisonViewer(baseSrc, onDestroy = () => {}) {
}
});
} else {
wrapper.style.maxWidth = "85%";
wrapper.style.maxHeight = "85%";
wrapper.style.maxWidth = "none";
wrapper.style.maxHeight = "none";
wrapper.style.width = "auto";
wrapper.style.height = "auto";
wrapper.style.aspectRatio = `${maxW} / ${maxH}`;
wrapper.style.cursor = "zoom-in";
[mediaA, mediaB].forEach(el => {
if (el) {
el.style.maxWidth = "100%";
el.style.maxHeight = "80vh";
el.style.width = "100%";
el.style.height = "100%";
el.style.objectFit = "fill";
el.style.aspectRatio = `${maxW} / ${maxH}`;
el.style.maxWidth = "calc(100vw - 120px)";
el.style.maxHeight = "calc(100vh - 120px)";
el.style.width = "auto";
el.style.height = "auto";
el.style.objectFit = "contain";
}
});
}
+16 -2
View File
@@ -152,10 +152,24 @@ export function setupDragAndDrop() {
return;
}
const url = e.dataTransfer.getData("text/uri-list") || e.dataTransfer.getData("text/plain");
const jsonStr = e.dataTransfer.getData("application/json");
const isOurMedia = url && url.includes("/view?");
const isOurWorkflow = jsonStr && jsonStr.includes('"nodes"');
// If it's an external file drop from outside the browser, let ComfyUI handle it natively
if (!isOurMedia && !isOurWorkflow) {
return;
}
// Synchronously stop ComfyUI core drop listeners from double-handling
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
for (const handler of dropHandlers) {
if (await handler(e)) {
e.preventDefault();
e.stopPropagation();
return;
}
}
+100 -10
View File
@@ -17,7 +17,64 @@ export async function syncQueue() {
const runningList = q.Running || q.queue_running || [];
const pendingList = q.Pending || q.queue_pending || [];
const pendingIds = new Set();
const runningIds = new Set();
runningList.forEach(p => {
let pid = null;
let pWorkflow = null;
let pOutputs = {};
if (Array.isArray(p)) {
pid = p[1] ? String(p[1]) : null;
const extraData = p[3] || {};
pWorkflow = parseWorkflow(extraData.extra_pnginfo?.workflow || extraData.workflow);
pOutputs = p[4] || {};
} else if (p && typeof p === "object") {
pid = (p.prompt_id || p.id || p.uuid) ? String(p.prompt_id || p.id || p.uuid) : null;
const extraData = p.extra_data || {};
pWorkflow = parseWorkflow(extraData.extra_pnginfo?.workflow || extraData.workflow);
pOutputs = p.outputs || {};
}
if (pid) {
runningIds.add(pid);
State.currentlyActivePromptId = pid;
// Restore active card if refreshed mid-generation
if (!promptStates.has(pid)) {
State.sequenceNumber++;
const activeWorkflow = pWorkflow || app.graph?.serialize?.() || null;
const images = getPrimaryOutputImages(pOutputs, activeWorkflow);
const texts = findTextsInOutputs(pOutputs, activeWorkflow);
promptStates.set(pid, {
pid: pid,
status: "active",
images: images,
texts: texts,
nodeOutputs: pOutputs,
progress: 0,
progressText: "Processing...",
timestamp: State.sequenceNumber,
workflow: activeWorkflow,
startTime: Date.now(),
duration: null
});
} else {
const st = promptStates.get(pid);
if (st.status !== "active") {
st.status = "active";
st.progressText = "Processing...";
st.rendered = false;
if (!st.startTime) st.startTime = Date.now();
}
if (!st.workflow && pWorkflow) {
st.workflow = pWorkflow;
}
}
}
});
const normalizedPending = pendingList.map((p, idx) => {
let pid = null, seq = idx;
if (Array.isArray(p)) {
@@ -55,10 +112,14 @@ export async function syncQueue() {
});
for (const [pid, state] of promptStates.entries()) {
if (state.status === "pending" && !pendingIds.has(pid)) promptStates.delete(pid);
if (state.status === "pending" && !pendingIds.has(pid)) {
promptStates.delete(pid);
} else if (state.status === "active" && !runningIds.has(pid)) {
concludeRun(pid, "cancelled");
}
}
updateSidebarBadgeFn(pendingIds.size + (runningList.length > 0 ? 1 : 0));
updateSidebarBadgeFn(pendingIds.size + (runningIds.size > 0 ? 1 : 0));
renderDOMFn();
} catch (err) {
console.error("Comfy Sidebar: Failed to sync queue state", err);
@@ -96,6 +157,19 @@ const concludeRun = async (pid, statusStr) => {
st.workflow = parseWorkflow(rawWf) || st.workflow;
st.nodeOutputs = hItem[key].outputs;
const serverStatus = hItem[key].status?.status_str;
const isInterrupted = statusStr === "cancelled" ||
serverStatus === "interrupted" ||
(hItem[key].status?.messages && JSON.stringify(hItem[key].status.messages).toLowerCase().includes("interrupted"));
if (serverStatus === "success" || serverStatus === "completed") {
st.status = "completed";
} else if (isInterrupted) {
st.status = "cancelled";
} else if (serverStatus === "error") {
st.status = "error";
}
const primaryImgs = getPrimaryOutputImages(hItem[key].outputs, st.workflow);
if (primaryImgs.length > 0) st.images = primaryImgs;
@@ -141,13 +215,13 @@ export function setupApiListeners() {
if (promptStates.has(pid)) {
const st = promptStates.get(pid);
st.status = "active"; st.progressText = "Sampling..."; st.workflow = activeWorkspaceWorkflow;
st.status = "active"; st.progressText = "Processing..."; st.workflow = activeWorkspaceWorkflow;
st.rendered = false; st.startTime = Date.now(); st.duration = null;
} else {
State.sequenceNumber++;
promptStates.set(pid, {
pid: pid, status: "active", images: [], progress: 0,
progressText: "Sampling...", timestamp: State.sequenceNumber,
progressText: "Processing...", timestamp: State.sequenceNumber,
workflow: activeWorkspaceWorkflow, startTime: Date.now(), duration: null
});
}
@@ -165,12 +239,14 @@ export function setupApiListeners() {
if (cardObj && cardObj.progressBar) {
cardObj.progressBar.style.width = `${st.progress}%`;
if (cardObj.statusText && cardObj.statusText.style.display !== "none") {
const nodeName = st.activeNodeName ? (st.activeNodeName === "Finishing..." ? "Finishing..." : `[${st.activeNodeName}]`) : "Sampling...";
const nodeName = st.activeNodeName ? (st.activeNodeName === "Finishing..." ? "Finishing..." : `[${st.activeNodeName}]`) : "Processing...";
cardObj.statusText.textContent = `${nodeName} ${st.progress}%`;
}
} else {
renderDOMFn();
}
} else if (pid && !promptStates.has(pid)) {
syncQueue();
}
};
@@ -181,7 +257,7 @@ export function setupApiListeners() {
if (showWorkingNode && State.currentlyActivePromptId && promptStates.has(State.currentlyActivePromptId)) {
const st = promptStates.get(State.currentlyActivePromptId);
if (nodeId) {
const node = app.graph.getNodeById(nodeId);
const node = app.graph?.getNodeById ? app.graph.getNodeById(nodeId) : null;
st.activeNodeName = node ? (node.title || node.type) : `Node #${nodeId}`;
} else {
st.activeNodeName = "Finishing...";
@@ -241,7 +317,6 @@ export function setupApiListeners() {
api.addEventListener("execution_error", onExecutionError);
api.addEventListener("execution_interrupted", onExecutionInterrupted);
// Return cleanup function to unhook listeners when destroyed
return () => {
api.removeEventListener("status", onStatus);
api.removeEventListener("reconnected", onReconnected);
@@ -280,8 +355,6 @@ export async function initSessionAndHistory() {
});
rawItems.forEach(({ pid, data }) => {
if (promptStates.has(pid)) return;
const extraData = data.extra_data || data.prompt?.[3] || {};
const workflow = parseWorkflow(extraData.extra_pnginfo?.workflow || extraData.workflow);
const outputs = data.outputs || {};
@@ -290,11 +363,28 @@ export async function initSessionAndHistory() {
const texts = findTextsInOutputs(outputs, workflow);
const statusStr = data.status?.status_str || "completed";
const isInterrupted = statusStr === "interrupted" ||
(data.status?.messages && JSON.stringify(data.status.messages).toLowerCase().includes("interrupted"));
const status = (statusStr === "success" || statusStr === "completed") ? "completed" :
(statusStr === "error" ? "error" : "cancelled");
(isInterrupted ? "cancelled" : (statusStr === "error" ? "error" : "cancelled"));
if (images.length === 0 && texts.length === 0 && status === "completed") return;
if (promptStates.has(pid)) {
const st = promptStates.get(pid);
if (st.status === "active" || st.status === "pending") {
st.status = status;
st.images = images;
st.texts = texts;
st.nodeOutputs = outputs;
st.workflow = workflow || st.workflow;
st.progressText = "";
st.rendered = false;
}
return;
}
State.sequenceNumber++;
promptStates.set(pid, {
pid: pid,
+159 -4
View File
@@ -64,7 +64,6 @@ export function injectStyles() {
color: #ffffff !important;
}
/* Hover Panels CSS Hover Control for Completed / Cancelled / Error cards */
.comfy-sidebar-hover-panel,
.comfy-sidebar-left-hover-panel {
display: none !important;
@@ -106,7 +105,88 @@ export function injectStyles() {
box-shadow: 0 0 8px rgba(220, 53, 69, 0.6) !important;
}
/* Header Icons & Header Action Buttons */
/* Disk Delete Confirmation */
.comfy-sidebar-card-action-btn.confirm-delete-disk,
.comfy-sidebar-card-action-btn.confirm-delete-disk:hover {
background-color: #7f1d1d !important;
color: #ffc9c9 !important;
box-shadow: 0 0 10px rgba(220, 38, 38, 0.85) !important;
border: 1px solid #ef4444 !important;
}
/* Dynamic Ctrl Key Hover State */
.comfy-sidebar-del-label {
display: none;
margin-left: 5px;
font-size: 10px;
font-weight: bold;
white-space: nowrap;
font-family: sans-serif;
letter-spacing: 0.3px;
}
body.comfy-sidebar-ctrl-active .comfy-sidebar-card-action-btn:not(.comfy-sidebar-btn-del) {
display: none !important;
}
body.comfy-sidebar-ctrl-active .comfy-sidebar-left-hover-panel {
display: none !important;
}
body.comfy-sidebar-ctrl-active .comfy-sidebar-btn-del {
background-color: #991b1b !important;
color: #ffffff !important;
border: 1px solid #ef4444 !important;
width: auto !important;
padding: 0 8px !important;
border-radius: 6px !important;
box-shadow: 0 0 10px rgba(239, 68, 68, 0.6) !important;
}
body.comfy-sidebar-ctrl-active .comfy-sidebar-btn-del .comfy-sidebar-del-label {
display: inline;
}
/* Translucent Folder Indicator on Ctrl Press */
.comfy-sidebar-ctrl-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(15, 23, 42, 0.75);
backdrop-filter: blur(2px);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
color: #38bdf8;
z-index: 15;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
border-radius: 4px;
}
.comfy-sidebar-ctrl-overlay i {
font-size: 26px;
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.6));
}
.comfy-sidebar-ctrl-overlay span {
font-size: 11px;
font-weight: bold;
color: #f1f5f9;
font-family: sans-serif;
letter-spacing: 0.5px;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8);
}
body.comfy-sidebar-ctrl-active .comfy-sidebar-card:not(.pending):not(.active):hover .comfy-sidebar-ctrl-overlay {
opacity: 1;
}
.comfy-sidebar-header-btn {
background: transparent;
color: var(--desc-color, #aaa);
@@ -134,7 +214,6 @@ export function injectStyles() {
opacity: 1;
}
/* Scroll To Top Floating Button */
.comfy-sidebar-scroll-top-btn {
position: absolute;
bottom: 14px;
@@ -333,7 +412,83 @@ export function injectStyles() {
100% { transform: scaleY(1.3); }
}
/* Text Output Card Clamping */
/* Generic / Unknown File Preview Cards (LoRA, Checkpoint, Latent, etc.) */
.comfy-sidebar-file-wrapper {
width: 100%;
min-height: 88px;
background: linear-gradient(135deg, #131d2e 0%, #0b1120 100%);
border-radius: 4px;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 8px 10px;
box-sizing: border-box;
position: relative;
border: 1px solid rgba(255, 255, 255, 0.12);
transition: all 0.2s ease-in-out;
user-select: none;
cursor: pointer;
gap: 8px;
}
.comfy-sidebar-file-wrapper:hover {
border-color: #0ea5e9;
box-shadow: 0 0 14px rgba(14, 165, 233, 0.35);
transform: translateY(-1px);
}
.comfy-sidebar-file-badge {
background: rgba(14, 165, 233, 0.9);
color: #fff;
font-size: 9px;
font-weight: bold;
padding: 0 6px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
border-radius: 3px;
text-transform: uppercase;
letter-spacing: 0.5px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
flex-shrink: 0;
white-space: nowrap;
}
.comfy-sidebar-file-title {
font-size: 11px;
color: #f1f5f9;
font-family: monospace;
font-weight: 600;
word-break: break-all;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.comfy-sidebar-file-icon {
font-size: 14px;
color: #38bdf8;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
transform: translateY(-3px);
}
.comfy-sidebar-file-subtext {
font-size: 9px;
color: #94a3b8;
font-family: sans-serif;
display: flex;
align-items: center;
gap: 4px;
opacity: 0.9;
}
.comfy-sidebar-text-clamp {
font-size: 11px;
line-height: 1.45;
+440 -18
View File
@@ -1,7 +1,7 @@
import { app } from "/scripts/app.js";
import { api } from "/scripts/api.js";
import { State, promptStates, cardElements, scheduleStateSave, deletePromptState } from "./state.js";
import { isVideoFormat, is3DFormat, isAudioFormat, getFilenameFromUrl, matchesFilter, getRunOutputs } from "./utils.js";
import { isImageFormat, isVideoFormat, is3DFormat, isAudioFormat, getFilenameFromUrl, matchesFilter, getRunOutputs } from "./utils.js";
import { showFullscreenPreview, isAudioViewerOpen } from "./comparison.js";
export let syncQueueFn = async () => {};
@@ -17,6 +17,65 @@ export function stopAllAudioPlayback() {
}
}
function removeImageFromNodeOutputs(nodeOutputs, targetImg) {
if (!nodeOutputs || !targetImg || !targetImg.filename) return;
const scanAndRemove = (obj) => {
if (!obj || typeof obj !== "object") return;
for (const key in obj) {
const val = obj[key];
if (Array.isArray(val)) {
for (let i = val.length - 1; i >= 0; i--) {
const item = val[i];
if (item && typeof item === "object") {
if (item.filename === targetImg.filename && (item.subfolder || "") === (targetImg.subfolder || "")) {
val.splice(i, 1);
} else {
scanAndRemove(item);
}
} else if (typeof item === "string" && (item === targetImg.filename || item.endsWith("/" + targetImg.filename))) {
val.splice(i, 1);
}
}
} else if (typeof val === "object") {
scanAndRemove(val);
}
}
};
for (const nodeId in nodeOutputs) {
scanAndRemove(nodeOutputs[nodeId]);
}
}
async function openFileOrFolder(img) {
if (!img) return;
// 1. Try server-side open-folder endpoint if backend endpoint exists
try {
const res = await fetch("/comfy-sidebar/open-folder", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
filename: img.filename,
subfolder: img.subfolder || "",
type: img.type || "output"
})
});
if (res.ok) return;
} catch (e) {}
// 2. Fallback: open / view / download the file
const src = img.url || `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`;
const a = document.createElement("a");
a.href = src;
a.target = "_blank";
a.download = img.filename || "file";
document.body.appendChild(a);
a.click();
a.remove();
}
function resetAllCardHoverStates() {
for (const cardObj of cardElements.values()) {
if (cardObj.hoverPanel) cardObj.hoverPanel.style.display = "none";
@@ -59,6 +118,26 @@ const scrollListener = () => {
}
};
const handleGlobalKeyDown = (e) => {
if (e.key === "Control" || e.key === "Meta") {
document.body.classList.add("comfy-sidebar-ctrl-active");
}
};
const handleGlobalKeyUp = (e) => {
if (e.key === "Control" || e.key === "Meta") {
document.body.classList.remove("comfy-sidebar-ctrl-active");
}
};
const handleGlobalBlur = () => {
document.body.classList.remove("comfy-sidebar-ctrl-active");
};
window.addEventListener("keydown", handleGlobalKeyDown);
window.addEventListener("keyup", handleGlobalKeyUp);
window.addEventListener("blur", handleGlobalBlur);
export function setupScrollListener() {
document.addEventListener("scroll", scrollListener, { capture: true, passive: true });
return () => {
@@ -403,7 +482,21 @@ function syncCardButtonVisibility(cardObj, state) {
if (cardObj.leftHoverBtn) {
const outputs = getRunOutputs(state.nodeOutputs, state.workflow);
if (outputs.length > 1) {
// Gather all unique files across all output nodes
const allUniqueFiles = new Set();
for (const out of outputs) {
for (const img of (out.images || [])) {
if (img && img.filename) {
allUniqueFiles.add(`${img.subfolder || ""}/${img.filename}`);
}
}
}
// Show intermediate button if there are multiple output nodes AND more than 1 distinct file
const hasIntermediates = outputs.length > 1 && allUniqueFiles.size > 1;
if (hasIntermediates) {
cardObj.leftHoverBtn.style.removeProperty("display");
cardObj.leftHoverBtn.style.display = "inline-flex";
cardObj.leftHoverBtn.onclick = (ev) => {
@@ -426,7 +519,7 @@ function render3DCardPreview(cardObj, wrapper, src, img, state) {
let preview3D = wrapper.querySelector(".comfy-sidebar-3d-wrapper");
const fullUrl = img.url ? img.url : window.location.origin + `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`;
const imageAssets = state?.images?.filter(i => !is3DFormat(i.filename || i.url) && !isVideoFormat(i.filename || i.url) && !isAudioFormat(i.filename || i.url)) || [];
const imageAssets = state?.images?.filter(i => isImageFormat(i.filename || i.url)) || [];
const lastImageAsset = imageAssets.length > 0 ? imageAssets[imageAssets.length - 1] : null;
const bgImgSrc = lastImageAsset
? (lastImageAsset.url || window.location.origin + `/view?filename=${encodeURIComponent(lastImageAsset.filename)}&type=${lastImageAsset.type || 'output'}&subfolder=${encodeURIComponent(lastImageAsset.subfolder || '')}`)
@@ -482,6 +575,10 @@ function render3DCardPreview(cardObj, wrapper, src, img, state) {
preview3D.onclick = (ev) => {
ev.stopPropagation();
if (ev.ctrlKey || ev.metaKey) {
openFileOrFolder(img);
return;
}
showFullscreenPreview([fullUrl], ev.shiftKey);
};
@@ -660,6 +757,10 @@ function renderAudioCardPreview(cardObj, wrapper, src, img, state) {
previewAudio.onclick = (e) => {
if (e.target.closest('button, input') || e.target === scrubber || e.target === scrubberFill) return;
stopAllAudioPlayback();
if (e.ctrlKey || e.metaKey) {
openFileOrFolder(img);
return;
}
showFullscreenPreview([fullUrl]);
};
}
@@ -682,6 +783,87 @@ function renderAudioCardPreview(cardObj, wrapper, src, img, state) {
};
}
function renderGenericFileCardPreview(cardObj, wrapper, src, img, state) {
let previewFile = wrapper.querySelector(".comfy-sidebar-file-wrapper");
const fullUrl = img.url ? img.url : window.location.origin + `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`;
const filename = img.filename || getFilenameFromUrl(src) || "output_file";
const ext = (filename.split('.').pop() || "FILE").toUpperCase();
const folderName = img.subfolder ? `${img.subfolder}/` : (img.type || "output");
if (!previewFile) {
wrapper.innerHTML = "";
previewFile = document.createElement("div");
previewFile.className = "comfy-sidebar-file-wrapper";
// Top Row: Right-aligned badge
const topRow = document.createElement("div");
Object.assign(topRow.style, { display: "flex", justifyContent: "flex-end", width: "100%", minHeight: "18px" });
const badge = document.createElement("span");
badge.className = "comfy-sidebar-file-badge";
badge.textContent = ext;
topRow.appendChild(badge);
// Center Area: Inline icon + file name
const centerArea = document.createElement("div");
Object.assign(centerArea.style, { display: "flex", alignItems: "center", gap: "8px", width: "100%", margin: "2px 0 6px 0" });
const icon = document.createElement("span");
icon.className = "pi pi-file comfy-sidebar-file-icon";
const title = document.createElement("span");
title.className = "comfy-sidebar-file-title";
title.textContent = filename;
title.title = filename;
centerArea.append(icon, title);
// Bottom Row: Folder location + open action
const bottomRow = document.createElement("div");
Object.assign(bottomRow.style, { display: "flex", alignItems: "center", justifyContent: "space-between", width: "100%" });
const folderInfo = document.createElement("div");
folderInfo.className = "comfy-sidebar-file-subtext";
folderInfo.innerHTML = `<i class="pi pi-folder" style="font-size: 10px;"></i><span>${folderName}</span>`;
const openHint = document.createElement("div");
openHint.className = "comfy-sidebar-file-subtext";
openHint.innerHTML = `<i class="pi pi-external-link" style="font-size: 9px;"></i><span>Open Folder</span>`;
bottomRow.append(folderInfo, openHint);
previewFile.append(topRow, centerArea, bottomRow);
wrapper.appendChild(previewFile);
} else {
const title = previewFile.querySelector(".comfy-sidebar-file-title");
if (title) { title.textContent = filename; title.title = filename; }
const badge = previewFile.querySelector(".comfy-sidebar-file-badge");
if (badge) badge.textContent = ext;
}
cardObj.firstImgElement = previewFile;
previewFile.onclick = (ev) => {
ev.stopPropagation();
openFileOrFolder(img);
};
previewFile.setAttribute("draggable", "true");
previewFile.ondragstart = (e) => {
e.stopPropagation();
const mimeType = "application/octet-stream";
try {
e.dataTransfer.setData("text/uri-list", fullUrl);
e.dataTransfer.setData("text/plain", fullUrl);
e.dataTransfer.setData("DownloadURL", `${mimeType}:${filename}:${fullUrl}`);
if (state && state.workflow) {
e.dataTransfer.setData("application/json", JSON.stringify(state.workflow));
}
} catch (err) {}
e.dataTransfer.effectAllowed = "copy";
};
}
function renderCardImages(cardObj, state) {
cardObj.currentImageIndex = cardObj.currentImageIndex || 0;
if (cardObj.currentImageIndex >= state.images.length) {
@@ -701,6 +883,7 @@ function renderCardImages(cardObj, state) {
const isVideo = isVideoFormat(src);
const is3D = is3DFormat(src) || is3DFormat(img.filename);
const isAudio = isAudioFormat(src) || isAudioFormat(img.filename);
const isImage = isImageFormat(src) || isImageFormat(img.filename) || (!isVideo && !is3D && !isAudio && !img.filename?.includes("."));
let wrapper = cardObj.grid.querySelector(".comfy-sidebar-media-wrapper");
if (!wrapper) {
@@ -711,6 +894,14 @@ function renderCardImages(cardObj, state) {
cardObj.grid.appendChild(wrapper);
}
let ctrlOverlay = wrapper.querySelector(".comfy-sidebar-ctrl-overlay");
if (!ctrlOverlay) {
ctrlOverlay = document.createElement("div");
ctrlOverlay.className = "comfy-sidebar-ctrl-overlay";
ctrlOverlay.innerHTML = `<i class="pi pi-folder-open"></i><span>Open Location</span>`;
wrapper.appendChild(ctrlOverlay);
}
if (is3D) {
render3DCardPreview(cardObj, wrapper, src, img, state);
return;
@@ -721,6 +912,11 @@ function renderCardImages(cardObj, state) {
return;
}
if (!isImage && !isVideo) {
renderGenericFileCardPreview(cardObj, wrapper, src, img, state);
return;
}
let mediaEl = wrapper.querySelector("img, video");
const needsRebuild = !mediaEl || (isVideo !== (mediaEl.tagName.toLowerCase() === "video"));
@@ -742,6 +938,10 @@ function renderCardImages(cardObj, state) {
mediaEl.onclick = (ev) => {
ev.stopPropagation();
if (ev.ctrlKey || ev.metaKey) {
openFileOrFolder(img);
return;
}
showFullscreenPreview([src], ev.shiftKey);
};
@@ -756,10 +956,7 @@ function renderCardImages(cardObj, state) {
return;
}
} catch (e) {}
if (cardObj.placeholder) {
cardObj.placeholder.textContent = "Error loading media preview";
cardObj.placeholder.style.display = "block";
}
renderGenericFileCardPreview(cardObj, wrapper, src, img, state);
}
};
@@ -1045,7 +1242,93 @@ export function renderDOM() {
const btnImg = document.createElement("span");
btnImg.className = "pi pi-image comfy-sidebar-card-action-btn";
btnImg.title = "Download Object";
hoverPanel.appendChild(btnImg);
const btnDel = document.createElement("span");
btnDel.className = "pi pi-trash comfy-sidebar-card-action-btn comfy-sidebar-btn-del";
btnDel.title = "Remove from batch (Hold Ctrl to delete file from disk)";
const btnDelLabel = document.createElement("span");
btnDelLabel.className = "comfy-sidebar-del-label";
btnDelLabel.textContent = "Delete File";
btnDel.appendChild(btnDelLabel);
let isDeletePending = false, isDiskDelete = false, deleteTimeout = null;
const resetDelete = () => {
isDeletePending = false;
isDiskDelete = false;
btnDel.classList.remove("confirm-delete", "confirm-delete-disk");
btnDel.title = "Remove from batch (Hold Ctrl to delete file from disk)";
if (deleteTimeout) { clearTimeout(deleteTimeout); deleteTimeout = null; }
};
btnDel.onclick = async (ev) => {
ev.stopPropagation();
const wantsDiskDelete = ev.ctrlKey || ev.metaKey;
if (!isDeletePending) {
isDeletePending = true;
isDiskDelete = wantsDiskDelete;
btnDel.classList.add(wantsDiskDelete ? "confirm-delete-disk" : "confirm-delete");
btnDel.title = wantsDiskDelete
? "Ctrl+Click again to delete file from DISK / TRASH"
: "Click again to confirm removing from batch";
deleteTimeout = setTimeout(resetDelete, 2000);
} else {
const shouldDeleteFromDisk = isDiskDelete || wantsDiskDelete;
resetDelete();
// 1. Delete only this single file from disk
if (shouldDeleteFromDisk && img && img.filename) {
try {
await fetch("/comfy-sidebar/delete-file", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
filename: img.filename,
subfolder: img.subfolder || "",
type: img.type || "output"
})
});
} catch (e) {}
}
// 2. Remove this image from batch list, prompt state, and nodeOutputs
const pid = batchInfo.pid;
const state = promptStates.get(pid);
const imgIdx = batchInfo.images.indexOf(img);
if (imgIdx > -1) batchInfo.images.splice(imgIdx, 1);
if (state) {
if (Array.isArray(state.images)) {
const stIdx = state.images.findIndex(i => i.filename === img.filename && (i.subfolder || "") === (img.subfolder || ""));
if (stIdx > -1) state.images.splice(stIdx, 1);
}
// Sync nodeOutputs so the Intermediate Outputs submenu stays updated
if (state.nodeOutputs) {
removeImageFromNodeOutputs(state.nodeOutputs, img);
}
state.rendered = false;
}
// Invalidate main card's cached index & signature
const mainCardObj = cardElements.get(pid);
if (mainCardObj) {
mainCardObj.currentImageIndex = 0;
mainCardObj.lastImagesSignature = "";
}
// If batch is now empty, return to main queue
if (batchInfo.images.length === 0) {
State.activeSubmenuBatchImages = null;
}
scheduleStateSave();
renderDOM();
}
};
hoverPanel.append(btnImg, btnDel);
const leftHoverPanel = document.createElement("div");
leftHoverPanel.className = "comfy-sidebar-left-hover-panel";
@@ -1151,7 +1434,79 @@ export function renderDOM() {
const btnImg = document.createElement("span");
btnImg.className = "pi pi-image comfy-sidebar-card-action-btn";
btnImg.title = "Download Object";
hoverPanel.appendChild(btnImg);
const btnDel = document.createElement("span");
btnDel.className = "pi pi-trash comfy-sidebar-card-action-btn comfy-sidebar-btn-del";
btnDel.title = "Delete node output (Hold Ctrl to delete file from disk)";
const btnDelLabel = document.createElement("span");
btnDelLabel.className = "comfy-sidebar-del-label";
btnDelLabel.textContent = "Delete File";
btnDel.appendChild(btnDelLabel);
let isDeletePending = false, isDiskDelete = false, deleteTimeout = null;
const resetDelete = () => {
isDeletePending = false;
isDiskDelete = false;
btnDel.classList.remove("confirm-delete", "confirm-delete-disk");
btnDel.title = "Delete node output (Hold Ctrl to delete file from disk)";
if (deleteTimeout) { clearTimeout(deleteTimeout); deleteTimeout = null; }
};
btnDel.onclick = async (ev) => {
ev.stopPropagation();
const wantsDiskDelete = ev.ctrlKey || ev.metaKey;
if (!isDeletePending) {
isDeletePending = true;
isDiskDelete = wantsDiskDelete;
btnDel.classList.add(wantsDiskDelete ? "confirm-delete-disk" : "confirm-delete");
btnDel.title = wantsDiskDelete
? "Ctrl+Click again to delete this node's files from DISK / TRASH"
: "Click again to confirm removing output";
deleteTimeout = setTimeout(resetDelete, 2000);
} else {
const shouldDeleteFromDisk = isDiskDelete || wantsDiskDelete;
resetDelete();
// 1. Delete files belonging only to this specific node
if (shouldDeleteFromDisk && out.images) {
for (const imgItem of out.images) {
if (imgItem && imgItem.filename) {
try {
await fetch("/comfy-sidebar/delete-file", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
filename: imgItem.filename,
subfolder: imgItem.subfolder || "",
type: imgItem.type || "output"
})
});
} catch (e) {}
}
}
}
// 2. Remove node from state.nodeOutputs
if (st.nodeOutputs && st.nodeOutputs[out.nodeId]) {
delete st.nodeOutputs[out.nodeId];
}
// 3. Update primary images if needed
const remainingOutputs = getRunOutputs(st.nodeOutputs, st.workflow);
if (remainingOutputs.length > 0) {
st.images = remainingOutputs[remainingOutputs.length - 1].images || [];
} else {
st.images = [];
State.activeSubmenuPromptId = null;
}
scheduleStateSave();
renderDOM();
}
};
hoverPanel.append(btnImg, btnDel);
const leftHoverPanel = document.createElement("div");
leftHoverPanel.className = "comfy-sidebar-left-hover-panel";
@@ -1215,6 +1570,20 @@ export function renderDOM() {
let cardObj = cardElements.get(state.pid);
const isFinalStatus = state.status === "completed" || state.status === "cancelled" || state.status === "error";
if (cardObj && isFinalStatus && state.rendered) {
// Ensure image changes (e.g. deletions from batch submenu) update the preview immediately
const currentImagesSignature = `${state.status || ""}:${state.images ? state.images.map(img => img.url || img.filename).join("|") : ""}`;
if (cardObj.lastImagesSignature !== currentImagesSignature) {
cardObj.currentImageIndex = Math.min(cardObj.currentImageIndex || 0, Math.max(0, (state.images?.length || 1) - 1));
if (!state.images || state.images.length === 0) {
cardObj.grid.innerHTML = "";
cardObj.firstImgElement = null;
cardObj.placeholder.style.display = "block";
} else {
cardObj.placeholder.style.display = "none";
renderCardImages(cardObj, state);
}
cardObj.lastImagesSignature = currentImagesSignature;
}
syncCardButtonVisibility(cardObj, state);
return cardObj.element;
}
@@ -1252,7 +1621,13 @@ export function renderDOM() {
const btnImg = document.createElement("span"); btnImg.className = "pi pi-image comfy-sidebar-card-action-btn"; btnImg.title = "Download Object";
const btnJson = document.createElement("span"); btnJson.className = "pi pi-file comfy-sidebar-card-action-btn"; btnJson.title = "Download JSON";
const btnDel = document.createElement("span"); btnDel.className = "pi pi-trash comfy-sidebar-card-action-btn"; btnDel.title = "Delete Card";
const btnDel = document.createElement("span");
btnDel.className = "pi pi-trash comfy-sidebar-card-action-btn comfy-sidebar-btn-del";
btnDel.title = "Delete Card (Hold Ctrl to delete file from disk)";
const btnDelLabel = document.createElement("span");
btnDelLabel.className = "comfy-sidebar-del-label";
btnDelLabel.textContent = "Delete File";
btnDel.appendChild(btnDelLabel);
const leftHoverPanel = document.createElement("div");
leftHoverPanel.className = "comfy-sidebar-left-hover-panel";
@@ -1345,23 +1720,69 @@ export function renderDOM() {
cardObj.cancelBtn.style.display = "none";
}
let deleteTimeout = null, isDeletePending = false;
let deleteTimeout = null, isDeletePending = false, isDiskDelete = false;
const resetDeleteBtn = () => {
isDeletePending = false;
cardObj.btnDel.classList.remove("confirm-delete");
cardObj.btnDel.title = "Delete Card";
isDiskDelete = false;
cardObj.btnDel.classList.remove("confirm-delete", "confirm-delete-disk");
cardObj.btnDel.title = "Delete Card (Ctrl+Click to delete from disk)";
if (deleteTimeout) { clearTimeout(deleteTimeout); deleteTimeout = null; }
};
cardObj.btnDel.onclick = async (ev) => {
ev.stopPropagation();
const wantsDiskDelete = ev.ctrlKey || ev.metaKey;
if (!isDeletePending) {
isDeletePending = true;
cardObj.btnDel.classList.add("confirm-delete");
cardObj.btnDel.title = "Click again to confirm deletion";
deleteTimeout = setTimeout(resetDeleteBtn, 1500);
isDeletePending = true;
isDiskDelete = wantsDiskDelete;
cardObj.btnDel.classList.add(wantsDiskDelete ? "confirm-delete-disk" : "confirm-delete");
cardObj.btnDel.title = wantsDiskDelete
? "Ctrl+Click again to delete file from DISK / TRASH"
: "Click again to confirm removing card";
deleteTimeout = setTimeout(resetDeleteBtn, 2000);
} else {
const shouldDeleteFromDisk = isDiskDelete || wantsDiskDelete;
resetDeleteBtn();
// Delete all files from disk across ALL nodes in this run
if (shouldDeleteFromDisk) {
const filesToDelete = [];
const seen = new Set();
const addFile = (item) => {
if (item && item.filename) {
const key = `${item.type || ""}/${item.subfolder || ""}/${item.filename}`;
if (!seen.has(key)) {
seen.add(key);
filesToDelete.push(item);
}
}
};
// 1. Primary card images/files
(state.images || []).forEach(addFile);
// 2. All intermediate node outputs
const allOutputs = getRunOutputs(state.nodeOutputs, state.workflow);
allOutputs.forEach(out => (out.images || []).forEach(addFile));
// 3. Delete each file from disk / trash
for (const fileItem of filesToDelete) {
try {
await fetch("/comfy-sidebar/delete-file", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
filename: fileItem.filename,
subfolder: fileItem.subfolder || "",
type: fileItem.type || "output"
})
});
} catch (e) {}
}
}
promptStates.delete(state.pid);
await api.fetchApi("/history", { method: "POST", body: JSON.stringify({ delete: [state.pid] }) });
scheduleStateSave();
@@ -1386,6 +1807,7 @@ export function renderDOM() {
cardObj.placeholder.textContent = fullText;
cardObj.placeholder.title = "Click to read full text";
cardObj.placeholder.className = "comfy-sidebar-text-clamp";
cardObj.placeholder.style.cssText = "";
cardObj.placeholder.style.display = "-webkit-box";
cardObj.placeholder.onclick = (e) => {
e.stopPropagation();
@@ -1406,7 +1828,7 @@ export function renderDOM() {
if (state.status === "active") {
if (showWorkingNode) {
cardObj.statusText.style.display = "block";
cardObj.statusText.textContent = state.activeNodeName ? (state.activeNodeName === "Finishing..." ? "Finishing..." : `[${state.activeNodeName}]${state.progress ? ` ${state.progress}%` : ""}`) : `Sampling...${state.progress ? ` ${state.progress}%` : ""}`;
cardObj.statusText.textContent = state.activeNodeName ? (state.activeNodeName === "Finishing..." ? "Finishing..." : `[${state.activeNodeName}]${state.progress ? ` ${state.progress}%` : ""}`) : `Processing...${state.progress ? ` ${state.progress}%` : ""}`;
} else cardObj.statusText.style.display = "none";
cardObj.progressContainer.style.display = "block"; cardObj.progressBar.style.width = `${state.progress || 0}%`;
} else { cardObj.statusText.style.display = "none"; cardObj.progressContainer.style.display = "none"; }
+146 -31
View File
@@ -1,25 +1,37 @@
import { app } from "/scripts/app.js";
const FILE_EXT_REGEX = /\.(safetensors|ckpt|pt|pth|bin|latent|onnx|engine|gguf|lora|zip|tar|gz|7z|json|csv|parquet|txt|yaml|yml|xml|pdf|blend|fbx|obj|glb|gltf|stl|ply|splat|spz|ksplat|png|jpg|jpeg|webp|gif|bmp|tiff|svg|mp4|webm|wav|mp3|ogg|flac|m4a|aac|opus)$/i;
export const isImageFormat = (url) => {
if (!url) return false;
if (String(url).startsWith("blob:") || String(url).startsWith("data:image/")) return true;
const s = String(url).toLowerCase().split("?")[0];
return s.endsWith(".png") || s.endsWith(".jpg") || s.endsWith(".jpeg") ||
s.endsWith(".webp") || s.endsWith(".gif") || s.endsWith(".bmp") ||
s.endsWith(".tiff") || s.endsWith(".tif") || s.endsWith(".svg") ||
s.endsWith(".avif") || s.endsWith(".ico") || s.endsWith(".apng");
};
export const isVideoFormat = (url) => {
if (!url) return false;
const s = String(url).toLowerCase();
return s.includes(".mp4") || s.includes(".webm");
const s = String(url).toLowerCase().split("?")[0];
return s.endsWith(".mp4") || s.endsWith(".webm") || s.endsWith(".mov") || s.endsWith(".mkv");
};
export const is3DFormat = (url) => {
if (!url) return false;
const s = String(url).toLowerCase();
return s.includes(".glb") || s.includes(".gltf") || s.includes(".obj") ||
s.includes(".ply") || s.includes(".stl") || s.includes(".splat") ||
s.includes(".spz") || s.includes(".ksplat") || s.includes(".fbx");
const s = String(url).toLowerCase().split("?")[0];
return s.endsWith(".glb") || s.endsWith(".gltf") || s.endsWith(".obj") ||
s.endsWith(".ply") || s.endsWith(".stl") || s.endsWith(".splat") ||
s.endsWith(".spz") || s.endsWith(".ksplat") || s.endsWith(".fbx");
};
export const isAudioFormat = (url) => {
if (!url) return false;
const s = String(url).toLowerCase();
return s.includes(".wav") || s.includes(".mp3") || s.includes(".ogg") ||
s.includes(".flac") || s.includes(".m4a") || s.includes(".aac") ||
s.includes(".opus");
const s = String(url).toLowerCase().split("?")[0];
return s.endsWith(".wav") || s.endsWith(".mp3") || s.endsWith(".ogg") ||
s.endsWith(".flac") || s.endsWith(".m4a") || s.endsWith(".aac") ||
s.endsWith(".opus");
};
export function getFilenameFromUrl(url) {
@@ -54,37 +66,135 @@ function isNodeIgnored(nodeId, rawWorkflow) {
return !!(node && node.properties && node.properties.ignoreInQueue);
}
function extractFileCandidate(val, subfolder = "", type = "output") {
if (!val || typeof val !== "string") return null;
const trimmed = val.trim();
// 1. Ignore directory dots, floats/numbers, and empty strings
if (trimmed === "." || trimmed === ".." || trimmed === "./" || trimmed === "../" || trimmed === "") {
return null;
}
if (!isNaN(Number(trimmed))) {
return null;
}
// 2. Extract base file name
const cleanName = trimmed.split("/").pop()?.split("\\").pop()?.trim() || "";
if (!cleanName || cleanName === "." || cleanName === "..") return null;
// 3. Must have a dot with real name before and after (e.g. "model.safetensors")
const dotIndex = cleanName.lastIndexOf(".");
if (dotIndex <= 0 || dotIndex === cleanName.length - 1) {
return null;
}
const ext = cleanName.substring(dotIndex + 1).toLowerCase();
const base = cleanName.substring(0, dotIndex).trim();
if (!base || !ext) return null;
// 4. Must match a known extension or a valid 1-8 char alphanumeric extension without whitespace
const isValidExt = FILE_EXT_REGEX.test("." + ext) || (/^[a-z0-9]{1,8}$/i.test(ext) && !trimmed.includes(" ") && trimmed.length < 100);
if (isValidExt) {
return {
filename: cleanName,
subfolder: subfolder || (trimmed.includes("/") ? trimmed.substring(0, trimmed.lastIndexOf("/")) : ""),
type: type || "output"
};
}
return null;
}
export function findImagesInOutputs(outputs, rawWorkflow) {
const list = [];
if (!outputs) return list;
const seen = new Set();
const scan = (obj) => {
const addCandidate = (item) => {
if (!item || !item.filename) return;
const key = `${item.subfolder || ""}/${item.filename}`;
if (!seen.has(key)) {
seen.add(key);
list.push(item);
}
};
const scan = (obj, sub = "", typ = "output") => {
if (!obj) return;
if (typeof obj === "string") {
const cand = extractFileCandidate(obj, sub, typ);
if (cand) addCandidate(cand);
return;
}
if (Array.isArray(obj)) {
obj.forEach(item => scan(item));
} else if (typeof obj === "object") {
if (obj.filename || obj.name) {
const fname = obj.filename || obj.name;
if (typeof fname === "string" && (fname.includes(".") || obj.type)) {
list.push({
filename: fname,
subfolder: obj.subfolder || "",
type: obj.type || "output"
});
obj.forEach(item => scan(item, sub, typ));
return;
}
if (typeof obj === "object") {
const targetSub = obj.subfolder || sub || "";
const targetTyp = obj.type || typ || "output";
const fname = obj.filename || obj.name || obj.file || obj.path ||
obj.lora_name || obj.model_name || obj.save_name ||
obj.saved_file || obj.output_path || obj.file_name;
if (fname && typeof fname === "string") {
const cand = extractFileCandidate(fname, targetSub, targetTyp);
if (cand) {
addCandidate(cand);
return;
}
}
for (const key in obj) {
scan(obj[key]);
scan(obj[key], targetSub, targetTyp);
}
}
};
const workflow = parseWorkflow(rawWorkflow);
for (const nodeId in outputs) {
if (isNodeIgnored(nodeId, workflow)) continue;
scan(outputs[nodeId]);
if (outputs) {
for (const nodeId in outputs) {
if (isNodeIgnored(nodeId, workflow)) continue;
scan(outputs[nodeId]);
}
}
if (list.length > 0) return list;
// Fallback: If scanning a specific node ID, only look at that matching node in the workflow
if (workflow && Array.isArray(workflow.nodes)) {
const targetNodeIds = outputs ? Object.keys(outputs).map(String) : null;
for (const node of workflow.nodes) {
if (node && !node.properties?.ignoreInQueue) {
if (targetNodeIds && !targetNodeIds.includes(String(node.id))) {
continue;
}
const typeStr = (node.type || "").toLowerCase();
const isLoaderNode = typeStr.includes("load") || typeStr.includes("loader") || typeStr.includes("input");
const isSaveNode = !isLoaderNode && (typeStr.includes("save") || typeStr.includes("extract") ||
typeStr.includes("export") || typeStr.includes("writer"));
if (isSaveNode && Array.isArray(node.widgets_values)) {
for (const val of node.widgets_values) {
if (typeof val === "string" && val.trim().length > 0) {
let fname = val.trim();
if (typeStr.includes("lora") && !fname.includes(".")) {
fname += ".safetensors";
} else if (typeStr.includes("latent") && !fname.includes(".")) {
fname += ".latent";
}
const cand = extractFileCandidate(fname, typeStr.includes("lora") ? "loras" : "", "output");
if (cand) addCandidate(cand);
}
}
}
}
}
}
return list;
}
@@ -98,14 +208,14 @@ export function findTextsInOutputs(outputs, rawWorkflow) {
const val = outputs[nodeId][key];
if (Array.isArray(val)) {
val.forEach(item => {
if (typeof item === 'string') {
if (typeof item === 'string' && !FILE_EXT_REGEX.test(item.trim())) {
list.push(item);
} else if (item && typeof item === 'object' && item.text) {
if (Array.isArray(item.text)) list.push(...item.text);
else if (typeof item.text === 'string') list.push(item.text);
}
});
} else if (typeof val === 'string') {
} else if (typeof val === 'string' && !FILE_EXT_REGEX.test(val.trim())) {
list.push(val);
} else if (val && typeof val === 'object' && val.text) {
if (Array.isArray(val.text)) list.push(...val.text);
@@ -121,7 +231,12 @@ export function getRunOutputs(nodeOutputs, rawWorkflow) {
const list = [];
if (!nodeOutputs) return list;
for (const nodeId in nodeOutputs) {
const imgs = findImagesInOutputs({ [nodeId]: nodeOutputs[nodeId] }, workflow);
const nodeOut = nodeOutputs[nodeId];
// Ignore empty node outputs
if (!nodeOut || (typeof nodeOut === "object" && Object.keys(nodeOut).length === 0)) {
continue;
}
const imgs = findImagesInOutputs({ [nodeId]: nodeOut }, workflow);
if (imgs.length > 0) {
list.push({ nodeId, images: imgs });
}
@@ -130,9 +245,9 @@ export function getRunOutputs(nodeOutputs, rawWorkflow) {
}
export function getPrimaryOutputImages(nodeOutputs, rawWorkflow) {
if (!nodeOutputs) return [];
if (!nodeOutputs) return findImagesInOutputs(null, rawWorkflow);
const runOutputs = getRunOutputs(nodeOutputs, rawWorkflow);
if (runOutputs.length === 0) return [];
if (runOutputs.length === 0) return findImagesInOutputs(nodeOutputs, rawWorkflow);
return runOutputs[runOutputs.length - 1].images || [];
}
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "comfy-sidebar"
description = "A custom queue sidebar and image comparison extension that reintroduces and improves a sidebar queue menu and unified compact topbar layout for ComfyUI."
version = "1.3.2"
version = "1.4.1"
license = { file = "LICENSE" }
dependencies = []