Compare commits

...
2 Commits
7 changed files with 494 additions and 58 deletions
+11 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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() {
+342 -30
View File
@@ -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; }
@@ -17,6 +39,37 @@ export function stopAllAudioPlayback() {
}
}
function removeImageFromNodeOutputs(nodeOutputs, targetImg) {
if (!nodeOutputs || !targetImg || !targetImg.filename) return;
const scanAndRemove = (obj) => {
if (!obj || typeof obj !== "object") return;
for (const key in obj) {
const val = obj[key];
if (Array.isArray(val)) {
for (let i = val.length - 1; i >= 0; i--) {
const item = val[i];
if (item && typeof item === "object") {
if (item.filename === targetImg.filename && (item.subfolder || "") === (targetImg.subfolder || "")) {
val.splice(i, 1);
} else {
scanAndRemove(item);
}
} else if (typeof item === "string" && (item === targetImg.filename || item.endsWith("/" + targetImg.filename))) {
val.splice(i, 1);
}
}
} else if (typeof val === "object") {
scanAndRemove(val);
}
}
};
for (const nodeId in nodeOutputs) {
scanAndRemove(nodeOutputs[nodeId]);
}
}
async function openFileOrFolder(img) {
if (!img) return;
@@ -387,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) {
@@ -406,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");
@@ -431,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");
@@ -451,7 +537,23 @@ function syncCardButtonVisibility(cardObj, state) {
if (cardObj.leftHoverBtn) {
const outputs = getRunOutputs(state.nodeOutputs, state.workflow);
if (outputs.length > 1) {
const validOutputs = outputs.filter(o => o.images && o.images.length > 0);
// 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 ONLY if there are genuine distinct/extra outputs
const hasIntermediates = validOutputs.length > 1 && distinctOutputs.length > 0;
if (hasIntermediates) {
cardObj.leftHoverBtn.style.removeProperty("display");
cardObj.leftHoverBtn.style.display = "inline-flex";
cardObj.leftHoverBtn.onclick = (ev) => {
@@ -1197,7 +1299,93 @@ export function renderDOM() {
const btnImg = document.createElement("span");
btnImg.className = "pi pi-image comfy-sidebar-card-action-btn";
btnImg.title = "Download Object";
hoverPanel.appendChild(btnImg);
const btnDel = document.createElement("span");
btnDel.className = "pi pi-trash comfy-sidebar-card-action-btn comfy-sidebar-btn-del";
btnDel.title = "Remove from batch (Hold Ctrl to delete file from disk)";
const btnDelLabel = document.createElement("span");
btnDelLabel.className = "comfy-sidebar-del-label";
btnDelLabel.textContent = "Delete File";
btnDel.appendChild(btnDelLabel);
let isDeletePending = false, isDiskDelete = false, deleteTimeout = null;
const resetDelete = () => {
isDeletePending = false;
isDiskDelete = false;
btnDel.classList.remove("confirm-delete", "confirm-delete-disk");
btnDel.title = "Remove from batch (Hold Ctrl to delete file from disk)";
if (deleteTimeout) { clearTimeout(deleteTimeout); deleteTimeout = null; }
};
btnDel.onclick = async (ev) => {
ev.stopPropagation();
const wantsDiskDelete = ev.ctrlKey || ev.metaKey;
if (!isDeletePending) {
isDeletePending = true;
isDiskDelete = wantsDiskDelete;
btnDel.classList.add(wantsDiskDelete ? "confirm-delete-disk" : "confirm-delete");
btnDel.title = wantsDiskDelete
? "Ctrl+Click again to delete file from DISK / TRASH"
: "Click again to confirm removing from batch";
deleteTimeout = setTimeout(resetDelete, 2000);
} else {
const shouldDeleteFromDisk = isDiskDelete || wantsDiskDelete;
resetDelete();
// 1. Delete only this single file from disk
if (shouldDeleteFromDisk && img && img.filename) {
try {
await fetch("/comfy-sidebar/delete-file", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
filename: img.filename,
subfolder: img.subfolder || "",
type: img.type || "output"
})
});
} catch (e) {}
}
// 2. Remove this image from batch list, prompt state, and nodeOutputs
const pid = batchInfo.pid;
const state = promptStates.get(pid);
const imgIdx = batchInfo.images.indexOf(img);
if (imgIdx > -1) batchInfo.images.splice(imgIdx, 1);
if (state) {
if (Array.isArray(state.images)) {
const stIdx = state.images.findIndex(i => i.filename === img.filename && (i.subfolder || "") === (img.subfolder || ""));
if (stIdx > -1) state.images.splice(stIdx, 1);
}
// Sync nodeOutputs so the Intermediate Outputs submenu stays updated
if (state.nodeOutputs) {
removeImageFromNodeOutputs(state.nodeOutputs, img);
}
state.rendered = false;
}
// Invalidate main card's cached index & signature
const mainCardObj = cardElements.get(pid);
if (mainCardObj) {
mainCardObj.currentImageIndex = 0;
mainCardObj.lastImagesSignature = "";
}
// If batch is now empty, return to main queue
if (batchInfo.images.length === 0) {
State.activeSubmenuBatchImages = null;
}
scheduleStateSave();
renderDOM();
}
};
hoverPanel.append(btnImg, btnDel);
const leftHoverPanel = document.createElement("div");
leftHoverPanel.className = "comfy-sidebar-left-hover-panel";
@@ -1260,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) => {
@@ -1303,7 +1507,79 @@ export function renderDOM() {
const btnImg = document.createElement("span");
btnImg.className = "pi pi-image comfy-sidebar-card-action-btn";
btnImg.title = "Download Object";
hoverPanel.appendChild(btnImg);
const btnDel = document.createElement("span");
btnDel.className = "pi pi-trash comfy-sidebar-card-action-btn comfy-sidebar-btn-del";
btnDel.title = "Delete node output (Hold Ctrl to delete file from disk)";
const btnDelLabel = document.createElement("span");
btnDelLabel.className = "comfy-sidebar-del-label";
btnDelLabel.textContent = "Delete File";
btnDel.appendChild(btnDelLabel);
let isDeletePending = false, isDiskDelete = false, deleteTimeout = null;
const resetDelete = () => {
isDeletePending = false;
isDiskDelete = false;
btnDel.classList.remove("confirm-delete", "confirm-delete-disk");
btnDel.title = "Delete node output (Hold Ctrl to delete file from disk)";
if (deleteTimeout) { clearTimeout(deleteTimeout); deleteTimeout = null; }
};
btnDel.onclick = async (ev) => {
ev.stopPropagation();
const wantsDiskDelete = ev.ctrlKey || ev.metaKey;
if (!isDeletePending) {
isDeletePending = true;
isDiskDelete = wantsDiskDelete;
btnDel.classList.add(wantsDiskDelete ? "confirm-delete-disk" : "confirm-delete");
btnDel.title = wantsDiskDelete
? "Ctrl+Click again to delete this node's files from DISK / TRASH"
: "Click again to confirm removing output";
deleteTimeout = setTimeout(resetDelete, 2000);
} else {
const shouldDeleteFromDisk = isDiskDelete || wantsDiskDelete;
resetDelete();
// 1. Delete files belonging only to this specific node
if (shouldDeleteFromDisk && out.images) {
for (const imgItem of out.images) {
if (imgItem && imgItem.filename) {
try {
await fetch("/comfy-sidebar/delete-file", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
filename: imgItem.filename,
subfolder: imgItem.subfolder || "",
type: imgItem.type || "output"
})
});
} catch (e) {}
}
}
}
// 2. Remove node from state.nodeOutputs
if (st.nodeOutputs && st.nodeOutputs[out.nodeId]) {
delete st.nodeOutputs[out.nodeId];
}
// 3. Update primary images if needed
const remainingOutputs = getRunOutputs(st.nodeOutputs, st.workflow);
if (remainingOutputs.length > 0) {
st.images = remainingOutputs[remainingOutputs.length - 1].images || [];
} else {
st.images = [];
State.activeSubmenuPromptId = null;
}
scheduleStateSave();
renderDOM();
}
};
hoverPanel.append(btnImg, btnDel);
const leftHoverPanel = document.createElement("div");
leftHoverPanel.className = "comfy-sidebar-left-hover-panel";
@@ -1340,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;
}
@@ -1367,6 +1641,20 @@ export function renderDOM() {
let cardObj = cardElements.get(state.pid);
const isFinalStatus = state.status === "completed" || state.status === "cancelled" || state.status === "error";
if (cardObj && isFinalStatus && state.rendered) {
// Ensure image changes (e.g. deletions from batch submenu) update the preview immediately
const currentImagesSignature = `${state.status || ""}:${state.images ? state.images.map(img => img.url || img.filename).join("|") : ""}`;
if (cardObj.lastImagesSignature !== currentImagesSignature) {
cardObj.currentImageIndex = Math.min(cardObj.currentImageIndex || 0, Math.max(0, (state.images?.length || 1) - 1));
if (!state.images || state.images.length === 0) {
cardObj.grid.innerHTML = "";
cardObj.firstImgElement = null;
cardObj.placeholder.style.display = "block";
} else {
cardObj.placeholder.style.display = "none";
renderCardImages(cardObj, state);
}
cardObj.lastImagesSignature = currentImagesSignature;
}
syncCardButtonVisibility(cardObj, state);
return cardObj.element;
}
@@ -1403,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";
@@ -1428,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);
@@ -1528,22 +1821,41 @@ export function renderDOM() {
const shouldDeleteFromDisk = isDiskDelete || wantsDiskDelete;
resetDeleteBtn();
// Delete files from disk if confirmed with Ctrl
if (shouldDeleteFromDisk && state.images && state.images.length > 0) {
for (const imgItem of state.images) {
if (imgItem && imgItem.filename) {
try {
await fetch("/comfy-sidebar/delete-file", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
filename: imgItem.filename,
subfolder: imgItem.subfolder || "",
type: imgItem.type || "output"
})
});
} catch (e) {}
// Delete all files from disk across ALL nodes in this run
if (shouldDeleteFromDisk) {
const filesToDelete = [];
const seen = new Set();
const addFile = (item) => {
if (item && item.filename) {
const key = `${item.type || ""}/${item.subfolder || ""}/${item.filename}`;
if (!seen.has(key)) {
seen.add(key);
filesToDelete.push(item);
}
}
};
// 1. Primary card images/files
(state.images || []).forEach(addFile);
// 2. All intermediate node outputs
const allOutputs = getRunOutputs(state.nodeOutputs, state.workflow);
allOutputs.forEach(out => (out.images || []).forEach(addFile));
// 3. Delete each file from disk / trash
for (const fileItem of filesToDelete) {
try {
await fetch("/comfy-sidebar/delete-file", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
filename: fileItem.filename,
subfolder: fileItem.subfolder || "",
type: fileItem.type || "output"
})
});
} catch (e) {}
}
}
+101 -15
View File
@@ -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;
@@ -67,16 +75,40 @@ function isNodeIgnored(nodeId, rawWorkflow) {
}
function extractFileCandidate(val, subfolder = "", type = "output") {
if (!val) return null;
if (typeof val === "string") {
const trimmed = val.trim();
if (FILE_EXT_REGEX.test(trimmed) || (trimmed.includes(".") && trimmed.length < 180 && !trimmed.includes("\n"))) {
return {
filename: trimmed.split("/").pop()?.split("\\").pop() || trimmed,
subfolder: subfolder || (trimmed.includes("/") ? trimmed.substring(0, trimmed.lastIndexOf("/")) : ""),
type: type || "output"
};
}
if (!val || typeof val !== "string") return null;
const trimmed = val.trim();
// 1. Ignore directory dots, floats/numbers, and empty strings
if (trimmed === "." || trimmed === ".." || trimmed === "./" || trimmed === "../" || trimmed === "") {
return null;
}
if (!isNaN(Number(trimmed))) {
return null;
}
// 2. Extract base file name
const cleanName = trimmed.split("/").pop()?.split("\\").pop()?.trim() || "";
if (!cleanName || cleanName === "." || cleanName === "..") return null;
// 3. Must have a dot with real name before and after (e.g. "model.safetensors")
const dotIndex = cleanName.lastIndexOf(".");
if (dotIndex <= 0 || dotIndex === cleanName.length - 1) {
return null;
}
const ext = cleanName.substring(dotIndex + 1).toLowerCase();
const base = cleanName.substring(0, dotIndex).trim();
if (!base || !ext) return null;
// 4. Must match a known extension or a valid 1-8 char alphanumeric extension without whitespace
const isValidExt = FILE_EXT_REGEX.test("." + ext) || (/^[a-z0-9]{1,8}$/i.test(ext) && !trimmed.includes(" ") && trimmed.length < 100);
if (isValidExt) {
return {
filename: cleanName,
subfolder: subfolder || (trimmed.includes("/") ? trimmed.substring(0, trimmed.lastIndexOf("/")) : ""),
type: type || "output"
};
}
return null;
}
@@ -136,13 +168,22 @@ export function findImagesInOutputs(outputs, rawWorkflow) {
}
}
// Fallback: Check workflow nodes for model/LoRA/file save nodes if no outputs were parsed
if (list.length === 0 && workflow && Array.isArray(workflow.nodes)) {
if (list.length > 0) return list;
// Fallback: If scanning a specific node ID, only look at that matching node in the workflow
if (workflow && Array.isArray(workflow.nodes)) {
const targetNodeIds = outputs ? Object.keys(outputs).map(String) : null;
for (const node of workflow.nodes) {
if (node && !node.properties?.ignoreInQueue) {
if (targetNodeIds && !targetNodeIds.includes(String(node.id))) {
continue;
}
const typeStr = (node.type || "").toLowerCase();
const isSaveNode = typeStr.includes("save") || typeStr.includes("extract") ||
typeStr.includes("export") || typeStr.includes("writer");
const isLoaderNode = typeStr.includes("load") || typeStr.includes("loader") || typeStr.includes("input");
const isSaveNode = !isLoaderNode && (typeStr.includes("save") || typeStr.includes("extract") ||
typeStr.includes("export") || typeStr.includes("writer"));
if (isSaveNode && Array.isArray(node.widgets_values)) {
for (const val of node.widgets_values) {
@@ -198,7 +239,12 @@ export function getRunOutputs(nodeOutputs, rawWorkflow) {
const list = [];
if (!nodeOutputs) return list;
for (const nodeId in nodeOutputs) {
const imgs = findImagesInOutputs({ [nodeId]: nodeOutputs[nodeId] }, workflow);
const nodeOut = nodeOutputs[nodeId];
// Ignore empty node outputs
if (!nodeOut || (typeof nodeOut === "object" && Object.keys(nodeOut).length === 0)) {
continue;
}
const imgs = findImagesInOutputs({ [nodeId]: nodeOut }, workflow);
if (imgs.length > 0) {
list.push({ nodeId, images: imgs });
}
@@ -237,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
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.4.0"
version = "1.4.2"
license = { file = "LICENSE" }
dependencies = []