Compare commits

...
6 Commits
12 changed files with 750 additions and 315 deletions
+37 -14
View File
@@ -1,20 +1,22 @@
# ComfyUI-ComfySidebar
A lightweight, high-performance ComfyUI extension that replaces the cluttered stock Job History/Queue interface with a compact drag&drop queue panel, inspired by the pre-vue ComfyUI frontend design.
A compact sidebar for ComfyUI that replaces the stock queue/history UI with a faster way to browse, preview, compare, and manage generation results.
> ⚠️ **Note:** This repository was developed collaboratively with AI. While it is fully functional and has been tested, there is always room for optimization. If you have ideas for improvements, contributions via Pull Requests are highly welcome!
## ✨ Features
* **Advanced Drag & Drop Support**: Designed specifically for precise mouse pointer controls. Easily drag previous generations back to the empty canvas to restore workflows, or drop images directly into `LoadImage` and input nodes to hot-swap them.
* **High-Visibility Preview Cards**: Keep your past runs instantly recognizable. Supports aspect-ratio locked thumbnails for images and auto-playing muted loops for video formats, as well as clean rendering for multi-line text outputs.
* **Quick Save Buttons**: Dedicated interactive shortcuts appear on hover, allowing you to instantly download generation outputs or export exact workflow JSON files with a single click.
* **Compact Adaptive Design**: A clean, smart layout that automatically balances single or multi-column grids based on your sidebar width. It maximizes viewable content and respects your active screen area without wasting precious space.
* **Live Queue Tracking**: Stay informed on your server state. Includes a visual progress bar that tracks generations dynamically and displays the exact node title currently being executed.
* A compact topbar layout displaying both workflow tabs and the control panel on a single unified top bar. See the 'Comfy Layout' toggle in the settings.
* Toggles to hide buttons you don't need from the side panel.
* **[NEW]** Preview compare slider - just click the result image preview in the sidebar, and then `Shift+Click` another to load it alongside the first one for pixel by pixel comparison.
* **[NEW]** Prevent selected nodes from showing the output in the queue bar and intermediate results. `Ctrl+Q` to toggle.
* Browse generation results in a compact grid.
* Drag results back onto the canvas to restore workflows.
* Drag images directly into `LoadImage` and compatible input nodes.
* Preview images, videos, and text outputs.
* Compare two results side by side with `Shift+Click`.
* Download generated files or workflow JSON directly from result cards.
* Show queue progress and the node currently being executed.
* Hide sidebar tabs and UI elements you don't use.
* Optionally replace the stock Job History sidebar.
* Optional unified top bar for workflow tabs and controls.
* Exclude selected nodes from sidebar results with `Ctrl+Q`.
<img width="385" height="336" alt="image" src="https://github.com/user-attachments/assets/743fbf85-9281-4561-9dd0-cd9af4d8c0e4" />
@@ -33,10 +35,31 @@ Restart your ComfyUI server and refresh the browser tab.
## ⚙️ Configuration
See additional tweaks in ComfyUI settings menu > Comfy Sidebar. From there, you can adjust the layout or fully replace the stock V2 panel by enabling options like **"Comfy Layout"** and **"Replace the stock Job History sidebar with Comfy Queue"**.
Open **Settings → Comfy Sidebar** to configure:
* Queue grid layout
* Result aspect ratio
* Queue display mode
* Working node display
* Automatic cleanup of cancelled/failed jobs
* Sidebar tabs to hide
* Stock Job History replacement
* Graph button visibility
* Unified top bar layout
## ⌨️ Shortcuts
* **`Q`**: Toggle sidebar queue panel.
* **`Ctrl+Q on a selected node`**: Sets the node to be ignored in the sidebar panel results.
* **`Click`** on a card to enter preview. **`Shift+Click`** on another card in preview to enter compare mode.
| Shortcut | Action |
| ------------- | -------------------------------------------------- |
| `Q` | Toggle ComfySidebar |
| `Ctrl+Q` | Toggle sidebar output filtering for selected nodes |
| `Click` | Open result preview |
| `Shift+Click` | Compare another result |
## 📝 Notes
ComfySidebar integrates with the ComfyUI frontend and therefore depends on parts of its UI structure. Frontend changes in ComfyUI may require corresponding updates to this extension.
## License
See [LICENSE](LICENSE).
+1 -13
View File
@@ -1,20 +1,8 @@
# Standard ComfyUI extension loader
from server import PromptServer
from aiohttp import web
import uuid
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
# Tells ComfyUI to serve and load the contents of the js/ folder
WEB_DIRECTORY = "./js"
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
# Create a unique session ID for this backend boot to track server restarts
server_session_id = str(uuid.uuid4())
# Register a custom route to check for backend restarts
@PromptServer.instance.routes.get("/classic-sidebar/session")
async def get_session_id(request):
return web.json_response({"session_id": server_session_id})
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
+27 -3
View File
@@ -1,4 +1,5 @@
import { isVideoFormat } from "./utils.js";
import { isVideoFormat, is3DFormat } from "./utils.js";
import { app } from "/scripts/app.js";
let activeComparisonViewer = null;
let globalKeydownHandler = null;
@@ -373,11 +374,34 @@ function createComparisonViewer(baseSrc) {
export function showFullscreenPreview(imgSrcs, isShiftClick = false) {
if (!imgSrcs || imgSrcs.length === 0) return;
const src = imgSrcs[0];
// Handle 3D assets natively or by focusing 3D node
if (is3DFormat(src)) {
if (window.app?.ui?.show3DViewer) {
window.app.ui.show3DViewer(src);
return;
}
if (app.graph && app.canvas) {
const nodes = app.graph._nodes || [];
const node = nodes.find(n =>
n.type?.includes("Preview3D") || n.type?.includes("Load3D") ||
n.type?.includes("SaveGLB") || n.type?.includes("Save 3D")
);
if (node) {
app.canvas.centerOnNode(node);
app.canvas.selectNode(node);
}
}
return;
}
if (activeComparisonViewer) {
activeComparisonViewer.loadTarget(imgSrcs[0], isShiftClick);
activeComparisonViewer.loadTarget(src, isShiftClick);
return;
}
activeComparisonViewer = createComparisonViewer(imgSrcs[0]);
activeComparisonViewer = createComparisonViewer(src);
}
+12 -5
View File
@@ -59,7 +59,7 @@ export function setupDragAndDrop() {
} catch (err) {}
}
// 2. Check for image / video URL drop
// 2. Check for image / video / 3D URL drop
const url = e.dataTransfer.getData("text/uri-list") || e.dataTransfer.getData("text/plain");
if (url) {
try {
@@ -82,19 +82,26 @@ export function setupDragAndDrop() {
targetNode = canvas.graph.getNodeOnPos((e.clientX - rect.left - canvas.ds.offset[0]) / canvas.ds.scale, (e.clientY - rect.top - canvas.ds.offset[1]) / canvas.ds.scale);
}
// Check if dropped on a LoadImage, LoadVideo, VHS_LoadVideo, or image/video widget node
// Check if dropped on a LoadImage, LoadVideo, Load3D, VHS_LoadVideo, or image/video/3D widget node
const widget = targetNode?.widgets?.find(w =>
w.name === "image" || w.name === "video" || w.name === "file" || w.name === "video_path" || w.type === "image" || w.type === "customtext"
w.name === "image" || w.name === "video" || w.name === "file" || w.name === "video_path" || w.name === "model_file" || w.name === "3d_file" || w.type === "image" || w.type === "customtext"
);
const isLoadNode = targetNode && (widget || targetNode.type?.includes("LoadImage") || targetNode.type?.includes("LoadVideo") || targetNode.type?.includes("VHS_LoadVideo"));
const isLoadNode = targetNode && (
widget ||
targetNode.type?.includes("LoadImage") ||
targetNode.type?.includes("LoadVideo") ||
targetNode.type?.includes("VHS_LoadVideo") ||
targetNode.type?.includes("Load3D") ||
targetNode.type?.includes("Load 3D") ||
targetNode.type?.includes("Preview3D")
);
if (isLoadNode) {
const targetWidget = widget || targetNode.widgets?.[0];
if (targetWidget) {
const newFilename = await uploadDroppedImageToInput({ filename, type, subfolder });
if (newFilename) {
// Populate dropdown values list so combo widgets (like VHS_LoadVideo) accept the new video immediately!
if (targetWidget.options && Array.isArray(targetWidget.options.values)) {
if (!targetWidget.options.values.includes(newFilename)) {
targetWidget.options.values.push(newFilename);
+51 -46
View File
@@ -3,12 +3,24 @@ import { app } from "/scripts/app.js";
const STYLE_ID = "comfy-sidebar-classic-layout-override";
const CLASSIC_LAYOUT_CSS_MEDIA = `
/* Remove the ghost underlay container border & background (the oval) without affecting the topbar header */
.actionbar-container,
.shadow-interface.rounded-lg.bg-comfy-menu-bg,
.shadow-interface.rounded-lg.border-interface-stroke,
.shadow-interface.border.rounded-lg:has([class*="actionbar-buttons"]),
.shadow-interface.border.rounded-lg:has(.actionbar-buttons),
div.border-interface-stroke.rounded-lg:has([class*="actionbar-buttons"]) {
border: none !important;
background: transparent !important;
box-shadow: none !important;
}
/* Style and align the custom plugins bar (Manager, LoRA, etc.) */
[class*="actionbar"]:not(.actionbar),
[class*="actionbar-buttons"],
.actionbar-buttons {
position: fixed !important;
top: -5px !important;
top: 3px !important;
right: 4px !important;
left: auto !important;
transform: none !important;
@@ -97,46 +109,26 @@ button:has([data-pc-name="avatar"]),
}
`;
/**
* Native settings synchronization without direct DOM element querying or simulated clicks.
*/
export function syncStockHistoryAndProgressSettings(enable) {
if (!app.ui?.settings) return;
const dockedVal = !!enable;
const progressVal = !enable;
const QPOV2_ID = "Comfy.Queue.QPOV2";
try {
app.ui.settings.setSettingValue(QPOV2_ID, dockedVal);
localStorage.setItem(QPOV2_ID, JSON.stringify(dockedVal));
} catch (e) {}
try {
const qpovContainer = document.querySelector('[data-setting-id="Comfy.Queue.QPOV2"]') ||
document.getElementById(QPOV2_ID)?.closest('.setting-item, tr, div');
if (qpovContainer) {
const toggle = qpovContainer.querySelector('input[type="checkbox"], [role="switch"], .p-toggleswitch');
if (toggle) {
const isChecked = toggle.checked ||
toggle.getAttribute('data-p-checked') === 'true' ||
toggle.classList.contains('p-toggleswitch-checked');
if (isChecked !== dockedVal) toggle.click();
}
}
} catch (e) {}
const progressKey = "Comfy.Queue.ShowRunProgressBar";
try {
app.ui.settings.setSettingValue(progressKey, progressVal);
localStorage.setItem(progressKey, JSON.stringify(progressVal));
} catch (e) {}
try {
const progressBarBtn = document.querySelector('[data-testid="show-run-progress-bar-action"]');
if (progressBarBtn) {
const isChecked = !!progressBarBtn.querySelector('[class*="lucide--check"], .icon-\\[lucide--check\\]');
if (isChecked !== progressVal) progressBarBtn.click();
}
} catch (e) {}
// Use official extensionManager setting API if present, with fallback to app.ui.settings
if (app.extensionManager?.setting) {
try { app.extensionManager.setting.set(QPOV2_ID, dockedVal); } catch (e) {}
try { app.extensionManager.setting.set(progressKey, progressVal); } catch (e) {}
} else if (app.ui?.settings) {
try { app.ui.settings.setSettingValue(QPOV2_ID, dockedVal); } catch (e) {}
try { app.ui.settings.setSettingValue(progressKey, progressVal); } catch (e) {}
}
try { localStorage.setItem(QPOV2_ID, JSON.stringify(dockedVal)); } catch (e) {}
try { localStorage.setItem(progressKey, JSON.stringify(progressVal)); } catch (e) {}
try { window.dispatchEvent(new Event("storage")); } catch (e) {}
}
@@ -144,8 +136,12 @@ export function applyClassicLayout(enable, updateSetting = false) {
let styleEl = document.getElementById(STYLE_ID);
if (enable) {
if (updateSetting && app.ui?.settings) {
app.ui.settings.setSettingValue("Comfy.Workflow.WorkflowTabsPosition", "Topbar");
if (updateSetting) {
if (app.extensionManager?.setting) {
app.extensionManager.setting.set("Comfy.Workflow.WorkflowTabsPosition", "Topbar");
} else if (app.ui?.settings) {
app.ui.settings.setSettingValue("Comfy.Workflow.WorkflowTabsPosition", "Topbar");
}
}
if (!styleEl) {
styleEl = document.createElement("style");
@@ -155,8 +151,12 @@ export function applyClassicLayout(enable, updateSetting = false) {
styleEl.textContent = CLASSIC_LAYOUT_CSS_MEDIA;
} else {
if (styleEl) styleEl.remove();
if (updateSetting && app.ui?.settings) {
app.ui.settings.setSettingValue("Comfy.Workflow.WorkflowTabsPosition", "Sidebar");
if (updateSetting) {
if (app.extensionManager?.setting) {
app.extensionManager.setting.set("Comfy.Workflow.WorkflowTabsPosition", "Sidebar");
} else if (app.ui?.settings) {
app.ui.settings.setSettingValue("Comfy.Workflow.WorkflowTabsPosition", "Sidebar");
}
}
}
}
@@ -166,6 +166,11 @@ 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');
@@ -257,7 +262,7 @@ function updateSidebarTabsVisibility() {
"Assets": '[class*="comfy--image-ai-edit"]',
"Nodes": '[class*="comfy--node"]',
"Models": '[class*="comfy--ai-model"]',
"Workflows": '[class*="comfy--workflow"]',
"Workflows": '[class*="comfy--workflow"], [class*="workflow"]',
"Apps": '[class*="lucide--panels-top-left"]',
"Templates": '[class*="comfy--template"]'
};
@@ -288,18 +293,18 @@ function updateSidebarTabsVisibility() {
}
function findGraphButton() {
const elements = document.querySelectorAll('.bg-secondary-background.rounded-lg.items-center.inline-flex.pointer-events-auto');
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')) continue;
return el;
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-secondary-background');
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')) continue;
const text = (el.textContent || el.getAttribute("title") || "").toLowerCase();
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;
return el.closest('.group, .p-1, .bg-base-background, .bg-secondary-background') || el;
}
}
return null;
+99 -62
View File
@@ -1,7 +1,7 @@
import { app } from "/scripts/app.js";
import { api } from "/scripts/api.js";
import { State, promptStates, pruneHistory, cardElements, saveStatesToLocalStorage } from "./state.js";
import { findImagesInOutputs, findTextsInOutputs } from "./utils.js";
import { findImagesInOutputs, findTextsInOutputs, parseWorkflow, getPrimaryOutputImages } from "./utils.js";
export let renderDOMFn = () => {};
export let updateSidebarBadgeFn = () => {};
@@ -13,6 +13,7 @@ export function setUIDependencies(renderFn, 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();
@@ -26,7 +27,7 @@ export async function syncQueue() {
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, seq, original: p };
return { pid: pid ? String(pid) : null, seq, original: p };
});
normalizedPending.sort((a, b) => a.seq - b.seq);
@@ -65,10 +66,11 @@ export async function syncQueue() {
}
const concludeRun = async (pid, statusStr) => {
if (!pid || !promptStates.has(pid)) return;
if (State.currentlyActivePromptId === pid) State.currentlyActivePromptId = null;
const key = String(pid);
if (!key || !promptStates.has(key)) return;
if (State.currentlyActivePromptId === key) State.currentlyActivePromptId = null;
const st = promptStates.get(pid);
const st = promptStates.get(key);
if (st._previewBlobUrl) {
try { URL.revokeObjectURL(st._previewBlobUrl); } catch(e){}
@@ -85,25 +87,40 @@ const concludeRun = async (pid, statusStr) => {
st.endTime = Date.now();
if (st.startTime) st.duration = (st.endTime - st.startTime) / 1000;
try {
const res = await fetch(`/history/${pid}`);
const hItem = await res.json();
if (hItem && hItem[pid]) {
if (!st.workflow) st.workflow = hItem[pid].extra_data?.extra_pnginfo?.workflow || null;
st.nodeOutputs = hItem[pid].outputs;
if (st.images.length === 0) st.images = findImagesInOutputs(hItem[pid].outputs, st.workflow);
st.texts = findTextsInOutputs(hItem[pid].outputs, st.workflow);
}
} catch (err) {}
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 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);
saveStatesToLocalStorage(); // Persist history state only when a job concludes
saveStatesToLocalStorage();
syncQueue();
};
export function setupApiListeners() {
api.addEventListener("status", syncQueue);
api.addEventListener("reconnected", async () => {
console.log("Comfy Sidebar: Server reconnected, syncing state and history");
await initSessionAndHistory();
await syncQueue();
});
api.addEventListener("execution_start", (e) => {
if (app.ui.settings.getSettingValue("Comfy Sidebar.Auto Clear Interrupted") ?? false) {
const toDelete = [];
@@ -119,7 +136,7 @@ export function setupApiListeners() {
}
}
const pid = e.detail.prompt_id;
const pid = String(e.detail.prompt_id);
State.currentlyActivePromptId = pid;
const activeWorkspaceWorkflow = app.graph.serialize();
@@ -140,12 +157,11 @@ export function setupApiListeners() {
});
api.addEventListener("progress", (e) => {
const pid = e.detail.prompt_id;
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);
// Direct fast DOM update for the progress bar without triggering a full history re-sort
const cardObj = cardElements.get(pid);
if (cardObj && cardObj.progressBar) {
cardObj.progressBar.style.width = `${st.progress}%`;
@@ -181,7 +197,6 @@ export function setupApiListeners() {
const st = activeTasks[0];
const newBlobUrl = URL.createObjectURL(e.detail);
// Retain old blob URL so ui.js can revoke it ONLY AFTER the image element finishes loading
st._oldPreviewBlobUrl = st._previewBlobUrl;
st._previewBlobUrl = newBlobUrl;
st.images = [{ url: newBlobUrl }];
@@ -190,15 +205,16 @@ export function setupApiListeners() {
});
api.addEventListener("executed", (e) => {
if (promptStates.has(e.detail.prompt_id)) {
const st = promptStates.get(e.detail.prompt_id);
const finalImgs = findImagesInOutputs({ [e.detail.node]: e.detail.output }, st.workflow);
if (finalImgs.length > 0) {
st.images = finalImgs;
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 finalTexts = findTextsInOutputs({ [e.detail.node]: e.detail.output }, st.workflow);
if (finalTexts.length > 0) {
st.texts = finalTexts;
const nodeTexts = findTextsInOutputs({ [e.detail.node]: e.detail.output }, st.workflow);
if (nodeTexts.length > 0) {
st.texts = nodeTexts;
}
if (!st.nodeOutputs) st.nodeOutputs = {};
@@ -217,42 +233,63 @@ export function setupApiListeners() {
}
export async function initSessionAndHistory() {
let backendSessionId = null;
try {
const res = await fetch("/classic-sidebar/session");
const data = await res.json();
backendSessionId = data.session_id;
} catch (err) {
console.warn("Comfy Sidebar: Custom session endpoint not found. Queue persistence will fall back to cache.");
}
const historyData = await api.getHistory();
if (!historyData) return;
const storedSessionId = localStorage.getItem("comfy_sidebar_backend_session_id");
if (backendSessionId && backendSessionId !== storedSessionId) {
promptStates.clear();
cardElements.clear();
localStorage.removeItem("comfy_sidebar_prompt_states");
if (backendSessionId) localStorage.setItem("comfy_sidebar_backend_session_id", backendSessionId);
} else if (!storedSessionId && backendSessionId) {
localStorage.setItem("comfy_sidebar_backend_session_id", backendSessionId);
}
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 });
});
}
const historyData = await api.getHistory();
const ids = Object.keys(historyData).sort((a,b) => Number(a)-Number(b));
ids.forEach(id => {
if (promptStates.has(id)) return;
const workflow = historyData[id].extra_data?.extra_pnginfo?.workflow || null;
const images = findImagesInOutputs(historyData[id].outputs, workflow);
const texts = findTextsInOutputs(historyData[id].outputs, workflow);
if (images.length === 0 && texts.length === 0) return;
State.globalOrderCounter++;
promptStates.set(id, {
pid: id, status: "completed", images, texts,
nodeOutputs: historyData[id].outputs,
workflow: workflow,
progressText: "", timestamp: State.globalOrderCounter, rendered: true
rawItems.sort((a, b) => {
const numA = a.data.prompt?.[0] ?? 0;
const numB = b.data.prompt?.[0] ?? 0;
return numA - numB;
});
});
pruneHistory(app);
await syncQueue();
rawItems.forEach(({ pid, data }) => {
if (promptStates.has(pid)) return;
const extraData = data.extra_data || data.prompt?.[3] || {};
const workflow = parseWorkflow(extraData.extra_pnginfo?.workflow || extraData.workflow);
const outputs = data.outputs || {};
const images = getPrimaryOutputImages(outputs, workflow);
const texts = findTextsInOutputs(outputs, workflow);
const statusStr = data.status?.status_str || "completed";
const status = (statusStr === "success" || statusStr === "completed") ? "completed" :
(statusStr === "error" ? "error" : "cancelled");
// Filter out empty records from history so dummy "No Outputs" cards don't pollute queue
if (images.length === 0 && texts.length === 0 && status === "completed") return;
State.globalOrderCounter++;
promptStates.set(pid, {
pid: pid,
status: status,
images: images,
texts: texts,
nodeOutputs: outputs,
workflow: workflow,
progressText: "",
timestamp: State.globalOrderCounter,
rendered: true
});
});
pruneHistory(app);
saveStatesToLocalStorage();
await syncQueue();
} catch (err) {
console.error("Comfy Sidebar: Failed to initialize history from server API", err);
}
}
+111 -26
View File
@@ -6,6 +6,8 @@ import { setupApiListeners, initSessionAndHistory, syncQueue, setUIDependencies
import { applyClassicLayout, setupPropertiesPanelToggleFix, syncClassicLayout, syncStockHistoryAndProgressSettings } from "./layout.js";
let isInitialized = false;
let activeKeydownHandler = null;
let nodeDOMObserver = null;
// Helper to scan node container for the exact text element rendering the title (Vue Mode)
function findHeaderByText(parent, text) {
@@ -24,7 +26,12 @@ function findHeaderByText(parent, text) {
}
function syncNodeVueBadge(node, isIgnored) {
const nodeEl = document.querySelector(`.comfy-node[data-node-id="${node.id}"], [data-node-id="${node.id}"]`);
// Robust multi-selector search for Vue node containers across different frontend versions
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);
@@ -92,6 +99,40 @@ export function syncAllNodeBadges() {
});
}
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;
@@ -188,6 +229,33 @@ app.registerExtension({
});
},
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());
},
async setup() {
if (!app.extensionManager || !app.extensionManager.registerSidebarTab) return;
@@ -215,41 +283,42 @@ app.registerExtension({
await initSessionAndHistory();
// Keyboard shortcuts listener
document.addEventListener("keydown", (e) => {
// Watch for Vue DOM node mounting events (e.g. LiteGraph ↔ Vue mode switches)
setupVueNodeObserver();
// Remove any existing keydown listener from previous hot-reload
if (activeKeydownHandler) {
document.removeEventListener("keydown", activeKeydownHandler, true);
activeKeydownHandler = null;
}
// Clean, direct keyboard listener
activeKeydownHandler = (e) => {
const activeEl = document.activeElement;
if (activeEl && (activeEl.tagName === "INPUT" || activeEl.tagName === "TEXTAREA" || activeEl.isContentEditable || activeEl.tagName === "SELECT")) return;
if (activeEl && (
activeEl.tagName === "INPUT" ||
activeEl.tagName === "TEXTAREA" ||
activeEl.isContentEditable ||
activeEl.tagName === "SELECT"
)) return;
// Toggle sidebar with Q
if (e.key.toLowerCase() === "q" && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
e.preventDefault(); e.stopPropagation();
e.preventDefault();
e.stopPropagation();
const ourBtn = findOurSidebarButton();
if (ourBtn) ourBtn.click();
}
if (e.key.toLowerCase() === "q" && (e.ctrlKey || e.metaKey)) {
e.preventDefault(); e.stopPropagation();
// Toggle ignore node with Ctrl+Q or Cmd+Q (Mac)
if (e.key.toLowerCase() === "q" && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
e.preventDefault();
e.stopPropagation();
toggleIgnoreActiveNode();
}
}, true);
// Event hooks for node additions and workflow loading (eliminates continuous polling loops)
if (app.graph) {
const originalConfigure = app.graph.configure;
app.graph.configure = function() {
const res = originalConfigure ? originalConfigure.apply(this, arguments) : undefined;
requestAnimationFrame(() => syncAllNodeBadges());
return res;
};
};
const originalOnNodeAdded = app.graph.onNodeAdded;
app.graph.onNodeAdded = function(node) {
if (originalOnNodeAdded) originalOnNodeAdded.apply(this, arguments);
if (node && node.properties?.ignoreInQueue) {
node.boxcolor = "#ff3333";
syncNodeVueBadge(node, true);
}
};
}
document.addEventListener("keydown", activeKeydownHandler, true);
// Apply sidebar override when initializing
applySidebarOverride();
@@ -262,5 +331,21 @@ app.registerExtension({
type: "custom",
render: (el) => { el.appendChild(sidebarContainer); }
});
},
destroy() {
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");
}
}
});
+19 -26
View File
@@ -12,11 +12,9 @@ export const State = {
activeSubmenuBatchImages: null // Active batch images explorer pointer
};
/**
* Safely removes a prompt state from memory and revokes any active preview blob URLs.
*/
export function deletePromptState(pid) {
const state = promptStates.get(pid);
const key = String(pid);
const state = promptStates.get(key);
if (state) {
if (state._previewBlobUrl) {
try { URL.revokeObjectURL(state._previewBlobUrl); } catch (e) {}
@@ -25,13 +23,10 @@ export function deletePromptState(pid) {
try { URL.revokeObjectURL(state._oldPreviewBlobUrl); } catch (e) {}
}
}
promptStates.delete(pid);
cardElements.delete(pid);
promptStates.delete(key);
cardElements.delete(key);
}
/**
* Prunes completed/cancelled history when total items exceed user setting.
*/
export function pruneHistory(app) {
const maxItems = app.ui.settings.getSettingValue("Comfy.Queue.MaxHistoryItems") ?? 64;
const tasks = Array.from(promptStates.entries())
@@ -48,10 +43,6 @@ export function pruneHistory(app) {
}
}
/**
* Saves all prompt states (including workflows, text outputs, video runs, and failed tasks) to localStorage.
* Call ONLY on job completion, cancellation, error, or card deletion.
*/
export function saveStatesToLocalStorage() {
try {
let serializable = [];
@@ -61,13 +52,17 @@ export function saveStatesToLocalStorage() {
return img;
}).filter(Boolean);
// Include workflow JSON ONLY for image-less / unfinished / failed tasks
// so users can load or save their workflows even after browser reloads!
const includeWorkflow = cleanedImages.length === 0 || state.status !== "completed";
serializable.push({
pid: state.pid,
pid: String(state.pid),
status: state.status,
images: cleanedImages,
texts: state.texts || [],
nodeOutputs: state.nodeOutputs,
workflow: state.workflow,
workflow: includeWorkflow ? state.workflow : null,
progress: state.progress || 0,
queueNumber: state.queueNumber,
progressText: state.progressText || "",
@@ -80,6 +75,11 @@ export function saveStatesToLocalStorage() {
});
}
if (serializable.length === 0) {
localStorage.removeItem("comfy_sidebar_prompt_states");
return;
}
serializable.sort((a, b) => a.timestamp - b.timestamp);
while (serializable.length > 0) {
@@ -90,32 +90,25 @@ export function saveStatesToLocalStorage() {
if (e.name === "QuotaExceededError" || e.code === 22 || e.code === 1014) {
serializable.shift();
} else {
console.error("Comfy Sidebar: Failed to save state to localStorage", e);
break;
}
}
}
} catch (e) {
console.error("Comfy Sidebar: Error preparing localStorage data", e);
}
} catch (e) {}
}
/**
* Loads cached card states on extension initialization.
*/
export function loadStatesFromLocalStorage() {
try {
const data = localStorage.getItem("comfy_sidebar_prompt_states");
if (data) {
const list = JSON.parse(data);
list.forEach(state => {
promptStates.set(state.pid, state);
const key = String(state.pid);
promptStates.set(key, state);
if (state.timestamp > State.globalOrderCounter) State.globalOrderCounter = state.timestamp;
});
}
} catch (e) {
console.error("Comfy Sidebar: Failed to load state from localStorage", e);
}
} catch (e) {}
}
loadStatesFromLocalStorage();
+164 -1
View File
@@ -33,7 +33,7 @@ export function injectStyles() {
.comfy-sidebar-card-timer {
position: absolute; top: 6px; left: 8px; font-size: 10px;
font-family: monospace; opacity: 0.7; background: rgba(0, 0, 0, 0.6);
padding: 2px 4px; border-radius: 3px; pointerEvents: none; z-index: 5; color: #fff;
padding: 2px 4px; border-radius: 3px; pointer-events: none; z-index: 5; color: #fff;
}
.comfy-sidebar-queue-cancel-btn {
@@ -56,6 +56,17 @@ export function injectStyles() {
color: #ffffff !important;
}
/* Hover Panels CSS Hover Control */
.comfy-sidebar-hover-panel,
.comfy-sidebar-left-hover-panel {
display: none !important;
}
.comfy-sidebar-card:hover .comfy-sidebar-hover-panel,
.comfy-sidebar-card:hover .comfy-sidebar-left-hover-panel {
display: flex !important;
}
/* Card Action Buttons */
.comfy-sidebar-card-action-btn {
display: inline-flex;
@@ -86,6 +97,158 @@ export function injectStyles() {
color: #ffffff !important;
box-shadow: 0 0 8px rgba(220, 53, 69, 0.6) !important;
}
/* Header Icons & Header Action Buttons */
.comfy-sidebar-header-btn {
background: transparent;
color: var(--desc-color, #aaa);
border: 1px solid var(--border-color, #555);
border-radius: 3px;
padding: 4px 8px;
cursor: pointer;
font-size: 13px;
transition: all 0.15s ease-in-out;
}
.comfy-sidebar-header-btn:hover {
border-color: var(--fg-color, #eee);
color: var(--fg-color, #eee);
}
.comfy-sidebar-icon-btn {
cursor: pointer;
font-size: 13px;
opacity: 0.6;
transition: opacity 0.15s ease-in-out;
}
.comfy-sidebar-icon-btn:hover {
opacity: 1;
}
/* Scroll To Top Floating Button */
.comfy-sidebar-scroll-top-btn {
position: absolute;
bottom: 12px;
right: 12px;
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(30, 30, 30, 0.9);
color: #eee;
border: 1px solid var(--border-color, #555);
cursor: pointer;
font-size: 13px;
display: none;
align-items: center;
justify-content: center;
z-index: 999;
box-shadow: 0 4px 12px rgba(0,0,0,0.6);
transition: all 0.15s ease-in-out;
opacity: 0.85;
}
.comfy-sidebar-scroll-top-btn:hover {
opacity: 1;
background: var(--p-primary-color, #3b82f6);
color: #fff;
border-color: var(--p-primary-color, #3b82f6);
}
/* 3D Preview Cards */
.comfy-sidebar-3d-wrapper {
width: 100%;
height: 160px;
background: linear-gradient(135deg, #1e1e2e 0%, #0f172a 100%);
border-radius: 4px;
display: flex;
position: relative;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.12);
transition: all 0.2s ease-in-out;
user-select: none;
cursor: pointer;
}
.comfy-sidebar-3d-wrapper:hover {
border-color: #3b82f6;
box-shadow: 0 0 12px rgba(59, 130, 246, 0.35);
}
.comfy-sidebar-3d-preview-img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
filter: brightness(0.45);
transition: filter 0.2s ease, transform 0.2s ease;
pointer-events: none;
z-index: 1;
}
.comfy-sidebar-3d-wrapper:hover .comfy-sidebar-3d-preview-img {
filter: brightness(0.6);
transform: scale(1.03);
}
.comfy-sidebar-3d-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 2;
pointer-events: none;
}
.comfy-sidebar-3d-icon {
font-size: 38px;
color: #60a5fa;
margin-bottom: 6px;
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.8));
transition: transform 0.2s ease;
}
.comfy-sidebar-3d-wrapper:hover .comfy-sidebar-3d-icon {
transform: scale(1.1) rotate(6deg);
}
.comfy-sidebar-3d-badge {
position: absolute;
top: 6px;
right: 6px;
background: rgba(59, 130, 246, 0.9);
color: #fff;
font-size: 9px;
font-weight: bold;
padding: 2px 6px;
border-radius: 3px;
text-transform: uppercase;
letter-spacing: 0.5px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
z-index: 3;
}
.comfy-sidebar-3d-title {
font-size: 10px;
color: #f1f5f9;
font-family: monospace;
max-width: 85%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
background: rgba(0, 0, 0, 0.65);
padding: 2px 6px;
border-radius: 3px;
backdrop-filter: blur(2px);
z-index: 3;
}
`;
document.head.appendChild(style);
}
+166 -93
View File
@@ -1,7 +1,7 @@
import { app } from "/scripts/app.js";
import { api } from "/scripts/api.js";
import { State, promptStates, cardElements, saveStatesToLocalStorage } from "./state.js";
import { isVideoFormat, matchesFilter, getRunOutputs } from "./utils.js";
import { State, promptStates, cardElements, saveStatesToLocalStorage, deletePromptState } from "./state.js";
import { isVideoFormat, is3DFormat, matchesFilter, getRunOutputs } from "./utils.js";
import { showFullscreenPreview } from "./comparison.js";
export let syncQueueFn = async () => {};
@@ -67,6 +67,20 @@ function findNodeIdForImage(state, img) {
return null;
}
function findNodeIdFor3DAsset(state, img) {
const exactNodeId = findNodeIdForImage(state, img);
if (exactNodeId) return exactNodeId;
if (app.graph && app.graph._nodes) {
const node = app.graph._nodes.find(n =>
n.type?.includes("Preview3D") || n.type?.includes("Load3D") ||
n.type?.includes("SaveGLB") || n.type?.includes("Save 3D")
);
if (node) return String(node.id);
}
return null;
}
export const updateSidebarBadge = (count) => {
const icons = document.querySelectorAll('.pi-images');
icons.forEach(icon => {
@@ -162,11 +176,8 @@ export function setupSidebarUI() {
Object.assign(titleGroup.style, { display: "flex", alignItems: "center", gap: "8px" });
const searchIcon = document.createElement("span");
searchIcon.className = "pi pi-search";
searchIcon.className = "pi pi-search comfy-sidebar-icon-btn";
searchIcon.title = "Search History";
Object.assign(searchIcon.style, { cursor: "pointer", fontSize: "13px", opacity: "0.6", transition: "opacity 0.15s ease-in-out" });
searchIcon.onmouseenter = () => searchIcon.style.opacity = "1";
searchIcon.onmouseleave = () => searchIcon.style.opacity = "0.6";
const title = document.createElement("h3");
title.textContent = "Queue";
@@ -181,12 +192,8 @@ export function setupSidebarUI() {
const createActionBtn = (iconClass, tooltip, hoverColor, onClickFn) => {
const btn = document.createElement("button");
btn.className = iconClass;
btn.className = `${iconClass} comfy-sidebar-header-btn`;
btn.title = tooltip;
Object.assign(btn.style, {
background: "transparent", color: "var(--desc-color, #aaa)", border: "1px solid var(--border-color, #555)",
borderRadius: "3px", padding: "4px 8px", cursor: "pointer", fontSize: "13px", transition: "all 0.15s ease-in-out"
});
let timeout = null, isPending = false;
const reset = () => {
@@ -195,10 +202,7 @@ export function setupSidebarUI() {
if (timeout) { clearTimeout(timeout); timeout = null; }
};
btn.onmouseenter = () => { if (!isPending) { btn.style.borderColor = "var(--fg-color, #eee)"; btn.style.color = "var(--fg-color, #eee)"; } };
btn.onmouseleave = () => { if (!isPending) { btn.style.borderColor = "var(--border-color, #555)"; btn.style.color = "var(--desc-color, #aaa)"; } };
btn.onclick = async (ev) => {
btn.addEventListener("click", async (ev) => {
ev.stopPropagation();
if (!isPending) {
isPending = true;
@@ -208,7 +212,7 @@ export function setupSidebarUI() {
reset();
await onClickFn();
}
};
});
return btn;
};
@@ -251,59 +255,38 @@ export function setupSidebarUI() {
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";
clearSearchBtn.className = "pi pi-times comfy-sidebar-icon-btn";
clearSearchBtn.title = "Clear & Close Search";
Object.assign(clearSearchBtn.style, { cursor: "pointer", fontSize: "11px", opacity: "0.6", marginLeft: "6px", transition: "opacity 0.15s ease" });
clearSearchBtn.onmouseenter = () => clearSearchBtn.style.opacity = "1"; clearSearchBtn.onmouseleave = () => clearSearchBtn.style.opacity = "0.6";
Object.assign(clearSearchBtn.style, { marginLeft: "6px" });
searchContainer.appendChild(searchInputIcon); searchContainer.appendChild(searchInput); searchContainer.appendChild(clearSearchBtn);
header.appendChild(standardHeader); header.appendChild(searchContainer); State.sidebarContainer.appendChild(header);
searchIcon.onclick = (e) => { e.stopPropagation(); standardHeader.style.display = "none"; searchContainer.style.display = "flex"; searchInput.focus(); };
searchIcon.addEventListener("click", (e) => { e.stopPropagation(); standardHeader.style.display = "none"; searchContainer.style.display = "flex"; searchInput.focus(); });
const closeSearch = () => { searchInput.value = ""; State.currentSearchQuery = ""; searchContainer.style.display = "none"; standardHeader.style.display = "flex"; renderDOM(); };
clearSearchBtn.onclick = (e) => { e.stopPropagation(); closeSearch(); };
searchInput.onkeydown = (e) => { if (e.key === "Escape") closeSearch(); };
searchInput.oninput = () => { State.currentSearchQuery = searchInput.value.trim(); renderDOM(); };
clearSearchBtn.addEventListener("click", (e) => { e.stopPropagation(); closeSearch(); });
searchInput.addEventListener("keydown", (e) => { if (e.key === "Escape") closeSearch(); });
searchInput.addEventListener("input", () => { State.currentSearchQuery = searchInput.value.trim(); renderDOM(); });
State.cardStack = document.createElement("div");
Object.assign(State.cardStack.style, { flex: "1", overflowY: "visible", display: "block", paddingBottom: "28px" });
State.sidebarContainer.appendChild(State.cardStack);
scrollToTopBtnEl = document.createElement("button");
scrollToTopBtnEl.className = "pi pi-chevron-up";
scrollToTopBtnEl.className = "pi pi-chevron-up comfy-sidebar-scroll-top-btn";
scrollToTopBtnEl.title = "Scroll to Top";
Object.assign(scrollToTopBtnEl.style, {
position: "absolute", bottom: "12px", right: "12px", width: "32px", height: "32px",
borderRadius: "50%", background: "rgba(30, 30, 30, 0.9)", color: "#eee",
border: "1px solid var(--border-color, #555)", cursor: "pointer", fontSize: "13px",
display: "none", alignItems: "center", justifyContent: "center", zIndex: "999",
boxShadow: "0 4px 12px rgba(0,0,0,0.6)", transition: "all 0.15s ease-in-out", opacity: "0.85"
});
scrollToTopBtnEl.onmouseenter = () => {
scrollToTopBtnEl.style.opacity = "1";
scrollToTopBtnEl.style.background = "var(--p-primary-color, #3b82f6)";
scrollToTopBtnEl.style.color = "#fff";
scrollToTopBtnEl.style.borderColor = "var(--p-primary-color, #3b82f6)";
};
scrollToTopBtnEl.onmouseleave = () => {
scrollToTopBtnEl.style.opacity = "0.85";
scrollToTopBtnEl.style.background = "rgba(30, 30, 30, 0.9)";
scrollToTopBtnEl.style.color = "#eee";
scrollToTopBtnEl.style.borderColor = "var(--border-color, #555)";
};
scrollToTopBtnEl.onclick = (e) => {
scrollToTopBtnEl.addEventListener("click", (e) => {
e.stopPropagation();
const scrollEl = getScrollContainer();
if (scrollEl) {
scrollEl.scrollTo({ top: 0, behavior: "smooth" });
}
};
});
State.sidebarContainer.appendChild(scrollToTopBtnEl);
State.sidebarContainer.onclick = (e) => {
State.sidebarContainer.addEventListener("click", (e) => {
if (State.activeSubmenuPromptId || State.activeSubmenuBatchImages) {
if (e.target.closest('img, video, .comfy-sidebar-card-timer, .pi-times, .comfy-sidebar-left-hover-btn, .comfy-sidebar-queue-cancel-btn, button, span')) return;
State.activeSubmenuPromptId = null;
@@ -313,7 +296,7 @@ export function setupSidebarUI() {
document.removeEventListener("click", handleGlobalClick, true);
globalClickRegistered = false;
}
};
});
new ResizeObserver((entries) => {
const threshold = app.ui.settings.getSettingValue("Comfy Sidebar.Grid Columns Threshold") ?? 350;
@@ -324,7 +307,6 @@ export function setupSidebarUI() {
}
}).observe(State.sidebarContainer);
// Light 250ms active timer tick
setInterval(() => {
if (State.currentlyActivePromptId === null) return;
for (const [pid, state] of promptStates.entries()) {
@@ -341,12 +323,6 @@ export function setupSidebarUI() {
function syncCardButtonVisibility(cardObj, state) {
if (!cardObj) return;
if (state.status === "active" || state.status === "pending") {
if (cardObj.hoverPanel) cardObj.hoverPanel.style.setProperty("display", "none", "important");
if (cardObj.leftHoverPanel) cardObj.leftHoverPanel.style.setProperty("display", "none", "important");
return;
}
const isCompleted = state.status === "completed";
const hasRealImages = isCompleted && state.images && state.images.length > 0 && !state.images.some(img => img.url && img.url.startsWith("blob:"));
@@ -425,31 +401,108 @@ function syncCardButtonVisibility(cardObj, state) {
cardObj.leftHoverBtn.style.setProperty("display", "none", "important");
}
}
}
const hasRightButtons = (cardObj.btnImg && cardObj.btnImg.style.display !== "none") ||
(cardObj.btnJson && cardObj.btnJson.style.display !== "none") ||
(cardObj.btnDel && cardObj.btnDel.style.display !== "none");
const hasLeftButtons = (cardObj.btnFocus && cardObj.btnFocus.style.display !== "none") ||
(cardObj.leftHoverBtn && cardObj.leftHoverBtn.style.display !== "none");
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 || '')}`;
if (cardObj.hoverPanel) {
if (hasRightButtons) {
cardObj.hoverPanel.style.removeProperty("display");
cardObj.hoverPanel.style.display = "flex";
} else {
cardObj.hoverPanel.style.setProperty("display", "none", "important");
// Find the LAST 2D image generated in the same prompt run to use as background thumbnail
const imageAssets = state?.images?.filter(i => !is3DFormat(i.filename || i.url) && !isVideoFormat(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";
}
if (cardObj.leftHoverPanel) {
if (hasLeftButtons) {
cardObj.leftHoverPanel.style.removeProperty("display");
cardObj.leftHoverPanel.style.display = "flex";
} else {
cardObj.leftHoverPanel.style.setProperty("display", "none", "important");
cardObj.firstImgElement = preview3D;
preview3D.onclick = (ev) => {
ev.stopPropagation();
// 1. Try Native Frontend 3D Viewer if available
if (window.app?.ui?.show3DViewer) {
window.app.ui.show3DViewer(fullUrl);
return;
}
}
// 2. Focus and select 3D node on workflow canvas
const nodeId = findNodeIdFor3DAsset(state, img);
if (nodeId && app.graph && app.canvas) {
const node = app.graph.getNodeById(Number(nodeId));
if (node) {
app.canvas.centerOnNode(node);
app.canvas.selectNode(node);
return;
}
}
// 3. Fallback to comparison/fullscreen modal handler
showFullscreenPreview([fullUrl], ev.shiftKey);
};
// Configure drag & drop for 3D files
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";
};
}
function renderCardImages(cardObj, state, keepAspect) {
@@ -469,6 +522,7 @@ function renderCardImages(cardObj, state, keepAspect) {
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);
const is3D = is3DFormat(src) || is3DFormat(img.filename);
let wrapper = cardObj.grid.querySelector(".comfy-sidebar-media-wrapper");
if (!wrapper) {
@@ -479,6 +533,11 @@ function renderCardImages(cardObj, state, keepAspect) {
cardObj.grid.appendChild(wrapper);
}
if (is3D) {
render3DCardPreview(cardObj, wrapper, src, img, state);
return;
}
let mediaEl = wrapper.querySelector("img, video");
const needsRebuild = !mediaEl || (isVideo !== (mediaEl.tagName.toLowerCase() === "video"));
@@ -503,6 +562,27 @@ function renderCardImages(cardObj, state, keepAspect) {
showFullscreenPreview([src], ev.shiftKey);
};
// Auto-prune card ONLY if file returns 404 Not Found from server
mediaEl.onerror = async () => {
if (src && !src.startsWith("blob:") && !is3D) {
try {
const res = await fetch(src, { method: "HEAD" });
if (res.status === 404) {
deletePromptState(state.pid);
saveStatesToLocalStorage();
if (cardObj.element) cardObj.element.remove();
return;
}
} catch (e) {
// Ignore network error - keep card
}
if (cardObj.placeholder) {
cardObj.placeholder.textContent = "Error loading media preview";
cardObj.placeholder.style.display = "block";
}
}
};
const applyDimensions = (width, height) => {
if (keepAspect && width && height) {
mediaEl.style.aspectRatio = `${width} / ${height}`;
@@ -588,7 +668,6 @@ function renderCardImages(cardObj, state, keepAspect) {
} else {
mediaEl.onload = () => {
applyDimensions(mediaEl.naturalWidth, mediaEl.naturalHeight);
// Safely revoke old preview blob URL ONLY after new frame finishes rendering!
if (state._oldPreviewBlobUrl) {
try { URL.revokeObjectURL(state._oldPreviewBlobUrl); } catch(e){}
delete state._oldPreviewBlobUrl;
@@ -781,8 +860,9 @@ export function renderDOM() {
});
const hoverPanel = document.createElement("div");
hoverPanel.className = "comfy-sidebar-hover-panel";
Object.assign(hoverPanel.style, {
position: "absolute", bottom: "4px", right: "4px", display: "none",
position: "absolute", bottom: "4px", right: "4px",
flexDirection: "column", gap: "4px", zIndex: "20"
});
@@ -792,8 +872,9 @@ export function renderDOM() {
hoverPanel.appendChild(btnImg);
const leftHoverPanel = document.createElement("div");
leftHoverPanel.className = "comfy-sidebar-left-hover-panel";
Object.assign(leftHoverPanel.style, {
position: "absolute", bottom: "4px", left: "4px", display: "none",
position: "absolute", bottom: "4px", left: "4px",
flexDirection: "column", gap: "4px", zIndex: "20"
});
@@ -809,10 +890,6 @@ export function renderDOM() {
card.addEventListener("mouseenter", () => {
syncCardButtonVisibility(cardObj, { status: "completed", images: [img], workflow: batchInfo.workflow, nodeOutputs: batchInfo.nodeOutputs });
});
card.addEventListener("mouseleave", () => {
cardObj.hoverPanel.style.display = "none";
cardObj.leftHoverPanel.style.display = "none";
});
}
cardObj.placeholder.style.display = "none";
@@ -894,8 +971,9 @@ export function renderDOM() {
});
const hoverPanel = document.createElement("div");
hoverPanel.className = "comfy-sidebar-hover-panel";
Object.assign(hoverPanel.style, {
position: "absolute", bottom: "4px", right: "4px", display: "none",
position: "absolute", bottom: "4px", right: "4px",
flexDirection: "column", gap: "4px", zIndex: "20"
});
@@ -905,8 +983,9 @@ export function renderDOM() {
hoverPanel.appendChild(btnImg);
const leftHoverPanel = document.createElement("div");
leftHoverPanel.className = "comfy-sidebar-left-hover-panel";
Object.assign(leftHoverPanel.style, {
position: "absolute", bottom: "4px", left: "4px", display: "none",
position: "absolute", bottom: "4px", left: "4px",
flexDirection: "column", gap: "4px", zIndex: "20"
});
@@ -922,10 +1001,6 @@ export function renderDOM() {
card.addEventListener("mouseenter", () => {
syncCardButtonVisibility(cardObj, { status: "completed", images: out.images, workflow: st.workflow, nodeOutputs: st.nodeOutputs });
});
card.addEventListener("mouseleave", () => {
cardObj.hoverPanel.style.display = "none";
cardObj.leftHoverPanel.style.display = "none";
});
}
if (out.images && out.images.length > 0) {
@@ -1000,8 +1075,9 @@ export function renderDOM() {
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", display: "none",
position: "absolute", bottom: "4px", right: "4px",
flexDirection: "column", gap: "4px", zIndex: "20"
});
@@ -1010,8 +1086,9 @@ export function renderDOM() {
const btnDel = document.createElement("span"); btnDel.className = "pi pi-trash comfy-sidebar-card-action-btn"; btnDel.title = "Delete Card";
const leftHoverPanel = document.createElement("div");
leftHoverPanel.className = "comfy-sidebar-left-hover-panel";
Object.assign(leftHoverPanel.style, {
position: "absolute", bottom: "4px", left: "4px", display: "none",
position: "absolute", bottom: "4px", left: "4px",
flexDirection: "column", gap: "4px", zIndex: "20"
});
@@ -1033,10 +1110,6 @@ export function renderDOM() {
card.addEventListener("mouseenter", () => {
syncCardButtonVisibility(cardObj, state);
});
card.addEventListener("mouseleave", () => {
cardObj.hoverPanel.style.display = "none";
cardObj.leftHoverPanel.style.display = "none";
});
card.addEventListener("dragstart", (e) => {
const isUnfinished = state.status && state.status !== "completed";
+62 -25
View File
@@ -1,44 +1,73 @@
import { app } from "/scripts/app.js";
export const isVideoFormat = (url) => {
const s = url.toLowerCase();
if (!url) return false;
const s = String(url).toLowerCase();
return s.includes(".mp4") || s.includes(".webm");
};
function isNodeIgnored(nodeId, workflow) {
if (!workflow || !workflow.nodes) {
// Fallback to active graph if workflow is missing (e.g. during live execution)
const liveNode = app.graph?.getNodeById(Number(nodeId));
if (liveNode) {
return !!(liveNode.properties && liveNode.properties.ignoreInQueue);
}
export const is3DFormat = (url) => {
if (!url) return false;
const s = String(url).toLowerCase();
return s.includes(".glb") || s.includes(".gltf") || s.includes(".obj") ||
s.includes(".ply") || s.includes(".stl") || s.includes(".splat") ||
s.includes(".spz") || s.includes(".ksplat") || s.includes(".fbx");
};
export function parseWorkflow(workflow) {
if (!workflow) return null;
if (typeof workflow === "object") return workflow;
if (typeof workflow === "string") {
try { return JSON.parse(workflow); } catch (e) { return null; }
}
return null;
}
function isNodeIgnored(nodeId, rawWorkflow) {
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);
}
// Robust media output scanner supporting both arrays and single objects (gifs, mp4s, etc.)
export function findImagesInOutputs(outputs, workflow) {
export function findImagesInOutputs(outputs, rawWorkflow) {
const list = [];
if (!outputs) return list;
for (const nodeId in outputs) {
if (isNodeIgnored(nodeId, workflow)) continue;
for (const key in outputs[nodeId]) {
const val = outputs[nodeId][key];
if (Array.isArray(val)) {
val.forEach(item => {
if (item && typeof item === 'object' && item.filename) list.push(item);
});
} else if (val && typeof val === 'object' && val.filename) {
list.push(val);
const scan = (obj) => {
if (!obj) return;
if (Array.isArray(obj)) {
obj.forEach(item => scan(item));
} else if (typeof obj === "object") {
if (obj.filename || obj.name) {
const fname = obj.filename || obj.name;
if (typeof fname === "string" && (fname.includes(".") || obj.type)) {
list.push({
filename: fname,
subfolder: obj.subfolder || "",
type: obj.type || "output"
});
return;
}
}
for (const key in obj) {
scan(obj[key]);
}
}
};
const workflow = parseWorkflow(rawWorkflow);
for (const nodeId in outputs) {
if (isNodeIgnored(nodeId, workflow)) continue;
scan(outputs[nodeId]);
}
return list;
}
export function findTextsInOutputs(outputs, workflow) {
export function findTextsInOutputs(outputs, rawWorkflow) {
const workflow = parseWorkflow(rawWorkflow);
const list = [];
if (!outputs) return list;
for (const nodeId in outputs) {
@@ -65,8 +94,8 @@ export function findTextsInOutputs(outputs, workflow) {
return list;
}
// Organizes raw outputs dictionaries into lists of individual node-level output blocks (ignoring text-only outputs)
export function getRunOutputs(nodeOutputs, workflow) {
export function getRunOutputs(nodeOutputs, rawWorkflow) {
const workflow = parseWorkflow(rawWorkflow);
const list = [];
if (!nodeOutputs) return list;
for (const nodeId in nodeOutputs) {
@@ -78,6 +107,13 @@ export function getRunOutputs(nodeOutputs, workflow) {
return list;
}
export function getPrimaryOutputImages(nodeOutputs, rawWorkflow) {
if (!nodeOutputs) return [];
const runOutputs = getRunOutputs(nodeOutputs, rawWorkflow);
if (runOutputs.length === 0) return [];
return runOutputs[runOutputs.length - 1].images || [];
}
export function matchesFilter(state, query) {
if (!query) return true;
const q = query.toLowerCase();
@@ -87,8 +123,9 @@ export function matchesFilter(state, query) {
if (state.images && state.images.some(img => (img.filename || "").toLowerCase().includes(q))) return true;
if (state.activeNodeName && state.activeNodeName.toLowerCase().includes(q)) return true;
if (state.workflow && Array.isArray(state.workflow.nodes)) {
for (const node of state.workflow.nodes) {
const workflow = parseWorkflow(state.workflow);
if (workflow && Array.isArray(workflow.nodes)) {
for (const node of workflow.nodes) {
if (node.title && node.title.toLowerCase().includes(q)) return true;
if (node.type && node.type.toLowerCase().includes(q)) return true;
if (Array.isArray(node.widgets)) {
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "comfy-sidebar"
description = "A custom queue sidebar and image comparison extension that reintroduces and improves a sidebar queue menu and unified compact topbar layout for ComfyUI."
version = "1.2.9"
version = "1.3.0"
license = { file = "LICENSE" }
dependencies = []