From 3e7448dd801ff585fc64a83ae00af3e1a3208486 Mon Sep 17 00:00:00 2001 From: m0rtus59 Date: Sun, 6 Sep 2026 20:57:34 +0500 Subject: [PATCH] fix: workflow persistence on restart, card copy button, and submenu stability --- __init__.py | 17 ++++--- js/comparison.js | 9 +++- js/queue.js | 24 +++++++++- js/sidebar.js | 12 ++++- js/ui.js | 118 ++++++++++++++++++++++++++++++++++++++--------- js/utils.js | 48 +++++++++++++++++++ pyproject.toml | 2 +- 7 files changed, 196 insertions(+), 34 deletions(-) diff --git a/__init__.py b/__init__.py index ccb3864..fd4f1cb 100644 --- a/__init__.py +++ b/__init__.py @@ -89,7 +89,8 @@ def open_file_in_os(target_path): if not os.path.exists(norm_path): raise FileNotFoundError(f"Path not found: {norm_path}") if os.path.isfile(norm_path): - subprocess.Popen(["explorer", "/select,", norm_path]) + # Passed as a single argument so Windows Explorer highlights the file properly + subprocess.Popen(["explorer", f"/select,{norm_path}"]) else: os.startfile(norm_path) @@ -106,7 +107,7 @@ def open_file_in_os(target_path): if not os.path.exists(folder): raise FileNotFoundError(f"Path not found: {folder}") if "DISPLAY" in os.environ or "WAYLAND_DISPLAY" in os.environ: - subprocess.Popen(["xdg-open", folder]) + subprocess.Popen(["xdg-open", folder], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) else: raise RuntimeError("Headless Linux server detected (no desktop GUI available).") @@ -162,16 +163,20 @@ async def promote_output_handler(request): try: import shutil data = await request.json() - filename = data.get("filename", "") - subfolder = data.get("subfolder", "") - folder_type = data.get("type", "output") + raw_filename = (data.get("filename") or "").strip() + filename = os.path.basename(raw_filename) + subfolder = (data.get("subfolder") or "").strip() + folder_type = (data.get("type") or "output").strip() - target_file, root = resolve_existing_path(filename, subfolder, folder_type) + target_file, root = resolve_existing_path(raw_filename, subfolder, folder_type) if not target_file or not os.path.isfile(target_file): return web.json_response({"error": "File not found"}, status=404) input_dir = folder_paths.get_input_directory() dest_path = os.path.join(input_dir, filename) + if not is_path_safe(input_dir, dest_path): + return web.json_response({"error": "Unsafe destination path"}, status=403) + shutil.copy2(target_file, dest_path) return web.json_response({"name": filename}) except Exception as e: diff --git a/js/comparison.js b/js/comparison.js index 9686238..5c088b3 100644 --- a/js/comparison.js +++ b/js/comparison.js @@ -240,11 +240,13 @@ const createMediaElement = (src, muted = false) => { width: "auto", height: "auto", objectFit: "contain", - pointerEvents: "none", + pointerEvents: isVideo ? "none" : "auto", userSelect: "none", webkitUserSelect: "none", display: "block" }); + el.draggable = false; + el.ondragstart = (e) => e.preventDefault(); el.src = isVideo ? src + "#t=0.001" : src; return el; }; @@ -444,6 +446,7 @@ function createComparisonViewer(baseSrc, onDestroy = () => {}) { if (!mediaB) { slider.style.display = "none"; + if (mediaA) mediaA.style.pointerEvents = isBaseVideo ? "none" : "auto"; if (isZoomed && wA && hA) { wrapper.style.maxWidth = "none"; wrapper.style.maxHeight = "none"; @@ -497,8 +500,10 @@ function createComparisonViewer(baseSrc, onDestroy = () => {}) { } slider.style.display = "block"; + if (mediaA) mediaA.style.pointerEvents = "none"; + if (mediaB) mediaB.style.pointerEvents = "none"; infoText.style.color = "#aaa"; - infoText.textContent = "Drag slider to compare. Click image to zoom (100%/Fit) | Shift+Click another card | Esc to close."; + infoText.textContent = "Drag slider to compare. Click image to zoom (100%/Fit) | Shift+Click another card to compare | Esc to close."; updateSliderPosition(50); const maxW = Math.max(wA, wB); diff --git a/js/queue.js b/js/queue.js index b8d999c..56ba143 100644 --- a/js/queue.js +++ b/js/queue.js @@ -262,7 +262,10 @@ export function setupApiListeners() { } else { st.activeNodeName = "Finishing..."; } - renderDOMFn(); + // Don't re-render if user is currently browsing an intermediate/batch submenu + if (!State.activeSubmenuPromptId && !State.activeSubmenuBatchImages) { + renderDOMFn(); + } } }; @@ -275,7 +278,10 @@ export function setupApiListeners() { st._oldPreviewBlobUrl = st._previewBlobUrl; st._previewBlobUrl = newBlobUrl; st.images = [{ url: newBlobUrl }]; - renderDOMFn(); + // Keep preview updated in memory, but don't kick the user out of an active submenu + if (!State.activeSubmenuPromptId && !State.activeSubmenuBatchImages) { + renderDOMFn(); + } } }; @@ -381,6 +387,20 @@ export async function initSessionAndHistory() { 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; } diff --git a/js/sidebar.js b/js/sidebar.js index 26b7298..760149d 100644 --- a/js/sidebar.js +++ b/js/sidebar.js @@ -165,8 +165,13 @@ function toggleIgnoreActiveNode() { syncNodeVueBadge(node, isIgnored); }); - if (app.graph) app.graph.setDirtyCanvas(true, true); + 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({ @@ -251,7 +256,10 @@ app.registerExtension({ }, afterConfigureGraph() { - requestAnimationFrame(() => syncAllNodeBadges()); + requestAnimationFrame(() => { + syncAllNodeBadges(); + renderDOM(); + }); }, async setup() { diff --git a/js/ui.js b/js/ui.js index 2e8e61b..39dafb2 100644 --- a/js/ui.js +++ b/js/ui.js @@ -1,9 +1,31 @@ import { app } from "/scripts/app.js"; import { api } from "/scripts/api.js"; import { State, promptStates, cardElements, scheduleStateSave, deletePromptState } from "./state.js"; -import { isImageFormat, isVideoFormat, is3DFormat, isAudioFormat, getFilenameFromUrl, matchesFilter, getRunOutputs } from "./utils.js"; +import { isImageFormat, isVideoFormat, is3DFormat, isAudioFormat, getFilenameFromUrl, matchesFilter, getRunOutputs, extractWorkflowFromPng } from "./utils.js"; import { showFullscreenPreview, isAudioViewerOpen } from "./comparison.js"; +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 let syncQueueFn = async () => {}; export function setSyncQueue(fn) { syncQueueFn = fn; } @@ -418,6 +440,8 @@ function syncCardButtonVisibility(cardObj, state) { const isCompleted = state.status === "completed"; const hasRealImages = isCompleted && state.images && state.images.length > 0 && !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) { @@ -437,19 +461,51 @@ function syncCardButtonVisibility(cardObj, state) { } } + if (cardObj.btnCopy) { + if (hasRealImages && currentImg) { + 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) { + if (state.workflow || isPng) { cardObj.btnJson.style.removeProperty("display"); cardObj.btnJson.style.display = "inline-flex"; - cardObj.btnJson.onclick = (ev) => { + cardObj.btnJson.onclick = async (ev) => { ev.stopPropagation(); - const blob = new Blob([JSON.stringify(state.workflow, null, 2)], { type: "application/json" }); + 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}.json`; + a.download = `workflow_${state.pid || "comfy"}.json`; a.click(); - URL.revokeObjectURL(url); + setTimeout(() => URL.revokeObjectURL(url), 1500); }; } else { cardObj.btnJson.style.setProperty("display", "none", "important"); @@ -462,7 +518,6 @@ function syncCardButtonVisibility(cardObj, state) { } if (cardObj.btnFocus) { - const currentImg = hasRealImages ? state.images[cardObj.currentImageIndex || 0] : null; const nodeId = currentImg ? findNodeIdForImage(state, currentImg) : null; if (nodeId) { cardObj.btnFocus.style.removeProperty("display"); @@ -482,19 +537,21 @@ function syncCardButtonVisibility(cardObj, state) { if (cardObj.leftHoverBtn) { const outputs = getRunOutputs(state.nodeOutputs, state.workflow); + const validOutputs = outputs.filter(o => o.images && o.images.length > 0); - // Gather all unique files across all output nodes - const allUniqueFiles = new Set(); - for (const out of outputs) { - for (const img of (out.images || [])) { - if (img && img.filename) { - allUniqueFiles.add(`${img.subfolder || ""}/${img.filename}`); - } + // Signatures of images already showing on the primary card + 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 there are multiple output nodes AND more than 1 distinct file - const hasIntermediates = outputs.length > 1 && allUniqueFiles.size > 1; + // Show intermediate button ONLY if there are genuine distinct/extra outputs + const hasIntermediates = validOutputs.length > 1 && distinctOutputs.length > 0; if (hasIntermediates) { cardObj.leftHoverBtn.style.removeProperty("display"); @@ -1391,7 +1448,23 @@ export function renderDOM() { globalClickRegistered = true; } - const outputs = getRunOutputs(st.nodeOutputs, st.workflow); + const rawOutputs = getRunOutputs(st.nodeOutputs, st.workflow); + const seenFiles = new Set(); + const outputs = []; + + // Deduplicate across nodes so two nodes producing the exact same image don't duplicate cards + 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 }); + } + }); + const targetElements = []; outputs.forEach((out) => { @@ -1543,8 +1616,6 @@ export function renderDOM() { targetElements.forEach((el, index) => { if (State.cardStack.children[index] !== el) State.cardStack.insertBefore(el, State.cardStack.children[index] || null); }); while (State.cardStack.children.length > targetElements.length) State.cardStack.removeChild(State.cardStack.lastChild); - const scrollEl = getScrollContainer(); - if (scrollEl) scrollEl.scrollTop = 0; updateScrollTopBtnVisibility(); return; } @@ -1620,6 +1691,11 @@ export function renderDOM() { }); 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 Image to Clipboard"; + 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"; @@ -1645,11 +1721,11 @@ export function renderDOM() { leftHoverBtn.title = "View all intermediate outputs"; hoverPanel.append(btnImg, btnJson, btnDel); - leftHoverPanel.append(btnFocus, leftHoverBtn); + leftHoverPanel.append(btnCopy, btnFocus, leftHoverBtn); card.append(timerEl, cancelX, sBadge, grid, p, pt, statusText, hoverPanel, leftHoverPanel); - cardObj = { element: card, timerEl, statusBadge: sBadge, grid, placeholder: p, progressContainer: pt, progressBar: pb, cancelBtn: cancelX, hoverPanel, leftHoverPanel, btnFocus, leftHoverBtn, btnImg, btnJson, btnDel, statusText, firstImgElement: null, lastImagesSignature: "" }; + cardObj = { element: card, timerEl, statusBadge: sBadge, grid, placeholder: p, progressContainer: pt, progressBar: pb, cancelBtn: cancelX, hoverPanel, leftHoverPanel, btnFocus, leftHoverBtn, btnImg, btnCopy, btnJson, btnDel, statusText, firstImgElement: null, lastImagesSignature: "" }; card.addEventListener("mouseenter", () => { syncCardButtonVisibility(cardObj, state); diff --git a/js/utils.js b/js/utils.js index 373dbd6..1f8d59f 100644 --- a/js/utils.js +++ b/js/utils.js @@ -58,6 +58,14 @@ export function parseWorkflow(workflow) { } function isNodeIgnored(nodeId, rawWorkflow) { + // 1. Check live canvas node property first + if (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; @@ -275,4 +283,44 @@ export function matchesFilter(state, query) { } } return false; +} + +export async function extractWorkflowFromPng(imageUrl) { + try { + const res = await fetch(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; + } + + let offset = 8; + const utf8Decoder = new TextDecoder("utf-8"); + + while (offset < buffer.byteLength) { + const length = view.getUint32(offset); + const type = utf8Decoder.decode(new Uint8Array(buffer, offset + 4, 4)); + + if (type === "tEXt") { + const chunkData = new Uint8Array(buffer, offset + 8, length); + const nullIdx = chunkData.indexOf(0); + if (nullIdx !== -1) { + const keyword = utf8Decoder.decode(chunkData.subarray(0, nullIdx)); + if (keyword === "workflow") { + const text = utf8Decoder.decode(chunkData.subarray(nullIdx + 1)); + return JSON.parse(text); + } + } + } else if (type === "IEND") { + break; + } + + offset += 12 + length; // 4 (length) + 4 (type) + length (data) + 4 (crc) + } + } catch (e) { + console.warn("Comfy Sidebar: Could not extract workflow from PNG", e); + } + return null; } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 40e6c38..a044b7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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.1" +version = "1.4.2" license = { file = "LICENSE" } dependencies = []