Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e7448dd80 |
+11
-6
@@ -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:
|
||||
|
||||
+7
-2
@@ -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);
|
||||
|
||||
+22
-2
@@ -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;
|
||||
}
|
||||
|
||||
+10
-2
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
+48
@@ -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;
|
||||
}
|
||||
+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.1"
|
||||
version = "1.4.2"
|
||||
license = { file = "LICENSE" }
|
||||
dependencies = []
|
||||
|
||||
|
||||
Reference in New Issue
Block a user