Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f09e2c7921 | ||
|
|
9adfb2a53f | ||
|
|
a17314673e | ||
|
|
8343f216fc |
+2
-1
@@ -1,2 +1,3 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyc
|
||||
js/vendor/
|
||||
+58
-1
@@ -180,4 +180,61 @@ async def promote_output_handler(request):
|
||||
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)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Local Three.js Proxy & Offline Cache (Bypasses CSP & --disable-api-nodes)
|
||||
# ============================================================================
|
||||
SIDEBAR_CACHE_DIR = os.path.join(os.path.dirname(__file__), "js", "vendor", "cache")
|
||||
|
||||
@PromptServer.instance.routes.get("/comfy-sidebar/three-proxy/{path:.*}")
|
||||
async def three_proxy_handler(request):
|
||||
raw_path = request.match_info.get("path", "").lstrip("/")
|
||||
if not raw_path:
|
||||
return web.Response(status=404)
|
||||
|
||||
query = request.query_string
|
||||
full_req = f"{raw_path}?{query}" if query else raw_path
|
||||
|
||||
os.makedirs(SIDEBAR_CACHE_DIR, exist_ok=True)
|
||||
import re
|
||||
safe_name = re.sub(r'[^a-zA-Z0-9_.-]', '_', full_req)
|
||||
if not any(safe_name.endswith(ext) for ext in [".js", ".mjs", ".wasm", ".bin", ".json"]):
|
||||
safe_name += ".js"
|
||||
cache_file = os.path.join(SIDEBAR_CACHE_DIR, safe_name)
|
||||
|
||||
# 1. Serve immediately from disk cache if present
|
||||
if os.path.isfile(cache_file) and os.path.getsize(cache_file) > 0:
|
||||
content_type = "application/wasm" if raw_path.endswith(".wasm") else "application/javascript"
|
||||
return web.FileResponse(cache_file, headers={"Content-Type": content_type})
|
||||
|
||||
# 2. Otherwise download via Python (not blocked by browser CSP)
|
||||
url = f"https://esm.sh/{full_req}"
|
||||
try:
|
||||
import urllib.request
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"})
|
||||
with urllib.request.urlopen(req, timeout=20) as resp:
|
||||
raw_data = resp.read()
|
||||
|
||||
if raw_path.endswith((".wasm", ".bin", ".png", ".jpg")):
|
||||
with open(cache_file, "wb") as f:
|
||||
f.write(raw_data)
|
||||
return web.Response(body=raw_data, content_type="application/octet-stream")
|
||||
|
||||
content = raw_data.decode("utf-8", errors="replace")
|
||||
# Rewrite absolute esm.sh paths to route through our local 'self' proxy
|
||||
content = content.replace("https://esm.sh/", "/comfy-sidebar/three-proxy/")
|
||||
# Rewrite root-relative module paths (e.g. from "/v135/...")
|
||||
content = re.sub(
|
||||
r'((?:from|import)\s*["\'])/(v\d+|[a-zA-Z0-9@_.~-]+/)',
|
||||
r'\1/comfy-sidebar/three-proxy/\2',
|
||||
content
|
||||
)
|
||||
|
||||
with open(cache_file, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
return web.Response(text=content, content_type="application/javascript")
|
||||
except Exception as e:
|
||||
return web.Response(text=f"/* Proxy error fetching {url}: {e} */", status=502, content_type="application/javascript")
|
||||
@@ -0,0 +1,202 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
|
||||
export function findOriginalPropertiesButton() {
|
||||
const byTestId = document.querySelector('[data-testid="properties-panel-toggle"]');
|
||||
if (byTestId && !byTestId.classList.contains("comfy-sidebar-custom-properties-toggle")) {
|
||||
return byTestId;
|
||||
}
|
||||
|
||||
const icons = document.querySelectorAll('[class*="lucide--panel-right"], [class*="lucide--panel-left"], [class*="lucide--panel-bottom"]');
|
||||
for (const icon of icons) {
|
||||
const btn = icon.closest('button, [role="button"], .comfyui-menu-item, .p-button');
|
||||
if (btn && !btn.classList.contains("comfy-sidebar-custom-properties-toggle")) {
|
||||
return btn;
|
||||
}
|
||||
}
|
||||
|
||||
const buttons = document.querySelectorAll('button, [role="button"], .p-button');
|
||||
for (const btn of buttons) {
|
||||
if (btn.classList.contains("comfy-sidebar-custom-properties-toggle")) continue;
|
||||
const title = (btn.getAttribute("title") || btn.getAttribute("aria-label") || "").toLowerCase();
|
||||
if (title.includes("workflow overview") || title.includes("properties") || title.includes("toggle panel")) {
|
||||
return btn;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function findTopbarContainer() {
|
||||
const container = document.querySelector('[data-testid="action-bar-buttons"], [class*="actionbar-buttons"], .actionbar-buttons, [class*="actionbar"]');
|
||||
if (container) return container;
|
||||
|
||||
const orig = findOriginalPropertiesButton();
|
||||
if (orig?.parentNode) return orig.parentNode;
|
||||
|
||||
const buttons = document.querySelectorAll("button, .p-button");
|
||||
for (const btn of buttons) {
|
||||
if (btn.classList.contains("comfy-sidebar-custom-properties-toggle")) continue;
|
||||
const title = (btn.getAttribute("title") || btn.getAttribute("aria-label") || "").toLowerCase();
|
||||
if (title.includes("run") || title.includes("manager")) {
|
||||
if (btn.parentNode) return btn.parentNode;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isPropertiesPanelOpen() {
|
||||
const panel = document.querySelector('[data-testid="properties-panel"]');
|
||||
if (panel && panel.offsetWidth > 0 && panel.offsetHeight > 0) return true;
|
||||
|
||||
const headings = document.querySelectorAll('h1, h2, h3, h4');
|
||||
for (const h of headings) {
|
||||
if (h.textContent.trim() === "Workflow Overview" && h.offsetWidth > 0 && h.offsetHeight > 0) return true;
|
||||
}
|
||||
|
||||
const origBtn = findOriginalPropertiesButton();
|
||||
if (origBtn) {
|
||||
if (origBtn.classList.contains("p-button-active") ||
|
||||
origBtn.classList.contains("active") ||
|
||||
origBtn.getAttribute("aria-expanded") === "true" ||
|
||||
origBtn.getAttribute("aria-pressed") === "true") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function findActiveQueueIndicator() {
|
||||
const container = findTopbarContainer();
|
||||
if (!container) return null;
|
||||
|
||||
const buttons = container.querySelectorAll("button, .p-button, .comfyui-menu-item");
|
||||
for (const btn of buttons) {
|
||||
if (btn.classList.contains("comfy-sidebar-custom-properties-toggle")) continue;
|
||||
const text = (btn.textContent || "").toLowerCase();
|
||||
if (text.includes("active") || text.includes("queued") || text.includes("running") || text.includes("pending")) {
|
||||
return btn;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function findNativeExtensionsPanel() {
|
||||
return Array.from(document.querySelectorAll('.shadow-interface:not(.actionbar-container)'))
|
||||
.find(el => el.textContent.toLowerCase().includes('extensions') || el.querySelector('button')?.textContent.toLowerCase().includes('extensions'));
|
||||
}
|
||||
|
||||
export function findGraphButton() {
|
||||
const elements = document.querySelectorAll('.p-1.bg-base-background.rounded-lg, .bg-base-background.rounded-lg, .bg-secondary-background.rounded-lg.items-center.inline-flex.pointer-events-auto, [data-testid="graph-view-button"]');
|
||||
for (const el of elements) {
|
||||
if (el.closest('.p-dialog, .comfy-modal, [role="dialog"], .p-sidebar, .comfy-settings, .comfyui-sidebar, .comfy-sidebar')) continue;
|
||||
return el.closest('.group, .p-1') || el;
|
||||
}
|
||||
|
||||
const fallbacks = document.querySelectorAll('button, .bg-base-background, .bg-secondary-background');
|
||||
for (const el of fallbacks) {
|
||||
if (el.closest('.p-dialog, .comfy-modal, [role="dialog"], .p-sidebar, .comfy-settings, .comfyui-sidebar, .comfy-sidebar')) continue;
|
||||
const text = (el.textContent || el.getAttribute("title") || el.getAttribute("aria-label") || "").toLowerCase();
|
||||
if (text.includes("graph") || text.includes("workflow") || el.querySelector('[class*="sitemap"], [class*="workflow"]')) {
|
||||
return el.closest('.group, .p-1, .bg-base-background, .bg-secondary-background') || el;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function findOurSidebarButton() {
|
||||
const icon = document.querySelector('.pi-images');
|
||||
return icon ? icon.closest('.comfyui-sidebar-tab, button, [role="tab"]') : null;
|
||||
}
|
||||
|
||||
export function findStandardQueueButton() {
|
||||
for (const iconSelector of [".pi-history", ".pi-clock", ".pi-server", ".pi-list", ".pi-sliders-h"]) {
|
||||
const icon = document.querySelector(iconSelector);
|
||||
if (icon) {
|
||||
const btn = icon.closest('.comfyui-sidebar-tab, button, [role="tab"]');
|
||||
if (btn && !btn.querySelector('.pi-images')) return btn;
|
||||
}
|
||||
}
|
||||
const buttons = document.querySelectorAll('.comfyui-sidebar-tab, button, [role="tab"]');
|
||||
for (const btn of buttons) {
|
||||
const title = btn.title || btn.getAttribute('aria-label') || '';
|
||||
if ((title.toLowerCase().includes('queue') || title.toLowerCase().includes('history')) &&
|
||||
!btn.querySelector('.pi-images') &&
|
||||
!btn.id?.includes('classic-comfy-sidebar')) {
|
||||
return btn;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function updateSidebarBadge(count) {
|
||||
const icons = document.querySelectorAll('.pi-images');
|
||||
icons.forEach(icon => {
|
||||
const btn = icon.closest('.comfyui-sidebar-tab, button, [role="tab"]');
|
||||
if (btn) {
|
||||
let badge = btn.querySelector('.comfy-sidebar-badge');
|
||||
if (count > 0) {
|
||||
if (!badge) {
|
||||
btn.style.position = 'relative';
|
||||
badge = document.createElement('div');
|
||||
badge.className = 'comfy-sidebar-badge';
|
||||
Object.assign(badge.style, {
|
||||
position: 'absolute', top: '2px', right: '2px', background: '#0ea5e9', color: '#fff',
|
||||
borderRadius: '50%', width: '16px', height: '16px', fontSize: '10px', display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center', fontWeight: 'bold', pointerEvents: 'none'
|
||||
});
|
||||
btn.appendChild(badge);
|
||||
}
|
||||
badge.textContent = count > 99 ? '99+' : count;
|
||||
} else if (badge) {
|
||||
badge.remove();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function applySidebarOverride() {
|
||||
const overrideStock = app.ui.settings?.getSettingValue?.("Comfy Sidebar.Hide Junk.Override Stock Job History Tab") ?? false;
|
||||
const stdBtn = findStandardQueueButton();
|
||||
const ourBtn = findOurSidebarButton();
|
||||
|
||||
if (stdBtn) {
|
||||
if (!stdBtn._originalDisplay) {
|
||||
stdBtn._originalDisplay = window.getComputedStyle(stdBtn).display || "block";
|
||||
}
|
||||
if (overrideStock) {
|
||||
stdBtn.style.setProperty("display", "none", "important");
|
||||
if (!stdBtn._overrideClickListener) {
|
||||
stdBtn._overrideClickListener = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const b = findOurSidebarButton();
|
||||
if (b) b.click();
|
||||
};
|
||||
stdBtn.addEventListener('click', stdBtn._overrideClickListener, true);
|
||||
}
|
||||
if (ourBtn && stdBtn.parentNode && ourBtn.nextSibling !== stdBtn) {
|
||||
stdBtn.parentNode.insertBefore(ourBtn, stdBtn);
|
||||
}
|
||||
} else {
|
||||
stdBtn.style.setProperty("display", stdBtn._originalDisplay === "none" ? "block" : stdBtn._originalDisplay);
|
||||
if (stdBtn._overrideClickListener) {
|
||||
stdBtn.removeEventListener('click', stdBtn._overrideClickListener, true);
|
||||
stdBtn._overrideClickListener = null;
|
||||
}
|
||||
if (ourBtn && stdBtn.parentNode && ourBtn.parentNode === stdBtn.parentNode && ourBtn !== stdBtn.parentNode.lastChild) {
|
||||
stdBtn.parentNode.appendChild(ourBtn);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function centerAndSelectCanvasNode(nodeId) {
|
||||
if (!app.graph || !app.canvas) return false;
|
||||
const id = typeof nodeId === "string" ? Number(nodeId) : nodeId;
|
||||
const node = app.graph.getNodeById ? (app.graph.getNodeById(id) || app.graph.getNodeById(String(nodeId))) : null;
|
||||
if (node) {
|
||||
if (app.canvas.centerOnNode) app.canvas.centerOnNode(node);
|
||||
if (app.canvas.selectNode) app.canvas.selectNode(node);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -1,4 +1,12 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import {
|
||||
findOriginalPropertiesButton,
|
||||
findTopbarContainer,
|
||||
isPropertiesPanelOpen,
|
||||
findActiveQueueIndicator,
|
||||
findNativeExtensionsPanel,
|
||||
findGraphButton
|
||||
} from "./adapter.js";
|
||||
|
||||
const STYLE_ID = "comfy-sidebar-classic-layout-override";
|
||||
|
||||
@@ -151,90 +159,6 @@ let savedButtonData = null;
|
||||
let domObserver = null;
|
||||
let syncScheduled = false;
|
||||
|
||||
function findOriginalButton() {
|
||||
const byTestId = document.querySelector('[data-testid="properties-panel-toggle"]');
|
||||
if (byTestId && !byTestId.classList.contains("comfy-sidebar-custom-properties-toggle")) {
|
||||
return byTestId;
|
||||
}
|
||||
|
||||
const icons = document.querySelectorAll('[class*="lucide--panel-right"], [class*="lucide--panel-left"], [class*="lucide--panel-bottom"]');
|
||||
for (const icon of icons) {
|
||||
const btn = icon.closest('button, [role="button"], .comfyui-menu-item, .p-button');
|
||||
if (btn && !btn.classList.contains("comfy-sidebar-custom-properties-toggle")) {
|
||||
return btn;
|
||||
}
|
||||
}
|
||||
|
||||
const buttons = document.querySelectorAll('button, [role="button"], .p-button');
|
||||
for (const btn of buttons) {
|
||||
if (btn.classList.contains("comfy-sidebar-custom-properties-toggle")) continue;
|
||||
const title = (btn.getAttribute("title") || btn.getAttribute("aria-label") || "").toLowerCase();
|
||||
if (title.includes("workflow overview") || title.includes("properties") || title.includes("toggle panel")) {
|
||||
return btn;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findTopbarContainer() {
|
||||
const container = document.querySelector('[data-testid="action-bar-buttons"], [class*="actionbar-buttons"], .actionbar-buttons, [class*="actionbar"]');
|
||||
if (container) return container;
|
||||
|
||||
const orig = findOriginalButton();
|
||||
if (orig?.parentNode) return orig.parentNode;
|
||||
|
||||
const buttons = document.querySelectorAll("button, .p-button");
|
||||
for (const btn of buttons) {
|
||||
if (btn.classList.contains("comfy-sidebar-custom-properties-toggle")) continue;
|
||||
const title = (btn.getAttribute("title") || btn.getAttribute("aria-label") || "").toLowerCase();
|
||||
if (title.includes("run") || title.includes("manager")) {
|
||||
if (btn.parentNode) return btn.parentNode;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPropertiesPanelOpen() {
|
||||
const panel = document.querySelector('[data-testid="properties-panel"]');
|
||||
if (panel && panel.offsetWidth > 0 && panel.offsetHeight > 0) return true;
|
||||
|
||||
const headings = document.querySelectorAll('h1, h2, h3, h4');
|
||||
for (const h of headings) {
|
||||
if (h.textContent.trim() === "Workflow Overview" && h.offsetWidth > 0 && h.offsetHeight > 0) return true;
|
||||
}
|
||||
|
||||
const origBtn = findOriginalButton();
|
||||
if (origBtn) {
|
||||
if (origBtn.classList.contains("p-button-active") ||
|
||||
origBtn.classList.contains("active") ||
|
||||
origBtn.getAttribute("aria-expanded") === "true" ||
|
||||
origBtn.getAttribute("aria-pressed") === "true") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function findActiveQueueIndicator() {
|
||||
const container = findTopbarContainer();
|
||||
if (!container) return null;
|
||||
|
||||
const buttons = container.querySelectorAll("button, .p-button, .comfyui-menu-item");
|
||||
for (const btn of buttons) {
|
||||
if (btn.classList.contains("comfy-sidebar-custom-properties-toggle")) continue;
|
||||
const text = (btn.textContent || "").toLowerCase();
|
||||
if (text.includes("active") || text.includes("queued") || text.includes("running") || text.includes("pending")) {
|
||||
return btn;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findNativeExtensionsPanel() {
|
||||
return Array.from(document.querySelectorAll('.shadow-interface:not(.actionbar-container)'))
|
||||
.find(el => el.textContent.toLowerCase().includes('extensions') || el.querySelector('button')?.textContent.toLowerCase().includes('extensions'));
|
||||
}
|
||||
|
||||
function updateSidebarTabsVisibility() {
|
||||
const sidebar = document.querySelector('.comfyui-sidebar, .comfy-sidebar, .sidebar, [class*="sidebar-nav"], [class*="sidebar"]');
|
||||
if (!sidebar) return;
|
||||
@@ -273,24 +197,6 @@ function updateSidebarTabsVisibility() {
|
||||
}
|
||||
}
|
||||
|
||||
function findGraphButton() {
|
||||
const elements = document.querySelectorAll('.p-1.bg-base-background.rounded-lg, .bg-base-background.rounded-lg, .bg-secondary-background.rounded-lg.items-center.inline-flex.pointer-events-auto, [data-testid="graph-view-button"]');
|
||||
for (const el of elements) {
|
||||
if (el.closest('.p-dialog, .comfy-modal, [role="dialog"], .p-sidebar, .comfy-settings, .comfyui-sidebar, .comfy-sidebar')) continue;
|
||||
return el.closest('.group, .p-1') || el;
|
||||
}
|
||||
|
||||
const fallbacks = document.querySelectorAll('button, .bg-base-background, .bg-secondary-background');
|
||||
for (const el of fallbacks) {
|
||||
if (el.closest('.p-dialog, .comfy-modal, [role="dialog"], .p-sidebar, .comfy-settings, .comfyui-sidebar, .comfy-sidebar')) continue;
|
||||
const text = (el.textContent || el.getAttribute("title") || el.getAttribute("aria-label") || "").toLowerCase();
|
||||
if (text.includes("graph") || text.includes("workflow") || el.querySelector('[class*="sitemap"], [class*="workflow"]')) {
|
||||
return el.closest('.group, .p-1, .bg-base-background, .bg-secondary-background') || el;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function syncErrorBadge(originalBtn, customBtn) {
|
||||
if (!customBtn) return;
|
||||
|
||||
@@ -352,7 +258,7 @@ export function syncClassicLayout() {
|
||||
}
|
||||
|
||||
if (!isClassicLayoutEnabled) {
|
||||
const originalBtn = findOriginalButton();
|
||||
const originalBtn = findOriginalPropertiesButton();
|
||||
if (originalBtn?.classList.contains("comfy-sidebar-hide-original-properties-btn")) {
|
||||
originalBtn.classList.remove("comfy-sidebar-hide-original-properties-btn");
|
||||
}
|
||||
@@ -367,7 +273,7 @@ export function syncClassicLayout() {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalBtn = findOriginalButton();
|
||||
const originalBtn = findOriginalPropertiesButton();
|
||||
const container = findTopbarContainer();
|
||||
const openState = isPropertiesPanelOpen();
|
||||
|
||||
@@ -404,7 +310,7 @@ export function syncClassicLayout() {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const nativeBtn = findOriginalButton();
|
||||
const nativeBtn = findOriginalPropertiesButton();
|
||||
if (nativeBtn) {
|
||||
nativeBtn.click();
|
||||
requestAnimationFrame(() => syncClassicLayout());
|
||||
@@ -548,4 +454,4 @@ export function destroyLayoutFix() {
|
||||
if (styleEl) styleEl.remove();
|
||||
const fixStyles = document.getElementById("comfy-sidebar-layout-fix-styles");
|
||||
if (fixStyles) fixStyles.remove();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { applySidebarOverride } from "./adapter.js";
|
||||
|
||||
let nodeDOMObserver = null;
|
||||
|
||||
function findHeaderByText(parent, text) {
|
||||
if (!text) return null;
|
||||
const cleanText = text.trim();
|
||||
|
||||
const elements = parent.querySelectorAll("span, div, h1, h2, h3, h4, p, [class*='title'], [class*='text']");
|
||||
for (const el of elements) {
|
||||
if (el.textContent.trim() === cleanText) {
|
||||
if (el.offsetWidth > 0 && el.offsetHeight > 0 && el.offsetWidth < parent.offsetWidth * 0.9) {
|
||||
return el;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function syncNodeVueBadge(node, isIgnored) {
|
||||
const nodeEl = document.querySelector(
|
||||
`.comfy-node[data-node-id="${node.id}"], ` +
|
||||
`[data-node-id="${node.id}"], ` +
|
||||
`[data-id="${node.id}"]`
|
||||
);
|
||||
if (!nodeEl) return;
|
||||
|
||||
const titleTextNode = findHeaderByText(nodeEl, node.title || node.type);
|
||||
if (!titleTextNode) {
|
||||
const badge = nodeEl.querySelector(".comfy-sidebar-ignore-badge");
|
||||
if (badge && !isIgnored) badge.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
const headerEl = titleTextNode.parentNode;
|
||||
let badge = headerEl.querySelector(".comfy-sidebar-ignore-badge");
|
||||
|
||||
if (isIgnored) {
|
||||
if (!badge) {
|
||||
badge = document.createElement("div");
|
||||
badge.className = "comfy-sidebar-ignore-badge";
|
||||
badge.innerHTML = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24"/>
|
||||
<path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68"/>
|
||||
<path d="M6.61 6.61A13.52 13.52 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61"/>
|
||||
<line x1="2" y1="2" x2="22" y2="22"/>
|
||||
</svg>
|
||||
`;
|
||||
Object.assign(badge.style, {
|
||||
marginLeft: "auto",
|
||||
marginRight: "6px",
|
||||
float: "right",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "inherit",
|
||||
pointerEvents: "none"
|
||||
});
|
||||
headerEl.appendChild(badge);
|
||||
}
|
||||
} else {
|
||||
if (badge) badge.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export function syncAllNodeBadges() {
|
||||
applySidebarOverride();
|
||||
if (!app.graph || !app.graph._nodes) return;
|
||||
|
||||
app.graph._nodes.forEach(node => {
|
||||
if (!node.properties) node.properties = {};
|
||||
const isIgnored = !!node.properties.ignoreInQueue;
|
||||
|
||||
if (isIgnored) {
|
||||
if (node.boxcolor !== "#ff3333") {
|
||||
node._oldBoxcolor = node.boxcolor || "";
|
||||
node.boxcolor = "#ff3333";
|
||||
if (app.graph) app.graph.setDirtyCanvas(true, true);
|
||||
}
|
||||
} else {
|
||||
if (node.boxcolor === "#ff3333") {
|
||||
node.boxcolor = node._oldBoxcolor || "";
|
||||
delete node._oldBoxcolor;
|
||||
if (app.graph) app.graph.setDirtyCanvas(true, true);
|
||||
}
|
||||
}
|
||||
|
||||
syncNodeVueBadge(node, isIgnored);
|
||||
});
|
||||
}
|
||||
|
||||
export function setupVueNodeObserver() {
|
||||
if (nodeDOMObserver) nodeDOMObserver.disconnect();
|
||||
|
||||
let timeoutId = null;
|
||||
nodeDOMObserver = new MutationObserver((mutations) => {
|
||||
let hasNodeMutation = false;
|
||||
for (const m of mutations) {
|
||||
for (const node of m.addedNodes) {
|
||||
if (node.nodeType === 1) {
|
||||
if (node.hasAttribute?.("data-node-id") ||
|
||||
node.classList?.contains("comfy-node") ||
|
||||
node.querySelector?.('[data-node-id], .comfy-node')) {
|
||||
hasNodeMutation = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hasNodeMutation) break;
|
||||
}
|
||||
|
||||
if (hasNodeMutation) {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => {
|
||||
syncAllNodeBadges();
|
||||
}, 50);
|
||||
}
|
||||
});
|
||||
|
||||
nodeDOMObserver.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (nodeDOMObserver) {
|
||||
nodeDOMObserver.disconnect();
|
||||
nodeDOMObserver = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function toggleIgnoreActiveNode(onModified) {
|
||||
const canvas = app.canvas;
|
||||
if (!canvas) return;
|
||||
|
||||
let nodes = [];
|
||||
if (canvas.selected_nodes && Object.keys(canvas.selected_nodes).length > 0) {
|
||||
nodes = Object.values(canvas.selected_nodes);
|
||||
} else if (canvas.current_active_node) {
|
||||
nodes = [canvas.current_active_node];
|
||||
}
|
||||
|
||||
if (nodes.length === 0) return;
|
||||
|
||||
nodes.forEach(node => {
|
||||
if (!node.properties) node.properties = {};
|
||||
node.properties.ignoreInQueue = !node.properties.ignoreInQueue;
|
||||
const isIgnored = !!node.properties.ignoreInQueue;
|
||||
|
||||
if (isIgnored) {
|
||||
if (node.boxcolor !== "#ff3333") {
|
||||
node._oldBoxcolor = node.boxcolor || "";
|
||||
node.boxcolor = "#ff3333";
|
||||
}
|
||||
} else {
|
||||
if (node.boxcolor === "#ff3333") {
|
||||
node.boxcolor = node._oldBoxcolor || "";
|
||||
delete node._oldBoxcolor;
|
||||
}
|
||||
}
|
||||
|
||||
syncNodeVueBadge(node, isIgnored);
|
||||
});
|
||||
|
||||
if (app.graph) {
|
||||
if (app.graph.change) app.graph.change();
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
}
|
||||
if (app.canvas) app.canvas.setDirty(true, true);
|
||||
|
||||
if (typeof onModified === "function") {
|
||||
onModified();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export const PromptStatus = Object.freeze({
|
||||
PENDING: "pending",
|
||||
ACTIVE: "active",
|
||||
COMPLETED: "completed",
|
||||
CANCELLED: "cancelled",
|
||||
ERROR: "error"
|
||||
});
|
||||
|
||||
export const StoreEvents = Object.freeze({
|
||||
PROMPT_ADDED: "prompt:added",
|
||||
PROMPT_UPDATED: "prompt:updated",
|
||||
PROMPT_DELETED: "prompt:deleted",
|
||||
PROMPTS_CLEARED: "prompts:cleared",
|
||||
ACTIVE_PROMPT_CHANGED: "activePrompt:changed",
|
||||
SEARCH_CHANGED: "search:changed",
|
||||
SUBMENU_CHANGED: "submenu:changed",
|
||||
QUEUE_SYNCED: "queue:synced"
|
||||
});
|
||||
|
||||
export const SettingIds = Object.freeze({
|
||||
GRID_COLUMNS_THRESHOLD: "Comfy Sidebar.Grid Columns Threshold",
|
||||
SHOW_PENDING_COUNT_ONLY: "Comfy Sidebar.Show Pending Count Only",
|
||||
SHOW_WORKING_NODE_NAME: "Comfy Sidebar.Show Working Node Name",
|
||||
AUTO_CLEAR_INTERRUPTED: "Comfy Sidebar.Auto Clear Interrupted",
|
||||
OVERRIDE_STOCK_HISTORY: "Comfy Sidebar.Hide Junk.Override Stock Job History Tab",
|
||||
HIDE_GRAPH_BUTTON: "Comfy Sidebar.Hide Junk.Graph Button",
|
||||
CLASSIC_LAYOUT: "Comfy Sidebar.Comfy Layout",
|
||||
MAX_HISTORY_ITEMS: "Comfy.Queue.MaxHistoryItems",
|
||||
DOCK_QUEUE: "Comfy.Queue.QPOV2",
|
||||
SHOW_PROGRESS_BAR: "Comfy.Queue.ShowRunProgressBar",
|
||||
TAB_POSITION: "Comfy.Workflow.WorkflowTabsPosition"
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { PromptStatus } from "./constants.js";
|
||||
|
||||
const STORAGE_KEY = "comfy_sidebar_prompt_states";
|
||||
|
||||
export const PersistenceAdapter = {
|
||||
load() {
|
||||
try {
|
||||
const data = localStorage.getItem(STORAGE_KEY);
|
||||
if (!data) return [];
|
||||
const list = JSON.parse(data);
|
||||
return Array.isArray(list) ? list : [];
|
||||
} catch (e) {
|
||||
console.warn("Comfy Sidebar: Failed to load states from localStorage", e);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
save(promptsMap) {
|
||||
try {
|
||||
const serializable = [];
|
||||
|
||||
for (const [pid, state] of promptsMap.entries()) {
|
||||
const cleanedImages = (state.images || []).map(img => {
|
||||
if (img && img.url && img.url.startsWith("blob:")) return null;
|
||||
return img;
|
||||
}).filter(Boolean);
|
||||
|
||||
const includeWorkflow = cleanedImages.length === 0 || state.status !== PromptStatus.COMPLETED;
|
||||
|
||||
serializable.push({
|
||||
pid: String(state.pid),
|
||||
status: state.status,
|
||||
images: cleanedImages,
|
||||
texts: state.texts || [],
|
||||
nodeOutputs: state.nodeOutputs || {},
|
||||
nodeTitles: state.nodeTitles || {}, // Always preserved across restarts
|
||||
workflow: includeWorkflow ? state.workflow : null,
|
||||
progress: state.progress || 0,
|
||||
queueNumber: state.queueNumber,
|
||||
progressText: state.progressText || "",
|
||||
timestamp: state.timestamp,
|
||||
activeNodeName: state.activeNodeName || "",
|
||||
rendered: !!state.rendered,
|
||||
startTime: state.startTime,
|
||||
endTime: state.endTime,
|
||||
duration: state.duration
|
||||
});
|
||||
}
|
||||
|
||||
if (serializable.length === 0) {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
return;
|
||||
}
|
||||
|
||||
serializable.sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
while (serializable.length > 0) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(serializable));
|
||||
break;
|
||||
} catch (e) {
|
||||
if (e.name === "QuotaExceededError" || e.code === 22 || e.code === 1014) {
|
||||
serializable.shift();
|
||||
} else {
|
||||
console.warn("Comfy Sidebar: LocalStorage error", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Comfy Sidebar: Failed to serialize states", e);
|
||||
}
|
||||
},
|
||||
|
||||
getPruneCandidates(promptsMap, maxItems = 64) {
|
||||
const finished = Array.from(promptsMap.entries())
|
||||
.filter(([_, state]) => state.status !== PromptStatus.PENDING && state.status !== PromptStatus.ACTIVE);
|
||||
|
||||
finished.sort((a, b) => (a[1].timestamp || 0) - (b[1].timestamp || 0));
|
||||
|
||||
if (finished.length > maxItems) {
|
||||
const deleteCount = finished.length - maxItems;
|
||||
return finished.slice(0, deleteCount).map(([pid]) => pid);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
import { PromptStatus } from "./constants.js";
|
||||
|
||||
export function createPromptState(pid, initial = {}) {
|
||||
return {
|
||||
pid: String(pid),
|
||||
status: initial.status || PromptStatus.PENDING,
|
||||
images: Array.isArray(initial.images) ? [...initial.images] : [],
|
||||
texts: Array.isArray(initial.texts) ? [...initial.texts] : [],
|
||||
nodeOutputs: initial.nodeOutputs ? { ...initial.nodeOutputs } : {},
|
||||
nodeTitles: initial.nodeTitles ? { ...initial.nodeTitles } : {},
|
||||
progress: typeof initial.progress === "number" ? initial.progress : 0,
|
||||
progressText: initial.progressText || "",
|
||||
queueNumber: initial.queueNumber || null,
|
||||
timestamp: typeof initial.timestamp === "number" ? initial.timestamp : Date.now(),
|
||||
workflow: initial.workflow || null,
|
||||
activeNodeName: initial.activeNodeName || "",
|
||||
startTime: initial.startTime || null,
|
||||
endTime: initial.endTime || null,
|
||||
duration: typeof initial.duration === "number" ? initial.duration : null,
|
||||
rendered: !!initial.rendered,
|
||||
_previewBlobUrl: initial._previewBlobUrl || null,
|
||||
_oldPreviewBlobUrl: initial._oldPreviewBlobUrl || null
|
||||
};
|
||||
}
|
||||
|
||||
export function revokePromptBlobs(state) {
|
||||
if (!state) return;
|
||||
if (state._previewBlobUrl) {
|
||||
try { URL.revokeObjectURL(state._previewBlobUrl); } catch (e) {}
|
||||
state._previewBlobUrl = null;
|
||||
}
|
||||
if (state._oldPreviewBlobUrl) {
|
||||
try { URL.revokeObjectURL(state._oldPreviewBlobUrl); } catch (e) {}
|
||||
state._oldPreviewBlobUrl = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function activatePrompt(state, patch = {}) {
|
||||
state.status = PromptStatus.ACTIVE;
|
||||
state.progress = 0;
|
||||
state.progressText = "Processing...";
|
||||
state.rendered = false;
|
||||
state.startTime = state.startTime || Date.now();
|
||||
state.duration = null;
|
||||
if (patch.workflow) state.workflow = patch.workflow;
|
||||
if (patch.activeNodeName !== undefined) state.activeNodeName = patch.activeNodeName;
|
||||
if (patch.nodeTitles) Object.assign(state.nodeTitles, patch.nodeTitles);
|
||||
if (typeof patch.timestamp === "number") state.timestamp = patch.timestamp;
|
||||
return state;
|
||||
}
|
||||
|
||||
export function updatePromptProgress(state, progress, activeNodeName = null) {
|
||||
state.progress = Math.max(0, Math.min(100, Math.round(progress)));
|
||||
if (activeNodeName !== null) {
|
||||
state.activeNodeName = activeNodeName;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
export function finalizePrompt(state, targetStatus, patch = {}) {
|
||||
revokePromptBlobs(state);
|
||||
state.status = targetStatus;
|
||||
state.progressText = "";
|
||||
state.rendered = false;
|
||||
state.endTime = patch.endTime || Date.now();
|
||||
if (state.startTime) {
|
||||
state.duration = (state.endTime - state.startTime) / 1000;
|
||||
}
|
||||
if (patch.workflow) state.workflow = patch.workflow;
|
||||
if (patch.nodeOutputs) state.nodeOutputs = patch.nodeOutputs;
|
||||
if (patch.nodeTitles) Object.assign(state.nodeTitles, patch.nodeTitles);
|
||||
if (patch.images && patch.images.length > 0) state.images = patch.images;
|
||||
if (patch.texts && patch.texts.length > 0) state.texts = patch.texts;
|
||||
return state;
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { PromptStatus, StoreEvents } from "./constants.js";
|
||||
import { createPromptState, revokePromptBlobs } from "./promptState.js";
|
||||
import { PersistenceAdapter } from "./persistence.js";
|
||||
|
||||
class Store {
|
||||
constructor() {
|
||||
this.prompts = new Map();
|
||||
this.ui = {
|
||||
searchQuery: "",
|
||||
currentlyActivePromptId: null,
|
||||
activeSubmenuPromptId: null,
|
||||
activeSubmenuBatchImages: null,
|
||||
mainQueueScrollTop: null,
|
||||
sequenceNumber: 0,
|
||||
sidebarContainer: null,
|
||||
cardStack: null
|
||||
};
|
||||
this.listeners = new Map();
|
||||
this._saveTimer = null;
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
const saved = PersistenceAdapter.load();
|
||||
for (const item of saved) {
|
||||
const state = createPromptState(item.pid, item);
|
||||
this.prompts.set(String(state.pid), state);
|
||||
if (state.timestamp > this.ui.sequenceNumber) {
|
||||
this.ui.sequenceNumber = state.timestamp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
on(event, callback) {
|
||||
if (!this.listeners.has(event)) {
|
||||
this.listeners.set(event, new Set());
|
||||
}
|
||||
this.listeners.get(event).add(callback);
|
||||
return () => this.off(event, callback);
|
||||
}
|
||||
|
||||
off(event, callback) {
|
||||
if (this.listeners.has(event)) {
|
||||
this.listeners.get(event).delete(callback);
|
||||
}
|
||||
}
|
||||
|
||||
emit(event, payload) {
|
||||
if (this.listeners.has(event)) {
|
||||
for (const callback of this.listeners.get(event)) {
|
||||
try {
|
||||
callback(payload);
|
||||
} catch (err) {
|
||||
console.error(`Store error during event '${event}':`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nextSequence() {
|
||||
return ++this.ui.sequenceNumber;
|
||||
}
|
||||
|
||||
scheduleSave(delay = 250) {
|
||||
if (this._saveTimer) clearTimeout(this._saveTimer);
|
||||
this._saveTimer = setTimeout(() => {
|
||||
this._saveTimer = null;
|
||||
PersistenceAdapter.save(this.prompts);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
getPrompt(pid) {
|
||||
return this.prompts.get(String(pid)) || null;
|
||||
}
|
||||
|
||||
hasPrompt(pid) {
|
||||
return this.prompts.has(String(pid));
|
||||
}
|
||||
|
||||
getAllPrompts() {
|
||||
return Array.from(this.prompts.values());
|
||||
}
|
||||
|
||||
setPrompt(pid, state) {
|
||||
const key = String(pid);
|
||||
const isNew = !this.prompts.has(key);
|
||||
this.prompts.set(key, state);
|
||||
this.scheduleSave();
|
||||
this.emit(isNew ? StoreEvents.PROMPT_ADDED : StoreEvents.PROMPT_UPDATED, { pid: key, prompt: state });
|
||||
return state;
|
||||
}
|
||||
|
||||
updatePrompt(pid, patch) {
|
||||
const key = String(pid);
|
||||
const state = this.prompts.get(key);
|
||||
if (!state) return null;
|
||||
Object.assign(state, patch);
|
||||
this.scheduleSave();
|
||||
this.emit(StoreEvents.PROMPT_UPDATED, { pid: key, prompt: state, patch });
|
||||
return state;
|
||||
}
|
||||
|
||||
deletePrompt(pid) {
|
||||
const key = String(pid);
|
||||
const state = this.prompts.get(key);
|
||||
if (state) {
|
||||
revokePromptBlobs(state);
|
||||
this.prompts.delete(key);
|
||||
if (this.ui.currentlyActivePromptId === key) {
|
||||
this.ui.currentlyActivePromptId = null;
|
||||
}
|
||||
if (this.ui.activeSubmenuPromptId === key) {
|
||||
this.ui.activeSubmenuPromptId = null;
|
||||
}
|
||||
this.scheduleSave();
|
||||
this.emit(StoreEvents.PROMPT_DELETED, { pid: key });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
pruneHistory(maxItems = 64) {
|
||||
const candidates = PersistenceAdapter.getPruneCandidates(this.prompts, maxItems);
|
||||
for (const pid of candidates) {
|
||||
this.deletePrompt(pid);
|
||||
}
|
||||
}
|
||||
|
||||
clearFinished() {
|
||||
const toDelete = [];
|
||||
for (const [pid, state] of this.prompts.entries()) {
|
||||
if (state.status !== PromptStatus.PENDING && state.status !== PromptStatus.ACTIVE) {
|
||||
toDelete.push(pid);
|
||||
}
|
||||
}
|
||||
for (const pid of toDelete) {
|
||||
this.deletePrompt(pid);
|
||||
}
|
||||
this.emit(StoreEvents.PROMPTS_CLEARED, { count: toDelete.length });
|
||||
return toDelete;
|
||||
}
|
||||
|
||||
clearCancelledOrFailed() {
|
||||
const toDelete = [];
|
||||
for (const [pid, state] of this.prompts.entries()) {
|
||||
if (state.status === PromptStatus.CANCELLED || state.status === PromptStatus.ERROR) {
|
||||
toDelete.push(pid);
|
||||
}
|
||||
}
|
||||
for (const pid of toDelete) {
|
||||
this.deletePrompt(pid);
|
||||
}
|
||||
this.emit(StoreEvents.PROMPTS_CLEARED, { count: toDelete.length });
|
||||
return toDelete;
|
||||
}
|
||||
|
||||
setSearchQuery(query) {
|
||||
this.ui.searchQuery = (query || "").trim();
|
||||
this.emit(StoreEvents.SEARCH_CHANGED, { query: this.ui.searchQuery });
|
||||
}
|
||||
|
||||
setActivePromptId(pid) {
|
||||
this.ui.currentlyActivePromptId = pid ? String(pid) : null;
|
||||
this.emit(StoreEvents.ACTIVE_PROMPT_CHANGED, { pid: this.ui.currentlyActivePromptId });
|
||||
}
|
||||
|
||||
openBatchSubmenu(batchInfo) {
|
||||
this.ui.activeSubmenuBatchImages = batchInfo;
|
||||
this.ui.activeSubmenuPromptId = null;
|
||||
this.emit(StoreEvents.SUBMENU_CHANGED, { type: "batch", data: batchInfo });
|
||||
}
|
||||
|
||||
openOutputsSubmenu(pid) {
|
||||
this.ui.activeSubmenuPromptId = String(pid);
|
||||
this.ui.activeSubmenuBatchImages = null;
|
||||
this.emit(StoreEvents.SUBMENU_CHANGED, { type: "outputs", pid: this.ui.activeSubmenuPromptId });
|
||||
}
|
||||
|
||||
closeSubmenu() {
|
||||
const hadSubmenu = this.ui.activeSubmenuPromptId !== null || this.ui.activeSubmenuBatchImages !== null;
|
||||
this.ui.activeSubmenuPromptId = null;
|
||||
this.ui.activeSubmenuBatchImages = null;
|
||||
if (hadSubmenu) {
|
||||
this.emit(StoreEvents.SUBMENU_CHANGED, { type: null });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const store = new Store();
|
||||
-428
@@ -1,428 +0,0 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { api } from "/scripts/api.js";
|
||||
import { State, promptStates, pruneHistory, cardElements, scheduleStateSave } from "./state.js";
|
||||
import { findImagesInOutputs, findTextsInOutputs, parseWorkflow, getPrimaryOutputImages } from "./utils.js";
|
||||
|
||||
export let renderDOMFn = () => {};
|
||||
export let updateSidebarBadgeFn = () => {};
|
||||
export function setUIDependencies(renderFn, badgeFn) {
|
||||
renderDOMFn = renderFn;
|
||||
updateSidebarBadgeFn = badgeFn;
|
||||
}
|
||||
|
||||
export async function syncQueue() {
|
||||
try {
|
||||
const q = await api.getQueue();
|
||||
|
||||
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)) {
|
||||
seq = typeof p[0] === 'number' ? p[0] : idx;
|
||||
pid = p[1];
|
||||
} else if (p && typeof p === "object") {
|
||||
pid = p.prompt_id || p.id || p.uuid;
|
||||
seq = typeof p.number === 'number' ? p.number : (typeof p.prompt_number === 'number' ? p.prompt_number : idx);
|
||||
}
|
||||
return { pid: pid ? String(pid) : null, seq, original: p };
|
||||
});
|
||||
|
||||
normalizedPending.sort((a, b) => a.seq - b.seq);
|
||||
normalizedPending.forEach((item, index) => {
|
||||
const pid = item.pid;
|
||||
const number = index + 1;
|
||||
|
||||
if (pid) {
|
||||
pendingIds.add(pid);
|
||||
if (!promptStates.has(pid)) {
|
||||
State.sequenceNumber++;
|
||||
promptStates.set(pid, {
|
||||
pid: pid, status: "pending", images: [], progress: 0, queueNumber: number,
|
||||
progressText: `Pending... (#${number})`, timestamp: State.sequenceNumber,
|
||||
workflow: app.graph.serialize()
|
||||
});
|
||||
} else {
|
||||
const st = promptStates.get(pid);
|
||||
if (st.status === "pending") {
|
||||
st.queueNumber = number;
|
||||
st.progressText = `Pending... (#${number})`;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (const [pid, state] of promptStates.entries()) {
|
||||
if (state.status === "pending" && !pendingIds.has(pid)) {
|
||||
promptStates.delete(pid);
|
||||
} else if (state.status === "active" && !runningIds.has(pid)) {
|
||||
concludeRun(pid, "cancelled");
|
||||
}
|
||||
}
|
||||
|
||||
updateSidebarBadgeFn(pendingIds.size + (runningIds.size > 0 ? 1 : 0));
|
||||
renderDOMFn();
|
||||
} catch (err) {
|
||||
console.error("Comfy Sidebar: Failed to sync queue state", err);
|
||||
}
|
||||
}
|
||||
|
||||
const concludeRun = async (pid, statusStr) => {
|
||||
const key = String(pid);
|
||||
if (!key || !promptStates.has(key)) return;
|
||||
if (State.currentlyActivePromptId === key) State.currentlyActivePromptId = null;
|
||||
|
||||
const st = promptStates.get(key);
|
||||
|
||||
if (st._previewBlobUrl) {
|
||||
try { URL.revokeObjectURL(st._previewBlobUrl); } catch(e){}
|
||||
delete st._previewBlobUrl;
|
||||
}
|
||||
if (st._oldPreviewBlobUrl) {
|
||||
try { URL.revokeObjectURL(st._oldPreviewBlobUrl); } catch(e){}
|
||||
delete st._oldPreviewBlobUrl;
|
||||
}
|
||||
|
||||
st.status = statusStr;
|
||||
st.progressText = "";
|
||||
st.rendered = false;
|
||||
st.endTime = Date.now();
|
||||
if (st.startTime) st.duration = (st.endTime - st.startTime) / 1000;
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
const res = await fetch(`/history/${key}`);
|
||||
const hItem = await res.json();
|
||||
if (hItem && hItem[key]) {
|
||||
const rawWf = hItem[key].extra_data?.extra_pnginfo?.workflow || hItem[key].prompt?.[3]?.extra_pnginfo?.workflow || null;
|
||||
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;
|
||||
|
||||
const fetchedTexts = findTextsInOutputs(hItem[key].outputs, st.workflow);
|
||||
if (fetchedTexts.length > 0) st.texts = fetchedTexts;
|
||||
break;
|
||||
}
|
||||
} catch (err) {}
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
}
|
||||
|
||||
pruneHistory(app);
|
||||
scheduleStateSave();
|
||||
syncQueue();
|
||||
};
|
||||
|
||||
export function setupApiListeners() {
|
||||
const onStatus = () => syncQueue();
|
||||
const onReconnected = async () => {
|
||||
console.log("Comfy Sidebar: Server reconnected, syncing state and history");
|
||||
await initSessionAndHistory();
|
||||
await syncQueue();
|
||||
};
|
||||
|
||||
const onExecutionStart = (e) => {
|
||||
if (app.ui.settings.getSettingValue("Comfy Sidebar.Auto Clear Interrupted") ?? false) {
|
||||
const toDelete = [];
|
||||
for (const [p, s] of promptStates.entries()) {
|
||||
if (s.status === "cancelled" || s.status === "error") {
|
||||
toDelete.push(p);
|
||||
promptStates.delete(p);
|
||||
}
|
||||
}
|
||||
if (toDelete.length > 0) {
|
||||
api.fetchApi("/history", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ delete: toDelete }) }).catch(()=>{});
|
||||
scheduleStateSave();
|
||||
}
|
||||
}
|
||||
|
||||
const pid = String(e.detail.prompt_id);
|
||||
State.currentlyActivePromptId = pid;
|
||||
const activeWorkspaceWorkflow = app.graph.serialize();
|
||||
|
||||
if (promptStates.has(pid)) {
|
||||
const st = promptStates.get(pid);
|
||||
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: "Processing...", timestamp: State.sequenceNumber,
|
||||
workflow: activeWorkspaceWorkflow, startTime: Date.now(), duration: null
|
||||
});
|
||||
}
|
||||
renderDOMFn();
|
||||
syncQueue();
|
||||
};
|
||||
|
||||
const onProgress = (e) => {
|
||||
const pid = e.detail.prompt_id ? String(e.detail.prompt_id) : null;
|
||||
if (pid && promptStates.has(pid)) {
|
||||
const st = promptStates.get(pid);
|
||||
st.progress = Math.round((e.detail.value / e.detail.max) * 100);
|
||||
|
||||
const cardObj = cardElements.get(pid);
|
||||
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}]`) : "Processing...";
|
||||
cardObj.statusText.textContent = `${nodeName} ${st.progress}%`;
|
||||
}
|
||||
} else {
|
||||
renderDOMFn();
|
||||
}
|
||||
} else if (pid && !promptStates.has(pid)) {
|
||||
syncQueue();
|
||||
}
|
||||
};
|
||||
|
||||
const onExecuting = (e) => {
|
||||
const nodeId = e.detail;
|
||||
const showWorkingNode = app.ui.settings.getSettingValue("Comfy Sidebar.Show Working Node Name") ?? true;
|
||||
|
||||
if (showWorkingNode && State.currentlyActivePromptId && promptStates.has(State.currentlyActivePromptId)) {
|
||||
const st = promptStates.get(State.currentlyActivePromptId);
|
||||
if (nodeId) {
|
||||
const node = app.graph?.getNodeById ? app.graph.getNodeById(nodeId) : null;
|
||||
st.activeNodeName = node ? (node.title || node.type) : `Node #${nodeId}`;
|
||||
} else {
|
||||
st.activeNodeName = "Finishing...";
|
||||
}
|
||||
// Don't re-render if user is currently browsing an intermediate/batch submenu
|
||||
if (!State.activeSubmenuPromptId && !State.activeSubmenuBatchImages) {
|
||||
renderDOMFn();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onBPreview = (e) => {
|
||||
const activeTasks = Array.from(promptStates.values()).filter(t => t.status === "active");
|
||||
if (activeTasks.length > 0) {
|
||||
const st = activeTasks[0];
|
||||
const newBlobUrl = URL.createObjectURL(e.detail);
|
||||
|
||||
st._oldPreviewBlobUrl = st._previewBlobUrl;
|
||||
st._previewBlobUrl = newBlobUrl;
|
||||
st.images = [{ url: newBlobUrl }];
|
||||
// Keep preview updated in memory, but don't kick the user out of an active submenu
|
||||
if (!State.activeSubmenuPromptId && !State.activeSubmenuBatchImages) {
|
||||
renderDOMFn();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onExecuted = (e) => {
|
||||
const pid = e.detail.prompt_id ? String(e.detail.prompt_id) : null;
|
||||
if (pid && promptStates.has(pid)) {
|
||||
const st = promptStates.get(pid);
|
||||
const nodeImgs = findImagesInOutputs({ [e.detail.node]: e.detail.output }, st.workflow);
|
||||
if (nodeImgs.length > 0) {
|
||||
st.images = nodeImgs;
|
||||
}
|
||||
const nodeTexts = findTextsInOutputs({ [e.detail.node]: e.detail.output }, st.workflow);
|
||||
if (nodeTexts.length > 0) {
|
||||
st.texts = nodeTexts;
|
||||
}
|
||||
|
||||
if (!st.nodeOutputs) st.nodeOutputs = {};
|
||||
st.nodeOutputs[e.detail.node] = e.detail.output;
|
||||
|
||||
renderDOMFn();
|
||||
}
|
||||
};
|
||||
|
||||
const onExecutionSuccess = (e) => concludeRun(e.detail.prompt_id, "completed");
|
||||
const onExecutionError = (e) => concludeRun(e.detail.prompt_id, "error");
|
||||
const onExecutionInterrupted = () => {
|
||||
Array.from(promptStates.values()).filter(t => t.status === "active").forEach(t => concludeRun(t.pid, "cancelled"));
|
||||
syncQueue();
|
||||
};
|
||||
|
||||
api.addEventListener("status", onStatus);
|
||||
api.addEventListener("reconnected", onReconnected);
|
||||
api.addEventListener("execution_start", onExecutionStart);
|
||||
api.addEventListener("progress", onProgress);
|
||||
api.addEventListener("executing", onExecuting);
|
||||
api.addEventListener("b_preview", onBPreview);
|
||||
api.addEventListener("executed", onExecuted);
|
||||
api.addEventListener("execution_success", onExecutionSuccess);
|
||||
api.addEventListener("execution_error", onExecutionError);
|
||||
api.addEventListener("execution_interrupted", onExecutionInterrupted);
|
||||
|
||||
return () => {
|
||||
api.removeEventListener("status", onStatus);
|
||||
api.removeEventListener("reconnected", onReconnected);
|
||||
api.removeEventListener("execution_start", onExecutionStart);
|
||||
api.removeEventListener("progress", onProgress);
|
||||
api.removeEventListener("executing", onExecuting);
|
||||
api.removeEventListener("b_preview", onBPreview);
|
||||
api.removeEventListener("executed", onExecuted);
|
||||
api.removeEventListener("execution_success", onExecutionSuccess);
|
||||
api.removeEventListener("execution_error", onExecutionError);
|
||||
api.removeEventListener("execution_interrupted", onExecutionInterrupted);
|
||||
};
|
||||
}
|
||||
|
||||
export async function initSessionAndHistory() {
|
||||
try {
|
||||
const historyData = await api.getHistory();
|
||||
if (!historyData) return;
|
||||
|
||||
const rawItems = [];
|
||||
if (Array.isArray(historyData.History)) {
|
||||
historyData.History.forEach(item => {
|
||||
if (item && item.prompt_id) rawItems.push({ pid: String(item.prompt_id), data: item });
|
||||
});
|
||||
} else if (typeof historyData === 'object') {
|
||||
Object.keys(historyData).forEach(pidKey => {
|
||||
const item = historyData[pidKey];
|
||||
rawItems.push({ pid: String(pidKey), data: Array.isArray(item) ? { prompt: item[0], outputs: item[1], status: item[2] } : item });
|
||||
});
|
||||
}
|
||||
|
||||
rawItems.sort((a, b) => {
|
||||
const numA = a.data.prompt?.[0] ?? 0;
|
||||
const numB = b.data.prompt?.[0] ?? 0;
|
||||
return numA - numB;
|
||||
});
|
||||
|
||||
rawItems.forEach(({ pid, data }) => {
|
||||
const extraData = data.extra_data || data.prompt?.[3] || {};
|
||||
const workflow = parseWorkflow(extraData.extra_pnginfo?.workflow || extraData.workflow);
|
||||
const outputs = data.outputs || {};
|
||||
|
||||
const images = getPrimaryOutputImages(outputs, workflow);
|
||||
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" :
|
||||
(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;
|
||||
} else {
|
||||
// Restore workflow & outputs from server history on startup
|
||||
if (!st.workflow && workflow) {
|
||||
st.workflow = workflow;
|
||||
}
|
||||
if ((!st.images || st.images.length === 0) && images.length > 0) {
|
||||
st.images = images;
|
||||
}
|
||||
if ((!st.nodeOutputs || Object.keys(st.nodeOutputs).length === 0) && outputs && Object.keys(outputs).length > 0) {
|
||||
st.nodeOutputs = outputs;
|
||||
}
|
||||
if ((!st.texts || st.texts.length === 0) && texts && texts.length > 0) {
|
||||
st.texts = texts;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
State.sequenceNumber++;
|
||||
promptStates.set(pid, {
|
||||
pid: pid,
|
||||
status: status,
|
||||
images: images,
|
||||
texts: texts,
|
||||
nodeOutputs: outputs,
|
||||
workflow: workflow,
|
||||
progressText: "",
|
||||
timestamp: State.sequenceNumber,
|
||||
rendered: true
|
||||
});
|
||||
});
|
||||
|
||||
pruneHistory(app);
|
||||
scheduleStateSave();
|
||||
await syncQueue();
|
||||
} catch (err) {
|
||||
console.error("Comfy Sidebar: Failed to initialize history from server API", err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { api } from "/scripts/api.js";
|
||||
import { app } from "/scripts/app.js";
|
||||
import { store } from "../core/store.js";
|
||||
import { PromptStatus } from "../core/constants.js";
|
||||
import { createPromptState, activatePrompt, updatePromptProgress } from "../core/promptState.js";
|
||||
import { syncQueue, concludeRun, initSessionAndHistory, clearCancelledOrFailed } from "./queueService.js";
|
||||
import { findImagesInOutputs, findTextsInOutputs } from "../utils/utils.js";
|
||||
import { updateActiveComparisonPreview } from "../utils/comparison.js";
|
||||
|
||||
export function setupExecutionTracker(onTargetedProgressUpdate) {
|
||||
const onStatus = () => syncQueue();
|
||||
|
||||
const onReconnected = async () => {
|
||||
console.log("Comfy Sidebar: Server reconnected, syncing state and history");
|
||||
await initSessionAndHistory();
|
||||
await syncQueue();
|
||||
};
|
||||
|
||||
const onExecutionStart = (e) => {
|
||||
const autoClear = app.ui?.settings?.getSettingValue?.("Comfy Sidebar.Auto Clear Interrupted") ?? false;
|
||||
if (autoClear) {
|
||||
clearCancelledOrFailed();
|
||||
}
|
||||
|
||||
const pid = String(e.detail.prompt_id);
|
||||
store.setActivePromptId(pid);
|
||||
|
||||
const activeWorkspaceWorkflow = app.graph?.serialize ? app.graph.serialize() : null;
|
||||
const existing = store.getPrompt(pid);
|
||||
|
||||
if (existing) {
|
||||
activatePrompt(existing, { workflow: activeWorkspaceWorkflow });
|
||||
store.updatePrompt(pid, existing);
|
||||
} else {
|
||||
const seq = store.nextSequence();
|
||||
const newState = createPromptState(pid, {
|
||||
status: PromptStatus.ACTIVE,
|
||||
images: [],
|
||||
progress: 0,
|
||||
progressText: "Processing...",
|
||||
timestamp: seq,
|
||||
workflow: activeWorkspaceWorkflow,
|
||||
startTime: Date.now()
|
||||
});
|
||||
store.setPrompt(pid, newState);
|
||||
}
|
||||
|
||||
syncQueue();
|
||||
};
|
||||
|
||||
const onProgress = (e) => {
|
||||
const pid = e.detail.prompt_id ? String(e.detail.prompt_id) : null;
|
||||
if (pid && store.hasPrompt(pid)) {
|
||||
const prompt = store.getPrompt(pid);
|
||||
const percent = Math.round((e.detail.value / e.detail.max) * 100);
|
||||
updatePromptProgress(prompt, percent);
|
||||
store.updatePrompt(pid, prompt);
|
||||
|
||||
if (typeof onTargetedProgressUpdate === "function") {
|
||||
onTargetedProgressUpdate(pid, percent, prompt.activeNodeName);
|
||||
}
|
||||
} else if (pid && !store.hasPrompt(pid)) {
|
||||
syncQueue();
|
||||
}
|
||||
};
|
||||
|
||||
const onExecuting = (e) => {
|
||||
const nodeId = e.detail;
|
||||
const showWorkingNode = app.ui?.settings?.getSettingValue?.("Comfy Sidebar.Show Working Node Name") ?? true;
|
||||
const activePid = store.ui.currentlyActivePromptId;
|
||||
|
||||
if (activePid && store.hasPrompt(activePid)) {
|
||||
const prompt = store.getPrompt(activePid);
|
||||
if (nodeId) {
|
||||
prompt.activeNodeId = String(nodeId);
|
||||
const node = app.graph?.getNodeById ? app.graph.getNodeById(nodeId) : null;
|
||||
prompt.activeNodeName = node ? (node.title || node.type) : `Node #${nodeId}`;
|
||||
} else {
|
||||
prompt.activeNodeName = "Finishing...";
|
||||
}
|
||||
store.updatePrompt(activePid, prompt);
|
||||
|
||||
if (showWorkingNode && typeof onTargetedProgressUpdate === "function") {
|
||||
onTargetedProgressUpdate(activePid, prompt.progress, prompt.activeNodeName);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onBPreview = (e) => {
|
||||
const activeTasks = store.getAllPrompts().filter(t => t.status === PromptStatus.ACTIVE);
|
||||
if (activeTasks.length > 0) {
|
||||
const prompt = activeTasks[0];
|
||||
const newBlobUrl = URL.createObjectURL(e.detail);
|
||||
|
||||
prompt._oldPreviewBlobUrl = prompt._previewBlobUrl;
|
||||
prompt._previewBlobUrl = newBlobUrl;
|
||||
prompt.images = [{ url: newBlobUrl }];
|
||||
store.updatePrompt(prompt.pid, prompt);
|
||||
|
||||
// Update live fullscreen preview if currently open
|
||||
updateActiveComparisonPreview(prompt.pid, newBlobUrl);
|
||||
|
||||
// Update the active card in the sidebar
|
||||
if (typeof onTargetedProgressUpdate === "function") {
|
||||
onTargetedProgressUpdate(prompt.pid, prompt.progress, prompt.activeNodeName);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onExecuted = (e) => {
|
||||
const pid = e.detail.prompt_id ? String(e.detail.prompt_id) : null;
|
||||
if (pid && store.hasPrompt(pid)) {
|
||||
const prompt = store.getPrompt(pid);
|
||||
const nodeImgs = findImagesInOutputs({ [e.detail.node]: e.detail.output }, prompt.workflow);
|
||||
// Only update prompt.images if this node actually produced real output images
|
||||
const hasRealNewImgs = nodeImgs.some(img => !img.isFallback);
|
||||
if (hasRealNewImgs) {
|
||||
prompt.images = nodeImgs;
|
||||
// If fullscreen is open, smoothly transition to the final saved image
|
||||
const finalImg = nodeImgs[0];
|
||||
const finalSrc = finalImg.url || (window.location.origin + `/view?filename=${encodeURIComponent(finalImg.filename)}&type=${finalImg.type || 'output'}&subfolder=${encodeURIComponent(finalImg.subfolder || '')}`);
|
||||
updateActiveComparisonPreview(pid, finalSrc);
|
||||
}
|
||||
const nodeTexts = findTextsInOutputs({ [e.detail.node]: e.detail.output }, prompt.workflow);
|
||||
if (nodeTexts.length > 0) {
|
||||
prompt.texts = nodeTexts;
|
||||
}
|
||||
|
||||
if (!prompt.nodeOutputs) prompt.nodeOutputs = {};
|
||||
prompt.nodeOutputs[e.detail.node] = e.detail.output;
|
||||
|
||||
store.updatePrompt(pid, prompt);
|
||||
}
|
||||
};
|
||||
|
||||
const onExecutionSuccess = (e) => concludeRun(e.detail.prompt_id, PromptStatus.COMPLETED);
|
||||
const onExecutionError = (e) => {
|
||||
const pid = e.detail?.prompt_id;
|
||||
const nodeId = e.detail?.node_id;
|
||||
if (pid && nodeId && store.hasPrompt(String(pid))) {
|
||||
const prompt = store.getPrompt(String(pid));
|
||||
prompt.activeNodeId = String(nodeId);
|
||||
store.updatePrompt(String(pid), prompt);
|
||||
}
|
||||
concludeRun(e.detail.prompt_id, PromptStatus.ERROR);
|
||||
};
|
||||
const onExecutionInterrupted = () => {
|
||||
store.getAllPrompts()
|
||||
.filter(t => t.status === PromptStatus.ACTIVE)
|
||||
.forEach(t => concludeRun(t.pid, PromptStatus.CANCELLED));
|
||||
syncQueue();
|
||||
};
|
||||
|
||||
api.addEventListener("status", onStatus);
|
||||
api.addEventListener("reconnected", onReconnected);
|
||||
api.addEventListener("execution_start", onExecutionStart);
|
||||
api.addEventListener("progress", onProgress);
|
||||
api.addEventListener("executing", onExecuting);
|
||||
api.addEventListener("b_preview", onBPreview);
|
||||
api.addEventListener("executed", onExecuted);
|
||||
api.addEventListener("execution_success", onExecutionSuccess);
|
||||
api.addEventListener("execution_error", onExecutionError);
|
||||
api.addEventListener("execution_interrupted", onExecutionInterrupted);
|
||||
|
||||
return () => {
|
||||
api.removeEventListener("status", onStatus);
|
||||
api.removeEventListener("reconnected", onReconnected);
|
||||
api.removeEventListener("execution_start", onExecutionStart);
|
||||
api.removeEventListener("progress", onProgress);
|
||||
api.removeEventListener("executing", onExecuting);
|
||||
api.removeEventListener("b_preview", onBPreview);
|
||||
api.removeEventListener("executed", onExecuted);
|
||||
api.removeEventListener("execution_success", onExecutionSuccess);
|
||||
api.removeEventListener("execution_error", onExecutionError);
|
||||
api.removeEventListener("execution_interrupted", onExecutionInterrupted);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { parseWorkflow } from "../utils/utils.js";
|
||||
import { PromptStatus } from "../core/constants.js";
|
||||
|
||||
export function normalizeQueueItem(p, defaultIndex = 0) {
|
||||
let pid = null;
|
||||
let seq = defaultIndex;
|
||||
let rawWorkflow = null;
|
||||
let outputs = {};
|
||||
|
||||
if (Array.isArray(p)) {
|
||||
seq = typeof p[0] === "number" ? p[0] : defaultIndex;
|
||||
pid = p[1] ? String(p[1]) : null;
|
||||
const extraData = p[3] || {};
|
||||
rawWorkflow = extraData.extra_pnginfo?.workflow || extraData.workflow || null;
|
||||
outputs = 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;
|
||||
seq = typeof p.number === "number" ? p.number : (typeof p.prompt_number === "number" ? p.prompt_number : defaultIndex);
|
||||
const extraData = p.extra_data || {};
|
||||
rawWorkflow = extraData.extra_pnginfo?.workflow || extraData.workflow || null;
|
||||
outputs = p.outputs || {};
|
||||
}
|
||||
|
||||
return {
|
||||
pid,
|
||||
seq,
|
||||
workflow: parseWorkflow(rawWorkflow),
|
||||
outputs
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeHistoryItem(pidKey, rawData) {
|
||||
const pid = String(pidKey);
|
||||
let item = rawData;
|
||||
|
||||
if (Array.isArray(item)) {
|
||||
item = {
|
||||
prompt: item[0],
|
||||
outputs: item[1] || {},
|
||||
status: item[2] || {}
|
||||
};
|
||||
} else if (!item || typeof item !== "object") {
|
||||
item = { outputs: {}, status: {} };
|
||||
}
|
||||
|
||||
const extraData = item.extra_data || item.prompt?.[3] || {};
|
||||
const rawWf = extraData.extra_pnginfo?.workflow || extraData.workflow || null;
|
||||
const workflow = parseWorkflow(rawWf);
|
||||
const outputs = item.outputs || {};
|
||||
const queueNumber = item.prompt?.[0] ?? 0;
|
||||
|
||||
const serverStatus = item.status?.status_str || "completed";
|
||||
const isInterrupted = serverStatus === "interrupted" ||
|
||||
(item.status?.messages && JSON.stringify(item.status.messages).toLowerCase().includes("interrupted"));
|
||||
|
||||
let status = PromptStatus.COMPLETED;
|
||||
if (serverStatus === "success" || serverStatus === "completed") {
|
||||
status = PromptStatus.COMPLETED;
|
||||
} else if (isInterrupted) {
|
||||
status = PromptStatus.CANCELLED;
|
||||
} else if (serverStatus === "error") {
|
||||
status = PromptStatus.ERROR;
|
||||
} else {
|
||||
status = PromptStatus.CANCELLED;
|
||||
}
|
||||
|
||||
return {
|
||||
pid,
|
||||
queueNumber,
|
||||
workflow,
|
||||
outputs,
|
||||
status,
|
||||
serverStatus
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import { api } from "/scripts/api.js";
|
||||
import { app } from "/scripts/app.js";
|
||||
import { store } from "../core/store.js";
|
||||
import { PromptStatus, StoreEvents } from "../core/constants.js";
|
||||
import { createPromptState, activatePrompt, finalizePrompt } from "../core/promptState.js";
|
||||
import { normalizeQueueItem, normalizeHistoryItem } from "./queueNormalizer.js";
|
||||
import { findImagesInOutputs, findTextsInOutputs, getPrimaryOutputImages, getNodeTitle } from "../utils/utils.js";
|
||||
|
||||
function extractNodeTitles(outputs, workflow, promptGraph = null) {
|
||||
const titles = {};
|
||||
if (!outputs) return titles;
|
||||
for (const nodeId in outputs) {
|
||||
const title = getNodeTitle(nodeId, workflow, promptGraph);
|
||||
if (title) {
|
||||
titles[nodeId] = title;
|
||||
}
|
||||
}
|
||||
return titles;
|
||||
}
|
||||
|
||||
export async function syncQueue() {
|
||||
try {
|
||||
const q = await api.getQueue();
|
||||
const runningList = q.Running || q.queue_running || [];
|
||||
const pendingList = q.Pending || q.queue_pending || [];
|
||||
|
||||
const runningIds = new Set();
|
||||
const pendingIds = new Set();
|
||||
|
||||
runningList.forEach((raw, idx) => {
|
||||
const item = normalizeQueueItem(raw, idx);
|
||||
if (!item.pid) return;
|
||||
|
||||
runningIds.add(item.pid);
|
||||
store.setActivePromptId(item.pid);
|
||||
|
||||
const existing = store.getPrompt(item.pid);
|
||||
const activeWorkflow = item.workflow || (app.graph?.serialize?.() || null);
|
||||
const titles = extractNodeTitles(item.outputs, activeWorkflow);
|
||||
|
||||
if (!existing) {
|
||||
const seq = store.nextSequence();
|
||||
const images = getPrimaryOutputImages(item.outputs, activeWorkflow);
|
||||
const texts = findTextsInOutputs(item.outputs, activeWorkflow);
|
||||
|
||||
const newState = createPromptState(item.pid, {
|
||||
status: PromptStatus.ACTIVE,
|
||||
images,
|
||||
texts,
|
||||
nodeOutputs: item.outputs,
|
||||
nodeTitles: titles,
|
||||
progress: 0,
|
||||
progressText: "Processing...",
|
||||
timestamp: seq,
|
||||
workflow: activeWorkflow,
|
||||
startTime: Date.now()
|
||||
});
|
||||
store.setPrompt(item.pid, newState);
|
||||
} else {
|
||||
if (existing.status !== PromptStatus.ACTIVE) {
|
||||
activatePrompt(existing, { workflow: activeWorkflow, nodeTitles: titles });
|
||||
store.updatePrompt(item.pid, existing);
|
||||
} else {
|
||||
if (!existing.workflow && activeWorkflow) existing.workflow = activeWorkflow;
|
||||
if (Object.keys(titles).length > 0) Object.assign(existing.nodeTitles, titles);
|
||||
store.updatePrompt(item.pid, existing);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const normalizedPending = pendingList
|
||||
.map((raw, idx) => normalizeQueueItem(raw, idx))
|
||||
.filter(item => Boolean(item.pid))
|
||||
.sort((a, b) => a.seq - b.seq);
|
||||
|
||||
normalizedPending.forEach((item, index) => {
|
||||
const number = index + 1;
|
||||
pendingIds.add(item.pid);
|
||||
|
||||
const existing = store.getPrompt(item.pid);
|
||||
if (!existing) {
|
||||
const seq = store.nextSequence();
|
||||
const newState = createPromptState(item.pid, {
|
||||
status: PromptStatus.PENDING,
|
||||
images: [],
|
||||
progress: 0,
|
||||
queueNumber: number,
|
||||
progressText: `Pending... (#${number})`,
|
||||
timestamp: seq,
|
||||
workflow: app.graph?.serialize ? app.graph.serialize() : null
|
||||
});
|
||||
store.setPrompt(item.pid, newState);
|
||||
} else if (existing.status === PromptStatus.PENDING) {
|
||||
if (existing.queueNumber !== number) {
|
||||
store.updatePrompt(item.pid, {
|
||||
queueNumber: number,
|
||||
progressText: `Pending... (#${number})`
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (const prompt of store.getAllPrompts()) {
|
||||
if (prompt.status === PromptStatus.PENDING && !pendingIds.has(prompt.pid)) {
|
||||
store.deletePrompt(prompt.pid);
|
||||
} else if (prompt.status === PromptStatus.ACTIVE && !runningIds.has(prompt.pid)) {
|
||||
concludeRun(prompt.pid, PromptStatus.CANCELLED);
|
||||
}
|
||||
}
|
||||
|
||||
store.emit(StoreEvents.QUEUE_SYNCED, {
|
||||
pendingCount: pendingIds.size,
|
||||
runningCount: runningIds.size
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Comfy Sidebar: Failed to sync queue state", err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function concludeRun(pid, fallbackStatus = PromptStatus.CANCELLED) {
|
||||
const key = String(pid);
|
||||
if (!key || !store.hasPrompt(key)) return;
|
||||
|
||||
if (store.ui.currentlyActivePromptId === key) {
|
||||
store.setActivePromptId(null);
|
||||
}
|
||||
|
||||
const state = store.getPrompt(key);
|
||||
finalizePrompt(state, fallbackStatus);
|
||||
|
||||
// If cancelled while actively sampling, preserve the exact step preview where it was cancelled
|
||||
const isCancelled = fallbackStatus === PromptStatus.CANCELLED || state.status === PromptStatus.CANCELLED;
|
||||
const hasActivePreviewBlob = Boolean(state._previewBlobUrl || state.images?.some(img => img.url?.startsWith("blob:")));
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
const res = await fetch(`/history/${key}`);
|
||||
const hItem = await res.json();
|
||||
if (hItem && hItem[key]) {
|
||||
const norm = normalizeHistoryItem(key, hItem[key]);
|
||||
|
||||
state.workflow = norm.workflow || state.workflow;
|
||||
state.nodeOutputs = norm.outputs || state.nodeOutputs;
|
||||
state.status = norm.status;
|
||||
|
||||
const promptGraph = hItem[key].prompt?.[2] || null;
|
||||
const titles = extractNodeTitles(state.nodeOutputs, state.workflow, promptGraph);
|
||||
Object.assign(state.nodeTitles, titles);
|
||||
|
||||
const primaryImgs = getPrimaryOutputImages(norm.outputs, state.workflow);
|
||||
const hasActualSavedOutputs = primaryImgs.some(img => !img.isFallback);
|
||||
|
||||
// If cancelled while sampling, do NOT overwrite with older nodes; keep the exact step preview!
|
||||
if (!isCancelled || !hasActivePreviewBlob) {
|
||||
if (hasActualSavedOutputs || (state.status === PromptStatus.COMPLETED && primaryImgs.length > 0)) {
|
||||
state.images = primaryImgs;
|
||||
}
|
||||
}
|
||||
|
||||
const fetchedTexts = findTextsInOutputs(norm.outputs, state.workflow);
|
||||
if (fetchedTexts.length > 0) state.texts = fetchedTexts;
|
||||
break;
|
||||
}
|
||||
} catch (err) {}
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
}
|
||||
|
||||
// Ensure cancelled prompt keeps its preview blob if it had one
|
||||
if (isCancelled && hasActivePreviewBlob) {
|
||||
if (state._previewBlobUrl && (!state.images || state.images.length === 0 || !state.images[0].url?.startsWith("blob:"))) {
|
||||
state.images = [{ url: state._previewBlobUrl }];
|
||||
}
|
||||
}
|
||||
|
||||
const maxItems = app.ui?.settings?.getSettingValue?.("Comfy.Queue.MaxHistoryItems") ?? 64;
|
||||
store.pruneHistory(maxItems);
|
||||
store.updatePrompt(key, state);
|
||||
syncQueue();
|
||||
}
|
||||
|
||||
export async function initSessionAndHistory() {
|
||||
try {
|
||||
const historyData = await api.getHistory();
|
||||
if (!historyData) return;
|
||||
|
||||
const rawEntries = [];
|
||||
if (Array.isArray(historyData.History)) {
|
||||
historyData.History.forEach(item => {
|
||||
if (item && item.prompt_id) {
|
||||
rawEntries.push({ pid: String(item.prompt_id), data: item });
|
||||
}
|
||||
});
|
||||
} else if (typeof historyData === "object") {
|
||||
Object.keys(historyData).forEach(pidKey => {
|
||||
rawEntries.push({ pid: String(pidKey), data: historyData[pidKey] });
|
||||
});
|
||||
}
|
||||
|
||||
const normalized = rawEntries.map(e => ({
|
||||
norm: normalizeHistoryItem(e.pid, e.data),
|
||||
promptGraph: e.data.prompt?.[2] || null
|
||||
}));
|
||||
normalized.sort((a, b) => a.norm.queueNumber - b.norm.queueNumber);
|
||||
|
||||
normalized.forEach(({ norm: item, promptGraph }) => {
|
||||
const images = getPrimaryOutputImages(item.outputs, item.workflow);
|
||||
const texts = findTextsInOutputs(item.outputs, item.workflow);
|
||||
const titles = extractNodeTitles(item.outputs, item.workflow, promptGraph);
|
||||
|
||||
if (images.length === 0 && texts.length === 0 && item.status === PromptStatus.COMPLETED) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = store.getPrompt(item.pid);
|
||||
if (existing) {
|
||||
if (existing.status === PromptStatus.ACTIVE || existing.status === PromptStatus.PENDING) {
|
||||
existing.status = item.status;
|
||||
existing.images = images;
|
||||
existing.texts = texts;
|
||||
existing.nodeOutputs = item.outputs;
|
||||
existing.workflow = item.workflow || existing.workflow;
|
||||
Object.assign(existing.nodeTitles, titles);
|
||||
existing.progressText = "";
|
||||
existing.rendered = false;
|
||||
store.updatePrompt(item.pid, existing);
|
||||
} else {
|
||||
if (!existing.workflow && item.workflow) existing.workflow = item.workflow;
|
||||
if ((!existing.images || existing.images.length === 0) && images.length > 0) existing.images = images;
|
||||
if (!existing.nodeOutputs || Object.keys(existing.nodeOutputs).length === 0) {
|
||||
existing.nodeOutputs = item.outputs;
|
||||
}
|
||||
if (!existing.nodeTitles) existing.nodeTitles = {};
|
||||
Object.assign(existing.nodeTitles, titles);
|
||||
if ((!existing.texts || existing.texts.length === 0) && texts && texts.length > 0) existing.texts = texts;
|
||||
store.updatePrompt(item.pid, existing);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const seq = store.nextSequence();
|
||||
const newState = createPromptState(item.pid, {
|
||||
status: item.status,
|
||||
images,
|
||||
texts,
|
||||
nodeOutputs: item.outputs,
|
||||
nodeTitles: titles,
|
||||
workflow: item.workflow,
|
||||
progressText: "",
|
||||
timestamp: seq,
|
||||
rendered: true
|
||||
});
|
||||
store.setPrompt(item.pid, newState);
|
||||
});
|
||||
|
||||
const maxItems = app.ui?.settings?.getSettingValue?.("Comfy.Queue.MaxHistoryItems") ?? 64;
|
||||
store.pruneHistory(maxItems);
|
||||
await syncQueue();
|
||||
} catch (err) {
|
||||
console.error("Comfy Sidebar: Failed to initialize history from server API", err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function cancelPendingTask(pid) {
|
||||
try {
|
||||
await api.fetchApi("/queue", { method: "POST", body: JSON.stringify({ delete: [pid] }) });
|
||||
await syncQueue();
|
||||
} catch (e) {
|
||||
console.error("Comfy Sidebar: Failed to cancel pending task", e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function cancelAllPending() {
|
||||
try {
|
||||
await api.fetchApi("/queue", { method: "POST", body: JSON.stringify({ clear: true }) });
|
||||
await syncQueue();
|
||||
} catch (e) {
|
||||
console.error("Comfy Sidebar: Failed to cancel all pending tasks", e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function interruptActive() {
|
||||
try {
|
||||
await api.interrupt();
|
||||
await syncQueue();
|
||||
} catch (e) {
|
||||
console.error("Comfy Sidebar: Failed to interrupt active run", e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteHistoryItem(pid) {
|
||||
try {
|
||||
store.deletePrompt(pid);
|
||||
await api.fetchApi("/history", { method: "POST", body: JSON.stringify({ delete: [pid] }) });
|
||||
await syncQueue();
|
||||
} catch (e) {
|
||||
console.error("Comfy Sidebar: Failed to delete history item", e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearAllHistory() {
|
||||
try {
|
||||
store.clearFinished();
|
||||
await api.fetchApi("/history", { method: "POST", body: JSON.stringify({ clear: true }) });
|
||||
await syncQueue();
|
||||
} catch (e) {
|
||||
console.error("Comfy Sidebar: Failed to clear all history", e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearCancelledOrFailed() {
|
||||
try {
|
||||
const toDelete = store.clearCancelledOrFailed();
|
||||
if (toDelete.length > 0) {
|
||||
await api.fetchApi("/history", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ delete: toDelete })
|
||||
});
|
||||
}
|
||||
await syncQueue();
|
||||
} catch (e) {
|
||||
console.error("Comfy Sidebar: Failed to clear cancelled or failed tasks", e);
|
||||
}
|
||||
}
|
||||
+224
-358
@@ -1,358 +1,224 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { injectStyles } from "./styles.js";
|
||||
import { setupDragAndDrop } from "./dragdrop.js";
|
||||
import { setupSidebarUI, applySidebarOverride, findOurSidebarButton, setSyncQueue, updateSidebarBadge, renderDOM, setupScrollListener } from "./ui.js";
|
||||
import { setupApiListeners, initSessionAndHistory, syncQueue, setUIDependencies } from "./queue.js";
|
||||
import { applyClassicLayout, setupPropertiesPanelToggleFix, syncClassicLayout, syncStockHistoryAndProgressSettings, destroyLayoutFix } from "./layout.js";
|
||||
|
||||
let isInitialized = false;
|
||||
let activeKeydownHandler = null;
|
||||
let nodeDOMObserver = null;
|
||||
let cleanupFns = [];
|
||||
|
||||
function findHeaderByText(parent, text) {
|
||||
if (!text) return null;
|
||||
const cleanText = text.trim();
|
||||
|
||||
const elements = parent.querySelectorAll("span, div, h1, h2, h3, h4, p, [class*='title'], [class*='text']");
|
||||
for (const el of elements) {
|
||||
if (el.textContent.trim() === cleanText) {
|
||||
if (el.offsetWidth > 0 && el.offsetHeight > 0 && el.offsetWidth < parent.offsetWidth * 0.9) {
|
||||
return el;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function syncNodeVueBadge(node, isIgnored) {
|
||||
const nodeEl = document.querySelector(
|
||||
`.comfy-node[data-node-id="${node.id}"], ` +
|
||||
`[data-node-id="${node.id}"], ` +
|
||||
`[data-id="${node.id}"]`
|
||||
);
|
||||
if (!nodeEl) return;
|
||||
|
||||
const titleTextNode = findHeaderByText(nodeEl, node.title || node.type);
|
||||
if (!titleTextNode) {
|
||||
const badge = nodeEl.querySelector(".comfy-sidebar-ignore-badge");
|
||||
if (badge && !isIgnored) badge.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
const headerEl = titleTextNode.parentNode;
|
||||
let badge = headerEl.querySelector(".comfy-sidebar-ignore-badge");
|
||||
|
||||
if (isIgnored) {
|
||||
if (!badge) {
|
||||
badge = document.createElement("div");
|
||||
badge.className = "comfy-sidebar-ignore-badge";
|
||||
badge.innerHTML = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24"/>
|
||||
<path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68"/>
|
||||
<path d="M6.61 6.61A13.52 13.52 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61"/>
|
||||
<line x1="2" y1="2" x2="22" y2="22"/>
|
||||
</svg>
|
||||
`;
|
||||
Object.assign(badge.style, {
|
||||
marginLeft: "auto",
|
||||
marginRight: "6px",
|
||||
float: "right",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "inherit",
|
||||
pointerEvents: "none"
|
||||
});
|
||||
headerEl.appendChild(badge);
|
||||
}
|
||||
} else {
|
||||
if (badge) badge.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export function syncAllNodeBadges() {
|
||||
applySidebarOverride();
|
||||
if (!app.graph || !app.graph._nodes) return;
|
||||
|
||||
app.graph._nodes.forEach(node => {
|
||||
if (!node.properties) node.properties = {};
|
||||
const isIgnored = !!node.properties.ignoreInQueue;
|
||||
|
||||
if (isIgnored) {
|
||||
if (node.boxcolor !== "#ff3333") {
|
||||
node._oldBoxcolor = node.boxcolor || "";
|
||||
node.boxcolor = "#ff3333";
|
||||
if (app.graph) app.graph.setDirtyCanvas(true, true);
|
||||
}
|
||||
} else {
|
||||
if (node.boxcolor === "#ff3333") {
|
||||
node.boxcolor = node._oldBoxcolor || "";
|
||||
delete node._oldBoxcolor;
|
||||
if (app.graph) app.graph.setDirtyCanvas(true, true);
|
||||
}
|
||||
}
|
||||
|
||||
syncNodeVueBadge(node, isIgnored);
|
||||
});
|
||||
}
|
||||
|
||||
function setupVueNodeObserver() {
|
||||
if (nodeDOMObserver) nodeDOMObserver.disconnect();
|
||||
|
||||
let timeoutId = null;
|
||||
nodeDOMObserver = new MutationObserver((mutations) => {
|
||||
let hasNodeMutation = false;
|
||||
for (const m of mutations) {
|
||||
for (const node of m.addedNodes) {
|
||||
if (node.nodeType === 1) {
|
||||
if (node.hasAttribute?.("data-node-id") ||
|
||||
node.classList?.contains("comfy-node") ||
|
||||
node.querySelector?.('[data-node-id], .comfy-node')) {
|
||||
hasNodeMutation = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hasNodeMutation) break;
|
||||
}
|
||||
|
||||
if (hasNodeMutation) {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => {
|
||||
syncAllNodeBadges();
|
||||
}, 50);
|
||||
}
|
||||
});
|
||||
|
||||
nodeDOMObserver.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
}
|
||||
|
||||
function toggleIgnoreActiveNode() {
|
||||
const canvas = app.canvas;
|
||||
if (!canvas) return;
|
||||
|
||||
let nodes = [];
|
||||
if (canvas.selected_nodes && Object.keys(canvas.selected_nodes).length > 0) {
|
||||
nodes = Object.values(canvas.selected_nodes);
|
||||
} else if (canvas.current_active_node) {
|
||||
nodes = [canvas.current_active_node];
|
||||
}
|
||||
|
||||
if (nodes.length === 0) return;
|
||||
|
||||
nodes.forEach(node => {
|
||||
if (!node.properties) node.properties = {};
|
||||
node.properties.ignoreInQueue = !node.properties.ignoreInQueue;
|
||||
const isIgnored = !!node.properties.ignoreInQueue;
|
||||
|
||||
if (isIgnored) {
|
||||
if (node.boxcolor !== "#ff3333") {
|
||||
node._oldBoxcolor = node.boxcolor || "";
|
||||
node.boxcolor = "#ff3333";
|
||||
}
|
||||
} else {
|
||||
if (node.boxcolor === "#ff3333") {
|
||||
node.boxcolor = node._oldBoxcolor || "";
|
||||
delete node._oldBoxcolor;
|
||||
}
|
||||
}
|
||||
|
||||
syncNodeVueBadge(node, isIgnored);
|
||||
});
|
||||
|
||||
if (app.graph) {
|
||||
if (app.graph.change) app.graph.change(); // Marks workflow as modified so ComfyUI autosaves it
|
||||
app.graph.setDirtyCanvas(true, true);
|
||||
}
|
||||
if (app.canvas) app.canvas.setDirty(true, true);
|
||||
|
||||
renderDOM(); // Immediately update sidebar buttons without needing a reload
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfySidebar.ClassicRestore",
|
||||
|
||||
init() {
|
||||
app.ui.settings.addSetting({ id: "Comfy Sidebar.Grid Columns Threshold", name: "Width Threshold for Queue Columns (px)", type: "number", defaultValue: 350 });
|
||||
app.ui.settings.addSetting({ id: "Comfy Sidebar.Show Pending Count Only", name: "If disabled, each queued job will have a separate individual card", type: "boolean", defaultValue: true });
|
||||
app.ui.settings.addSetting({ id: "Comfy Sidebar.Show Working Node Name", name: "Shows the name of the node which is currently in the process", type: "boolean", defaultValue: true });
|
||||
app.ui.settings.addSetting({ id: "Comfy Sidebar.Auto Clear Interrupted", name: "Auto-clear cancelled & failed jobs on new generation", type: "boolean", defaultValue: false });
|
||||
|
||||
const sidebarTabs = ["Assets", "Nodes", "Models", "Workflows", "Apps", "Templates"];
|
||||
sidebarTabs.forEach(tab => {
|
||||
app.ui.settings.addSetting({
|
||||
id: `Comfy Sidebar.Hide Junk.${tab}`,
|
||||
name: `Hide Tab: ${tab}`,
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
onChange: () => {
|
||||
setTimeout(() => { syncClassicLayout(); }, 0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: "Comfy Sidebar.Hide Junk.Override Stock Job History Tab",
|
||||
name: "Replace the stock Job History sidebar with Comfy Queue",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
onChange: (value) => {
|
||||
if (isInitialized && app.ui && app.ui.settings) {
|
||||
syncStockHistoryAndProgressSettings(value);
|
||||
}
|
||||
setTimeout(() => { syncClassicLayout(); }, 0);
|
||||
}
|
||||
});
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: "Comfy Sidebar.Hide Junk.Graph Button",
|
||||
name: "Hide floating 'Graph' (Workflow/Node Map) button",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
onChange: () => {
|
||||
setTimeout(() => { syncClassicLayout(); }, 0);
|
||||
}
|
||||
});
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: "Comfy Sidebar.Comfy Layout",
|
||||
name: "Places the controls and the open workflow tabs on a single unified top bar",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
onChange: (value) => {
|
||||
if (isInitialized) {
|
||||
applyClassicLayout(value, true);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
commands: [
|
||||
{
|
||||
id: "ComfySidebar.ToggleSidebar",
|
||||
label: "Toggle Comfy Queue Sidebar",
|
||||
function: () => {
|
||||
const ourBtn = findOurSidebarButton();
|
||||
if (ourBtn) ourBtn.click();
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "ComfySidebar.ToggleIgnoreNode",
|
||||
label: "Toggle Ignore Selected Node(s) in Queue",
|
||||
function: () => toggleIgnoreActiveNode()
|
||||
}
|
||||
],
|
||||
|
||||
nodeCreated(node) {
|
||||
if (node && node.properties?.ignoreInQueue) {
|
||||
node.boxcolor = "#ff3333";
|
||||
syncNodeVueBadge(node, true);
|
||||
}
|
||||
},
|
||||
|
||||
afterConfigureGraph() {
|
||||
requestAnimationFrame(() => {
|
||||
syncAllNodeBadges();
|
||||
renderDOM();
|
||||
});
|
||||
},
|
||||
|
||||
async setup() {
|
||||
if (!app.extensionManager || !app.extensionManager.registerSidebarTab) return;
|
||||
|
||||
const isClassicLayoutEnabled = app.ui.settings.getSettingValue("Comfy Sidebar.Comfy Layout") ?? false;
|
||||
applyClassicLayout(isClassicLayoutEnabled, false);
|
||||
|
||||
const isHistoryOverrideEnabled = app.ui.settings.getSettingValue("Comfy Sidebar.Hide Junk.Override Stock Job History Tab") ?? false;
|
||||
if (isHistoryOverrideEnabled) {
|
||||
syncStockHistoryAndProgressSettings(true);
|
||||
}
|
||||
|
||||
isInitialized = true;
|
||||
|
||||
setupPropertiesPanelToggleFix();
|
||||
|
||||
setSyncQueue(syncQueue);
|
||||
setUIDependencies(renderDOM, updateSidebarBadge);
|
||||
|
||||
injectStyles();
|
||||
|
||||
// Register disposable subsystems
|
||||
cleanupFns.push(setupDragAndDrop());
|
||||
cleanupFns.push(setupScrollListener());
|
||||
cleanupFns.push(setupApiListeners());
|
||||
|
||||
const sidebarContainer = setupSidebarUI();
|
||||
|
||||
await initSessionAndHistory();
|
||||
|
||||
setupVueNodeObserver();
|
||||
|
||||
if (activeKeydownHandler) {
|
||||
document.removeEventListener("keydown", activeKeydownHandler, true);
|
||||
activeKeydownHandler = null;
|
||||
}
|
||||
|
||||
activeKeydownHandler = (e) => {
|
||||
const activeEl = document.activeElement;
|
||||
if (activeEl && (
|
||||
activeEl.tagName === "INPUT" ||
|
||||
activeEl.tagName === "TEXTAREA" ||
|
||||
activeEl.isContentEditable ||
|
||||
activeEl.tagName === "SELECT"
|
||||
)) return;
|
||||
|
||||
if (e.key.toLowerCase() === "q" && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const ourBtn = findOurSidebarButton();
|
||||
if (ourBtn) ourBtn.click();
|
||||
}
|
||||
|
||||
if (e.key.toLowerCase() === "q" && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggleIgnoreActiveNode();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", activeKeydownHandler, true);
|
||||
|
||||
applySidebarOverride();
|
||||
|
||||
app.extensionManager.registerSidebarTab({
|
||||
id: "classic-comfy-sidebar",
|
||||
icon: "pi pi-images",
|
||||
title: "Queue",
|
||||
tooltip: "Comfy Queue (Q)",
|
||||
type: "custom",
|
||||
render: (el) => { el.appendChild(sidebarContainer); }
|
||||
});
|
||||
},
|
||||
|
||||
destroy() {
|
||||
for (const fn of cleanupFns.splice(0)) {
|
||||
try { if (typeof fn === "function") fn(); } catch (e) {}
|
||||
}
|
||||
destroyLayoutFix();
|
||||
|
||||
if (nodeDOMObserver) {
|
||||
nodeDOMObserver.disconnect();
|
||||
nodeDOMObserver = null;
|
||||
}
|
||||
|
||||
if (activeKeydownHandler) {
|
||||
document.removeEventListener("keydown", activeKeydownHandler, true);
|
||||
activeKeydownHandler = null;
|
||||
}
|
||||
|
||||
if (app.extensionManager?.unregisterSidebarTab) {
|
||||
app.extensionManager.unregisterSidebarTab("classic-comfy-sidebar");
|
||||
}
|
||||
}
|
||||
});
|
||||
import { app } from "/scripts/app.js";
|
||||
import { SettingIds } from "./core/constants.js";
|
||||
import { injectStyles } from "./utils/styles.js";
|
||||
import { setupDragAndDrop } from "./utils/dragdrop.js";
|
||||
import { findOurSidebarButton } from "./comfy/adapter.js";
|
||||
import { syncAllNodeBadges, setupVueNodeObserver, toggleIgnoreActiveNode } from "./comfy/nodes.js";
|
||||
import { applyClassicLayout, setupPropertiesPanelToggleFix, syncClassicLayout, syncStockHistoryAndProgressSettings, destroyLayoutFix } from "./comfy/layout.js";
|
||||
import { initSessionAndHistory, syncQueue } from "./queue/queueService.js";
|
||||
import { setupExecutionTracker } from "./queue/executionTracker.js";
|
||||
import { setupSidebarView, renderSidebar, teardownSidebarView } from "./ui/sidebarView.js";
|
||||
import { updateCardProgressTargeted, cardPool } from "./ui/cardRenderer.js";
|
||||
import { store } from "./core/store.js";
|
||||
|
||||
let isInitialized = false;
|
||||
let activeKeydownHandler = null;
|
||||
let cleanupFns = [];
|
||||
|
||||
function registerSettings() {
|
||||
app.ui.settings.addSetting({
|
||||
id: SettingIds.GRID_COLUMNS_THRESHOLD,
|
||||
name: "Width Threshold for Queue Columns (px)",
|
||||
type: "number",
|
||||
defaultValue: 350
|
||||
});
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: SettingIds.SHOW_PENDING_COUNT_ONLY,
|
||||
name: "If disabled, each queued job will have a separate individual card",
|
||||
type: "boolean",
|
||||
defaultValue: true
|
||||
});
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: SettingIds.SHOW_WORKING_NODE_NAME,
|
||||
name: "Shows the name of the node which is currently in the process",
|
||||
type: "boolean",
|
||||
defaultValue: true
|
||||
});
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: SettingIds.AUTO_CLEAR_INTERRUPTED,
|
||||
name: "Auto-clear cancelled & failed jobs on new generation",
|
||||
type: "boolean",
|
||||
defaultValue: false
|
||||
});
|
||||
|
||||
const sidebarTabs = ["Assets", "Nodes", "Models", "Workflows", "Apps", "Templates"];
|
||||
sidebarTabs.forEach(tab => {
|
||||
app.ui.settings.addSetting({
|
||||
id: `Comfy Sidebar.Hide Junk.${tab}`,
|
||||
name: `Hide Tab: ${tab}`,
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
onChange: () => {
|
||||
setTimeout(() => { syncClassicLayout(); }, 0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: SettingIds.OVERRIDE_STOCK_HISTORY,
|
||||
name: "Replace the stock Job History sidebar with Comfy Queue",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
onChange: (value) => {
|
||||
if (isInitialized && app.ui?.settings) {
|
||||
syncStockHistoryAndProgressSettings(value);
|
||||
}
|
||||
setTimeout(() => { syncClassicLayout(); }, 0);
|
||||
}
|
||||
});
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: SettingIds.HIDE_GRAPH_BUTTON,
|
||||
name: "Hide floating 'Graph' (Workflow/Node Map) button",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
onChange: () => {
|
||||
setTimeout(() => { syncClassicLayout(); }, 0);
|
||||
}
|
||||
});
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: SettingIds.CLASSIC_LAYOUT,
|
||||
name: "Places the controls and the open workflow tabs on a single unified top bar",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
onChange: (value) => {
|
||||
if (isInitialized) {
|
||||
applyClassicLayout(value, true);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setupKeydownShortcuts() {
|
||||
activeKeydownHandler = (e) => {
|
||||
const activeEl = document.activeElement;
|
||||
if (activeEl && (
|
||||
activeEl.tagName === "INPUT" ||
|
||||
activeEl.tagName === "TEXTAREA" ||
|
||||
activeEl.isContentEditable ||
|
||||
activeEl.tagName === "SELECT"
|
||||
)) return;
|
||||
|
||||
if (e.key.toLowerCase() === "q" && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const ourBtn = findOurSidebarButton();
|
||||
if (ourBtn) ourBtn.click();
|
||||
}
|
||||
|
||||
if (e.key.toLowerCase() === "q" && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggleIgnoreActiveNode(() => renderSidebar());
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", activeKeydownHandler, true);
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfySidebar.ClassicRestore",
|
||||
|
||||
init() {
|
||||
registerSettings();
|
||||
},
|
||||
|
||||
commands: [
|
||||
{
|
||||
id: "ComfySidebar.ToggleSidebar",
|
||||
label: "Toggle Comfy Queue Sidebar",
|
||||
function: () => {
|
||||
const ourBtn = findOurSidebarButton();
|
||||
if (ourBtn) ourBtn.click();
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "ComfySidebar.ToggleIgnoreNode",
|
||||
label: "Toggle Ignore Selected Node(s) in Queue",
|
||||
function: () => toggleIgnoreActiveNode(() => renderSidebar())
|
||||
}
|
||||
],
|
||||
|
||||
nodeCreated(node) {
|
||||
if (node && node.properties?.ignoreInQueue) {
|
||||
node.boxcolor = "#ff3333";
|
||||
syncAllNodeBadges();
|
||||
}
|
||||
},
|
||||
|
||||
afterConfigureGraph() {
|
||||
requestAnimationFrame(() => {
|
||||
syncAllNodeBadges();
|
||||
renderSidebar();
|
||||
});
|
||||
},
|
||||
|
||||
async setup() {
|
||||
if (!app.extensionManager || !app.extensionManager.registerSidebarTab) return;
|
||||
|
||||
const isClassicLayoutEnabled = app.ui.settings.getSettingValue(SettingIds.CLASSIC_LAYOUT) ?? false;
|
||||
applyClassicLayout(isClassicLayoutEnabled, false);
|
||||
|
||||
const isHistoryOverrideEnabled = app.ui.settings.getSettingValue(SettingIds.OVERRIDE_STOCK_HISTORY) ?? false;
|
||||
if (isHistoryOverrideEnabled) {
|
||||
syncStockHistoryAndProgressSettings(true);
|
||||
}
|
||||
|
||||
isInitialized = true;
|
||||
|
||||
setupPropertiesPanelToggleFix();
|
||||
injectStyles();
|
||||
|
||||
cleanupFns.push(setupDragAndDrop());
|
||||
cleanupFns.push(setupVueNodeObserver());
|
||||
|
||||
// Setup execution tracker with targeted fast-path update for active card
|
||||
cleanupFns.push(setupExecutionTracker((pid, progress, nodeName) => {
|
||||
const cardObj = cardPool.get(pid);
|
||||
const state = store.getPrompt(pid);
|
||||
if (cardObj && state) {
|
||||
const showWorkingNode = app.ui.settings.getSettingValue(SettingIds.SHOW_WORKING_NODE_NAME) ?? true;
|
||||
updateCardProgressTargeted(cardObj, progress, nodeName, showWorkingNode, state);
|
||||
} else {
|
||||
renderSidebar();
|
||||
}
|
||||
}));
|
||||
|
||||
const sidebarContainer = setupSidebarView();
|
||||
|
||||
await initSessionAndHistory();
|
||||
setupKeydownShortcuts();
|
||||
|
||||
syncAllNodeBadges();
|
||||
|
||||
app.extensionManager.registerSidebarTab({
|
||||
id: "classic-comfy-sidebar",
|
||||
icon: "pi pi-images",
|
||||
title: "Queue",
|
||||
tooltip: "Comfy Queue (Q)",
|
||||
type: "custom",
|
||||
render: (el) => { el.appendChild(sidebarContainer); }
|
||||
});
|
||||
},
|
||||
|
||||
destroy() {
|
||||
for (const fn of cleanupFns.splice(0)) {
|
||||
try { if (typeof fn === "function") fn(); } catch (e) {}
|
||||
}
|
||||
destroyLayoutFix();
|
||||
teardownSidebarView();
|
||||
|
||||
if (activeKeydownHandler) {
|
||||
document.removeEventListener("keydown", activeKeydownHandler, true);
|
||||
activeKeydownHandler = null;
|
||||
}
|
||||
|
||||
if (app.extensionManager?.unregisterSidebarTab) {
|
||||
app.extensionManager.unregisterSidebarTab("classic-comfy-sidebar");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
-121
@@ -1,121 +0,0 @@
|
||||
export const promptStates = new Map();
|
||||
export const cardElements = new Map();
|
||||
|
||||
export const State = {
|
||||
currentSearchQuery: "",
|
||||
sequenceNumber: 0,
|
||||
sidebarContainer: null,
|
||||
cardStack: null,
|
||||
submenuStack: null,
|
||||
currentlyActivePromptId: null,
|
||||
activeSubmenuPromptId: null,
|
||||
activeSubmenuBatchImages: null
|
||||
};
|
||||
|
||||
let saveTimer = null;
|
||||
export function scheduleStateSave(delay = 250) {
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(() => {
|
||||
saveTimer = null;
|
||||
saveStatesToLocalStorage();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
export function deletePromptState(pid) {
|
||||
const key = String(pid);
|
||||
const state = promptStates.get(key);
|
||||
if (state) {
|
||||
if (state._previewBlobUrl) {
|
||||
try { URL.revokeObjectURL(state._previewBlobUrl); } catch (e) {}
|
||||
}
|
||||
if (state._oldPreviewBlobUrl) {
|
||||
try { URL.revokeObjectURL(state._oldPreviewBlobUrl); } catch (e) {}
|
||||
}
|
||||
}
|
||||
promptStates.delete(key);
|
||||
cardElements.delete(key);
|
||||
}
|
||||
|
||||
export function pruneHistory(app) {
|
||||
const maxItems = app.ui.settings.getSettingValue("Comfy.Queue.MaxHistoryItems") ?? 64;
|
||||
const tasks = Array.from(promptStates.entries())
|
||||
.filter(([pid, state]) => state.status !== "pending" && state.status !== "active");
|
||||
|
||||
tasks.sort((a, b) => (a[1].timestamp || 0) - (b[1].timestamp || 0));
|
||||
|
||||
if (tasks.length > maxItems) {
|
||||
const deleteCount = tasks.length - maxItems;
|
||||
for (let i = 0; i < deleteCount; i++) {
|
||||
const [pid] = tasks[i];
|
||||
deletePromptState(pid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function saveStatesToLocalStorage() {
|
||||
try {
|
||||
let serializable = [];
|
||||
for (const [pid, state] of promptStates.entries()) {
|
||||
const cleanedImages = (state.images || []).map(img => {
|
||||
if (img.url && img.url.startsWith("blob:")) return null;
|
||||
return img;
|
||||
}).filter(Boolean);
|
||||
|
||||
const includeWorkflow = cleanedImages.length === 0 || state.status !== "completed";
|
||||
|
||||
serializable.push({
|
||||
pid: String(state.pid),
|
||||
status: state.status,
|
||||
images: cleanedImages,
|
||||
texts: state.texts || [],
|
||||
nodeOutputs: state.nodeOutputs,
|
||||
workflow: includeWorkflow ? state.workflow : null,
|
||||
progress: state.progress || 0,
|
||||
queueNumber: state.queueNumber,
|
||||
progressText: state.progressText || "",
|
||||
timestamp: state.timestamp,
|
||||
activeNodeName: state.activeNodeName || "",
|
||||
rendered: state.rendered || false,
|
||||
startTime: state.startTime,
|
||||
endTime: state.endTime,
|
||||
duration: state.duration
|
||||
});
|
||||
}
|
||||
|
||||
if (serializable.length === 0) {
|
||||
localStorage.removeItem("comfy_sidebar_prompt_states");
|
||||
return;
|
||||
}
|
||||
|
||||
serializable.sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
while (serializable.length > 0) {
|
||||
try {
|
||||
localStorage.setItem("comfy_sidebar_prompt_states", JSON.stringify(serializable));
|
||||
break;
|
||||
} catch (e) {
|
||||
if (e.name === "QuotaExceededError" || e.code === 22 || e.code === 1014) {
|
||||
serializable.shift();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
export function loadStatesFromLocalStorage() {
|
||||
try {
|
||||
const data = localStorage.getItem("comfy_sidebar_prompt_states");
|
||||
if (data) {
|
||||
const list = JSON.parse(data);
|
||||
list.forEach(state => {
|
||||
const key = String(state.pid);
|
||||
promptStates.set(key, state);
|
||||
if (state.timestamp > State.sequenceNumber) State.sequenceNumber = state.timestamp;
|
||||
});
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
loadStatesFromLocalStorage();
|
||||
@@ -0,0 +1,34 @@
|
||||
let currentlyPlayingAudio = null;
|
||||
let activeComparisonViewer = null;
|
||||
|
||||
export function stopAllAudioPlayback() {
|
||||
if (currentlyPlayingAudio) {
|
||||
currentlyPlayingAudio.pause();
|
||||
currentlyPlayingAudio.currentTime = 0;
|
||||
if (typeof currentlyPlayingAudio._onResetUI === "function") {
|
||||
currentlyPlayingAudio._onResetUI();
|
||||
}
|
||||
currentlyPlayingAudio = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setCurrentlyPlayingAudio(audio) {
|
||||
stopAllAudioPlayback();
|
||||
currentlyPlayingAudio = audio;
|
||||
}
|
||||
|
||||
export function getCurrentlyPlayingAudio() {
|
||||
return currentlyPlayingAudio;
|
||||
}
|
||||
|
||||
export function setActiveComparisonViewer(viewer) {
|
||||
activeComparisonViewer = viewer;
|
||||
}
|
||||
|
||||
export function getActiveComparisonViewer() {
|
||||
return activeComparisonViewer;
|
||||
}
|
||||
|
||||
export function isAudioViewerOpen() {
|
||||
return !!(activeComparisonViewer && activeComparisonViewer.isAudio);
|
||||
}
|
||||
@@ -0,0 +1,593 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { PromptStatus } from "../core/constants.js";
|
||||
import { store } from "../core/store.js";
|
||||
import { renderCardImages } from "./mediaPreview.js";
|
||||
import { copyImageToClipboard, openFileOrFolder, deleteFileOnServer, findNodeIdForImage, removeImageFromNodeOutputs } from "./mediaActions.js";
|
||||
import { centerAndSelectCanvasNode } from "../comfy/adapter.js";
|
||||
import { getRunOutputs, extractWorkflowFromPng, isImageFormat } from "../utils/utils.js";
|
||||
import { showFullscreenPreview } from "../utils/comparison.js";
|
||||
import { stopAllAudioPlayback } from "./audioController.js";
|
||||
import { deleteHistoryItem, cancelPendingTask, interruptActive } from "../queue/queueService.js";
|
||||
|
||||
export const cardPool = new Map();
|
||||
|
||||
export function resetAllCardHoverStates() {
|
||||
for (const cardObj of cardPool.values()) {
|
||||
if (cardObj.hoverPanel) cardObj.hoverPanel.style.display = "none";
|
||||
if (cardObj.leftHoverPanel) cardObj.leftHoverPanel.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
export function syncCardButtonVisibility(cardObj, state) {
|
||||
if (!cardObj) return;
|
||||
|
||||
const isPendingOrActive = state.status === PromptStatus.PENDING || state.status === PromptStatus.ACTIVE;
|
||||
if (isPendingOrActive) {
|
||||
if (cardObj.hoverPanel) cardObj.hoverPanel.style.setProperty("display", "none", "important");
|
||||
if (cardObj.leftHoverPanel) cardObj.leftHoverPanel.style.setProperty("display", "none", "important");
|
||||
return;
|
||||
} else {
|
||||
if (cardObj.hoverPanel) cardObj.hoverPanel.style.removeProperty("display");
|
||||
if (cardObj.leftHoverPanel) cardObj.leftHoverPanel.style.removeProperty("display");
|
||||
}
|
||||
|
||||
// Check if the card has an actual verified file saved on disk
|
||||
const hasRealDiskFiles = state.images && state.images.length > 0 &&
|
||||
state.images.some(img => img.filename && !img.isFallback && (!img.url || !img.url.startsWith("blob:")));
|
||||
|
||||
const hasRealImages = hasRealDiskFiles && !state.images.some(img => img.url && img.url.startsWith("blob:"));
|
||||
const currentImg = hasRealImages ? state.images[cardObj.currentImageIndex || 0] : null;
|
||||
const isPng = currentImg && (currentImg.filename || "").toLowerCase().endsWith(".png");
|
||||
|
||||
if (cardObj.btnImg) {
|
||||
if (hasRealImages) {
|
||||
cardObj.btnImg.style.removeProperty("display");
|
||||
cardObj.btnImg.style.display = "inline-flex";
|
||||
cardObj.btnImg.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
state.images.forEach(img => {
|
||||
const a = document.createElement("a");
|
||||
a.href = img.url ? img.url : `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`;
|
||||
a.download = img.filename || "output";
|
||||
a.click();
|
||||
});
|
||||
};
|
||||
} else {
|
||||
cardObj.btnImg.style.setProperty("display", "none", "important");
|
||||
}
|
||||
}
|
||||
|
||||
if (cardObj.btnCopy) {
|
||||
const isCopyableImage = hasRealImages && currentImg && isImageFormat(currentImg.filename || currentImg.url);
|
||||
if (isCopyableImage) {
|
||||
cardObj.btnCopy.style.removeProperty("display");
|
||||
cardObj.btnCopy.style.display = "inline-flex";
|
||||
cardObj.btnCopy.onclick = async (ev) => {
|
||||
ev.stopPropagation();
|
||||
const src = currentImg.url ? currentImg.url : `/view?filename=${encodeURIComponent(currentImg.filename)}&type=${currentImg.type || 'output'}&subfolder=${encodeURIComponent(currentImg.subfolder || '')}`;
|
||||
const success = await copyImageToClipboard(src);
|
||||
if (success) {
|
||||
cardObj.btnCopy.className = "pi pi-check comfy-sidebar-card-action-btn";
|
||||
cardObj.btnCopy.style.color = "#4ade80";
|
||||
setTimeout(() => {
|
||||
cardObj.btnCopy.className = "pi pi-copy comfy-sidebar-card-action-btn";
|
||||
cardObj.btnCopy.style.color = "";
|
||||
}, 1500);
|
||||
}
|
||||
};
|
||||
} else {
|
||||
cardObj.btnCopy.style.setProperty("display", "none", "important");
|
||||
}
|
||||
}
|
||||
|
||||
if (cardObj.btnJson) {
|
||||
if (state.workflow || isPng) {
|
||||
cardObj.btnJson.style.removeProperty("display");
|
||||
cardObj.btnJson.style.display = "inline-flex";
|
||||
cardObj.btnJson.onclick = async (ev) => {
|
||||
ev.stopPropagation();
|
||||
let wf = state.workflow;
|
||||
if (!wf && currentImg) {
|
||||
const src = currentImg.url ? currentImg.url : `/view?filename=${encodeURIComponent(currentImg.filename)}&type=${currentImg.type || 'output'}&subfolder=${encodeURIComponent(currentImg.subfolder || '')}`;
|
||||
wf = await extractWorkflowFromPng(src);
|
||||
if (wf) state.workflow = wf;
|
||||
}
|
||||
if (!wf) {
|
||||
alert("No workflow metadata found in this file.");
|
||||
return;
|
||||
}
|
||||
const blob = new Blob([JSON.stringify(wf, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `workflow_${state.pid || "comfy"}.json`;
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1500);
|
||||
};
|
||||
} else {
|
||||
cardObj.btnJson.style.setProperty("display", "none", "important");
|
||||
}
|
||||
}
|
||||
|
||||
if (cardObj.btnDel) {
|
||||
// Always show the delete button so cards can be cleared from history
|
||||
cardObj.btnDel.style.removeProperty("display");
|
||||
cardObj.btnDel.style.display = "inline-flex";
|
||||
|
||||
cardObj.canDeleteFromDisk = hasRealDiskFiles;
|
||||
if (cardObj.btnDelLabel) {
|
||||
cardObj.btnDelLabel.style.display = hasRealDiskFiles ? "" : "none";
|
||||
}
|
||||
cardObj.btnDel.title = hasRealDiskFiles
|
||||
? "Delete Card (Hold Ctrl to delete file from disk)"
|
||||
: "Delete Card";
|
||||
}
|
||||
|
||||
if (cardObj.btnFocus) {
|
||||
// Focus the image's node, or the node where the workflow stopped/errored
|
||||
const nodeId = (currentImg ? findNodeIdForImage(state, currentImg) : null) || state.activeNodeId;
|
||||
if (nodeId) {
|
||||
cardObj.btnFocus.style.removeProperty("display");
|
||||
cardObj.btnFocus.style.display = "inline-flex";
|
||||
cardObj.btnFocus.title = (!currentImg && state.activeNodeId) ? "Show Stopped/Failed Node" : "Show Node";
|
||||
cardObj.btnFocus.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
centerAndSelectCanvasNode(nodeId);
|
||||
};
|
||||
} else {
|
||||
cardObj.btnFocus.style.setProperty("display", "none", "important");
|
||||
}
|
||||
}
|
||||
|
||||
if (cardObj.leftHoverBtn) {
|
||||
const outputs = getRunOutputs(state.nodeOutputs, state.workflow);
|
||||
const validOutputs = outputs.filter(o => o.images && o.images.length > 0);
|
||||
|
||||
const primarySignatures = new Set((state.images || []).map(i => `${i.subfolder || ""}/${i.filename}`));
|
||||
const distinctOutputs = [];
|
||||
|
||||
for (const out of validOutputs) {
|
||||
const hasNewImage = out.images.some(img => !primarySignatures.has(`${img.subfolder || ""}/${img.filename}`));
|
||||
if (hasNewImage) {
|
||||
distinctOutputs.push(out);
|
||||
}
|
||||
}
|
||||
|
||||
// Show intermediate button if run completed with multiple outputs, or if stopped with saved outputs
|
||||
const hasIntermediates = (validOutputs.length > 1 && distinctOutputs.length > 0) ||
|
||||
(state.status !== PromptStatus.COMPLETED && validOutputs.length > 0);
|
||||
|
||||
if (hasIntermediates) {
|
||||
// Check if intermediate files still exist on disk
|
||||
for (const out of distinctOutputs) {
|
||||
for (const img of out.images) {
|
||||
const src = img.url ? img.url : `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`;
|
||||
if (!src.startsWith("blob:")) {
|
||||
fetch(src, { method: "HEAD" }).then(res => {
|
||||
if (res.status === 404) {
|
||||
const liveState = store.getPrompt(state.pid);
|
||||
if (liveState && liveState.nodeOutputs) {
|
||||
removeImageFromNodeOutputs(liveState.nodeOutputs, img);
|
||||
store.updatePrompt(liveState.pid, liveState);
|
||||
syncCardButtonVisibility(cardObj, liveState);
|
||||
}
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cardObj.leftHoverBtn.style.removeProperty("display");
|
||||
cardObj.leftHoverBtn.style.display = "inline-flex";
|
||||
|
||||
cardObj.leftHoverBtn.onclick = async (ev) => {
|
||||
ev.stopPropagation();
|
||||
|
||||
// On click: verify distinct outputs still exist before opening
|
||||
const liveState = store.getPrompt(state.pid) || state;
|
||||
const curOutputs = getRunOutputs(liveState.nodeOutputs, liveState.workflow);
|
||||
const primSigs = new Set((liveState.images || []).map(i => `${i.subfolder || ""}/${i.filename}`));
|
||||
const curDistinct = curOutputs.filter(o => o.images && o.images.some(img => !primSigs.has(`${img.subfolder || ""}/${img.filename}`)));
|
||||
|
||||
if (curDistinct.length === 0) {
|
||||
cardObj.leftHoverBtn.style.setProperty("display", "none", "important");
|
||||
return;
|
||||
}
|
||||
|
||||
let anyMissing = false;
|
||||
for (const out of curDistinct) {
|
||||
for (const img of out.images) {
|
||||
const src = img.url ? img.url : `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`;
|
||||
if (!src.startsWith("blob:")) {
|
||||
try {
|
||||
const res = await fetch(src, { method: "HEAD" });
|
||||
if (res.status === 404) {
|
||||
removeImageFromNodeOutputs(liveState.nodeOutputs, img);
|
||||
anyMissing = true;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (anyMissing) {
|
||||
store.updatePrompt(liveState.pid, liveState);
|
||||
syncCardButtonVisibility(cardObj, liveState);
|
||||
const remaining = getRunOutputs(liveState.nodeOutputs, liveState.workflow)
|
||||
.filter(o => o.images && o.images.some(img => !primSigs.has(`${img.subfolder || ""}/${img.filename}`)));
|
||||
if (remaining.length === 0) {
|
||||
return; // All intermediate outputs were wiped; button is now hidden
|
||||
}
|
||||
}
|
||||
|
||||
const scrollEl = document.querySelector('.sidebar-content-container, [class*="sidebar-content-container"], [class*="overflow-y-auto"]')
|
||||
|| store.ui.cardStack?.parentElement
|
||||
|| store.ui.cardStack;
|
||||
if (scrollEl) {
|
||||
store.ui.mainQueueScrollTop = scrollEl.scrollTop;
|
||||
}
|
||||
store.openOutputsSubmenu(liveState.pid);
|
||||
};
|
||||
} else {
|
||||
cardObj.leftHoverBtn.style.setProperty("display", "none", "important");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function updateCardProgressTargeted(cardObj, progress, activeNodeName, showWorkingNode = true, state = null) {
|
||||
if (!cardObj) return;
|
||||
if (cardObj.progressBar) {
|
||||
cardObj.progressBar.style.width = `${progress}%`;
|
||||
}
|
||||
if (cardObj.statusText && showWorkingNode) {
|
||||
cardObj.statusText.style.display = "block";
|
||||
const nodeText = activeNodeName ? (activeNodeName === "Finishing..." ? "Finishing..." : `[${activeNodeName}]`) : "Processing...";
|
||||
cardObj.statusText.textContent = `${nodeText} ${progress}%`;
|
||||
}
|
||||
|
||||
// Fast-path: update live preview image directly on active card
|
||||
if (state && state.images && state.images.length > 0) {
|
||||
const firstImg = state.images[0];
|
||||
const previewUrl = firstImg.url || (firstImg.filename ? `/view?filename=${encodeURIComponent(firstImg.filename)}&type=${firstImg.type || 'output'}&subfolder=${encodeURIComponent(firstImg.subfolder || '')}` : null);
|
||||
if (previewUrl && cardObj.firstImgElement && cardObj.firstImgElement.tagName === "IMG") {
|
||||
const currentSrc = cardObj.firstImgElement.getAttribute("src") || cardObj.firstImgElement.src;
|
||||
if (currentSrc !== previewUrl && !cardObj.firstImgElement.src.endsWith(previewUrl)) {
|
||||
cardObj.firstImgElement.src = previewUrl;
|
||||
}
|
||||
} else {
|
||||
updateCardDOM(cardObj, state, true, showWorkingNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getOrCreateCard(state, callbacks = {}) {
|
||||
let cardObj = cardPool.get(state.pid);
|
||||
if (cardObj) return cardObj;
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.id = `card-${state.pid}`;
|
||||
card.className = `comfy-sidebar-card ${state.status || "completed"}`;
|
||||
card.style.position = "relative";
|
||||
|
||||
const timerEl = document.createElement("div");
|
||||
timerEl.className = "comfy-sidebar-card-timer";
|
||||
|
||||
const cancelX = document.createElement("span");
|
||||
cancelX.className = "pi pi-times comfy-sidebar-queue-cancel-btn";
|
||||
Object.assign(cancelX.style, {
|
||||
position: "absolute", top: "4px", right: "4px", display: "none", zIndex: "10"
|
||||
});
|
||||
|
||||
const sBadge = document.createElement("div");
|
||||
Object.assign(sBadge.style, {
|
||||
position: "absolute", top: "6px", right: "8px", fontSize: "9px", fontWeight: "bold",
|
||||
padding: "2px 6px", borderRadius: "2px", textTransform: "uppercase", display: "none",
|
||||
pointerEvents: "none", zIndex: "10"
|
||||
});
|
||||
|
||||
const dimEl = document.createElement("div");
|
||||
Object.assign(dimEl.style, {
|
||||
position: "absolute", top: "6px", right: "8px", fontSize: "10px",
|
||||
fontFamily: "monospace", opacity: "0.7", background: "rgba(0, 0, 0, 0.6)",
|
||||
padding: "2px 4px", borderRadius: "3px", pointerEvents: "none", zIndex: "5", color: "#fff",
|
||||
display: "none", transform: "translateZ(0)"
|
||||
});
|
||||
|
||||
const grid = document.createElement("div");
|
||||
grid.style.display = "flex";
|
||||
grid.style.flexDirection = "column";
|
||||
grid.style.gap = "6px";
|
||||
|
||||
const p = document.createElement("div");
|
||||
p.className = "comfy-sidebar-text-clamp";
|
||||
|
||||
const pt = document.createElement("div");
|
||||
Object.assign(pt.style, {
|
||||
width: "100%", height: "4px", background: "#333", borderRadius: "2px", marginTop: "8px",
|
||||
overflow: "hidden", display: "none"
|
||||
});
|
||||
const pb = document.createElement("div");
|
||||
Object.assign(pb.style, {
|
||||
width: "0%", height: "100%", background: "#3b82f6", transition: "width 0.1s linear"
|
||||
});
|
||||
pt.appendChild(pb);
|
||||
|
||||
const statusText = document.createElement("div");
|
||||
Object.assign(statusText.style, {
|
||||
fontSize: "11px", opacity: "0.9", color: "#3b82f6", textAlign: "center", marginTop: "6px",
|
||||
display: "none", fontWeight: "bold"
|
||||
});
|
||||
|
||||
const hoverPanel = document.createElement("div");
|
||||
hoverPanel.className = "comfy-sidebar-hover-panel";
|
||||
Object.assign(hoverPanel.style, {
|
||||
position: "absolute", bottom: "4px", right: "4px", flexDirection: "column", gap: "4px", zIndex: "20"
|
||||
});
|
||||
|
||||
const btnImg = document.createElement("span");
|
||||
btnImg.className = "pi pi-image comfy-sidebar-card-action-btn";
|
||||
btnImg.title = "Download Object";
|
||||
|
||||
const btnCopy = document.createElement("span");
|
||||
btnCopy.className = "pi pi-copy comfy-sidebar-card-action-btn";
|
||||
btnCopy.title = "Copy to Clipboard";
|
||||
btnCopy.style.display = "none";
|
||||
|
||||
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 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";
|
||||
Object.assign(leftHoverPanel.style, {
|
||||
position: "absolute", bottom: "4px", left: "4px", flexDirection: "column", gap: "4px", zIndex: "20"
|
||||
});
|
||||
|
||||
const btnFocus = document.createElement("span");
|
||||
btnFocus.className = "pi pi-eye comfy-sidebar-card-action-btn";
|
||||
btnFocus.title = "Show Node";
|
||||
|
||||
const leftHoverBtn = document.createElement("span");
|
||||
leftHoverBtn.className = "pi pi-images comfy-sidebar-card-action-btn";
|
||||
leftHoverBtn.title = "View all intermediate outputs";
|
||||
|
||||
hoverPanel.append(btnImg, btnJson, btnDel);
|
||||
leftHoverPanel.append(btnCopy, btnFocus, leftHoverBtn);
|
||||
|
||||
card.append(timerEl, cancelX, sBadge, dimEl, grid, p, pt, statusText, hoverPanel, leftHoverPanel);
|
||||
|
||||
cardObj = {
|
||||
element: card,
|
||||
timerEl,
|
||||
statusBadge: sBadge,
|
||||
dimEl,
|
||||
grid,
|
||||
placeholder: p,
|
||||
progressContainer: pt,
|
||||
progressBar: pb,
|
||||
cancelBtn: cancelX,
|
||||
hoverPanel,
|
||||
leftHoverPanel,
|
||||
btnFocus,
|
||||
leftHoverBtn,
|
||||
btnImg,
|
||||
btnCopy,
|
||||
btnJson,
|
||||
btnDel,
|
||||
btnDelLabel,
|
||||
canDeleteFromDisk: true,
|
||||
statusText,
|
||||
firstImgElement: null,
|
||||
lastImagesSignature: "",
|
||||
currentImageIndex: 0
|
||||
};
|
||||
|
||||
card.addEventListener("mouseenter", () => {
|
||||
// Only run automatic button visibility sync on main queue cards (avoid synthetic submenu IDs)
|
||||
if (store.hasPrompt(state.pid)) {
|
||||
const liveState = store.getPrompt(state.pid);
|
||||
syncCardButtonVisibility(cardObj, liveState);
|
||||
}
|
||||
});
|
||||
|
||||
card.addEventListener("dragstart", (e) => {
|
||||
const liveState = store.getPrompt(state.pid) || state;
|
||||
const isUnfinished = liveState.status && liveState.status !== PromptStatus.COMPLETED;
|
||||
const hasNoImages = !liveState.images || liveState.images.length === 0;
|
||||
|
||||
if (liveState.workflow && (hasNoImages || isUnfinished)) {
|
||||
if (cardObj.firstImgElement) {
|
||||
try { e.dataTransfer.setDragImage(cardObj.firstImgElement, 15, 15); } catch(err){}
|
||||
}
|
||||
const jsonStr = JSON.stringify(liveState.workflow, null, 2);
|
||||
const filename = `workflow_${liveState.pid}.json`;
|
||||
const base64Data = btoa(unescape(encodeURIComponent(jsonStr)));
|
||||
|
||||
try {
|
||||
e.dataTransfer.setData("DownloadURL", `application/json:${filename}:data:application/json;base64,${base64Data}`);
|
||||
e.dataTransfer.setData("application/json", jsonStr);
|
||||
} catch (err) {}
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
}
|
||||
});
|
||||
|
||||
let deleteTimeout = null, isDeletePending = false, isDiskDelete = false;
|
||||
const resetDeleteBtn = () => {
|
||||
isDeletePending = false;
|
||||
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; }
|
||||
};
|
||||
|
||||
btnDel.onclick = async (ev) => {
|
||||
ev.stopPropagation();
|
||||
const wantsDiskDelete = (ev.ctrlKey || ev.metaKey) && cardObj.canDeleteFromDisk !== false;
|
||||
|
||||
if (!isDeletePending) {
|
||||
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();
|
||||
|
||||
const liveState = store.getPrompt(state.pid) || state;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
(liveState.images || []).forEach(addFile);
|
||||
const allOutputs = getRunOutputs(liveState.nodeOutputs, liveState.workflow);
|
||||
allOutputs.forEach(out => (out.images || []).forEach(addFile));
|
||||
|
||||
for (const fileItem of filesToDelete) {
|
||||
await deleteFileOnServer(fileItem);
|
||||
}
|
||||
}
|
||||
|
||||
deleteHistoryItem(state.pid);
|
||||
}
|
||||
};
|
||||
|
||||
cardPool.set(state.pid, cardObj);
|
||||
return cardObj;
|
||||
}
|
||||
|
||||
export function updateCardDOM(cardObj, state, showPendingSummary = true, showWorkingNode = true) {
|
||||
cardObj.element.className = `comfy-sidebar-card ${state.status}`;
|
||||
cardObj.element.style.position = "relative";
|
||||
|
||||
const isUnfinished = state.status && state.status !== PromptStatus.COMPLETED;
|
||||
if (state.images && state.images.length > 0 && !isUnfinished) {
|
||||
cardObj.element.removeAttribute("draggable");
|
||||
} else {
|
||||
cardObj.element.setAttribute("draggable", "true");
|
||||
}
|
||||
|
||||
if (state.status === PromptStatus.ACTIVE) {
|
||||
cardObj.timerEl.textContent = state.startTime ? ((Date.now() - state.startTime) / 1000).toFixed(2) + "s" : "...";
|
||||
cardObj.timerEl.style.display = "block";
|
||||
} else if (state.duration !== undefined && state.duration !== null) {
|
||||
cardObj.timerEl.textContent = state.duration.toFixed(2) + "s";
|
||||
cardObj.timerEl.style.display = "block";
|
||||
} else {
|
||||
cardObj.timerEl.style.display = "none";
|
||||
}
|
||||
|
||||
if (state.status === PromptStatus.CANCELLED) {
|
||||
Object.assign(cardObj.statusBadge.style, { display: "block", background: "#ffc107", color: "#000" });
|
||||
cardObj.statusBadge.textContent = "Cancelled";
|
||||
} else if (state.status === PromptStatus.ERROR) {
|
||||
Object.assign(cardObj.statusBadge.style, { display: "block", background: "#dc3545", color: "#fff" });
|
||||
cardObj.statusBadge.textContent = "Error";
|
||||
} else {
|
||||
cardObj.statusBadge.style.display = "none";
|
||||
}
|
||||
|
||||
if (state.status === PromptStatus.PENDING && !showPendingSummary) {
|
||||
cardObj.cancelBtn.style.display = "flex";
|
||||
cardObj.cancelBtn.onclick = async (ev) => {
|
||||
ev.stopPropagation();
|
||||
cancelPendingTask(state.pid);
|
||||
};
|
||||
} else if (state.status === PromptStatus.ACTIVE) {
|
||||
cardObj.cancelBtn.style.display = "flex";
|
||||
cardObj.cancelBtn.onclick = async (ev) => {
|
||||
ev.stopPropagation();
|
||||
interruptActive();
|
||||
};
|
||||
} else {
|
||||
cardObj.cancelBtn.style.display = "none";
|
||||
}
|
||||
|
||||
const currentImagesSignature = `${state.status || ""}:${state.images ? state.images.map(img => img.url || img.filename).join("|") : ""}`;
|
||||
if (cardObj.lastImagesSignature !== currentImagesSignature) {
|
||||
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, (st) => {
|
||||
const scrollEl = document.querySelector('.sidebar-content-container, [class*="sidebar-content-container"], [class*="overflow-y-auto"]')
|
||||
|| store.ui.cardStack?.parentElement
|
||||
|| store.ui.cardStack;
|
||||
if (scrollEl) {
|
||||
store.ui.mainQueueScrollTop = scrollEl.scrollTop;
|
||||
}
|
||||
store.openBatchSubmenu({
|
||||
pid: st.pid,
|
||||
images: st.images,
|
||||
workflow: st.workflow,
|
||||
nodeOutputs: st.nodeOutputs
|
||||
});
|
||||
});
|
||||
}
|
||||
cardObj.lastImagesSignature = currentImagesSignature;
|
||||
}
|
||||
|
||||
if (state.images.length === 0) {
|
||||
if (state.texts && state.texts.length > 0) {
|
||||
const fullText = state.texts.join("\n\n");
|
||||
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();
|
||||
stopAllAudioPlayback();
|
||||
showFullscreenPreview([{ text: fullText, pid: state.pid }]);
|
||||
};
|
||||
} else {
|
||||
cardObj.placeholder.textContent = state.progressText || "No Outputs";
|
||||
cardObj.placeholder.className = "";
|
||||
Object.assign(cardObj.placeholder.style, {
|
||||
fontSize: "11px", opacity: "0.5", textAlign: "center", padding: "12px", marginTop: "12px",
|
||||
userSelect: "none", whiteSpace: "normal", maxHeight: "none", display: "block", cursor: "default"
|
||||
});
|
||||
cardObj.placeholder.onclick = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (state.status === PromptStatus.ACTIVE) {
|
||||
if (showWorkingNode) {
|
||||
cardObj.statusText.style.display = "block";
|
||||
const nodeText = state.activeNodeName ? (state.activeNodeName === "Finishing..." ? "Finishing..." : `[${state.activeNodeName}]`) : "Processing...";
|
||||
cardObj.statusText.textContent = `${nodeText}${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";
|
||||
}
|
||||
|
||||
syncCardButtonVisibility(cardObj, state);
|
||||
return cardObj.element;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
export async function copyImageToClipboard(src) {
|
||||
try {
|
||||
const res = await fetch(src);
|
||||
let blob = await res.blob();
|
||||
if (blob.type !== "image/png") {
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
await new Promise((resolve, reject) => {
|
||||
img.onload = resolve;
|
||||
img.onerror = reject;
|
||||
img.src = src;
|
||||
});
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = img.naturalWidth || img.width;
|
||||
canvas.height = img.naturalHeight || img.height;
|
||||
canvas.getContext("2d").drawImage(img, 0, 0);
|
||||
blob = await new Promise(resolve => canvas.toBlob(resolve, "image/png"));
|
||||
}
|
||||
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("Comfy Sidebar: Failed to copy image to clipboard", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function openFileOrFolder(img) {
|
||||
if (!img) return;
|
||||
|
||||
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) {}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
export async function deleteFileOnServer(fileItem) {
|
||||
if (!fileItem || !fileItem.filename) return false;
|
||||
try {
|
||||
const res = 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"
|
||||
})
|
||||
});
|
||||
return res.ok;
|
||||
} catch (e) {
|
||||
console.error("Comfy Sidebar: Failed to delete file on server", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export 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]);
|
||||
}
|
||||
}
|
||||
|
||||
export function findNodeIdForImage(state, img) {
|
||||
if (!state || !state.nodeOutputs || !img) return null;
|
||||
for (const nodeId in state.nodeOutputs) {
|
||||
const out = state.nodeOutputs[nodeId];
|
||||
for (const key in out) {
|
||||
const val = out[key];
|
||||
if (Array.isArray(val)) {
|
||||
if (val.some(i => i && typeof i === "object" && i.filename === img.filename)) {
|
||||
return nodeId;
|
||||
}
|
||||
} else if (val && typeof val === "object" && val.filename === img.filename) {
|
||||
return nodeId;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,712 @@
|
||||
import { isImageFormat, isVideoFormat, is3DFormat, isAudioFormat, getFilenameFromUrl } from "../utils/utils.js";
|
||||
import { openFileOrFolder, removeImageFromNodeOutputs } from "./mediaActions.js";
|
||||
import { stopAllAudioPlayback, isAudioViewerOpen, setCurrentlyPlayingAudio, getCurrentlyPlayingAudio } from "./audioController.js";
|
||||
import { showFullscreenPreview } from "../utils/comparison.js";
|
||||
import { store } from "../core/store.js";
|
||||
import { PromptStatus } from "../core/constants.js";
|
||||
|
||||
export 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 => 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 || '')}`)
|
||||
: null;
|
||||
|
||||
if (!preview3D) {
|
||||
wrapper.innerHTML = "";
|
||||
preview3D = document.createElement("div");
|
||||
preview3D.className = "comfy-sidebar-3d-wrapper";
|
||||
|
||||
if (bgImgSrc) {
|
||||
const bgImg = document.createElement("img");
|
||||
bgImg.className = "comfy-sidebar-3d-preview-img";
|
||||
bgImg.src = bgImgSrc;
|
||||
bgImg.alt = "3D Thumbnail Preview";
|
||||
preview3D.appendChild(bgImg);
|
||||
}
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "comfy-sidebar-3d-overlay";
|
||||
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "comfy-sidebar-3d-badge";
|
||||
const ext = (img.filename || src).split('.').pop().toUpperCase();
|
||||
badge.textContent = ext || "3D";
|
||||
|
||||
const icon = document.createElement("span");
|
||||
icon.className = "pi pi-box comfy-sidebar-3d-icon";
|
||||
|
||||
const title = document.createElement("span");
|
||||
title.className = "comfy-sidebar-3d-title";
|
||||
title.textContent = img.filename || "3D Model";
|
||||
|
||||
overlay.append(badge, icon, title);
|
||||
preview3D.appendChild(overlay);
|
||||
wrapper.appendChild(preview3D);
|
||||
} else {
|
||||
const bgImg = preview3D.querySelector(".comfy-sidebar-3d-preview-img");
|
||||
if (bgImg && bgImgSrc && bgImg.src !== bgImgSrc) {
|
||||
bgImg.src = bgImgSrc;
|
||||
} else if (!bgImg && bgImgSrc) {
|
||||
const newBgImg = document.createElement("img");
|
||||
newBgImg.className = "comfy-sidebar-3d-preview-img";
|
||||
newBgImg.src = bgImgSrc;
|
||||
newBgImg.alt = "3D Thumbnail Preview";
|
||||
preview3D.insertBefore(newBgImg, preview3D.firstChild);
|
||||
}
|
||||
const title = preview3D.querySelector(".comfy-sidebar-3d-title");
|
||||
if (title) title.textContent = img.filename || "3D Model";
|
||||
}
|
||||
|
||||
cardObj.firstImgElement = preview3D;
|
||||
|
||||
preview3D.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
if (ev.ctrlKey || ev.metaKey) {
|
||||
openFileOrFolder(img);
|
||||
return;
|
||||
}
|
||||
showFullscreenPreview([fullUrl], ev.shiftKey);
|
||||
};
|
||||
|
||||
preview3D.setAttribute("draggable", "true");
|
||||
preview3D.ondragstart = (e) => {
|
||||
e.stopPropagation();
|
||||
const filename = img.filename || "model.glb";
|
||||
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";
|
||||
};
|
||||
}
|
||||
|
||||
export function renderAudioCardPreview(cardObj, wrapper, src, img, state) {
|
||||
let previewAudio = wrapper.querySelector(".comfy-sidebar-audio-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) || "audio.wav";
|
||||
const ext = filename.split('.').pop().toUpperCase();
|
||||
|
||||
const playIconSvg = `<svg viewBox="0 0 24 24" width="14" height="14" style="margin-left: 2px; pointer-events: none;"><polygon points="6,4 20,12 6,20" fill="#ffffff"/></svg>`;
|
||||
const stopIconSvg = `<svg viewBox="0 0 24 24" width="12" height="12" style="pointer-events: none;"><rect x="5" y="5" width="14" height="14" rx="2" fill="#ffffff"/></svg>`;
|
||||
|
||||
if (!previewAudio) {
|
||||
wrapper.innerHTML = "";
|
||||
previewAudio = document.createElement("div");
|
||||
previewAudio.className = "comfy-sidebar-audio-wrapper";
|
||||
|
||||
const topRow = document.createElement("div");
|
||||
Object.assign(topRow.style, { display: "flex", alignItems: "center", justifyContent: "space-between", width: "100%", paddingLeft: "42px" });
|
||||
|
||||
const title = document.createElement("span");
|
||||
title.className = "comfy-sidebar-audio-title";
|
||||
title.textContent = filename;
|
||||
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "comfy-sidebar-audio-badge";
|
||||
badge.textContent = ext || "AUDIO";
|
||||
|
||||
topRow.append(title, badge);
|
||||
|
||||
const centerArea = document.createElement("div");
|
||||
Object.assign(centerArea.style, { position: "relative", width: "100%", display: "flex", alignItems: "center", justifyContent: "center", margin: "6px 0" });
|
||||
|
||||
const soundwave = document.createElement("div");
|
||||
soundwave.className = "comfy-sidebar-soundwave-container";
|
||||
soundwave.style.width = "100%";
|
||||
const barHeights = [10, 16, 24, 18, 30, 22, 14, 28, 34, 20, 12, 26, 32, 18, 14, 22, 30, 16, 12];
|
||||
barHeights.forEach((h, i) => {
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "comfy-sidebar-soundwave-bar";
|
||||
bar.style.height = `${h}px`;
|
||||
bar.style.animationDelay = `${(i * 0.08).toFixed(2)}s`;
|
||||
soundwave.appendChild(bar);
|
||||
});
|
||||
|
||||
const playBtn = document.createElement("button");
|
||||
Object.assign(playBtn.style, {
|
||||
position: "absolute", width: "36px", height: "36px", borderRadius: "50%",
|
||||
background: "rgba(22, 22, 30, 0.88)", border: "1px solid rgba(255, 255, 255, 0.25)",
|
||||
color: "#ffffff", cursor: "pointer", display: "flex", alignItems: "center",
|
||||
justifyContent: "center", boxShadow: "0 4px 12px rgba(0, 0, 0, 0.65)", zIndex: "5",
|
||||
transition: "all 0.15s ease", outline: "none"
|
||||
});
|
||||
playBtn.innerHTML = playIconSvg;
|
||||
|
||||
centerArea.append(soundwave, playBtn);
|
||||
|
||||
const bottomRow = document.createElement("div");
|
||||
Object.assign(bottomRow.style, { display: "flex", flexDirection: "column", gap: "4px", width: "100%", padding: "0 34px", boxSizing: "border-box" });
|
||||
|
||||
const scrubber = document.createElement("div");
|
||||
Object.assign(scrubber.style, {
|
||||
width: "100%", height: "4px", background: "#334155", borderRadius: "2px",
|
||||
position: "relative", cursor: "pointer"
|
||||
});
|
||||
const scrubberFill = document.createElement("div");
|
||||
Object.assign(scrubberFill.style, { width: "0%", height: "100%", background: "#c084fc", borderRadius: "2px" });
|
||||
scrubber.appendChild(scrubberFill);
|
||||
|
||||
const timeLabel = document.createElement("div");
|
||||
Object.assign(timeLabel.style, { fontSize: "9px", fontFamily: "monospace", color: "#94a3b8", textAlign: "center" });
|
||||
timeLabel.textContent = "0:00 / 0:00";
|
||||
|
||||
bottomRow.append(scrubber, timeLabel);
|
||||
|
||||
const audioEl = document.createElement("audio");
|
||||
audioEl.src = fullUrl;
|
||||
audioEl.preload = "metadata";
|
||||
|
||||
const formatTime = (t) => {
|
||||
const m = Math.floor(t / 60);
|
||||
const s = Math.floor(t % 60);
|
||||
return `${m}:${s < 10 ? '0' : ''}${s}`;
|
||||
};
|
||||
|
||||
const resetAudioUI = () => {
|
||||
playBtn.innerHTML = playIconSvg;
|
||||
previewAudio.classList.remove("playing");
|
||||
scrubberFill.style.width = "0%";
|
||||
timeLabel.textContent = `0:00 / ${formatTime(audioEl.duration || 0)}`;
|
||||
};
|
||||
|
||||
audioEl._onResetUI = resetAudioUI;
|
||||
|
||||
audioEl.onloadedmetadata = () => {
|
||||
timeLabel.textContent = `0:00 / ${formatTime(audioEl.duration || 0)}`;
|
||||
};
|
||||
|
||||
audioEl.ontimeupdate = () => {
|
||||
if (audioEl.duration > 0) {
|
||||
const percent = (audioEl.currentTime / audioEl.duration) * 100;
|
||||
scrubberFill.style.width = `${percent}%`;
|
||||
timeLabel.textContent = `${formatTime(audioEl.currentTime)} / ${formatTime(audioEl.duration)}`;
|
||||
}
|
||||
};
|
||||
|
||||
audioEl.onended = () => {
|
||||
resetAudioUI();
|
||||
if (getCurrentlyPlayingAudio() === audioEl) stopAllAudioPlayback();
|
||||
};
|
||||
|
||||
const togglePlay = (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (isAudioViewerOpen()) {
|
||||
stopAllAudioPlayback();
|
||||
showFullscreenPreview([fullUrl]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (audioEl.paused) {
|
||||
setCurrentlyPlayingAudio(audioEl);
|
||||
audioEl.play().catch(()=>{});
|
||||
playBtn.innerHTML = stopIconSvg;
|
||||
previewAudio.classList.add("playing");
|
||||
} else {
|
||||
audioEl.pause();
|
||||
audioEl.currentTime = 0;
|
||||
resetAudioUI();
|
||||
stopAllAudioPlayback();
|
||||
}
|
||||
};
|
||||
|
||||
playBtn.onclick = togglePlay;
|
||||
|
||||
playBtn.onmouseenter = () => {
|
||||
playBtn.style.background = "#9333ea";
|
||||
playBtn.style.borderColor = "#c084fc";
|
||||
playBtn.style.transform = "scale(1.08)";
|
||||
};
|
||||
playBtn.onmouseleave = () => {
|
||||
playBtn.style.background = previewAudio.classList.contains("playing") ? "rgba(147, 51, 234, 0.85)" : "rgba(22, 22, 30, 0.88)";
|
||||
playBtn.style.borderColor = "rgba(255, 255, 255, 0.25)";
|
||||
playBtn.style.transform = "scale(1.0)";
|
||||
};
|
||||
|
||||
scrubber.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
const rect = scrubber.getBoundingClientRect();
|
||||
const pos = (e.clientX - rect.left) / rect.width;
|
||||
if (audioEl.duration > 0) audioEl.currentTime = pos * audioEl.duration;
|
||||
};
|
||||
|
||||
previewAudio.append(topRow, centerArea, bottomRow, audioEl);
|
||||
wrapper.appendChild(previewAudio);
|
||||
|
||||
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]);
|
||||
};
|
||||
}
|
||||
|
||||
cardObj.firstImgElement = previewAudio;
|
||||
|
||||
previewAudio.setAttribute("draggable", "true");
|
||||
previewAudio.ondragstart = (e) => {
|
||||
e.stopPropagation();
|
||||
const mimeType = "audio/wav";
|
||||
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";
|
||||
};
|
||||
}
|
||||
|
||||
export function renderGenericFileCardPreview(cardObj, wrapper, src, img, state) {
|
||||
if (cardObj.dimEl) cardObj.dimEl.style.display = "none";
|
||||
const playIcon = wrapper.querySelector(".comfy-sidebar-play-icon");
|
||||
if (playIcon) playIcon.remove();
|
||||
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";
|
||||
|
||||
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);
|
||||
|
||||
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);
|
||||
|
||||
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";
|
||||
};
|
||||
}
|
||||
|
||||
export function renderCardImages(cardObj, state, onNavigateBatch) {
|
||||
cardObj.currentImageIndex = cardObj.currentImageIndex || 0;
|
||||
if (cardObj.currentImageIndex >= state.images.length) {
|
||||
cardObj.currentImageIndex = 0;
|
||||
}
|
||||
|
||||
const idx = cardObj.currentImageIndex;
|
||||
const img = state.images[idx];
|
||||
if (!img) {
|
||||
cardObj.grid.innerHTML = "";
|
||||
cardObj.firstImgElement = null;
|
||||
if (cardObj.dimEl) cardObj.dimEl.style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
const src = img.url ? img.url : window.location.origin + `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`;
|
||||
const isVideo = isVideoFormat(src) || isVideoFormat(img.filename);
|
||||
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) {
|
||||
cardObj.grid.innerHTML = "";
|
||||
wrapper = document.createElement("div");
|
||||
wrapper.className = "comfy-sidebar-media-wrapper";
|
||||
Object.assign(wrapper.style, { position: "relative", width: "100%", display: "block" });
|
||||
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;
|
||||
}
|
||||
|
||||
if (isAudio) {
|
||||
renderAudioCardPreview(cardObj, wrapper, src, img, 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"));
|
||||
|
||||
if (needsRebuild) {
|
||||
if (mediaEl) mediaEl.remove();
|
||||
mediaEl = isVideo ? document.createElement("video") : document.createElement("img");
|
||||
Object.assign(mediaEl.style, {
|
||||
width: "100%",
|
||||
borderRadius: "2px",
|
||||
display: "block",
|
||||
cursor: "zoom-in",
|
||||
webkitUserDrag: "element",
|
||||
zIndex: "1"
|
||||
});
|
||||
wrapper.insertBefore(mediaEl, wrapper.firstChild);
|
||||
}
|
||||
|
||||
cardObj.firstImgElement = mediaEl;
|
||||
|
||||
mediaEl.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
if (ev.ctrlKey || ev.metaKey) {
|
||||
openFileOrFolder(img);
|
||||
return;
|
||||
}
|
||||
let activeSrc = mediaEl.currentSrc || mediaEl.src || src;
|
||||
// If it's a preview blob, snapshot the rendered pixels from the card so revoked blobs don't 404 in fullscreen
|
||||
if (activeSrc && activeSrc.startsWith("blob:") && mediaEl.naturalWidth > 0) {
|
||||
try {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = mediaEl.naturalWidth;
|
||||
canvas.height = mediaEl.naturalHeight;
|
||||
canvas.getContext("2d").drawImage(mediaEl, 0, 0);
|
||||
activeSrc = canvas.toDataURL("image/png");
|
||||
} catch (_) {}
|
||||
}
|
||||
showFullscreenPreview([activeSrc], ev.shiftKey, state?.pid);
|
||||
};
|
||||
|
||||
mediaEl.onerror = () => {
|
||||
mediaEl.onerror = null;
|
||||
if ((src && src.startsWith("blob:")) || is3D || isAudio) return;
|
||||
|
||||
const cardId = cardObj.element?.id || "";
|
||||
|
||||
// 1. If an intermediate output item is missing, remove only this item
|
||||
if (cardId.startsWith("card-submenu-")) {
|
||||
const parentState = store.getPrompt(state.pid);
|
||||
if (parentState && parentState.nodeOutputs) {
|
||||
if (state.nodeId && parentState.nodeOutputs[state.nodeId]) {
|
||||
delete parentState.nodeOutputs[state.nodeId];
|
||||
} else {
|
||||
removeImageFromNodeOutputs(parentState.nodeOutputs, img);
|
||||
}
|
||||
store.updatePrompt(parentState.pid, parentState);
|
||||
}
|
||||
cardObj.element?.remove();
|
||||
const remaining = store.ui.cardStack?.querySelectorAll('.comfy-sidebar-card');
|
||||
if (!remaining || remaining.length === 0) {
|
||||
store.closeSubmenu();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. If a batch item is missing, remove only this item from the batch
|
||||
if (cardId.startsWith("card-batch-")) {
|
||||
const parentPid = state.parentPromptId || state.pid;
|
||||
const parentState = store.getPrompt(parentPid);
|
||||
if (parentState) {
|
||||
if (Array.isArray(parentState.images)) {
|
||||
const idx = parentState.images.findIndex(i => i.filename === img.filename && (i.subfolder || "") === (img.subfolder || ""));
|
||||
if (idx > -1) parentState.images.splice(idx, 1);
|
||||
}
|
||||
if (parentState.nodeOutputs) {
|
||||
removeImageFromNodeOutputs(parentState.nodeOutputs, img);
|
||||
}
|
||||
store.updatePrompt(parentPid, parentState);
|
||||
}
|
||||
cardObj.element?.remove();
|
||||
const remaining = store.ui.cardStack?.querySelectorAll('.comfy-sidebar-card');
|
||||
if (!remaining || remaining.length === 0) {
|
||||
store.closeSubmenu();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Main card: if this file is gone, clean it up
|
||||
const liveState = store.getPrompt(state.pid);
|
||||
if (liveState) {
|
||||
if (Array.isArray(liveState.images)) {
|
||||
liveState.images = liveState.images.filter(i =>
|
||||
!(i.filename === img.filename && (i.subfolder || "") === (img.subfolder || ""))
|
||||
);
|
||||
}
|
||||
if (liveState.nodeOutputs) {
|
||||
removeImageFromNodeOutputs(liveState.nodeOutputs, img);
|
||||
}
|
||||
|
||||
// If the card still has other images (e.g. from a batch), persist and switch to next image
|
||||
if (liveState.images && liveState.images.length > 0) {
|
||||
cardObj.currentImageIndex = 0;
|
||||
cardObj.lastImagesSignature = "";
|
||||
store.updatePrompt(liveState.pid, liveState);
|
||||
return;
|
||||
}
|
||||
|
||||
// If the card has text outputs or is still in progress, persist
|
||||
if ((liveState.texts && liveState.texts.length > 0) || (liveState.status && liveState.status !== PromptStatus.COMPLETED)) {
|
||||
cardObj.lastImagesSignature = "";
|
||||
store.updatePrompt(liveState.pid, liveState);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only delete the main card when all items belonging to it are gone
|
||||
store.deletePrompt(liveState.pid);
|
||||
cardObj.element?.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
renderGenericFileCardPreview(cardObj, wrapper, src, img, state);
|
||||
};
|
||||
|
||||
const applyDimensions = (width, height) => {
|
||||
if (cardObj.dimEl && width && height) {
|
||||
// Don't show the dimension badge for active/running preview frames
|
||||
if (state.status === "active") {
|
||||
cardObj.dimEl.style.display = "none";
|
||||
return;
|
||||
}
|
||||
cardObj.dimEl.textContent = `${width}x${height}`;
|
||||
cardObj.dimEl.style.display = "block";
|
||||
}
|
||||
};
|
||||
|
||||
const isUnfinished = state.status ? (state.status !== "completed") : false;
|
||||
if (isUnfinished) {
|
||||
mediaEl.setAttribute("draggable", "false");
|
||||
mediaEl.style.cursor = "grab";
|
||||
if (mediaEl._currentDragStart) {
|
||||
mediaEl.removeEventListener("dragstart", mediaEl._currentDragStart);
|
||||
delete mediaEl._currentDragStart;
|
||||
}
|
||||
} else {
|
||||
mediaEl.setAttribute("draggable", "true");
|
||||
mediaEl.style.cursor = "zoom-in";
|
||||
|
||||
if (isVideo) {
|
||||
const fullSrc = src.startsWith("http") ? src : window.location.origin + src;
|
||||
const filename = img.filename || "output.mp4";
|
||||
const mimeType = src.includes(".webm") ? "video/webm" : "video/mp4";
|
||||
|
||||
const videoDragHandler = (e) => {
|
||||
try {
|
||||
e.dataTransfer.setData("text/uri-list", fullSrc);
|
||||
e.dataTransfer.setData("text/plain", fullSrc);
|
||||
e.dataTransfer.setData("DownloadURL", `${mimeType}:${filename}:${fullSrc}`);
|
||||
if (state && state.workflow) {
|
||||
e.dataTransfer.setData("application/json", JSON.stringify(state.workflow));
|
||||
}
|
||||
} catch (err) {}
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
if (mediaEl._currentDragStart) {
|
||||
mediaEl.removeEventListener("dragstart", mediaEl._currentDragStart);
|
||||
}
|
||||
mediaEl.addEventListener("dragstart", videoDragHandler);
|
||||
mediaEl._currentDragStart = videoDragHandler;
|
||||
} else {
|
||||
if (mediaEl._currentDragStart) {
|
||||
mediaEl.removeEventListener("dragstart", mediaEl._currentDragStart);
|
||||
delete mediaEl._currentDragStart;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isVideo) {
|
||||
mediaEl.muted = true;
|
||||
mediaEl.playsInline = true;
|
||||
mediaEl.preload = "metadata";
|
||||
mediaEl.loop = true;
|
||||
|
||||
mediaEl.onloadedmetadata = () => {
|
||||
applyDimensions(mediaEl.videoWidth, mediaEl.videoHeight);
|
||||
};
|
||||
if (mediaEl.readyState >= 1) {
|
||||
applyDimensions(mediaEl.videoWidth, mediaEl.videoHeight);
|
||||
}
|
||||
|
||||
let playIcon = wrapper.querySelector(".comfy-sidebar-play-icon");
|
||||
if (!playIcon) {
|
||||
playIcon = document.createElement("div");
|
||||
playIcon.className = "comfy-sidebar-play-icon";
|
||||
Object.assign(playIcon.style, {
|
||||
position: "absolute", top: "0", left: "0", width: "100%", height: "100%",
|
||||
pointerEvents: "none", zIndex: "2", transition: "opacity 0.2s ease"
|
||||
});
|
||||
playIcon.innerHTML = `
|
||||
<svg viewBox="0 0 100 100" preserveAspectRatio="xMidYMid meet" style="width: 100%; height: 100%; filter: drop-shadow(0 4px 6px rgba(0,0,0,0.3));">
|
||||
<circle cx="50" cy="50" r="16.65" fill="rgba(0, 0, 0, 0.55)" />
|
||||
<polygon points="45.7,42.5 45.7,57.5 58.7,50" fill="rgba(255, 255, 255, 0.9)" />
|
||||
</svg>
|
||||
`;
|
||||
wrapper.appendChild(playIcon);
|
||||
}
|
||||
|
||||
mediaEl.onmouseenter = () => { playIcon.style.opacity = "0"; mediaEl.play().catch(()=>{}); };
|
||||
mediaEl.onmouseleave = () => { playIcon.style.opacity = "1"; mediaEl.pause(); };
|
||||
} else {
|
||||
mediaEl.onload = () => {
|
||||
applyDimensions(mediaEl.naturalWidth, mediaEl.naturalHeight);
|
||||
if (state._oldPreviewBlobUrl) {
|
||||
try { URL.revokeObjectURL(state._oldPreviewBlobUrl); } catch(e){}
|
||||
delete state._oldPreviewBlobUrl;
|
||||
}
|
||||
};
|
||||
if (mediaEl.complete && mediaEl.naturalWidth) {
|
||||
applyDimensions(mediaEl.naturalWidth, mediaEl.naturalHeight);
|
||||
}
|
||||
|
||||
const playIcon = wrapper.querySelector(".comfy-sidebar-play-icon");
|
||||
if (playIcon) playIcon.remove();
|
||||
}
|
||||
|
||||
const currentSrc = mediaEl.getAttribute("src") || mediaEl.src;
|
||||
if (currentSrc !== src && currentSrc !== (currentSrc + "#t=0.001")) {
|
||||
if (src.startsWith("blob:") && !isVideo) {
|
||||
const tempImg = new Image();
|
||||
tempImg.onload = () => {
|
||||
const oldBlob = mediaEl._lastBlob;
|
||||
mediaEl.src = src;
|
||||
mediaEl._lastBlob = src;
|
||||
applyDimensions(tempImg.naturalWidth, tempImg.naturalHeight);
|
||||
if (oldBlob && oldBlob !== src && oldBlob.startsWith("blob:")) {
|
||||
try { URL.revokeObjectURL(oldBlob); } catch(e){}
|
||||
}
|
||||
};
|
||||
tempImg.src = src;
|
||||
} else {
|
||||
mediaEl.src = isVideo ? src + "#t=0.001" : src;
|
||||
}
|
||||
} else {
|
||||
if (isVideo) {
|
||||
if (mediaEl.videoWidth) applyDimensions(mediaEl.videoWidth, mediaEl.videoHeight);
|
||||
} else {
|
||||
if (mediaEl.naturalWidth) applyDimensions(mediaEl.naturalWidth, mediaEl.naturalHeight);
|
||||
}
|
||||
}
|
||||
|
||||
let navBar = wrapper.querySelector(".comfy-sidebar-batch-navbar");
|
||||
if (state.images.length > 1) {
|
||||
if (!navBar) {
|
||||
navBar = document.createElement("div");
|
||||
navBar.className = "comfy-sidebar-batch-navbar";
|
||||
Object.assign(navBar.style, {
|
||||
position: "absolute", bottom: "6px", left: "50%",
|
||||
display: "flex", alignItems: "center", gap: "8px", background: "rgba(0,0,0,0.75)",
|
||||
padding: "4px 10px", borderRadius: "12px", zIndex: "15", fontSize: "10px",
|
||||
fontFamily: "monospace", color: "#eee", userSelect: "none", pointerEvents: "auto",
|
||||
boxShadow: "0 1px 3px rgba(0,0,0,0.4)", transform: "translate3d(-50%, 0, 0)"
|
||||
});
|
||||
|
||||
const prevBtn = document.createElement("span");
|
||||
prevBtn.className = "pi pi-chevron-left";
|
||||
prevBtn.style.cursor = "pointer";
|
||||
prevBtn.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
cardObj.currentImageIndex = (cardObj.currentImageIndex - 1 + state.images.length) % state.images.length;
|
||||
renderCardImages(cardObj, state, onNavigateBatch);
|
||||
};
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "comfy-sidebar-batch-label";
|
||||
label.textContent = `${idx + 1}/${state.images.length}`;
|
||||
label.style.cursor = "pointer";
|
||||
label.title = "View all images in this batch";
|
||||
|
||||
label.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
if (typeof onNavigateBatch === "function") {
|
||||
onNavigateBatch(state);
|
||||
}
|
||||
};
|
||||
|
||||
const nextBtn = document.createElement("span");
|
||||
nextBtn.className = "pi pi-chevron-right";
|
||||
nextBtn.style.cursor = "pointer";
|
||||
nextBtn.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
cardObj.currentImageIndex = (cardObj.currentImageIndex + 1) % state.images.length;
|
||||
renderCardImages(cardObj, state, onNavigateBatch);
|
||||
};
|
||||
|
||||
navBar.append(prevBtn, label, nextBtn);
|
||||
wrapper.appendChild(navBar);
|
||||
}
|
||||
|
||||
const label = navBar.querySelector(".comfy-sidebar-batch-label");
|
||||
if (label) label.textContent = `${idx + 1}/${state.images.length}`;
|
||||
} else {
|
||||
if (navBar) navBar.remove();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,984 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { PromptStatus, StoreEvents, SettingIds } from "../core/constants.js";
|
||||
import { store } from "../core/store.js";
|
||||
import { getOrCreateCard, updateCardDOM, resetAllCardHoverStates, cardPool } from "./cardRenderer.js";
|
||||
import { clearCancelledOrFailed, clearAllHistory, cancelAllPending } from "../queue/queueService.js";
|
||||
import { updateSidebarBadge, centerAndSelectCanvasNode } from "../comfy/adapter.js";
|
||||
import { matchesFilter, getRunOutputs, isImageFormat } from "../utils/utils.js";
|
||||
import { renderCardImages } from "./mediaPreview.js";
|
||||
import { deleteFileOnServer, removeImageFromNodeOutputs, copyImageToClipboard, findNodeIdForImage } from "./mediaActions.js";
|
||||
|
||||
let scrollToTopBtnEl = null;
|
||||
let globalClickRegistered = false;
|
||||
let renderAnimationFrameId = null;
|
||||
let activeTimerInterval = null;
|
||||
let currentColumnCount = 1;
|
||||
|
||||
function mountCards(cardStack, fullWidthEls, cardEls, cols = 1) {
|
||||
if (!cardStack) return;
|
||||
|
||||
// 1. Full-width container (for banners like Pending Queue summary)
|
||||
let bannerContainer = cardStack.querySelector('.comfy-sidebar-banners');
|
||||
if (!bannerContainer) {
|
||||
bannerContainer = document.createElement('div');
|
||||
bannerContainer.className = 'comfy-sidebar-banners';
|
||||
Object.assign(bannerContainer.style, { display: 'flex', flexDirection: 'column', gap: '12px', width: '100%' });
|
||||
cardStack.insertBefore(bannerContainer, cardStack.firstChild);
|
||||
}
|
||||
fullWidthEls.forEach((el, i) => {
|
||||
if (bannerContainer.children[i] !== el) {
|
||||
bannerContainer.insertBefore(el, bannerContainer.children[i] || null);
|
||||
}
|
||||
});
|
||||
while (bannerContainer.children.length > fullWidthEls.length) {
|
||||
bannerContainer.removeChild(bannerContainer.lastChild);
|
||||
}
|
||||
bannerContainer.style.display = fullWidthEls.length > 0 ? 'flex' : 'none';
|
||||
bannerContainer.style.marginBottom = fullWidthEls.length > 0 ? '12px' : '0';
|
||||
|
||||
// 2. Masonry Columns container
|
||||
let columnsWrapper = cardStack.querySelector('.comfy-sidebar-masonry-wrapper');
|
||||
if (!columnsWrapper) {
|
||||
columnsWrapper = document.createElement('div');
|
||||
columnsWrapper.className = 'comfy-sidebar-masonry-wrapper';
|
||||
Object.assign(columnsWrapper.style, { display: 'flex', gap: '12px', alignItems: 'flex-start', width: '100%' });
|
||||
cardStack.appendChild(columnsWrapper);
|
||||
}
|
||||
|
||||
while (columnsWrapper.children.length < cols) {
|
||||
const colDiv = document.createElement('div');
|
||||
colDiv.className = 'comfy-sidebar-masonry-col';
|
||||
Object.assign(colDiv.style, { flex: '1', display: 'flex', flexDirection: 'column', gap: '12px', minWidth: '0' });
|
||||
columnsWrapper.appendChild(colDiv);
|
||||
}
|
||||
while (columnsWrapper.children.length > cols) {
|
||||
columnsWrapper.removeChild(columnsWrapper.lastChild);
|
||||
}
|
||||
|
||||
// Distribute cards into the shortest column so short cards stack next to tall cards
|
||||
const colBuckets = Array.from({ length: cols }, () => []);
|
||||
const colHeights = new Array(cols).fill(0);
|
||||
|
||||
cardEls.forEach((cardEl) => {
|
||||
let shortestCol = 0;
|
||||
for (let c = 1; c < cols; c++) {
|
||||
if (colHeights[c] < colHeights[shortestCol]) {
|
||||
shortestCol = c;
|
||||
}
|
||||
}
|
||||
colBuckets[shortestCol].push(cardEl);
|
||||
|
||||
const cardH = cardEl.offsetHeight > 0 ? cardEl.offsetHeight : (cardEl._cachedHeight || 160);
|
||||
cardEl._cachedHeight = cardH;
|
||||
colHeights[shortestCol] += cardH + 12;
|
||||
});
|
||||
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const colDiv = columnsWrapper.children[c];
|
||||
const bucket = colBuckets[c];
|
||||
bucket.forEach((el, i) => {
|
||||
if (colDiv.children[i] !== el) {
|
||||
colDiv.insertBefore(el, colDiv.children[i] || null);
|
||||
}
|
||||
});
|
||||
while (colDiv.children.length > bucket.length) {
|
||||
colDiv.removeChild(colDiv.lastChild);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getScrollContainer() {
|
||||
if (!store.ui.cardStack) return null;
|
||||
return store.ui.cardStack.closest('.sidebar-content-container, [class*="sidebar-content-container"], [class*="overflow-y-auto"]')
|
||||
|| store.ui.cardStack.parentElement
|
||||
|| store.ui.cardStack;
|
||||
}
|
||||
|
||||
function updateScrollTopBtnVisibility() {
|
||||
if (!scrollToTopBtnEl || !store.ui.sidebarContainer || !store.ui.sidebarContainer.isConnected) return;
|
||||
const scrollEl = getScrollContainer();
|
||||
if (scrollEl) {
|
||||
const viewportEl = scrollEl.parentElement || store.ui.sidebarContainer;
|
||||
if (viewportEl && scrollToTopBtnEl.parentNode !== viewportEl) {
|
||||
if (window.getComputedStyle(viewportEl).position === "static") {
|
||||
viewportEl.style.position = "relative";
|
||||
}
|
||||
viewportEl.appendChild(scrollToTopBtnEl);
|
||||
}
|
||||
|
||||
if (scrollEl.scrollTop > 200) {
|
||||
scrollToTopBtnEl.style.display = "flex";
|
||||
} else {
|
||||
scrollToTopBtnEl.style.display = "none";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
};
|
||||
|
||||
const handleGlobalClick = (e) => {
|
||||
if (!store.ui.activeSubmenuPromptId && !store.ui.activeSubmenuBatchImages) {
|
||||
document.removeEventListener("click", handleGlobalClick, true);
|
||||
globalClickRegistered = false;
|
||||
return;
|
||||
}
|
||||
const sidebar = store.ui.sidebarContainer;
|
||||
const clickedInsideSidebar = sidebar && sidebar.contains(e.target);
|
||||
const clickedFullscreenOverlay = e.target.closest('div[style*="zIndex: 999"], .comfy-sidebar-comparison-overlay');
|
||||
|
||||
if (!clickedInsideSidebar && !clickedFullscreenOverlay) {
|
||||
store.closeSubmenu();
|
||||
resetAllCardHoverStates();
|
||||
renderSidebar();
|
||||
document.removeEventListener("click", handleGlobalClick, true);
|
||||
globalClickRegistered = false;
|
||||
}
|
||||
};
|
||||
|
||||
export function setupSidebarView() {
|
||||
window.addEventListener("keydown", handleGlobalKeyDown);
|
||||
window.addEventListener("keyup", handleGlobalKeyUp);
|
||||
window.addEventListener("blur", handleGlobalBlur);
|
||||
|
||||
const container = document.createElement("div");
|
||||
store.ui.sidebarContainer = container;
|
||||
|
||||
Object.assign(container.style, {
|
||||
display: "flex", flexDirection: "column", height: "100%", padding: "14px", boxSizing: "border-box",
|
||||
background: "var(--comfy-menu-bg, #121212)", color: "var(--fg-color, #eee)", position: "relative"
|
||||
});
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "comfy-sidebar-header-root";
|
||||
Object.assign(header.style, { position: "relative", marginBottom: "12px", height: "26px", display: "flex", alignItems: "center" });
|
||||
|
||||
const standardHeader = document.createElement("div");
|
||||
standardHeader.className = "comfy-sidebar-standard-header";
|
||||
Object.assign(standardHeader.style, { display: "flex", width: "100%", alignItems: "center", justifyContent: "space-between" });
|
||||
|
||||
const titleGroup = document.createElement("div");
|
||||
Object.assign(titleGroup.style, { display: "flex", alignItems: "center", gap: "8px" });
|
||||
|
||||
const searchIcon = document.createElement("span");
|
||||
searchIcon.className = "pi pi-search comfy-sidebar-icon-btn";
|
||||
searchIcon.title = "Search History";
|
||||
|
||||
const title = document.createElement("h3");
|
||||
title.textContent = "Queue";
|
||||
Object.assign(title.style, { margin: "0", fontSize: "14px", fontWeight: "bold", opacity: "0.9", color: "var(--fg-color, #eee)" });
|
||||
|
||||
titleGroup.appendChild(searchIcon);
|
||||
titleGroup.appendChild(title);
|
||||
standardHeader.appendChild(titleGroup);
|
||||
|
||||
const actionsGroup = document.createElement("div");
|
||||
Object.assign(actionsGroup.style, { display: "flex", gap: "6px", alignItems: "center" });
|
||||
|
||||
const createActionBtn = (iconClass, tooltip, hoverColor, onClickFn) => {
|
||||
const btn = document.createElement("button");
|
||||
btn.className = `${iconClass} comfy-sidebar-header-btn`;
|
||||
btn.title = tooltip;
|
||||
|
||||
let timeout = null, isPending = false;
|
||||
const reset = () => {
|
||||
isPending = false;
|
||||
Object.assign(btn.style, {
|
||||
color: "var(--desc-color, #aaa)", background: "transparent",
|
||||
borderColor: "var(--border-color, #555)", boxShadow: "none"
|
||||
});
|
||||
if (timeout) { clearTimeout(timeout); timeout = null; }
|
||||
};
|
||||
|
||||
btn.addEventListener("click", async (ev) => {
|
||||
ev.stopPropagation();
|
||||
if (!isPending) {
|
||||
isPending = true;
|
||||
Object.assign(btn.style, {
|
||||
color: "#fff", background: hoverColor, borderColor: hoverColor,
|
||||
boxShadow: `0 0 8px ${hoverColor}80`
|
||||
});
|
||||
timeout = setTimeout(reset, 1500);
|
||||
} else {
|
||||
reset();
|
||||
await onClickFn();
|
||||
}
|
||||
});
|
||||
return btn;
|
||||
};
|
||||
|
||||
const btnClearInterrupted = createActionBtn("pi pi-eraser", "Clear Cancelled & Failed", "#ffc107", async () => {
|
||||
await clearCancelledOrFailed();
|
||||
renderSidebar();
|
||||
});
|
||||
|
||||
const btnClearAll = createActionBtn("pi pi-trash", "Clear All History", "#dc3545", async () => {
|
||||
await clearAllHistory();
|
||||
renderSidebar();
|
||||
});
|
||||
|
||||
actionsGroup.appendChild(btnClearInterrupted);
|
||||
actionsGroup.appendChild(btnClearAll);
|
||||
standardHeader.appendChild(actionsGroup);
|
||||
|
||||
const searchContainer = document.createElement("div");
|
||||
searchContainer.className = "comfy-sidebar-search-container";
|
||||
Object.assign(searchContainer.style, {
|
||||
display: "none", width: "100%", alignItems: "center", background: "var(--comfy-input-bg, #181818)",
|
||||
border: "1px solid var(--border-color, #555)", borderRadius: "4px", padding: "2px 8px",
|
||||
boxSizing: "border-box", height: "26px"
|
||||
});
|
||||
const searchInputIcon = document.createElement("span");
|
||||
searchInputIcon.className = "pi pi-search";
|
||||
Object.assign(searchInputIcon.style, { fontSize: "11px", opacity: "0.5", marginRight: "6px" });
|
||||
const searchInput = document.createElement("input");
|
||||
Object.assign(searchInput, { type: "text", placeholder: "Filter by text, images, nodes..." });
|
||||
Object.assign(searchInput.style, {
|
||||
flex: "1", background: "transparent", border: "none", outline: "none",
|
||||
color: "var(--comfy-input-color, var(--fg-color, #eee))", fontSize: "11px", padding: "0"
|
||||
});
|
||||
const clearSearchBtn = document.createElement("span");
|
||||
clearSearchBtn.className = "pi pi-times comfy-sidebar-icon-btn";
|
||||
clearSearchBtn.title = "Clear & Close Search";
|
||||
Object.assign(clearSearchBtn.style, { marginLeft: "6px" });
|
||||
|
||||
searchContainer.appendChild(searchInputIcon);
|
||||
searchContainer.appendChild(searchInput);
|
||||
searchContainer.appendChild(clearSearchBtn);
|
||||
|
||||
header.appendChild(standardHeader);
|
||||
header.appendChild(searchContainer);
|
||||
container.appendChild(header);
|
||||
|
||||
searchIcon.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
standardHeader.style.display = "none";
|
||||
searchContainer.style.display = "flex";
|
||||
searchInput.focus();
|
||||
});
|
||||
|
||||
const closeSearch = () => {
|
||||
searchInput.value = "";
|
||||
store.setSearchQuery("");
|
||||
searchContainer.style.display = "none";
|
||||
standardHeader.style.display = "flex";
|
||||
renderSidebar();
|
||||
};
|
||||
|
||||
clearSearchBtn.addEventListener("click", (e) => { e.stopPropagation(); closeSearch(); });
|
||||
searchInput.addEventListener("keydown", (e) => { if (e.key === "Escape") closeSearch(); });
|
||||
searchInput.addEventListener("input", () => {
|
||||
store.setSearchQuery(searchInput.value.trim());
|
||||
renderSidebar();
|
||||
});
|
||||
|
||||
const cardStack = document.createElement("div");
|
||||
store.ui.cardStack = cardStack;
|
||||
Object.assign(cardStack.style, {
|
||||
flex: "1",
|
||||
overflowY: "visible",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
paddingBottom: "28px"
|
||||
});
|
||||
container.appendChild(cardStack);
|
||||
|
||||
scrollToTopBtnEl = document.createElement("button");
|
||||
scrollToTopBtnEl.className = "pi pi-chevron-up comfy-sidebar-scroll-top-btn";
|
||||
scrollToTopBtnEl.title = "Scroll to Top";
|
||||
|
||||
scrollToTopBtnEl.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const scrollEl = getScrollContainer();
|
||||
if (scrollEl) scrollEl.scrollTo({ top: 0, behavior: "smooth" });
|
||||
});
|
||||
|
||||
container.appendChild(scrollToTopBtnEl);
|
||||
|
||||
document.addEventListener("scroll", updateScrollTopBtnVisibility, { capture: true, passive: true });
|
||||
|
||||
new ResizeObserver((entries) => {
|
||||
const threshold = app.ui?.settings?.getSettingValue?.(SettingIds.GRID_COLUMNS_THRESHOLD) ?? 350;
|
||||
const cols = Math.max(1, Math.floor(entries[0].contentRect.width / (threshold / 2)));
|
||||
if (cols !== currentColumnCount) {
|
||||
currentColumnCount = cols;
|
||||
renderSidebar();
|
||||
}
|
||||
}).observe(container);
|
||||
|
||||
activeTimerInterval = setInterval(() => {
|
||||
const activePid = store.ui.currentlyActivePromptId;
|
||||
if (!activePid) return;
|
||||
const prompt = store.getPrompt(activePid);
|
||||
if (prompt && prompt.status === PromptStatus.ACTIVE && prompt.startTime) {
|
||||
const cardObj = cardPool.get(activePid);
|
||||
if (cardObj && cardObj.timerEl) {
|
||||
cardObj.timerEl.textContent = ((Date.now() - prompt.startTime) / 1000).toFixed(2) + "s";
|
||||
}
|
||||
}
|
||||
}, 250);
|
||||
|
||||
store.on(StoreEvents.QUEUE_SYNCED, () => {
|
||||
const pendingCount = store.getAllPrompts().filter(t => t.status === PromptStatus.PENDING).length;
|
||||
const runningCount = store.getAllPrompts().filter(t => t.status === PromptStatus.ACTIVE).length;
|
||||
updateSidebarBadge(pendingCount + (runningCount > 0 ? 1 : 0));
|
||||
renderSidebar();
|
||||
});
|
||||
|
||||
store.on(StoreEvents.PROMPT_ADDED, () => renderSidebar());
|
||||
store.on(StoreEvents.PROMPT_DELETED, () => renderSidebar());
|
||||
store.on(StoreEvents.PROMPTS_CLEARED, () => renderSidebar());
|
||||
store.on(StoreEvents.SUBMENU_CHANGED, () => renderSidebar());
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
export function renderSidebar() {
|
||||
if (renderAnimationFrameId) cancelAnimationFrame(renderAnimationFrameId);
|
||||
renderAnimationFrameId = requestAnimationFrame(() => {
|
||||
if (!store.ui.sidebarContainer || !store.ui.cardStack) return;
|
||||
|
||||
const showPendingSummary = app.ui?.settings?.getSettingValue?.(SettingIds.SHOW_PENDING_COUNT_ONLY) ?? true;
|
||||
const showWorkingNode = app.ui?.settings?.getSettingValue?.(SettingIds.SHOW_WORKING_NODE_NAME) ?? true;
|
||||
|
||||
const headerTitle = store.ui.sidebarContainer.querySelector("h3");
|
||||
const headerSearchIcon = store.ui.sidebarContainer.querySelector(".comfy-sidebar-header-root .pi-search");
|
||||
const headerActions = store.ui.sidebarContainer.querySelector(".comfy-sidebar-header-btn")?.parentNode;
|
||||
|
||||
// Submenu 1: Batch Images View
|
||||
if (store.ui.activeSubmenuBatchImages) {
|
||||
const batchInfo = store.ui.activeSubmenuBatchImages;
|
||||
|
||||
if (headerTitle) {
|
||||
headerTitle.textContent = `Batch of #${batchInfo.pid}`;
|
||||
headerTitle.style.cursor = "pointer";
|
||||
headerTitle.title = "Go Back";
|
||||
headerTitle.onclick = () => {
|
||||
if (batchInfo.parentPromptId) {
|
||||
store.openOutputsSubmenu(batchInfo.parentPromptId);
|
||||
} else {
|
||||
store.closeSubmenu();
|
||||
}
|
||||
resetAllCardHoverStates();
|
||||
renderSidebar();
|
||||
};
|
||||
}
|
||||
if (headerSearchIcon) headerSearchIcon.style.display = "none";
|
||||
if (headerActions) headerActions.style.display = "none";
|
||||
|
||||
if (!globalClickRegistered) {
|
||||
document.addEventListener("click", handleGlobalClick, true);
|
||||
globalClickRegistered = true;
|
||||
}
|
||||
|
||||
const targetElements = [];
|
||||
|
||||
batchInfo.images.forEach((img, index) => {
|
||||
const cardId = `batch-${batchInfo.pid}-${index}`;
|
||||
let cardObj = cardPool.get(cardId);
|
||||
|
||||
if (!cardObj) {
|
||||
const card = document.createElement("div");
|
||||
card.id = `card-${cardId}`;
|
||||
card.className = "comfy-sidebar-card completed";
|
||||
card.style.position = "relative";
|
||||
|
||||
const timerEl = document.createElement("div");
|
||||
timerEl.className = "comfy-sidebar-card-timer";
|
||||
timerEl.textContent = `Image ${index + 1}/${batchInfo.images.length}`;
|
||||
timerEl.style.display = "block";
|
||||
|
||||
const dimEl = document.createElement("div");
|
||||
Object.assign(dimEl.style, {
|
||||
position: "absolute", top: "6px", right: "8px", fontSize: "10px",
|
||||
fontFamily: "monospace", opacity: "0.7", background: "rgba(0, 0, 0, 0.6)",
|
||||
padding: "2px 4px", borderRadius: "3px", pointerEvents: "none", zIndex: "5", color: "#fff",
|
||||
display: "none", transform: "translateZ(0)"
|
||||
});
|
||||
|
||||
const grid = document.createElement("div");
|
||||
grid.style.display = "flex";
|
||||
grid.style.flexDirection = "column";
|
||||
grid.style.gap = "6px";
|
||||
|
||||
const p = document.createElement("div");
|
||||
p.className = "comfy-sidebar-text-clamp";
|
||||
|
||||
const hoverPanel = document.createElement("div");
|
||||
hoverPanel.className = "comfy-sidebar-hover-panel";
|
||||
Object.assign(hoverPanel.style, {
|
||||
position: "absolute", bottom: "4px", right: "4px",
|
||||
flexDirection: "column", gap: "4px", zIndex: "20"
|
||||
});
|
||||
|
||||
const btnImg = document.createElement("span");
|
||||
btnImg.className = "pi pi-image comfy-sidebar-card-action-btn";
|
||||
btnImg.title = "Download Object";
|
||||
btnImg.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
const a = document.createElement("a");
|
||||
a.href = img.url ? img.url : `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`;
|
||||
a.download = img.filename || "output";
|
||||
a.click();
|
||||
};
|
||||
|
||||
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;
|
||||
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 from batch";
|
||||
deleteTimeout = setTimeout(resetDelete, 2000);
|
||||
} else {
|
||||
const shouldDeleteFromDisk = isDiskDelete || wantsDiskDelete;
|
||||
resetDelete();
|
||||
|
||||
if (shouldDeleteFromDisk && img && img.filename) {
|
||||
await deleteFileOnServer(img);
|
||||
}
|
||||
|
||||
const pid = batchInfo.parentPromptId || batchInfo.pid;
|
||||
const state = store.getPrompt(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);
|
||||
}
|
||||
if (state.nodeOutputs) {
|
||||
removeImageFromNodeOutputs(state.nodeOutputs, img);
|
||||
}
|
||||
state.rendered = false;
|
||||
store.updatePrompt(pid, state);
|
||||
}
|
||||
|
||||
const mainCardObj = cardPool.get(pid);
|
||||
if (mainCardObj) {
|
||||
mainCardObj.currentImageIndex = 0;
|
||||
mainCardObj.lastImagesSignature = "";
|
||||
}
|
||||
|
||||
if (batchInfo.images.length === 0) {
|
||||
if (batchInfo.parentPromptId) {
|
||||
store.openOutputsSubmenu(batchInfo.parentPromptId);
|
||||
} else {
|
||||
store.closeSubmenu();
|
||||
}
|
||||
}
|
||||
|
||||
renderSidebar();
|
||||
}
|
||||
};
|
||||
|
||||
hoverPanel.append(btnImg, btnDel);
|
||||
|
||||
const leftHoverPanel = document.createElement("div");
|
||||
leftHoverPanel.className = "comfy-sidebar-left-hover-panel";
|
||||
Object.assign(leftHoverPanel.style, {
|
||||
position: "absolute", bottom: "4px", left: "4px",
|
||||
flexDirection: "column", gap: "4px", zIndex: "20"
|
||||
});
|
||||
|
||||
const btnCopy = document.createElement("span");
|
||||
btnCopy.className = "pi pi-copy comfy-sidebar-card-action-btn";
|
||||
btnCopy.title = "Copy to Clipboard";
|
||||
const isCopyable = isImageFormat(img.filename || img.url);
|
||||
if (!isCopyable) {
|
||||
btnCopy.style.display = "none";
|
||||
} else {
|
||||
btnCopy.onclick = async (ev) => {
|
||||
ev.stopPropagation();
|
||||
const src = img.url ? img.url : `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`;
|
||||
const success = await copyImageToClipboard(src);
|
||||
if (success) {
|
||||
btnCopy.className = "pi pi-check comfy-sidebar-card-action-btn";
|
||||
btnCopy.style.color = "#4ade80";
|
||||
setTimeout(() => {
|
||||
btnCopy.className = "pi pi-copy comfy-sidebar-card-action-btn";
|
||||
btnCopy.style.color = "";
|
||||
}, 1500);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const btnFocus = document.createElement("span");
|
||||
btnFocus.className = "pi pi-eye comfy-sidebar-card-action-btn";
|
||||
btnFocus.title = "Show Node";
|
||||
const nodeId = findNodeIdForImage({ nodeOutputs: batchInfo.nodeOutputs }, img);
|
||||
if (nodeId) {
|
||||
btnFocus.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
centerAndSelectCanvasNode(nodeId);
|
||||
};
|
||||
} else {
|
||||
btnFocus.style.display = "none";
|
||||
}
|
||||
|
||||
leftHoverPanel.append(btnCopy, btnFocus);
|
||||
card.append(timerEl, dimEl, grid, p, hoverPanel, leftHoverPanel);
|
||||
|
||||
cardObj = {
|
||||
element: card,
|
||||
timerEl,
|
||||
dimEl,
|
||||
grid,
|
||||
placeholder: p,
|
||||
hoverPanel,
|
||||
leftHoverPanel,
|
||||
btnImg,
|
||||
btnCopy,
|
||||
btnFocus,
|
||||
btnDel,
|
||||
firstImgElement: null
|
||||
};
|
||||
|
||||
cardPool.set(cardId, cardObj);
|
||||
}
|
||||
|
||||
cardObj.placeholder.style.display = "none";
|
||||
renderCardImages(cardObj, {
|
||||
pid: batchInfo.pid,
|
||||
status: PromptStatus.COMPLETED,
|
||||
images: [img],
|
||||
workflow: batchInfo.workflow,
|
||||
nodeOutputs: batchInfo.nodeOutputs
|
||||
});
|
||||
|
||||
targetElements.push(cardObj.element);
|
||||
});
|
||||
|
||||
mountCards(store.ui.cardStack, [], targetElements, currentColumnCount);
|
||||
|
||||
const scrollEl = getScrollContainer();
|
||||
if (scrollEl) scrollEl.scrollTop = 0;
|
||||
updateScrollTopBtnVisibility();
|
||||
return;
|
||||
}
|
||||
|
||||
// Submenu 2: Intermediate Outputs View
|
||||
if (store.ui.activeSubmenuPromptId) {
|
||||
const st = store.getPrompt(store.ui.activeSubmenuPromptId);
|
||||
if (!st) {
|
||||
store.closeSubmenu();
|
||||
renderSidebar();
|
||||
return;
|
||||
}
|
||||
|
||||
if (headerTitle) {
|
||||
headerTitle.textContent = `Outputs of #${store.ui.activeSubmenuPromptId}`;
|
||||
headerTitle.style.cursor = "pointer";
|
||||
headerTitle.title = "Go Back to Queue";
|
||||
headerTitle.onclick = () => {
|
||||
store.closeSubmenu();
|
||||
resetAllCardHoverStates();
|
||||
renderSidebar();
|
||||
};
|
||||
}
|
||||
if (headerSearchIcon) headerSearchIcon.style.display = "none";
|
||||
if (headerActions) headerActions.style.display = "none";
|
||||
|
||||
if (!globalClickRegistered) {
|
||||
document.addEventListener("click", handleGlobalClick, true);
|
||||
globalClickRegistered = true;
|
||||
}
|
||||
|
||||
const rawOutputs = getRunOutputs(st.nodeOutputs, st.workflow);
|
||||
const seenFiles = new Set();
|
||||
const outputs = [];
|
||||
|
||||
rawOutputs.forEach(out => {
|
||||
const uniqueImgs = (out.images || []).filter(img => {
|
||||
const key = `${img.subfolder || ""}/${img.filename}`;
|
||||
if (seenFiles.has(key)) return false;
|
||||
seenFiles.add(key);
|
||||
return true;
|
||||
});
|
||||
if (uniqueImgs.length > 0) {
|
||||
outputs.push({ nodeId: out.nodeId, images: uniqueImgs, nodeTitle: out.nodeTitle });
|
||||
}
|
||||
});
|
||||
|
||||
const targetElements = [];
|
||||
|
||||
if (outputs.length === 0) {
|
||||
const emptyCard = document.createElement("div");
|
||||
emptyCard.className = "comfy-sidebar-card completed";
|
||||
Object.assign(emptyCard.style, {
|
||||
padding: "16px", textAlign: "center", color: "var(--desc-color, #aaa)", fontSize: "12px",
|
||||
gridColumn: "1 / -1"
|
||||
});
|
||||
emptyCard.textContent = "No separate intermediate outputs found.";
|
||||
targetElements.push(emptyCard);
|
||||
} else {
|
||||
outputs.forEach((out) => {
|
||||
const cardId = `submenu-${st.pid}-${out.nodeId}`;
|
||||
let cardObj = cardPool.get(cardId);
|
||||
|
||||
if (!cardObj) {
|
||||
const card = document.createElement("div");
|
||||
card.id = `card-${cardId}`;
|
||||
card.className = "comfy-sidebar-card completed";
|
||||
card.style.position = "relative";
|
||||
|
||||
const titleFromState = st.nodeTitles?.[out.nodeId];
|
||||
const node = st.workflow?.nodes?.find(n => String(n.id) === String(out.nodeId));
|
||||
const resolvedTitle = titleFromState || (node ? (node.title || node.type) : out.nodeTitle) || "Node";
|
||||
|
||||
const timerEl = document.createElement("div");
|
||||
timerEl.className = "comfy-sidebar-card-timer";
|
||||
timerEl.textContent = `${resolvedTitle} (#${out.nodeId})`;
|
||||
timerEl.style.display = "block";
|
||||
|
||||
const dimEl = document.createElement("div");
|
||||
Object.assign(dimEl.style, {
|
||||
position: "absolute", top: "6px", right: "8px", fontSize: "10px",
|
||||
fontFamily: "monospace", opacity: "0.7", background: "rgba(0, 0, 0, 0.6)",
|
||||
padding: "2px 4px", borderRadius: "3px", pointerEvents: "none", zIndex: "5", color: "#fff",
|
||||
display: "none", transform: "translateZ(0)"
|
||||
});
|
||||
|
||||
const grid = document.createElement("div");
|
||||
grid.style.display = "flex";
|
||||
grid.style.flexDirection = "column";
|
||||
grid.style.gap = "6px";
|
||||
|
||||
const p = document.createElement("div");
|
||||
p.className = "comfy-sidebar-text-clamp";
|
||||
|
||||
const hoverPanel = document.createElement("div");
|
||||
hoverPanel.className = "comfy-sidebar-hover-panel";
|
||||
Object.assign(hoverPanel.style, {
|
||||
position: "absolute", bottom: "4px", right: "4px",
|
||||
flexDirection: "column", gap: "4px", zIndex: "20"
|
||||
});
|
||||
|
||||
const btnImg = document.createElement("span");
|
||||
btnImg.className = "pi pi-image comfy-sidebar-card-action-btn";
|
||||
btnImg.title = "Download Object";
|
||||
btnImg.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
out.images.forEach(img => {
|
||||
const a = document.createElement("a");
|
||||
a.href = img.url ? img.url : `/view?filename=${encodeURIComponent(img.filename)}&type=${img.type || 'output'}&subfolder=${encodeURIComponent(img.subfolder || '')}`;
|
||||
a.download = img.filename || "output";
|
||||
a.click();
|
||||
});
|
||||
};
|
||||
|
||||
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();
|
||||
|
||||
if (shouldDeleteFromDisk && out.images) {
|
||||
for (const imgItem of out.images) {
|
||||
await deleteFileOnServer(imgItem);
|
||||
}
|
||||
}
|
||||
|
||||
if (st.nodeOutputs && st.nodeOutputs[out.nodeId]) {
|
||||
delete st.nodeOutputs[out.nodeId];
|
||||
}
|
||||
|
||||
const remainingOutputs = getRunOutputs(st.nodeOutputs, st.workflow);
|
||||
if (remainingOutputs.length > 0) {
|
||||
st.images = remainingOutputs[remainingOutputs.length - 1].images || [];
|
||||
} else {
|
||||
st.images = [];
|
||||
store.closeSubmenu();
|
||||
}
|
||||
|
||||
store.updatePrompt(st.pid, st);
|
||||
renderSidebar();
|
||||
}
|
||||
};
|
||||
|
||||
hoverPanel.append(btnImg, btnDel);
|
||||
|
||||
const leftHoverPanel = document.createElement("div");
|
||||
leftHoverPanel.className = "comfy-sidebar-left-hover-panel";
|
||||
Object.assign(leftHoverPanel.style, {
|
||||
position: "absolute", bottom: "4px", left: "4px",
|
||||
flexDirection: "column", gap: "4px", zIndex: "20"
|
||||
});
|
||||
|
||||
const btnCopy = document.createElement("span");
|
||||
btnCopy.className = "pi pi-copy comfy-sidebar-card-action-btn";
|
||||
btnCopy.title = "Copy to Clipboard";
|
||||
const firstImg = out.images[0];
|
||||
const isCopyable = firstImg && isImageFormat(firstImg.filename || firstImg.url);
|
||||
if (!isCopyable) {
|
||||
btnCopy.style.display = "none";
|
||||
} else {
|
||||
btnCopy.onclick = async (ev) => {
|
||||
ev.stopPropagation();
|
||||
const src = firstImg.url ? firstImg.url : `/view?filename=${encodeURIComponent(firstImg.filename)}&type=${firstImg.type || 'output'}&subfolder=${encodeURIComponent(firstImg.subfolder || '')}`;
|
||||
const success = await copyImageToClipboard(src);
|
||||
if (success) {
|
||||
btnCopy.className = "pi pi-check comfy-sidebar-card-action-btn";
|
||||
btnCopy.style.color = "#4ade80";
|
||||
setTimeout(() => {
|
||||
btnCopy.className = "pi pi-copy comfy-sidebar-card-action-btn";
|
||||
btnCopy.style.color = "";
|
||||
}, 1500);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const btnFocus = document.createElement("span");
|
||||
btnFocus.className = "pi pi-eye comfy-sidebar-card-action-btn";
|
||||
btnFocus.title = "Show Node";
|
||||
btnFocus.onclick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
centerAndSelectCanvasNode(out.nodeId);
|
||||
};
|
||||
|
||||
leftHoverPanel.append(btnCopy, btnFocus);
|
||||
card.append(timerEl, dimEl, grid, p, hoverPanel, leftHoverPanel);
|
||||
|
||||
cardObj = {
|
||||
element: card,
|
||||
timerEl,
|
||||
dimEl,
|
||||
grid,
|
||||
placeholder: p,
|
||||
hoverPanel,
|
||||
leftHoverPanel,
|
||||
btnImg,
|
||||
btnCopy,
|
||||
btnFocus,
|
||||
btnDel,
|
||||
firstImgElement: null
|
||||
};
|
||||
|
||||
cardPool.set(cardId, cardObj);
|
||||
}
|
||||
|
||||
if (out.images && out.images.length > 0) {
|
||||
cardObj.placeholder.style.display = "none";
|
||||
renderCardImages(cardObj, {
|
||||
pid: st.pid,
|
||||
nodeId: out.nodeId,
|
||||
status: PromptStatus.COMPLETED,
|
||||
images: out.images,
|
||||
workflow: st.workflow,
|
||||
nodeOutputs: st.nodeOutputs
|
||||
}, () => {
|
||||
// Clicking (1/N) opens batch drill-down for this specific intermediate node
|
||||
const scrollEl = getScrollContainer();
|
||||
if (scrollEl) {
|
||||
store.ui.mainQueueScrollTop = scrollEl.scrollTop;
|
||||
}
|
||||
const nodeTitle = st.nodeTitles?.[out.nodeId] || out.nodeTitle || "Node";
|
||||
store.openBatchSubmenu({
|
||||
pid: `${st.pid} - ${nodeTitle} (#${out.nodeId})`,
|
||||
parentPromptId: st.pid,
|
||||
images: out.images,
|
||||
workflow: st.workflow,
|
||||
nodeOutputs: { [out.nodeId]: st.nodeOutputs?.[out.nodeId] }
|
||||
});
|
||||
});
|
||||
} else {
|
||||
cardObj.placeholder.style.display = "block";
|
||||
cardObj.placeholder.textContent = "No Outputs";
|
||||
}
|
||||
|
||||
targetElements.push(cardObj.element);
|
||||
});
|
||||
}
|
||||
|
||||
if (outputs.length === 0) {
|
||||
mountCards(store.ui.cardStack, targetElements, [], currentColumnCount);
|
||||
} else {
|
||||
mountCards(store.ui.cardStack, [], targetElements, currentColumnCount);
|
||||
}
|
||||
|
||||
updateScrollTopBtnVisibility();
|
||||
return;
|
||||
}
|
||||
|
||||
// Main Queue View
|
||||
if (headerTitle) {
|
||||
headerTitle.textContent = "Queue";
|
||||
headerTitle.style.cursor = "default";
|
||||
headerTitle.onclick = null;
|
||||
}
|
||||
if (headerSearchIcon) headerSearchIcon.style.display = "inline";
|
||||
if (headerActions) headerActions.style.display = "flex";
|
||||
|
||||
let tasksArray = store.getAllPrompts();
|
||||
if (showPendingSummary) {
|
||||
tasksArray = tasksArray.filter(t => t.status !== PromptStatus.PENDING);
|
||||
}
|
||||
if (store.ui.searchQuery) {
|
||||
tasksArray = tasksArray.filter(t => matchesFilter(t, store.ui.searchQuery));
|
||||
}
|
||||
tasksArray.sort((a, b) => b.timestamp - a.timestamp);
|
||||
|
||||
const activeTasks = tasksArray.filter(t => t.status === PromptStatus.ACTIVE);
|
||||
const completedTasks = tasksArray.filter(t => t.status === PromptStatus.COMPLETED || t.status === PromptStatus.CANCELLED || t.status === PromptStatus.ERROR);
|
||||
const pendingTasks = tasksArray.filter(t => t.status === PromptStatus.PENDING).sort((a, b) => (b.queueNumber || 0) - (a.queueNumber || 0));
|
||||
|
||||
const syncTaskCard = (state) => {
|
||||
const cardObj = getOrCreateCard(state);
|
||||
return updateCardDOM(cardObj, state, showPendingSummary, showWorkingNode);
|
||||
};
|
||||
|
||||
const bannerElements = [];
|
||||
const cardElements = [];
|
||||
const pendingCount = store.getAllPrompts().filter(t => t.status === PromptStatus.PENDING).length;
|
||||
|
||||
if (pendingCount > 0) {
|
||||
if (showPendingSummary) {
|
||||
let pCard = cardPool.get("pending-summary-card");
|
||||
if (!pCard) {
|
||||
const el = document.createElement("div");
|
||||
Object.assign(el.style, {
|
||||
background: "var(--comfy-input-bg, #181818)", border: "2px solid #6c757d", borderRadius: "4px", padding: "10px",
|
||||
textAlign: "center", fontSize: "12px", fontWeight: "bold",
|
||||
color: "var(--desc-color, #aaa)", display: "flex", flexDirection: "column", gap: "8px"
|
||||
});
|
||||
const textDiv = document.createElement("div");
|
||||
el.appendChild(textDiv);
|
||||
const cancelBtn = document.createElement("button");
|
||||
cancelBtn.textContent = "Cancel All Pending";
|
||||
Object.assign(cancelBtn.style, {
|
||||
background: "#dc3545", color: "white", border: "none", borderRadius: "3px", padding: "4px",
|
||||
cursor: "pointer", fontSize: "11px", fontWeight: "bold"
|
||||
});
|
||||
cancelBtn.onclick = async () => {
|
||||
await cancelAllPending();
|
||||
};
|
||||
el.appendChild(cancelBtn);
|
||||
pCard = { element: el, textDiv };
|
||||
cardPool.set("pending-summary-card", pCard);
|
||||
}
|
||||
pCard.textDiv.textContent = `Pending Queue: ${pendingCount} tasks`;
|
||||
bannerElements.push(pCard.element);
|
||||
} else {
|
||||
let btnCard = cardPool.get("pending-cancel-all-standalone");
|
||||
if (!btnCard) {
|
||||
const btn = document.createElement("button");
|
||||
btn.textContent = "Cancel All Pending";
|
||||
Object.assign(btn.style, {
|
||||
background: "#dc3545", color: "white", border: "none", borderRadius: "3px", padding: "6px",
|
||||
cursor: "pointer", fontSize: "11px", fontWeight: "bold", width: "100%"
|
||||
});
|
||||
btn.onclick = async () => {
|
||||
await cancelAllPending();
|
||||
};
|
||||
btnCard = { element: btn };
|
||||
cardPool.set("pending-cancel-all-standalone", btnCard);
|
||||
}
|
||||
bannerElements.push(btnCard.element);
|
||||
pendingTasks.forEach(st => cardElements.push(syncTaskCard(st)));
|
||||
}
|
||||
}
|
||||
|
||||
activeTasks.forEach(st => cardElements.push(syncTaskCard(st)));
|
||||
completedTasks.forEach(st => cardElements.push(syncTaskCard(st)));
|
||||
|
||||
const threshold = app.ui?.settings?.getSettingValue?.(SettingIds.GRID_COLUMNS_THRESHOLD) ?? 350;
|
||||
const containerWidth = store.ui.sidebarContainer?.clientWidth || 0;
|
||||
if (containerWidth > 0) {
|
||||
currentColumnCount = Math.max(1, Math.floor(containerWidth / (threshold / 2)));
|
||||
}
|
||||
|
||||
mountCards(store.ui.cardStack, bannerElements, cardElements, currentColumnCount);
|
||||
|
||||
if (store.ui.mainQueueScrollTop !== null) {
|
||||
const restorePos = store.ui.mainQueueScrollTop;
|
||||
store.ui.mainQueueScrollTop = null;
|
||||
const scrollEl = getScrollContainer();
|
||||
if (scrollEl) {
|
||||
scrollEl.scrollTop = restorePos;
|
||||
requestAnimationFrame(() => { scrollEl.scrollTop = restorePos; });
|
||||
}
|
||||
}
|
||||
|
||||
updateScrollTopBtnVisibility();
|
||||
});
|
||||
}
|
||||
|
||||
export function teardownSidebarView() {
|
||||
window.removeEventListener("keydown", handleGlobalKeyDown);
|
||||
window.removeEventListener("keyup", handleGlobalKeyUp);
|
||||
window.removeEventListener("blur", handleGlobalBlur);
|
||||
document.removeEventListener("click", handleGlobalClick, true);
|
||||
document.removeEventListener("scroll", updateScrollTopBtnVisibility, { capture: true, passive: true });
|
||||
|
||||
if (activeTimerInterval) {
|
||||
clearInterval(activeTimerInterval);
|
||||
activeTimerInterval = null;
|
||||
}
|
||||
if (renderAnimationFrameId) {
|
||||
cancelAnimationFrame(renderAnimationFrameId);
|
||||
renderAnimationFrameId = null;
|
||||
}
|
||||
cardPool.clear();
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { isVideoFormat, is3DFormat, isAudioFormat, getFilenameFromUrl } from "./utils.js";
|
||||
import { create3DViewer } from "./viewer3d.js";
|
||||
import { createTextReader } from "./text_reader.js";
|
||||
import { stopAllAudioPlayback } from "./ui.js";
|
||||
import { stopAllAudioPlayback } from "../ui/audioController.js";
|
||||
import { SidebarOverlay } from "./overlay.js";
|
||||
|
||||
let activeComparisonViewer = null;
|
||||
@@ -10,6 +10,12 @@ export function isAudioViewerOpen() {
|
||||
return !!(activeComparisonViewer && activeComparisonViewer.isAudio);
|
||||
}
|
||||
|
||||
export function updateActiveComparisonPreview(pid, newSrc) {
|
||||
if (activeComparisonViewer && activeComparisonViewer.targetPid === String(pid)) {
|
||||
activeComparisonViewer.loadTarget(newSrc);
|
||||
}
|
||||
}
|
||||
|
||||
const getClientX = (e) => {
|
||||
if (e.touches && e.touches.length > 0) return e.touches[0].clientX;
|
||||
if (e.changedTouches && e.changedTouches.length > 0) return e.changedTouches[0].clientX;
|
||||
@@ -264,10 +270,14 @@ const setupVideoPlayback = (vid, container) => {
|
||||
const controlBar = document.createElement("div");
|
||||
Object.assign(controlBar.style, {
|
||||
position: "absolute", bottom: "16px", left: "50%",
|
||||
display: "flex", alignItems: "center", gap: "12px", background: "rgba(10,10,10,0.85)",
|
||||
display: "flex", alignItems: "center", gap: "12px",
|
||||
background: "var(--comfy-input-bg, rgba(18, 18, 18, 0.9))",
|
||||
border: "1px solid var(--border-color, rgba(255, 255, 255, 0.15))",
|
||||
padding: "8px 16px", borderRadius: "8px", zIndex: "40", fontSize: "11px",
|
||||
fontFamily: "monospace", color: "#eee", width: "80%", maxWidth: "500px",
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.5)", transform: "translate3d(-50%, 0, 0)",
|
||||
fontFamily: "monospace",
|
||||
color: "var(--fg-color, #eee)",
|
||||
width: "80%", maxWidth: "500px",
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.3)", transform: "translate3d(-50%, 0, 0)",
|
||||
pointerEvents: "auto"
|
||||
});
|
||||
|
||||
@@ -380,10 +390,14 @@ function createComparisonViewer(baseSrc, onDestroy = () => {}) {
|
||||
const header = document.createElement("div");
|
||||
Object.assign(header.style, {
|
||||
position: "absolute", top: "16px", left: "50%", transform: "translateX(-50%)",
|
||||
display: "flex", gap: "16px", zIndex: "30", color: "#aaa", 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)", maxWidth: "85%", textAlign: "center"
|
||||
display: "flex", gap: "16px", zIndex: "30",
|
||||
color: "var(--desc-color, #aaa)",
|
||||
fontSize: "12px",
|
||||
fontFamily: "sans-serif", pointerEvents: "none",
|
||||
background: "var(--comfy-input-bg, rgba(18, 18, 18, 0.85))",
|
||||
border: "1px solid var(--border-color, rgba(255, 255, 255, 0.15))",
|
||||
padding: "6px 14px", borderRadius: "6px", backdropFilter: "blur(6px)",
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.3)", maxWidth: "85%", textAlign: "center"
|
||||
});
|
||||
const infoText = document.createElement("span");
|
||||
infoText.textContent = isBaseVideo
|
||||
@@ -667,7 +681,7 @@ function createComparisonViewer(baseSrc, onDestroy = () => {}) {
|
||||
};
|
||||
}
|
||||
|
||||
export function showFullscreenPreview(imgSrcs, isShiftClick = false) {
|
||||
export function showFullscreenPreview(imgSrcs, isShiftClick = false, pid = null) {
|
||||
if (!imgSrcs || imgSrcs.length === 0) return;
|
||||
|
||||
const item = imgSrcs[0];
|
||||
@@ -743,4 +757,7 @@ export function showFullscreenPreview(imgSrcs, isShiftClick = false) {
|
||||
src,
|
||||
() => { activeComparisonViewer = null; }
|
||||
);
|
||||
if (activeComparisonViewer) {
|
||||
activeComparisonViewer.targetPid = pid ? String(pid) : null;
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import { app } from "/scripts/app.js";
|
||||
import { State } from "./state.js";
|
||||
import { store } from "../core/store.js";
|
||||
|
||||
async function promoteOrUploadToInput(imageObj) {
|
||||
// 1. Try server-side promotion first (no browser RAM/re-upload overhead)
|
||||
@@ -145,7 +145,7 @@ export function setupDragAndDrop() {
|
||||
|
||||
const onDrop = async (e) => {
|
||||
const isSidebarDrop = e.target.closest('.comfyui-sidebar, .comfy-sidebar, [class*="sidebar"]') ||
|
||||
(State.sidebarContainer && State.sidebarContainer.contains(e.target));
|
||||
(store.ui.sidebarContainer && store.ui.sidebarContainer.contains(e.target));
|
||||
if (isSidebarDrop) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -1,4 +1,4 @@
|
||||
import { State } from "./state.js";
|
||||
import { store } from "../core/store.js";
|
||||
|
||||
export class SidebarOverlay {
|
||||
constructor(options = {}) {
|
||||
@@ -10,7 +10,9 @@ export class SidebarOverlay {
|
||||
this.container.className = `comfy-sidebar-comparison-overlay ${options.className || ""}`;
|
||||
Object.assign(this.container.style, {
|
||||
position: "fixed", top: "0", left: "0", width: "100vw", height: "100vh",
|
||||
background: options.background || "rgba(10, 10, 10, 0.95)",
|
||||
background: options.background || "color-mix(in srgb, var(--comfy-menu-bg, #121212) 95%, transparent)",
|
||||
backdropFilter: "blur(8px)",
|
||||
color: "var(--fg-color, #eee)",
|
||||
display: "flex", flexDirection: "column", alignItems: "center",
|
||||
justifyContent: "center", zIndex: "1000", boxSizing: "border-box",
|
||||
overflow: "hidden", pointerEvents: "auto", userSelect: "none"
|
||||
@@ -22,11 +24,22 @@ export class SidebarOverlay {
|
||||
this.closeBtn.title = "Close (Esc)";
|
||||
Object.assign(this.closeBtn.style, {
|
||||
position: "absolute", top: "16px", right: "24px", zIndex: "30",
|
||||
cursor: "pointer", fontSize: "20px", color: "#aaa", transition: "color 0.15s ease",
|
||||
background: "rgba(10,10,10,0.6)", borderRadius: "50%", padding: "4px"
|
||||
cursor: "pointer", fontSize: "18px",
|
||||
color: "var(--desc-color, #aaa)",
|
||||
transition: "all 0.15s ease",
|
||||
background: "var(--comfy-input-bg, rgba(20, 20, 20, 0.6))",
|
||||
border: "1px solid var(--border-color, rgba(255, 255, 255, 0.1))",
|
||||
borderRadius: "50%", padding: "6px"
|
||||
});
|
||||
this.closeBtn.onmouseenter = () => this.closeBtn.style.color = "#fff";
|
||||
this.closeBtn.onmouseleave = () => this.closeBtn.style.color = "#aaa";
|
||||
this.closeBtn.onmouseenter = () => {
|
||||
this.closeBtn.style.color = "var(--fg-color, #fff)";
|
||||
this.closeBtn.style.borderColor = "var(--fg-color, #fff)";
|
||||
};
|
||||
this.closeBtn.onmouseleave = () => {
|
||||
this.closeBtn.style.color = "var(--desc-color, #aaa)";
|
||||
this.closeBtn.style.borderColor = "var(--border-color, rgba(255, 255, 255, 0.1))";
|
||||
};
|
||||
|
||||
this.closeBtn.onclick = () => this.destroy();
|
||||
this.container.appendChild(this.closeBtn);
|
||||
|
||||
@@ -41,7 +54,7 @@ export class SidebarOverlay {
|
||||
window.addEventListener("resize", this.updateOverlayBounds);
|
||||
this.cleanupFns.push(() => window.removeEventListener("resize", this.updateOverlayBounds));
|
||||
|
||||
const sidebarEl = State.sidebarContainer?.closest('.comfyui-sidebar, .comfy-sidebar, .p-sidebar, [class*="sidebar"]') || State.sidebarContainer;
|
||||
const sidebarEl = store.ui.sidebarContainer?.closest('.comfyui-sidebar, .comfy-sidebar, .p-sidebar, [class*="sidebar"]') || store.ui.sidebarContainer;
|
||||
if (sidebarEl && window.ResizeObserver) {
|
||||
const ro = new ResizeObserver(this.updateOverlayBounds);
|
||||
ro.observe(sidebarEl);
|
||||
@@ -63,7 +76,7 @@ export class SidebarOverlay {
|
||||
}
|
||||
|
||||
updateOverlayBounds() {
|
||||
const sidebarEl = State.sidebarContainer?.closest('.comfyui-sidebar, .comfy-sidebar, .p-sidebar, [class*="sidebar"]') || State.sidebarContainer;
|
||||
const sidebarEl = store.ui.sidebarContainer?.closest('.comfyui-sidebar, .comfy-sidebar, .p-sidebar, [class*="sidebar"]') || store.ui.sidebarContainer;
|
||||
if (sidebarEl && sidebarEl.offsetWidth > 0 && sidebarEl.isConnected) {
|
||||
const rect = sidebarEl.getBoundingClientRect();
|
||||
if (rect.left < window.innerWidth / 2) {
|
||||
@@ -6,7 +6,7 @@ export function injectStyles() {
|
||||
.comfy-sidebar-card {
|
||||
background: var(--comfy-input-bg, #181818);
|
||||
border-radius: 4px; padding: 8px; position: relative;
|
||||
min-height: 80px; margin-bottom: 12px; break-inside: avoid;
|
||||
min-height: 80px; margin-bottom: 0px; break-inside: avoid;
|
||||
user-select: none; -webkit-user-select: none;
|
||||
border: 2px solid var(--border-color, #333);
|
||||
color: var(--comfy-input-color, var(--fg-color, #eee));
|
||||
@@ -201,11 +201,14 @@ export function createTextReader(textData, onSwitchMedia = () => {}, onDestroy =
|
||||
|
||||
const readerBox = document.createElement("div");
|
||||
Object.assign(readerBox.style, {
|
||||
background: "rgba(18, 18, 24, 0.95)", backdropFilter: "blur(12px)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.15)", borderRadius: "12px",
|
||||
background: "var(--comfy-menu-bg, rgba(18, 18, 24, 0.95))",
|
||||
backdropFilter: "blur(12px)",
|
||||
border: "1px solid var(--border-color, rgba(255, 255, 255, 0.15))",
|
||||
borderRadius: "12px",
|
||||
padding: "20px 24px", width: "85%", maxWidth: "840px", height: "80vh",
|
||||
boxShadow: "0 12px 40px rgba(0,0,0,0.8)", display: "flex",
|
||||
flexDirection: "column", gap: "14px", zIndex: "20"
|
||||
boxShadow: "0 12px 40px rgba(0,0,0,0.4)", display: "flex",
|
||||
flexDirection: "column", gap: "14px", zIndex: "20",
|
||||
color: "var(--fg-color, #eee)"
|
||||
});
|
||||
|
||||
const headerRow = document.createElement("div");
|
||||
@@ -258,10 +261,14 @@ export function createTextReader(textData, onSwitchMedia = () => {}, onDestroy =
|
||||
const textArea = document.createElement("div");
|
||||
Object.assign(textArea.style, {
|
||||
flex: "1", overflowY: "auto", fontSize: "13px",
|
||||
lineHeight: "1.6", color: "#e2e8f0", wordBreak: "break-word",
|
||||
userSelect: "text", "-webkit-user-select": "text", background: "rgba(0,0,0,0.35)",
|
||||
padding: "16px 20px", borderRadius: "6px", border: "1px solid rgba(255,255,255,0.06)",
|
||||
scrollbarWidth: "thin", scrollbarColor: "#475569 rgba(0,0,0,0.2)"
|
||||
lineHeight: "1.6",
|
||||
color: "var(--fg-color, #e2e8f0)",
|
||||
wordBreak: "break-word",
|
||||
userSelect: "text", "-webkit-user-select": "text",
|
||||
background: "var(--comfy-input-bg, rgba(0, 0, 0, 0.2))",
|
||||
padding: "16px 20px", borderRadius: "6px",
|
||||
border: "1px solid var(--border-color, rgba(255, 255, 255, 0.08))",
|
||||
scrollbarWidth: "thin"
|
||||
});
|
||||
|
||||
const updateView = () => {
|
||||
+175
-86
@@ -2,52 +2,62 @@ 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().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().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().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) {
|
||||
if (!url) return "";
|
||||
if (typeof url === "object") {
|
||||
url = url.filename || url.url || "";
|
||||
}
|
||||
const str = String(url);
|
||||
try {
|
||||
const u = new URL(url, window.location.origin);
|
||||
const origin = window.location?.origin && window.location.origin !== "null" ? window.location.origin : "http://localhost";
|
||||
const u = new URL(str, origin);
|
||||
if (u.searchParams && u.searchParams.get("filename")) {
|
||||
return u.searchParams.get("filename");
|
||||
}
|
||||
const pathname = u.pathname || "";
|
||||
return pathname.split("/").pop() || "";
|
||||
} catch (e) {
|
||||
return String(url).split("?")[0].split("/").pop() || "";
|
||||
const m = str.match(/[?&]filename=([^&#]+)/);
|
||||
if (m) {
|
||||
try { return decodeURIComponent(m[1]); } catch (err) { return m[1]; }
|
||||
}
|
||||
return str.split("?")[0].split("#")[0].split("/").pop() || "";
|
||||
}
|
||||
}
|
||||
|
||||
const checkExt = (url, extensions) => {
|
||||
if (!url) return false;
|
||||
const name = getFilenameFromUrl(url).toLowerCase();
|
||||
const s = String(url).toLowerCase().split("?")[0].split("#")[0];
|
||||
return extensions.some(ext => name.endsWith(ext) || s.endsWith(ext));
|
||||
};
|
||||
|
||||
export const isImageFormat = (url) => {
|
||||
if (!url) return false;
|
||||
if (String(url).startsWith("blob:") || String(url).startsWith("data:image/")) return true;
|
||||
return checkExt(url, [
|
||||
".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp",
|
||||
".tiff", ".tif", ".svg", ".avif", ".ico", ".apng"
|
||||
]);
|
||||
};
|
||||
|
||||
export const isVideoFormat = (url) => {
|
||||
return checkExt(url, [".mp4", ".webm", ".mov", ".mkv"]);
|
||||
};
|
||||
|
||||
export const is3DFormat = (url) => {
|
||||
return checkExt(url, [
|
||||
".glb", ".gltf", ".obj", ".ply", ".stl",
|
||||
".splat", ".spz", ".ksplat", ".fbx"
|
||||
]);
|
||||
};
|
||||
|
||||
export const isAudioFormat = (url) => {
|
||||
return checkExt(url, [
|
||||
".wav", ".mp3", ".ogg", ".flac", ".m4a", ".aac", ".opus"
|
||||
]);
|
||||
};
|
||||
|
||||
export function parseWorkflow(workflow) {
|
||||
if (!workflow) return null;
|
||||
if (typeof workflow === "object") return workflow;
|
||||
@@ -57,28 +67,66 @@ export function parseWorkflow(workflow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function isNodeIgnored(nodeId, rawWorkflow) {
|
||||
// 1. Check live canvas node property first
|
||||
if (app.graph) {
|
||||
export function isNodeIgnored(nodeId, rawWorkflow) {
|
||||
const workflow = parseWorkflow(rawWorkflow);
|
||||
if (workflow && Array.isArray(workflow.nodes)) {
|
||||
const node = workflow.nodes.find(n => String(n.id) === String(nodeId));
|
||||
if (node?.properties?.ignoreInQueue !== undefined) {
|
||||
return !!node.properties.ignoreInQueue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!workflow && app.graph) {
|
||||
const canvasNode = app.graph.getNodeById ? (app.graph.getNodeById(Number(nodeId)) || app.graph.getNodeById(String(nodeId))) : null;
|
||||
if (canvasNode?.properties?.ignoreInQueue !== undefined) {
|
||||
return !!canvasNode.properties.ignoreInQueue;
|
||||
}
|
||||
}
|
||||
// 2. Fallback to historical workflow snapshot
|
||||
const workflow = parseWorkflow(rawWorkflow);
|
||||
if (!workflow || !Array.isArray(workflow.nodes)) {
|
||||
return false;
|
||||
}
|
||||
const node = workflow.nodes.find(n => String(n.id) === String(nodeId));
|
||||
return !!(node && node.properties && node.properties.ignoreInQueue);
|
||||
return false;
|
||||
}
|
||||
|
||||
function extractFileCandidate(val, subfolder = "", type = "output") {
|
||||
export function getNodeTitle(nodeId, rawWorkflow, promptGraph = null) {
|
||||
const idStr = String(nodeId);
|
||||
|
||||
const workflow = parseWorkflow(rawWorkflow);
|
||||
if (workflow && Array.isArray(workflow.nodes)) {
|
||||
const node = workflow.nodes.find(n => String(n.id) === idStr);
|
||||
if (node) {
|
||||
return node.title || node.type || null;
|
||||
}
|
||||
}
|
||||
|
||||
if (promptGraph && promptGraph[idStr]) {
|
||||
const pNode = promptGraph[idStr];
|
||||
if (pNode._meta?.title) return pNode._meta.title;
|
||||
if (pNode.class_type) return pNode.class_type;
|
||||
}
|
||||
|
||||
if (app.graph) {
|
||||
const canvasNode = app.graph.getNodeById ? (app.graph.getNodeById(Number(nodeId)) || app.graph.getNodeById(idStr)) : null;
|
||||
if (canvasNode) {
|
||||
return canvasNode.title || canvasNode.type || null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function cleanSubfolder(subfolder) {
|
||||
if (!subfolder) return "";
|
||||
let s = String(subfolder).replace(/\\/g, "/").trim();
|
||||
s = s.replace(/^\/+|\/+$/g, ""); // Strip leading/trailing slashes
|
||||
if (s.toLowerCase() === "output") return "";
|
||||
if (s.toLowerCase().startsWith("output/")) {
|
||||
s = s.substring(7);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function extractFileCandidate(val, subfolder = "", type = "output", nodeType = "") {
|
||||
if (!val || typeof val !== "string") return null;
|
||||
const trimmed = val.trim();
|
||||
let trimmed = val.replace(/\\/g, "/").trim();
|
||||
|
||||
// 1. Ignore directory dots, floats/numbers, and empty strings
|
||||
if (trimmed === "." || trimmed === ".." || trimmed === "./" || trimmed === "../" || trimmed === "") {
|
||||
return null;
|
||||
}
|
||||
@@ -86,11 +134,31 @@ function extractFileCandidate(val, subfolder = "", type = "output") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. Extract base file name
|
||||
const cleanName = trimmed.split("/").pop()?.split("\\").pop()?.trim() || "";
|
||||
// Extract any path ending with a known media or model extension from text messages
|
||||
const fileMatch = trimmed.match(/([a-zA-Z0-9_\-\.\/]+\.(?:safetensors|ckpt|pt|bin|latent|png|jpg|jpeg|webp|mp4|webm|wav|mp3|glb|gltf))/i);
|
||||
if (fileMatch) {
|
||||
trimmed = fileMatch[1];
|
||||
}
|
||||
|
||||
// Auto-append .safetensors if this is a LoRA node/subfolder without an extension
|
||||
if (!trimmed.includes(".")) {
|
||||
const typeLower = (nodeType || "").toLowerCase();
|
||||
const subLower = (subfolder || "").toLowerCase();
|
||||
const isLora = typeLower.includes("lora") || subLower.includes("lora") || trimmed.includes("lora");
|
||||
const isLatent = typeLower.includes("latent");
|
||||
|
||||
if (isLora) {
|
||||
trimmed += ".safetensors";
|
||||
} else if (isLatent) {
|
||||
trimmed += ".latent";
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const cleanName = trimmed.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;
|
||||
@@ -100,53 +168,58 @@ function extractFileCandidate(val, subfolder = "", type = "output") {
|
||||
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 null;
|
||||
|
||||
if (isValidExt) {
|
||||
return {
|
||||
filename: cleanName,
|
||||
subfolder: subfolder || (trimmed.includes("/") ? trimmed.substring(0, trimmed.lastIndexOf("/")) : ""),
|
||||
type: type || "output"
|
||||
};
|
||||
let detectedSub = subfolder;
|
||||
if (trimmed.includes("/")) {
|
||||
detectedSub = trimmed.substring(0, trimmed.lastIndexOf("/"));
|
||||
}
|
||||
return null;
|
||||
detectedSub = cleanSubfolder(detectedSub);
|
||||
|
||||
return {
|
||||
filename: cleanName,
|
||||
subfolder: detectedSub,
|
||||
type: type || "output"
|
||||
};
|
||||
}
|
||||
|
||||
export function findImagesInOutputs(outputs, rawWorkflow) {
|
||||
const list = [];
|
||||
const seen = new Set();
|
||||
const workflow = parseWorkflow(rawWorkflow);
|
||||
|
||||
const addCandidate = (item) => {
|
||||
if (!item || !item.filename) return;
|
||||
const key = `${item.subfolder || ""}/${item.filename}`;
|
||||
const key = `${item.type || "output"}/${item.subfolder || ""}/${item.filename}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
list.push(item);
|
||||
}
|
||||
};
|
||||
|
||||
const scan = (obj, sub = "", typ = "output") => {
|
||||
const scan = (obj, sub = "", typ = "output", nodeType = "") => {
|
||||
if (!obj) return;
|
||||
if (typeof obj === "string") {
|
||||
const cand = extractFileCandidate(obj, sub, typ);
|
||||
const cand = extractFileCandidate(obj, sub, typ, nodeType);
|
||||
if (cand) addCandidate(cand);
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(obj)) {
|
||||
obj.forEach(item => scan(item, sub, typ));
|
||||
obj.forEach(item => scan(item, sub, typ, nodeType));
|
||||
return;
|
||||
}
|
||||
if (typeof obj === "object") {
|
||||
const targetSub = obj.subfolder || sub || "";
|
||||
const targetSub = obj.subfolder !== undefined ? 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;
|
||||
obj.saved_file || obj.output_path || obj.file_name ||
|
||||
obj.output || obj.saved;
|
||||
|
||||
if (fname && typeof fname === "string") {
|
||||
const cand = extractFileCandidate(fname, targetSub, targetTyp);
|
||||
const cand = extractFileCandidate(fname, targetSub, targetTyp, nodeType);
|
||||
if (cand) {
|
||||
addCandidate(cand);
|
||||
return;
|
||||
@@ -154,48 +227,51 @@ export function findImagesInOutputs(outputs, rawWorkflow) {
|
||||
}
|
||||
|
||||
for (const key in obj) {
|
||||
scan(obj[key], targetSub, targetTyp);
|
||||
scan(obj[key], targetSub, targetTyp, nodeType || key);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const workflow = parseWorkflow(rawWorkflow);
|
||||
|
||||
if (outputs) {
|
||||
for (const nodeId in outputs) {
|
||||
if (isNodeIgnored(nodeId, workflow)) continue;
|
||||
scan(outputs[nodeId]);
|
||||
const node = workflow?.nodes?.find(n => String(n.id) === String(nodeId));
|
||||
const nodeType = node ? (node.type || "") : "";
|
||||
scan(outputs[nodeId], "", "output", nodeType);
|
||||
}
|
||||
}
|
||||
|
||||
if (list.length > 0) return list;
|
||||
|
||||
// Fallback: If scanning a specific node ID, only look at that matching node in the workflow
|
||||
// Fallback: Check workflow nodes for Save / Extract / Export nodes
|
||||
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))) {
|
||||
if (targetNodeIds && targetNodeIds.length > 0 && !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"));
|
||||
|
||||
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";
|
||||
let defaultSub = "";
|
||||
if (typeStr.includes("lora")) defaultSub = "loras";
|
||||
const cand = extractFileCandidate(val, defaultSub, "output", typeStr);
|
||||
if (cand) {
|
||||
cand.isFallback = true;
|
||||
addCandidate(cand);
|
||||
}
|
||||
const cand = extractFileCandidate(fname, typeStr.includes("lora") ? "loras" : "", "output");
|
||||
if (cand) addCandidate(cand);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -234,19 +310,26 @@ export function findTextsInOutputs(outputs, rawWorkflow) {
|
||||
return list;
|
||||
}
|
||||
|
||||
export function getRunOutputs(nodeOutputs, rawWorkflow) {
|
||||
export function getRunOutputs(nodeOutputs, rawWorkflow, promptGraph = null) {
|
||||
const workflow = parseWorkflow(rawWorkflow);
|
||||
const list = [];
|
||||
if (!nodeOutputs) return list;
|
||||
for (const nodeId in nodeOutputs) {
|
||||
if (isNodeIgnored(nodeId, workflow)) continue;
|
||||
const nodeOut = nodeOutputs[nodeId];
|
||||
// Ignore empty node outputs
|
||||
if (!nodeOut || (typeof nodeOut === "object" && Object.keys(nodeOut).length === 0)) {
|
||||
// Even if the output dict is empty, check if this node was a Save/Extract node
|
||||
const imgs = findImagesInOutputs({ [nodeId]: nodeOut }, workflow);
|
||||
if (imgs.length > 0) {
|
||||
const title = getNodeTitle(nodeId, workflow, promptGraph);
|
||||
list.push({ nodeId, images: imgs, nodeTitle: title });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const imgs = findImagesInOutputs({ [nodeId]: nodeOut }, workflow);
|
||||
if (imgs.length > 0) {
|
||||
list.push({ nodeId, images: imgs });
|
||||
const title = getNodeTitle(nodeId, workflow, promptGraph);
|
||||
list.push({ nodeId, images: imgs, nodeTitle: title });
|
||||
}
|
||||
}
|
||||
return list;
|
||||
@@ -256,6 +339,13 @@ export function getPrimaryOutputImages(nodeOutputs, rawWorkflow) {
|
||||
if (!nodeOutputs) return findImagesInOutputs(null, rawWorkflow);
|
||||
const runOutputs = getRunOutputs(nodeOutputs, rawWorkflow);
|
||||
if (runOutputs.length === 0) return findImagesInOutputs(nodeOutputs, rawWorkflow);
|
||||
// Find the latest executed node that actually produced real output images
|
||||
for (let i = runOutputs.length - 1; i >= 0; i--) {
|
||||
const imgs = runOutputs[i].images || [];
|
||||
if (imgs.some(img => !img.isFallback)) {
|
||||
return imgs;
|
||||
}
|
||||
}
|
||||
return runOutputs[runOutputs.length - 1].images || [];
|
||||
}
|
||||
|
||||
@@ -291,7 +381,6 @@ export async function extractWorkflowFromPng(imageUrl) {
|
||||
const buffer = await res.arrayBuffer();
|
||||
const view = new DataView(buffer);
|
||||
|
||||
// Verify PNG signature (89 50 4E 47 0D 0A 1A 0A)
|
||||
if (view.getUint32(0) !== 0x89504E47 || view.getUint32(4) !== 0x0D0A1A0A) {
|
||||
return null;
|
||||
}
|
||||
@@ -317,7 +406,7 @@ export async function extractWorkflowFromPng(imageUrl) {
|
||||
break;
|
||||
}
|
||||
|
||||
offset += 12 + length; // 4 (length) + 4 (type) + length (data) + 4 (crc)
|
||||
offset += 12 + length;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Comfy Sidebar: Could not extract workflow from PNG", e);
|
||||
@@ -1,35 +1,65 @@
|
||||
import { is3DFormat } from "./utils.js";
|
||||
import { is3DFormat, getFilenameFromUrl } from "./utils.js";
|
||||
import { SidebarOverlay } from "./overlay.js";
|
||||
|
||||
let cachedThreeLibs = null;
|
||||
|
||||
async function getThreeLibs() {
|
||||
if (cachedThreeLibs) return cachedThreeLibs;
|
||||
if (window.THREE && window.THREE.GLTFLoader && window.THREE.OrbitControls) {
|
||||
cachedThreeLibs = window.THREE;
|
||||
return cachedThreeLibs;
|
||||
}
|
||||
try {
|
||||
const [three, controls, gltf, obj, stl, ply] = await Promise.all([
|
||||
import("https://esm.sh/three@0.170.0"),
|
||||
import("https://esm.sh/three@0.170.0/examples/jsm/controls/OrbitControls.js"),
|
||||
import("https://esm.sh/three@0.170.0/examples/jsm/loaders/GLTFLoader.js"),
|
||||
import("https://esm.sh/three@0.170.0/examples/jsm/loaders/OBJLoader.js"),
|
||||
import("https://esm.sh/three@0.170.0/examples/jsm/loaders/STLLoader.js"),
|
||||
import("https://esm.sh/three@0.170.0/examples/jsm/loaders/PLYLoader.js")
|
||||
]);
|
||||
|
||||
// Check if Three.js was already loaded on window by another ComfyUI 3D node
|
||||
if (window.THREE) {
|
||||
const OrbitControls = window.THREE.OrbitControls || window.OrbitControls;
|
||||
const GLTFLoader = window.THREE.GLTFLoader || window.GLTFLoader;
|
||||
if (OrbitControls && GLTFLoader) {
|
||||
cachedThreeLibs = {
|
||||
...window.THREE,
|
||||
OrbitControls,
|
||||
GLTFLoader,
|
||||
OBJLoader: window.THREE.OBJLoader || window.OBJLoader,
|
||||
STLLoader: window.THREE.STLLoader || window.STLLoader,
|
||||
PLYLoader: window.THREE.PLYLoader || window.PLYLoader
|
||||
};
|
||||
return cachedThreeLibs;
|
||||
}
|
||||
}
|
||||
|
||||
const loadFromBase = async (base) => {
|
||||
const [three, controls, gltf, obj, stl, ply] = await Promise.all([
|
||||
import(`${base}three@0.170.0`),
|
||||
import(`${base}three@0.170.0/examples/jsm/controls/OrbitControls.js`),
|
||||
import(`${base}three@0.170.0/examples/jsm/loaders/GLTFLoader.js`),
|
||||
import(`${base}three@0.170.0/examples/jsm/loaders/OBJLoader.js`).catch(() => null),
|
||||
import(`${base}three@0.170.0/examples/jsm/loaders/STLLoader.js`).catch(() => null),
|
||||
import(`${base}three@0.170.0/examples/jsm/loaders/PLYLoader.js`).catch(() => null)
|
||||
]);
|
||||
return { three, controls, gltf, obj, stl, ply };
|
||||
};
|
||||
|
||||
try {
|
||||
let loaded = null;
|
||||
// 1. Try local Python proxy on 'self' (bypasses CSP & --disable-api-nodes)
|
||||
try {
|
||||
loaded = await loadFromBase("/comfy-sidebar/three-proxy/");
|
||||
window.__COMFY_SIDEBAR_THREE_BASE = "/comfy-sidebar/three-proxy/";
|
||||
} catch (proxyErr) {
|
||||
// 2. Fall back to direct CDN if proxy is unreachable
|
||||
console.warn("Comfy Sidebar: Proxy unavailable, trying direct CDN...", proxyErr);
|
||||
loaded = await loadFromBase("https://esm.sh/");
|
||||
window.__COMFY_SIDEBAR_THREE_BASE = "https://esm.sh/";
|
||||
}
|
||||
|
||||
const { three, controls, gltf, obj, stl, ply } = loaded;
|
||||
cachedThreeLibs = {
|
||||
...three,
|
||||
OrbitControls: controls.OrbitControls,
|
||||
GLTFLoader: gltf.GLTFLoader,
|
||||
OBJLoader: obj.OBJLoader,
|
||||
STLLoader: stl.STLLoader,
|
||||
PLYLoader: ply.PLYLoader
|
||||
OrbitControls: controls.OrbitControls || controls.default || controls,
|
||||
GLTFLoader: gltf.GLTFLoader || gltf.default || gltf,
|
||||
OBJLoader: obj?.OBJLoader || obj?.default || obj,
|
||||
STLLoader: stl?.STLLoader || stl?.default || stl,
|
||||
PLYLoader: ply?.PLYLoader || ply?.default || ply
|
||||
};
|
||||
return cachedThreeLibs;
|
||||
} catch (err) {
|
||||
console.error("Comfy Sidebar: Failed to load Three.js libraries", err);
|
||||
console.error("Comfy Sidebar: Failed to load Three.js libraries:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -49,7 +79,7 @@ export function create3DViewer(baseSrc, onSwitchMedia = () => {}, onDestroy = ()
|
||||
boxShadow: "0 2px 6px rgba(0,0,0,0.4)", maxWidth: "85%", textAlign: "center"
|
||||
});
|
||||
const titleText = document.createElement("span");
|
||||
titleText.textContent = baseSrc.split("/").pop().split("?")[0] || "3D Model";
|
||||
titleText.textContent = getFilenameFromUrl(baseSrc) || "3D Model";
|
||||
header.appendChild(titleText);
|
||||
overlay.container.appendChild(header);
|
||||
|
||||
@@ -202,21 +232,25 @@ export function create3DViewer(baseSrc, onSwitchMedia = () => {}, onDestroy = ()
|
||||
loadingSpinner.style.display = "flex";
|
||||
loadingSpinner.innerHTML = `<span class="pi pi-spin pi-spinner"></span> Exporting ${format}...`;
|
||||
|
||||
const base = window.__COMFY_SIDEBAR_THREE_BASE || "/comfy-sidebar/three-proxy/";
|
||||
if (format === "OBJ") {
|
||||
const { OBJExporter } = await import("https://esm.sh/three@0.170.0/examples/jsm/exporters/OBJExporter.js");
|
||||
const mod = await import(`${base}three@0.170.0/examples/jsm/exporters/OBJExporter.js`);
|
||||
const OBJExporter = mod.OBJExporter || mod.default || mod;
|
||||
const exporter = new OBJExporter();
|
||||
const result = exporter.parse(currentModel);
|
||||
const blob = new Blob([result], { type: "text/plain" });
|
||||
downloadBlob(blob, `${baseName}.obj`);
|
||||
} else if (format === "GLB") {
|
||||
const { GLTFExporter } = await import("https://esm.sh/three@0.170.0/examples/jsm/exporters/GLTFExporter.js");
|
||||
const mod = await import(`${base}three@0.170.0/examples/jsm/exporters/GLTFExporter.js`);
|
||||
const GLTFExporter = mod.GLTFExporter || mod.default || mod;
|
||||
const exporter = new GLTFExporter();
|
||||
exporter.parse(currentModel, (gltf) => {
|
||||
const blob = new Blob([gltf], { type: "application/octet-stream" });
|
||||
downloadBlob(blob, `${baseName}.glb`);
|
||||
}, (err) => { console.error(err); }, { binary: true });
|
||||
} else if (format === "STL") {
|
||||
const { STLExporter } = await import("https://esm.sh/three@0.170.0/examples/jsm/exporters/STLExporter.js");
|
||||
const mod = await import(`${base}three@0.170.0/examples/jsm/exporters/STLExporter.js`);
|
||||
const STLExporter = mod.STLExporter || mod.default || mod;
|
||||
const exporter = new STLExporter();
|
||||
const result = exporter.parse(currentModel, { binary: true });
|
||||
const blob = new Blob([result], { type: "application/octet-stream" });
|
||||
@@ -244,11 +278,16 @@ export function create3DViewer(baseSrc, onSwitchMedia = () => {}, onDestroy = ()
|
||||
const initSceneAndLoad = async (srcUrl) => {
|
||||
loadingSpinner.style.display = "flex";
|
||||
loadingSpinner.innerHTML = `<span class="pi pi-spin pi-spinner" style="font-size: 18px;"></span> Loading 3D Asset...`;
|
||||
titleText.textContent = srcUrl.split("/").pop().split("?")[0] || "3D Model";
|
||||
titleText.textContent = getFilenameFromUrl(srcUrl) || "3D Model";
|
||||
|
||||
THREE = await getThreeLibs();
|
||||
if (!THREE) {
|
||||
loadingSpinner.textContent = "Failed to load 3D engine.";
|
||||
loadingSpinner.innerHTML = `
|
||||
<div style="text-align: center; max-width: 320px; line-height: 1.4;">
|
||||
<span style="color: #f87171; font-weight: bold;">Failed to load 3D engine.</span><br>
|
||||
<span style="font-size: 11px; color: #94a3b8;">Blocked by ComfyUI Content Security Policy (CSP). Check if <code>--disable-api-nodes</code> is enabled in your startup arguments, or vendor Three.js locally.</span>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -450,7 +489,8 @@ export function create3DViewer(baseSrc, onSwitchMedia = () => {}, onDestroy = ()
|
||||
}
|
||||
|
||||
try {
|
||||
const ext = (srcUrl.split("?")[0].split(".").pop() || "glb").toLowerCase();
|
||||
const filename = getFilenameFromUrl(srcUrl);
|
||||
const ext = (filename.split(".").pop() || "glb").toLowerCase();
|
||||
let loadedObj = null;
|
||||
|
||||
if (ext === "obj" && THREE.OBJLoader) {
|
||||
+1
-1
@@ -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.4.2"
|
||||
version = "1.5.3"
|
||||
license = { file = "LICENSE" }
|
||||
dependencies = []
|
||||
|
||||
|
||||
Reference in New Issue
Block a user